Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ node_modules
bin
build
tests/mocks/*
.vscode
.vscodetsconfig.tsbuildinfo
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
203 changes: 203 additions & 0 deletions src/commands/init/InitRunner.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
buildConfig: {
cachePath: string;
appConfig: Record<string, {
script: string;
outputs: string[];
}>;
};
}

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<void> {
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<string, string> {
const projects: Record<string, string> = {};
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<string, string>
): 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<string, string>
): 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 <file>', 'Output file name', 'zenith.json');
program.parse(args);
const opts = program.opts();
super({ force: opts.force as boolean, output: opts.output as string });
}
}

14 changes: 11 additions & 3 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Expand Down
132 changes: 132 additions & 0 deletions tests/InitRunner.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
".github",
".eslintrc.js",
"tests/__mocks__",
"src/commands/graph/{ui,server}"
"src/commands/graph/{ui,server}",
"src/**/__tests__/**"
]
}