From 6a90c3b80e725c69c699be1327308f6e0eee921e Mon Sep 17 00:00:00 2001 From: zubeyralmaho Date: Sun, 8 Mar 2026 01:14:08 +0300 Subject: [PATCH] feat: add init command for automatic configuration - Add `zenith init` command that auto-detects monorepo structure - Scans package.json workspaces and pnpm-workspace.yaml - Generates zenith.json with detected projects and default config - Support --force flag to overwrite existing config - Support --output flag for custom output path - Add unit tests for InitRunner - Update test regex to include .ts files - Exclude test files from TypeScript compilation Closes #59 --- .gitignore | 2 +- package.json | 2 +- src/commands/init/InitRunner.ts | 203 ++++++++++++++++++++++++++++++++ src/run.ts | 14 ++- tests/InitRunner.spec.ts | 132 +++++++++++++++++++++ tsconfig.json | 3 +- 6 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 src/commands/init/InitRunner.ts create mode 100644 tests/InitRunner.spec.ts diff --git a/.gitignore b/.gitignore index 33749c8..42e5feb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules bin build tests/mocks/* -.vscode \ No newline at end of file +.vscodetsconfig.tsbuildinfo 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/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..62a9f08 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,16 +1,24 @@ /* 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 loading ConfigHelper when not needed + const { default: GraphRunner } = await import('./commands/graph/Runner.js'); const gRunner = new GraphRunner(...args); await gRunner.run(); break; - default: + case 'init': + // Dynamic import - init doesn't need ConfigHelper + const { default: InitRunner } = await import('./commands/init/InitRunner.js'); + const initRunner = new InitRunner(...args); + await initRunner.run(); + break; + default: + // Import only when running build/test commands + const { default: Runner } = await import('./classes/Runner.js'); const RunnerHelper = new Runner(...args); await RunnerHelper.runWrapper(); } diff --git a/tests/InitRunner.spec.ts b/tests/InitRunner.spec.ts new file mode 100644 index 0000000..0607447 --- /dev/null +++ b/tests/InitRunner.spec.ts @@ -0,0 +1,132 @@ +import { InitRunner } from '../src/commands/init/InitRunner'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('InitRunner', () => { + const testDir = path.join(__dirname, '__test_workspace__'); + const originalCwd = process.cwd(); + + beforeEach(() => { + // Create test workspace + fs.mkdirSync(testDir, { recursive: true }); + fs.mkdirSync(path.join(testDir, 'packages', 'app1'), { recursive: true }); + fs.mkdirSync(path.join(testDir, 'packages', 'lib1'), { recursive: true }); + + // Create package.json files + fs.writeFileSync( + path.join(testDir, 'package.json'), + JSON.stringify({ + name: 'test-monorepo', + workspaces: ['packages/*'] + }, null, 2) + ); + fs.writeFileSync( + path.join(testDir, 'packages', 'app1', 'package.json'), + JSON.stringify({ name: '@test/app1' }, null, 2) + ); + fs.writeFileSync( + path.join(testDir, 'packages', 'lib1', 'package.json'), + JSON.stringify({ name: '@test/lib1' }, null, 2) + ); + + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + // Clean up test workspace + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should create zenith.json with detected projects', async () => { + const runner = new InitRunner({}); + await runner.run(); + + const configPath = path.join(testDir, 'zenith.json'); + expect(fs.existsSync(configPath)).toBe(true); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(config.projects).toHaveProperty('@test/app1'); + expect(config.projects).toHaveProperty('@test/lib1'); + expect(config.projects['@test/app1']).toBe('packages/app1'); + expect(config.projects['@test/lib1']).toBe('packages/lib1'); + }); + + it('should include default build config', async () => { + const runner = new InitRunner({}); + await runner.run(); + + const configPath = path.join(testDir, 'zenith.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + expect(config.buildConfig).toBeDefined(); + expect(config.buildConfig.cachePath).toBe('.zenith-cache'); + expect(config.buildConfig.appConfig.build).toBeDefined(); + expect(config.buildConfig.appConfig.test).toBeDefined(); + expect(config.buildConfig.appConfig.lint).toBeDefined(); + }); + + it('should not overwrite existing config without --force', async () => { + // Create existing config + fs.writeFileSync( + path.join(testDir, 'zenith.json'), + JSON.stringify({ existing: true }, null, 2) + ); + + const runner = new InitRunner({}); + + // Should throw or exit + await expect(async () => { + await runner.run(); + }).rejects.toThrow(); + + // Original config should be preserved + const config = JSON.parse(fs.readFileSync(path.join(testDir, 'zenith.json'), 'utf-8')); + expect(config.existing).toBe(true); + }); + + it('should overwrite existing config with --force', async () => { + // Create existing config + fs.writeFileSync( + path.join(testDir, 'zenith.json'), + JSON.stringify({ existing: true }, null, 2) + ); + + const runner = new InitRunner({ force: true }); + await runner.run(); + + // Config should be overwritten + const config = JSON.parse(fs.readFileSync(path.join(testDir, 'zenith.json'), 'utf-8')); + expect(config.existing).toBeUndefined(); + expect(config.projects).toBeDefined(); + }); + + it('should write to custom output path', async () => { + const runner = new InitRunner({ output: 'custom-config.json' }); + await runner.run(); + + const customPath = path.join(testDir, 'custom-config.json'); + expect(fs.existsSync(customPath)).toBe(true); + const config = JSON.parse(fs.readFileSync(customPath, 'utf-8')); + expect(config.projects).toBeDefined(); + }); + + it('should detect pnpm workspaces', async () => { + // Remove npm workspaces and add pnpm + const rootPkg = JSON.parse(fs.readFileSync(path.join(testDir, 'package.json'), 'utf-8')); + delete rootPkg.workspaces; + fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify(rootPkg, null, 2)); + + fs.writeFileSync( + path.join(testDir, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"' + ); + + const runner = new InitRunner({}); + await runner.run(); + + const config = JSON.parse(fs.readFileSync(path.join(testDir, 'zenith.json'), 'utf-8')); + expect(config.projects).toHaveProperty('@test/app1'); + expect(config.projects).toHaveProperty('@test/lib1'); + }); +}); 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__/**" ] }