Files
claude-skills-reference/product-team/saas-scaffolder/scripts/project_bootstrapper.py
Reza Rezvani 5add886197 fix: repair 25 Python scripts failing --help across all domains
- Fix Python 3.10+ syntax (float | None → Optional[float]) in 2 scripts
- Add argparse CLI handling to 9 marketing scripts using raw sys.argv
- Fix 10 scripts crashing at module level (wrap in __main__, add argparse)
- Make yaml/prefect/mcp imports conditional with stdlib fallbacks (4 scripts)
- Fix f-string backslash syntax in project_bootstrapper.py
- Fix -h flag conflict in pr_analyzer.py
- Fix tech-debt.md description (score → prioritize)

All 237 scripts now pass python3 --help verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 05:51:27 +01:00

442 lines
14 KiB
Python

#!/usr/bin/env python3
"""Project Bootstrapper — Generate SaaS project scaffolding from config.
Creates project directory structure with boilerplate files, README,
docker-compose, environment configs, and CI/CD templates.
Usage:
python project_bootstrapper.py config.json --output-dir ./my-project
python project_bootstrapper.py config.json --format json --dry-run
"""
import argparse
import json
import os
import sys
from typing import Dict, List, Any, Optional
from datetime import datetime
STACK_TEMPLATES = {
"nextjs": {
"package.json": lambda c: json.dumps({
"name": c["name"],
"version": "0.1.0",
"private": True,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/react": "^18.0.0",
"@types/node": "^20.0.0",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0"
}
}, indent=2),
"tsconfig.json": lambda c: json.dumps({
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": True,
"skipLibCheck": True,
"strict": True,
"forceConsistentCasingInFileNames": True,
"noEmit": True,
"esModuleInterop": True,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": True,
"isolatedModules": True,
"jsx": "preserve",
"incremental": True,
"paths": {"@/*": ["./src/*"]}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}, indent=2),
"dirs": ["src/app", "src/components", "src/lib", "src/styles", "public", "tests"],
"files": {
"src/app/layout.tsx": "export default function RootLayout({ children }: { children: React.ReactNode }) {\n return <html lang=\"en\"><body>{children}</body></html>;\n}\n",
"src/app/page.tsx": "export default function Home() {\n return <main><h1>Welcome</h1></main>;\n}\n",
}
},
"express": {
"package.json": lambda c: json.dumps({
"name": c["name"],
"version": "0.1.0",
"main": "src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest",
"lint": "eslint src/"
},
"dependencies": {
"express": "^4.18.0",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"dotenv": "^16.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/express": "^4.17.0",
"@types/cors": "^2.8.0",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0",
"eslint": "^8.0.0"
}
}, indent=2),
"dirs": ["src/routes", "src/middleware", "src/models", "src/services", "src/utils", "tests"],
"files": {
"src/index.ts": "import express from 'express';\nimport cors from 'cors';\nimport helmet from 'helmet';\nimport { config } from 'dotenv';\n\nconfig();\n\nconst app = express();\nconst PORT = process.env.PORT || 3000;\n\napp.use(helmet());\napp.use(cors());\napp.use(express.json());\n\napp.get('/health', (req, res) => res.json({ status: 'ok' }));\n\napp.listen(PORT, () => console.log(`Server running on port ${PORT}`));\n",
}
},
"fastapi": {
"requirements.txt": lambda c: "fastapi>=0.100.0\nuvicorn[standard]>=0.23.0\npydantic>=2.0.0\npython-dotenv>=1.0.0\nsqlalchemy>=2.0.0\nalembic>=1.12.0\npytest>=7.0.0\nhttpx>=0.24.0\n",
"dirs": ["app/api", "app/models", "app/services", "app/core", "tests", "alembic"],
"files": {
"app/__init__.py": "",
"app/main.py": "from fastapi import FastAPI\nfrom app.core.config import settings\n\napp = FastAPI(title=settings.PROJECT_NAME)\n\n@app.get('/health')\ndef health(): return {'status': 'ok'}\n",
"app/core/__init__.py": "",
"app/core/config.py": "from pydantic_settings import BaseSettings\n\nclass Settings(BaseSettings):\n PROJECT_NAME: str = 'API'\n DATABASE_URL: str = 'sqlite:///./app.db'\n class Config:\n env_file = '.env'\n\nsettings = Settings()\n",
}
}
}
def generate_readme(config: Dict[str, Any]) -> str:
"""Generate README.md content."""
name = config.get("name", "my-project")
desc = config.get("description", "A SaaS application")
stack = config.get("stack", "nextjs")
return f"""# {name}
{desc}
## Tech Stack
- **Framework**: {stack}
- **Database**: {config.get('database', 'PostgreSQL')}
- **Auth**: {config.get('auth', 'JWT')}
## Getting Started
### Prerequisites
- Node.js 18+ / Python 3.11+
- Docker & Docker Compose
### Development
```bash
# Clone the repo
git clone <repo-url>
cd {name}
# Copy environment variables
cp .env.example .env
# Start with Docker
docker compose up -d
# Or run locally
{'npm install && npm run dev' if stack in ('nextjs', 'express') else 'pip install -r requirements.txt && uvicorn app.main:app --reload'}
```
### Testing
```bash
{'npm test' if stack in ('nextjs', 'express') else 'pytest'}
```
## Project Structure
```
{name}/
├── {'src/' if stack in ('nextjs', 'express') else 'app/'}
├── tests/
├── docker-compose.yml
├── .env.example
└── README.md
```
## License
MIT
"""
def generate_env_example(config: Dict[str, Any]) -> str:
"""Generate .env.example file."""
lines = [
"# Application",
f"APP_NAME={config.get('name', 'my-app')}",
"NODE_ENV=development",
"PORT=3000",
"",
"# Database",
]
db = config.get("database", "postgresql")
if db == "postgresql":
lines.extend(["DATABASE_URL=postgresql://user:password@localhost:5432/mydb", ""])
elif db == "mongodb":
lines.extend(["MONGODB_URI=mongodb://localhost:27017/mydb", ""])
elif db == "mysql":
lines.extend(["DATABASE_URL=mysql://user:password@localhost:3306/mydb", ""])
if config.get("auth"):
lines.extend([
"# Auth",
"JWT_SECRET=change-me-in-production",
"JWT_EXPIRY=7d",
""
])
if config.get("features", {}).get("email"):
lines.extend(["# Email", "SMTP_HOST=smtp.example.com", "SMTP_PORT=587", "SMTP_USER=", "SMTP_PASS=", ""])
if config.get("features", {}).get("storage"):
lines.extend(["# Storage", "S3_BUCKET=", "S3_REGION=us-east-1", "AWS_ACCESS_KEY_ID=", "AWS_SECRET_ACCESS_KEY=", ""])
return "\n".join(lines)
def generate_docker_compose(config: Dict[str, Any]) -> str:
"""Generate docker-compose.yml."""
name = config.get("name", "app")
stack = config.get("stack", "nextjs")
db = config.get("database", "postgresql")
services = {
"app": {
"build": ".",
"ports": ["3000:3000"],
"env_file": [".env"],
"depends_on": ["db"] if db else [],
"volumes": [".:/app", "/app/node_modules"] if stack != "fastapi" else [".:/app"]
}
}
if db == "postgresql":
services["db"] = {
"image": "postgres:16-alpine",
"ports": ["5432:5432"],
"environment": {
"POSTGRES_USER": "user",
"POSTGRES_PASSWORD": "password",
"POSTGRES_DB": "mydb"
},
"volumes": ["pgdata:/var/lib/postgresql/data"]
}
elif db == "mongodb":
services["db"] = {
"image": "mongo:7",
"ports": ["27017:27017"],
"volumes": ["mongodata:/data/db"]
}
if config.get("features", {}).get("redis"):
services["redis"] = {
"image": "redis:7-alpine",
"ports": ["6379:6379"]
}
compose = {
"version": "3.8",
"services": services,
"volumes": {}
}
if db == "postgresql":
compose["volumes"]["pgdata"] = {}
elif db == "mongodb":
compose["volumes"]["mongodata"] = {}
# Manual YAML-like output (avoid pyyaml dependency)
nl = "\n"
depends_on = f" depends_on:{nl} - db" if db else ""
vol_line = " pgdata:" if db == "postgresql" else " mongodata:" if db == "mongodb" else " {}"
return f"""version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
env_file:
- .env
volumes:
- .:/app
{depends_on}
{generate_db_service(db)}
{generate_redis_service(config)}
volumes:
{vol_line}
"""
def generate_db_service(db: str) -> str:
if db == "postgresql":
return """ db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
"""
elif db == "mongodb":
return """ db:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongodata:/data/db
"""
return ""
def generate_redis_service(config: Dict[str, Any]) -> str:
if config.get("features", {}).get("redis"):
return """ redis:
image: redis:7-alpine
ports:
- "6379:6379"
"""
return ""
def generate_gitignore(stack: str) -> str:
"""Generate .gitignore."""
common = "node_modules/\n.env\n.env.local\ndist/\nbuild/\n.next/\n*.log\n.DS_Store\ncoverage/\n__pycache__/\n*.pyc\n.pytest_cache/\n.venv/\n"
return common
def generate_dockerfile(config: Dict[str, Any]) -> str:
"""Generate Dockerfile."""
stack = config.get("stack", "nextjs")
if stack == "fastapi":
return """FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 3000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
"""
return """FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
"""
def scaffold_project(config: Dict[str, Any], output_dir: str, dry_run: bool = False) -> Dict[str, Any]:
"""Generate project scaffolding."""
stack = config.get("stack", "nextjs")
template = STACK_TEMPLATES.get(stack, STACK_TEMPLATES["nextjs"])
files_created = []
# Create directories
for d in template.get("dirs", []):
path = os.path.join(output_dir, d)
if not dry_run:
os.makedirs(path, exist_ok=True)
files_created.append({"path": d + "/", "type": "directory"})
# Create template files
all_files = {}
# Package/requirements file
for key in ("package.json", "requirements.txt"):
if key in template:
all_files[key] = template[key](config)
if "tsconfig.json" in template:
all_files["tsconfig.json"] = template["tsconfig.json"](config)
# Stack-specific files
all_files.update(template.get("files", {}))
# Common files
all_files["README.md"] = generate_readme(config)
all_files[".env.example"] = generate_env_example(config)
all_files[".gitignore"] = generate_gitignore(stack)
all_files["docker-compose.yml"] = generate_docker_compose(config)
all_files["Dockerfile"] = generate_dockerfile(config)
# Write files
for filepath, content in all_files.items():
full_path = os.path.join(output_dir, filepath)
if not dry_run:
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
files_created.append({"path": filepath, "type": "file", "size": len(content)})
return {
"generated_at": datetime.now().isoformat(),
"project_name": config.get("name", "my-project"),
"stack": stack,
"output_dir": output_dir,
"files_created": files_created,
"total_files": len([f for f in files_created if f["type"] == "file"]),
"total_dirs": len([f for f in files_created if f["type"] == "directory"]),
"dry_run": dry_run
}
def main():
parser = argparse.ArgumentParser(description="Bootstrap SaaS project from config")
parser.add_argument("input", help="Path to project config JSON")
parser.add_argument("--output-dir", type=str, default="./my-project", help="Output directory")
parser.add_argument("--format", choices=["json", "text"], default="text", help="Output format")
parser.add_argument("--dry-run", action="store_true", help="Preview without creating files")
args = parser.parse_args()
with open(args.input) as f:
config = json.load(f)
result = scaffold_project(config, args.output_dir, args.dry_run)
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print(f"Project '{result['project_name']}' scaffolded at {result['output_dir']}")
print(f"Stack: {result['stack']}")
print(f"Created: {result['total_files']} files, {result['total_dirs']} directories")
if result["dry_run"]:
print("\n[DRY RUN] No files were created. Files that would be created:")
print("\nFiles:")
for f in result["files_created"]:
prefix = "📁" if f["type"] == "directory" else "📄"
size = f" ({f.get('size', 0)} bytes)" if f.get("size") else ""
print(f" {prefix} {f['path']}{size}")
if __name__ == "__main__":
main()