From 7e4bc61fbeba0940f313b2ee2e36a6ed6bfcb9d1 Mon Sep 17 00:00:00 2001 From: zubeyralmaho Date: Sun, 8 Mar 2026 01:20:41 +0300 Subject: [PATCH] feat: add clean command for cache management - Add `zenith clean` command to clean build cache - Show cache statistics (size, files, projects) - Support --all flag to clean entire cache - Support --dry-run flag to preview what would be deleted - Support --project flag to clean specific project cache - Uses dynamic imports to allow running without zenith.json - Add unit tests for CleanRunner Also includes: - Init command from feat/init-command branch - Refactored run.ts to use dynamic imports throughout --- package.json | 2 +- src/commands/clean/CleanRunner.ts | 148 ++++++++++++++++++++++ src/commands/init/InitRunner.ts | 203 ++++++++++++++++++++++++++++++ src/run.ts | 20 ++- tests/CleanRunner.spec.ts | 82 ++++++++++++ tsconfig.json | 3 +- 6 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 src/commands/clean/CleanRunner.ts create mode 100644 src/commands/init/InitRunner.ts create mode 100644 tests/CleanRunner.spec.ts diff --git a/package.json b/package.json index ee57a10..806cf70 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "lint": "eslint src/", "build": "tsc --build", - "test": "jest --testRegex=\"[^\\.]+\\.spec\\.js$\" --passWithNoTests", + "test": "jest --testRegex=\"[^\\.]+\\.spec\\.(js|ts)$\" --passWithNoTests", "tsc:w": "tsc -w" }, "keywords": [], diff --git a/src/commands/clean/CleanRunner.ts b/src/commands/clean/CleanRunner.ts new file mode 100644 index 0000000..98771a8 --- /dev/null +++ b/src/commands/clean/CleanRunner.ts @@ -0,0 +1,148 @@ +import { existsSync, rmSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { Command } from 'commander'; + +export interface CleanRunnerOptions { + all?: boolean; + dryRun?: boolean; + project?: string; +} + +export class CleanRunner { + protected all = false; + protected dryRun = false; + protected project?: string; + protected cachePath = '.zenith-cache'; + + constructor(options: CleanRunnerOptions = {}) { + this.all = options.all ?? false; + this.dryRun = options.dryRun ?? false; + this.project = options.project; + } + + async run(): Promise { + const rootPath = process.cwd(); + + // Try to get cache path from zenith.json + const configPath = join(rootPath, 'zenith.json'); + if (existsSync(configPath)) { + try { + const config = JSON.parse(require('fs').readFileSync(configPath, 'utf-8')); + if (config.buildConfig?.cachePath) { + this.cachePath = config.buildConfig.cachePath; + } + } catch (e) { + // Use default cache path + } + } + + const fullCachePath = join(rootPath, this.cachePath); + + if (!existsSync(fullCachePath)) { + console.log(`โœ… Cache directory does not exist: ${this.cachePath}`); + console.log(' Nothing to clean.'); + return; + } + + if (this.all) { + // Clean entire cache directory + await this.cleanDirectory(fullCachePath, 'entire cache'); + } else if (this.project) { + // Clean specific project cache + const projectCachePath = join(fullCachePath, this.project); + if (existsSync(projectCachePath)) { + await this.cleanDirectory(projectCachePath, `project "${this.project}"`); + } else { + console.log(`โš ๏ธ No cache found for project: ${this.project}`); + } + } else { + // Show cache info and clean with confirmation + await this.showCacheInfo(fullCachePath); + await this.cleanDirectory(fullCachePath, 'entire cache'); + } + } + + private async showCacheInfo(cachePath: string): Promise { + try { + const items = readdirSync(cachePath); + const stats = this.getCacheStats(cachePath); + + console.log('๐Ÿ“Š Cache Statistics:'); + console.log(` Path: ${this.cachePath}`); + console.log(` Projects: ${items.length}`); + console.log(` Total size: ${this.formatBytes(stats.totalSize)}`); + console.log(` Files: ${stats.fileCount}`); + console.log(''); + } catch (e) { + // Ignore errors when showing info + } + } + + private getCacheStats(dirPath: string): { totalSize: number; fileCount: number } { + let totalSize = 0; + let fileCount = 0; + + const scan = (currentPath: string) => { + try { + const items = readdirSync(currentPath); + for (const item of items) { + const itemPath = join(currentPath, item); + const stat = statSync(itemPath); + if (stat.isDirectory()) { + scan(itemPath); + } else { + totalSize += stat.size; + fileCount++; + } + } + } catch (e) { + // Skip inaccessible directories + } + }; + + scan(dirPath); + return { totalSize, fileCount }; + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + private async cleanDirectory(dirPath: string, description: string): Promise { + if (this.dryRun) { + console.log(`๐Ÿ” [DRY RUN] Would delete ${description}: ${dirPath}`); + return; + } + + console.log(`๐Ÿงน Cleaning ${description}...`); + try { + rmSync(dirPath, { recursive: true, force: true }); + console.log(`โœ… Successfully cleaned ${description}`); + } catch (error) { + console.error(`โŒ Failed to clean ${description}:`, error); + throw error; + } + } +} + +// CLI wrapper for commander (used as default export) +export default class CleanRunnerCLI extends CleanRunner { + constructor(...args: readonly string[]) { + const program = new Command(); + program + .option('-a, --all', 'Clean all cache without prompting', false) + .option('-d, --dry-run', 'Show what would be deleted without actually deleting', false) + .option('-p, --project ', 'Clean cache for a specific project'); + program.parse(args); + const opts = program.opts(); + super({ + all: opts.all as boolean, + dryRun: opts.dryRun as boolean, + project: opts.project as string | undefined, + }); + } +} diff --git a/src/commands/init/InitRunner.ts b/src/commands/init/InitRunner.ts new file mode 100644 index 0000000..f582d68 --- /dev/null +++ b/src/commands/init/InitRunner.ts @@ -0,0 +1,203 @@ +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { Command } from 'commander'; + +interface ZenithJsonConfig { + projects: Record; + buildConfig: { + cachePath: string; + appConfig: Record; + }; +} + +export interface InitRunnerOptions { + force?: boolean; + output?: string; +} + +export class InitRunner { + protected force = false; + protected outputFile = 'zenith.json'; + + constructor(options: InitRunnerOptions = {}) { + this.force = options.force ?? false; + this.outputFile = options.output ?? 'zenith.json'; + } + + async run(): Promise { + const rootPath = process.cwd(); + const configPath = join(rootPath, this.outputFile); + + // Check if zenith.json already exists + if (existsSync(configPath) && !this.force) { + const message = `${this.outputFile} already exists. Use --force to overwrite.`; + console.log(`โŒ ${message}`); + throw new Error(message); + } + + console.log('๐Ÿš€ Initializing Zenith configuration...\n'); + + // Try to detect projects + const projects = this.detectProjects(rootPath); + + if (Object.keys(projects).length === 0) { + console.log('โš ๏ธ No projects detected. Creating minimal configuration.'); + } else { + console.log(`โœ… Detected ${Object.keys(projects).length} project(s):`); + Object.entries(projects).forEach(([name, path]) => { + console.log(` - ${name}: ${path}`); + }); + } + + // Create default config + const config: ZenithJsonConfig = { + projects, + buildConfig: { + cachePath: '.zenith-cache', + appConfig: { + build: { + script: 'build', + outputs: ['build', 'dist'] + }, + test: { + script: 'test', + outputs: ['stdout'] + }, + lint: { + script: 'lint', + outputs: ['stdout'] + } + } + } + }; + + // Write config file + writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log(`\nโœ… Created ${this.outputFile}`); + console.log('\n๐Ÿ“ Next steps:'); + console.log(' 1. Review and customize zenith.json'); + console.log(' 2. Run: pnpm zenith --target=build --project=all'); + } + + protected detectProjects(rootPath: string): Record { + const projects: Record = {}; + const packageJsonPath = join(rootPath, 'package.json'); + + // Try to read workspaces from package.json + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + + // Check for pnpm workspaces + const pnpmWorkspacePath = join(rootPath, 'pnpm-workspace.yaml'); + if (existsSync(pnpmWorkspacePath)) { + const workspaceContent = readFileSync(pnpmWorkspacePath, 'utf-8'); + const workspacePatterns = this.parsePnpmWorkspace(workspaceContent); + this.resolveWorkspacePatterns(rootPath, workspacePatterns, projects); + } + // Check for yarn/npm workspaces in package.json + else if (packageJson.workspaces) { + const workspaces = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces.packages || []; + this.resolveWorkspacePatterns(rootPath, workspaces, projects); + } + } catch (e) { + console.warn('โš ๏ธ Could not parse package.json'); + } + } + + // If no workspaces found, look for common monorepo structures + if (Object.keys(projects).length === 0) { + const commonDirs = ['packages', 'apps', 'libs', 'projects']; + for (const dir of commonDirs) { + const dirPath = join(rootPath, dir); + if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { + this.scanDirectory(rootPath, dirPath, projects); + } + } + } + + return projects; + } + + protected parsePnpmWorkspace(content: string): string[] { + const patterns: string[] = []; + const lines = content.split('\n'); + let inPackages = false; + + for (const line of lines) { + if (line.trim() === 'packages:') { + inPackages = true; + continue; + } + if (inPackages && line.trim().startsWith('-')) { + const pattern = line.trim().replace(/^-\s*['"]?/, '').replace(/['"]?\s*$/, ''); + patterns.push(pattern); + } + } + + return patterns; + } + + protected resolveWorkspacePatterns( + rootPath: string, + patterns: string[], + projects: Record + ): void { + for (const pattern of patterns) { + // Handle glob patterns like "packages/*" + const basePath = pattern.replace(/\/\*.*$/, ''); + const fullBasePath = join(rootPath, basePath); + + if (existsSync(fullBasePath) && statSync(fullBasePath).isDirectory()) { + this.scanDirectory(rootPath, fullBasePath, projects); + } + } + } + + protected scanDirectory( + rootPath: string, + dirPath: string, + projects: Record + ): void { + try { + const items = readdirSync(dirPath); + for (const item of items) { + const itemPath = join(dirPath, item); + if (statSync(itemPath).isDirectory()) { + const packageJsonPath = join(itemPath, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const projectName = pkg.name || item; + const relativePath = relative(rootPath, itemPath); + projects[projectName] = relativePath; + } catch (e) { + // Skip invalid package.json + } + } + } + } + } catch (e) { + // Skip inaccessible directories + } + } +} + +// CLI wrapper for commander (used as default export) +export default class InitRunnerCLI extends InitRunner { + constructor(...args: readonly string[]) { + const program = new Command(); + program + .option('-f, --force', 'Overwrite existing zenith.json', false) + .option('-o, --output ', 'Output file name', 'zenith.json'); + program.parse(args); + const opts = program.opts(); + super({ force: opts.force as boolean, output: opts.output as string }); + } +} + diff --git a/src/run.ts b/src/run.ts index 720f81c..823a57a 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,16 +1,30 @@ /* eslint-disable no-case-declarations */ -import Runner from './classes/Runner.js'; -import GraphRunner from './commands/graph/Runner.js'; export const run = async () => { try { const args = process.argv; switch (args[2]) { case 'graph': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: GraphRunner } = await import('./commands/graph/Runner.js'); const gRunner = new GraphRunner(...args); await gRunner.run(); break; - default: + case 'init': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: InitRunner } = await import('./commands/init/InitRunner.js'); + const initRunner = new InitRunner(...args); + await initRunner.run(); + break; + case 'clean': + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: CleanRunner } = await import('./commands/clean/CleanRunner.js'); + const cleanRunner = new CleanRunner(...args); + await cleanRunner.run(); + break; + default: + // Dynamic import to avoid ConfigHelper loading before zenith.json exists + const { default: Runner } = await import('./classes/Runner.js'); const RunnerHelper = new Runner(...args); await RunnerHelper.runWrapper(); } diff --git a/tests/CleanRunner.spec.ts b/tests/CleanRunner.spec.ts new file mode 100644 index 0000000..3e3851e --- /dev/null +++ b/tests/CleanRunner.spec.ts @@ -0,0 +1,82 @@ +import { CleanRunner } from '../src/commands/clean/CleanRunner'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('CleanRunner', () => { + const testDir = path.join(__dirname, '__clean_test_workspace__'); + const cachePath = path.join(testDir, '.zenith-cache'); + const originalCwd = process.cwd(); + + beforeEach(() => { + // Create test workspace with cache + fs.mkdirSync(testDir, { recursive: true }); + fs.mkdirSync(path.join(cachePath, 'project1'), { recursive: true }); + fs.mkdirSync(path.join(cachePath, 'project2'), { recursive: true }); + + // Create some cache files + fs.writeFileSync(path.join(cachePath, 'project1', 'cache.txt'), 'cache data 1'); + fs.writeFileSync(path.join(cachePath, 'project2', 'cache.txt'), 'cache data 2'); + + // Create zenith.json + fs.writeFileSync( + path.join(testDir, 'zenith.json'), + JSON.stringify({ + projects: {}, + buildConfig: { cachePath: '.zenith-cache' } + }, null, 2) + ); + + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + // Clean up test workspace + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should clean entire cache with --all flag', async () => { + expect(fs.existsSync(cachePath)).toBe(true); + + const runner = new CleanRunner({ all: true }); + await runner.run(); + + expect(fs.existsSync(cachePath)).toBe(false); + }); + + it('should show what would be deleted with --dry-run flag', async () => { + expect(fs.existsSync(cachePath)).toBe(true); + + const runner = new CleanRunner({ all: true, dryRun: true }); + await runner.run(); + + // Cache should still exist after dry run + expect(fs.existsSync(cachePath)).toBe(true); + expect(fs.existsSync(path.join(cachePath, 'project1', 'cache.txt'))).toBe(true); + }); + + it('should clean specific project cache with --project flag', async () => { + const runner = new CleanRunner({ project: 'project1' }); + await runner.run(); + + // project1 cache should be deleted + expect(fs.existsSync(path.join(cachePath, 'project1'))).toBe(false); + // project2 cache should still exist + expect(fs.existsSync(path.join(cachePath, 'project2'))).toBe(true); + }); + + it('should handle non-existent cache gracefully', async () => { + // Remove cache first + fs.rmSync(cachePath, { recursive: true, force: true }); + + const runner = new CleanRunner({ all: true }); + // Should not throw + await expect(runner.run()).resolves.not.toThrow(); + }); + + it('should handle non-existent project cache gracefully', async () => { + const runner = new CleanRunner({ project: 'non-existent-project' }); + // Should not throw + await expect(runner.run()).resolves.not.toThrow(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d13fc19..bf68764 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ ".github", ".eslintrc.js", "tests/__mocks__", - "src/commands/graph/{ui,server}" + "src/commands/graph/{ui,server}", + "src/**/__tests__/**" ] }