create-sparc
Version:
NPX package to scaffold new projects with SPARC methodology structure
537 lines (446 loc) • 15.7 kB
JavaScript
/**
* Project Generator for create-sparc
*/
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const os = require('os');
const { logger, errorHandler, fsUtils, pathUtils } = require('../../utils');
const { fileManager } = require('../file-manager');
const { symlinkManager } = require('../file-manager/symlink');
const { configManager } = require('../config-manager');
/**
* Project Generator
*/
const projectGenerator = {
/**
* Generate a new SPARC project
* @param {Object} config - Project configuration
* @returns {Promise<void>}
*/
async generateProject(config) {
logger.debug('Starting project generation');
// Validate configuration
const validationResult = configManager.validateConfig(config);
if (!validationResult.valid) {
const errors = validationResult.errors.map(err => ` - ${err.property}: ${err.message}`).join('\n');
throw new Error(`Invalid configuration:\n${errors}`);
}
// Create project directory
const spinner = ora('Creating project directory').start();
try {
await this._createProjectDirectory(config);
spinner.succeed('Project directory created');
} catch (error) {
spinner.fail('Failed to create project directory');
throw error;
}
// Setup SPARC structure
spinner.text = 'Setting up SPARC structure';
spinner.start();
try {
await this._setupSparcStructure(config);
spinner.succeed('SPARC structure set up');
} catch (error) {
spinner.fail('Failed to set up SPARC structure');
throw error;
}
// Generate configuration files
spinner.text = 'Generating configuration files';
spinner.start();
try {
await this._generateConfigFiles(config);
spinner.succeed('Configuration files generated');
} catch (error) {
spinner.fail('Failed to generate configuration files');
throw error;
}
// Skip dependency installation and git initialization if we're only creating .roo and .roomodes files
if (!config.skipProjectStructure) {
// Install dependencies if requested
if (config.installDependencies) {
spinner.text = 'Installing dependencies';
spinner.start();
try {
await this._installDependencies(config);
spinner.succeed('Dependencies installed');
} catch (error) {
spinner.fail('Failed to install dependencies');
logger.warn('You can install dependencies manually later');
}
}
// Initialize git if requested
if (config.git && config.git.init) {
spinner.text = 'Initializing git repository';
spinner.start();
try {
await this._initializeGit(config);
spinner.succeed('Git repository initialized');
} catch (error) {
spinner.fail('Failed to initialize git repository');
logger.warn('You can initialize git manually later');
}
}
}
logger.debug('Project generation completed');
},
/**
* Add a component to an existing project
* @param {Object} componentConfig - Component configuration
* @returns {Promise<void>}
*/
async addComponent(componentConfig) {
logger.debug(`Adding component: ${componentConfig.name}`);
// Validate component configuration
if (!componentConfig.name) {
throw new Error('Component name is required');
}
// TODO: Implement component addition logic
logger.debug('Component added successfully');
},
/**
* Create the project directory
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _createProjectDirectory(config) {
const projectPath = pathUtils.resolve(config.projectPath);
// Check if directory already exists
if (await fsUtils.exists(projectPath)) {
if (config.projectPath === '.') {
// Current directory, check if it's empty
const files = await fs.readdir(projectPath);
const nonHiddenFiles = files.filter(file => !file.startsWith('.') && file !== 'node_modules');
if (nonHiddenFiles.length > 0 && !config.force) {
throw new Error('Current directory is not empty. Use a new directory, empty the current one, or use --force option.');
}
} else if (!config.force) {
throw new Error(`Directory ${config.projectPath} already exists. Use --force option to initialize anyway.`);
}
} else {
// Create the directory
await fs.mkdir(projectPath, { recursive: true });
}
},
/**
* Set up SPARC structure
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _setupSparcStructure(config) {
const projectPath = pathUtils.resolve(config.projectPath);
// If skipProjectStructure is true, only copy the .roo and .roomodes files
if (config.skipProjectStructure) {
logger.debug('Skipping project structure creation, only copying .roo and .roomodes files');
await this._copySparcFiles(config);
return;
}
// Create basic project structure
await fs.mkdir(path.join(projectPath, 'src'), { recursive: true });
// Create tests directory structure with index files
await fs.mkdir(path.join(projectPath, 'tests'), { recursive: true });
await fs.mkdir(path.join(projectPath, 'tests/unit'), { recursive: true });
await fs.mkdir(path.join(projectPath, 'tests/integration'), { recursive: true });
// Create index.js and index.ts in tests directory
await fs.writeFile(path.join(projectPath, 'tests/index.js'), '// Test entry point\n');
await fs.writeFile(path.join(projectPath, 'tests/index.ts'), '// TypeScript test entry point\n');
// Copy .roo directory and .roomodes file to the project
await this._copySparcFiles(config);
},
/**
* Copy SPARC files (.roo directory and .roomodes file) to the project
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _copySparcFiles(config) {
const projectPath = pathUtils.resolve(config.projectPath);
// Get paths to the actual .roo directory and .roomodes file
// Use custom source directory if provided, otherwise use the root directory
// Find the package root directory (where package.json is located)
const packageRoot = path.resolve(__dirname, '../../../');
// Resolve the source directory as an absolute path
const rootDir = config.sourceDir
? path.resolve(packageRoot, config.sourceDir)
: packageRoot;
console.log('Package root:', packageRoot); // Debug: Log package root
console.log('Root directory:', rootDir); // Debug: Log root directory
console.log('Project path:', projectPath); // Debug: Log project path
// Copy standard SPARC files (.roo and .roomodes)
for (const filePath of config.symlink.paths) {
const sourcePath = path.join(rootDir, filePath);
const targetPath = path.join(projectPath, filePath);
console.log(`Copying ${filePath} from ${sourcePath} to ${targetPath}`); // Debug: Log copy operation
// Ensure source exists
if (!await fsUtils.exists(sourcePath)) {
console.error(`Source path not found: ${sourcePath}`); // Debug: Log error
throw new Error(`Source path not found: ${sourcePath}`);
}
// Copy directory or file
if (await fsUtils.isDirectory(sourcePath)) {
logger.debug(`Copying directory: ${sourcePath} to ${targetPath}`);
await fs.copy(sourcePath, targetPath);
} else {
// Ensure parent directory exists
await fs.mkdir(path.dirname(targetPath), { recursive: true });
logger.debug(`Copying file: ${sourcePath} to ${targetPath}`);
await fs.copy(sourcePath, targetPath);
}
logger.debug(`Successfully copied ${filePath} to project`);
}
// If this is an AIGI project, also copy the aigi.md file
if (config.sourceDir === 'aiGI') {
const aigiMdSource = path.join(rootDir, 'aigi.md');
const aigiMdTarget = path.join(projectPath, 'aigi.md');
console.log(`Checking for aigi.md at ${aigiMdSource}`); // Debug: Log aigi.md path
// Check if aigi.md exists in the source directory
if (await fsUtils.exists(aigiMdSource)) {
logger.debug(`Copying aigi.md from ${aigiMdSource} to ${aigiMdTarget}`);
await fs.copy(aigiMdSource, aigiMdTarget);
logger.debug('Successfully copied aigi.md to project');
} else {
logger.debug(`aigi.md not found at ${aigiMdSource}`);
// Try alternative locations if the file is not found
const altLocations = [
path.join(packageRoot, 'aiGI', 'aigi.md'),
path.join(process.cwd(), 'aiGI', 'aigi.md')
];
for (const altPath of altLocations) {
console.log(`Trying alternative path: ${altPath}`); // Debug: Log alternative path
if (await fsUtils.exists(altPath)) {
logger.debug(`Found aigi.md at alternative location: ${altPath}`);
await fs.copy(altPath, aigiMdTarget);
logger.debug('Successfully copied aigi.md to project from alternative location');
break;
}
}
}
}
},
/**
* Generate configuration files
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _generateConfigFiles(config) {
const projectPath = pathUtils.resolve(config.projectPath);
// If skipProjectStructure is true, skip generating project files
if (config.skipProjectStructure) {
logger.debug('Skipping configuration file generation');
return;
}
// Generate package.json
const packageJson = {
name: config.projectName || path.basename(projectPath),
version: '0.1.0',
description: 'A project created with SPARC methodology',
main: 'src/index.js',
scripts: {
start: 'node src/index.js',
test: 'echo "Error: no test specified" && exit 1'
},
keywords: ['sparc'],
author: '',
license: 'MIT',
dependencies: {},
devDependencies: {}
};
// Add TypeScript configuration if requested
if (config.features && config.features.typescript) {
packageJson.main = 'dist/index.js';
packageJson.scripts.build = 'tsc';
packageJson.scripts.start = 'node dist/index.js';
packageJson.devDependencies.typescript = '^4.9.5';
// Create tsconfig.json
const tsConfig = {
compilerOptions: {
target: 'es2020',
module: 'commonjs',
outDir: './dist',
rootDir: './src',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true
},
include: ['src/**/*'],
exclude: ['node_modules', '**/*.test.ts']
};
await fs.writeJson(path.join(projectPath, 'tsconfig.json'), tsConfig, { spaces: 2 });
}
// Write package.json
await fs.writeJson(path.join(projectPath, 'package.json'), packageJson, { spaces: 2 });
// Create README.md
const readmeContent = `# ${config.projectName || 'SPARC Project'}
A project created with SPARC methodology.
## Getting Started
\`\`\`bash
# Install dependencies
${config.npmClient} install
# Start the project
${config.npmClient} start
\`\`\`
## Project Structure
This project follows the SPARC methodology:
- **S**pecification
- **P**seudocode
- **A**rchitecture
- **R**efinement
- **C**ompletion
## License
MIT
`;
await fs.writeFile(path.join(projectPath, 'README.md'), readmeContent);
// Create basic source file
const indexContent = config.features && config.features.typescript
? `/**
* Main entry point
*/
export function main(): void {
console.log('Hello from SPARC!');
}
main();
`
: `/**
* Main entry point
*/
function main() {
console.log('Hello from SPARC!');
}
main();
`;
// Ensure src directory exists
await fs.ensureDir(path.join(projectPath, 'src'));
// Create index.js/ts in src directory
await fs.writeFile(
path.join(projectPath, 'src', 'index.' + (config.features && config.features.typescript ? 'ts' : 'js')),
indexContent
);
// Create tests directory and index files if they don't exist yet
await fs.ensureDir(path.join(projectPath, 'tests'));
// Create index.js in tests directory
await fs.writeFile(
path.join(projectPath, 'tests', 'index.js'),
'// Test entry point\n'
);
// Create index.ts in tests directory
await fs.writeFile(
path.join(projectPath, 'tests', 'index.ts'),
'// TypeScript test entry point\n'
);
},
/**
* Install dependencies
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _installDependencies(config) {
const projectPath = pathUtils.resolve(config.projectPath);
const { execSync } = require('child_process');
// Determine package manager command
const command = config.npmClient || 'npm';
try {
execSync(`${command} install`, {
cwd: projectPath,
stdio: config.verbose ? 'inherit' : 'pipe'
});
} catch (error) {
throw new Error(`Failed to install dependencies: ${error.message}`);
}
},
/**
* Initialize git repository
* @param {Object} config - Project configuration
* @returns {Promise<void>}
* @private
*/
async _initializeGit(config) {
const projectPath = pathUtils.resolve(config.projectPath);
const { execSync } = require('child_process');
try {
// Initialize git repository
execSync('git init', {
cwd: projectPath,
stdio: config.verbose ? 'inherit' : 'pipe'
});
// Create .gitignore
const gitignoreContent = `# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Build output
dist/
build/
# Environment variables
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
# IDE files
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
# OS files
.DS_Store
Thumbs.db
`;
await fs.writeFile(path.join(projectPath, '.gitignore'), gitignoreContent);
// Initial commit if requested
if (config.git.initialCommit) {
execSync('git add .', {
cwd: projectPath,
stdio: config.verbose ? 'inherit' : 'pipe'
});
execSync('git commit -m "Initial commit"', {
cwd: projectPath,
stdio: config.verbose ? 'inherit' : 'pipe'
});
}
} catch (error) {
throw new Error(`Failed to initialize git repository: ${error.message}`);
}
}
};
module.exports = { projectGenerator };