367 lines
9.1 KiB
Python
367 lines
9.1 KiB
Python
"""Main CLI entry point for Project Scaffold CLI."""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
from click_completion import init
|
|
|
|
from . import __version__
|
|
from .config import Config
|
|
from .gitignore import GitignoreGenerator
|
|
from .prompts import ProjectPrompts
|
|
from .template_engine import TemplateEngine
|
|
|
|
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
|
|
|
|
def get_installed_shells():
|
|
"""Return list of supported shells for completion."""
|
|
return ["bash", "zsh", "fish", "powershell"]
|
|
|
|
|
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
@click.version_option(version=__version__, prog_name="psc")
|
|
@click.option(
|
|
"--config",
|
|
"-c",
|
|
type=click.Path(exists=True),
|
|
help="Path to configuration file",
|
|
)
|
|
@click.pass_context
|
|
def main(ctx, config):
|
|
"""Project Scaffold CLI - Generate standardized project scaffolding."""
|
|
ctx.ensure_object(dict)
|
|
cfg = Config.load(config) if config else Config.load()
|
|
ctx.obj["config"] = cfg
|
|
ctx.obj["config_path"] = config
|
|
|
|
|
|
@main.command()
|
|
@click.argument("project_name", required=False)
|
|
@click.option(
|
|
"--language",
|
|
"-l",
|
|
type=click.Choice(["python", "nodejs", "go", "rust"]),
|
|
help="Project language",
|
|
)
|
|
@click.option("--author", "-a", help="Author name")
|
|
@click.option("--email", "-e", help="Author email")
|
|
@click.option("--description", "-d", help="Project description")
|
|
@click.option(
|
|
"--license",
|
|
"-L",
|
|
type=click.Choice(["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "None"]),
|
|
help="License type",
|
|
)
|
|
@click.option(
|
|
"--ci",
|
|
type=click.Choice(["github", "gitlab", "none"]),
|
|
help="CI/CD provider",
|
|
)
|
|
@click.option(
|
|
"--template",
|
|
"-t",
|
|
help="Custom template path or name",
|
|
)
|
|
@click.option(
|
|
"--yes",
|
|
"-y",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Skip prompts and use defaults",
|
|
)
|
|
@click.option(
|
|
"--force",
|
|
"-f",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Force overwrite existing directory",
|
|
)
|
|
@click.pass_context
|
|
def create(
|
|
ctx,
|
|
project_name,
|
|
language,
|
|
author,
|
|
email,
|
|
description,
|
|
license,
|
|
ci,
|
|
template,
|
|
yes,
|
|
force,
|
|
):
|
|
"""Create a new project scaffold."""
|
|
config = ctx.obj.get("config", Config())
|
|
|
|
if project_name and not _validate_project_name(project_name):
|
|
click.echo(
|
|
click.style(
|
|
"Error: Invalid project name. Use lowercase letters, numbers, and hyphens only.",
|
|
fg="red",
|
|
)
|
|
)
|
|
sys.exit(1)
|
|
|
|
if not yes:
|
|
prompts = ProjectPrompts(config)
|
|
project_name, language, author, email, description, license, ci, template = (
|
|
prompts.collect_inputs(
|
|
project_name, language, author, email, description, license, ci, template
|
|
)
|
|
)
|
|
|
|
if not project_name:
|
|
click.echo(
|
|
click.style("Error: Project name is required.", fg="red")
|
|
)
|
|
sys.exit(1)
|
|
|
|
if not language:
|
|
language = config.default_language or "python"
|
|
|
|
project_slug = _to_kebab_case(project_name)
|
|
year = str(2024)
|
|
author = author or config.author or "Your Name"
|
|
email = email or config.email or "your.email@example.com"
|
|
description = description or config.description or "A new project"
|
|
license = license or config.license or "MIT"
|
|
ci = ci or config.ci or "none"
|
|
|
|
context = {
|
|
"project_name": project_name,
|
|
"project_slug": project_slug,
|
|
"author": author,
|
|
"email": email,
|
|
"description": description,
|
|
"license": license,
|
|
"year": year,
|
|
"language": language,
|
|
}
|
|
|
|
output_dir = Path(project_name).resolve()
|
|
|
|
if output_dir.exists() and not force:
|
|
click.echo(
|
|
click.style(
|
|
f"Error: Directory '{project_name}' already exists. Use --force to overwrite.",
|
|
fg="red",
|
|
)
|
|
)
|
|
sys.exit(1)
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
engine = TemplateEngine(template, config)
|
|
|
|
if template:
|
|
engine.render_project(context, output_dir, template)
|
|
else:
|
|
engine.render_language_template(language, context, output_dir)
|
|
|
|
gitignore_gen = GitignoreGenerator()
|
|
gitignore_path = output_dir / ".gitignore"
|
|
gitignore_gen.generate(language, gitignore_path)
|
|
|
|
if ci != "none":
|
|
engine.render_ci_template(ci, context, output_dir)
|
|
|
|
click.echo(
|
|
click.style(
|
|
f"Successfully created project '{project_name}' at {output_dir}",
|
|
fg="green",
|
|
)
|
|
)
|
|
|
|
if license == "None":
|
|
license_file = output_dir / "LICENSE"
|
|
license_file.unlink(missing_ok=True)
|
|
|
|
except Exception as e:
|
|
click.echo(click.style(f"Error creating project: {e}", fg="red"))
|
|
sys.exit(1)
|
|
|
|
|
|
@main.group()
|
|
def template():
|
|
"""Manage custom templates."""
|
|
pass
|
|
|
|
|
|
@template.command(name="list")
|
|
@click.pass_context
|
|
def list_templates(ctx):
|
|
"""List all custom templates."""
|
|
config = ctx.obj.get("config", Config())
|
|
template_dirs = config.get_template_dirs()
|
|
|
|
templates_found = False
|
|
for template_dir in template_dirs:
|
|
if Path(template_dir).exists():
|
|
for item in Path(template_dir).iterdir():
|
|
if item.is_dir() and not item.name.startswith("."):
|
|
click.echo(f"- {item.name} ({item})")
|
|
templates_found = True
|
|
|
|
if not templates_found:
|
|
click.echo("No custom templates found.")
|
|
|
|
|
|
@template.command(name="save")
|
|
@click.argument("name")
|
|
@click.argument("path", type=click.Path(exists=True, file_okay=False))
|
|
@click.pass_context
|
|
def save_template(ctx, name, path):
|
|
"""Save a new custom template."""
|
|
config = ctx.obj.get("config", Config())
|
|
template_dir = config.get_custom_templates_dir()
|
|
target_dir = Path(template_dir) / name
|
|
|
|
if target_dir.exists():
|
|
click.echo(
|
|
click.style(
|
|
f"Template '{name}' already exists. Use --force to overwrite.",
|
|
fg="yellow",
|
|
)
|
|
)
|
|
return
|
|
|
|
import shutil
|
|
|
|
try:
|
|
shutil.copytree(path, target_dir)
|
|
click.echo(
|
|
click.style(
|
|
f"Template '{name}' saved to {target_dir}",
|
|
fg="green",
|
|
)
|
|
)
|
|
except Exception as e:
|
|
click.echo(click.style(f"Error saving template: {e}", fg="red"))
|
|
sys.exit(1)
|
|
|
|
|
|
@template.command(name="delete")
|
|
@click.argument("name")
|
|
@click.pass_context
|
|
def delete_template(ctx, name):
|
|
"""Delete a custom template."""
|
|
config = ctx.obj.get("config", Config())
|
|
template_dir = config.get_custom_templates_dir()
|
|
target_dir = Path(template_dir) / name
|
|
|
|
if not target_dir.exists():
|
|
click.echo(
|
|
click.style(f"Template '{name}' not found.", fg="yellow")
|
|
)
|
|
return
|
|
|
|
import shutil
|
|
|
|
try:
|
|
shutil.rmtree(target_dir)
|
|
click.echo(
|
|
click.style(
|
|
f"Template '{name}' deleted.",
|
|
fg="green",
|
|
)
|
|
)
|
|
except Exception as e:
|
|
click.echo(click.style(f"Error deleting template: {e}", fg="red"))
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command()
|
|
@click.option(
|
|
"--output",
|
|
"-o",
|
|
type=click.Path(),
|
|
default="project.yaml",
|
|
help="Output file path",
|
|
)
|
|
def init_config(output):
|
|
"""Generate a template configuration file."""
|
|
config_content = '''project:
|
|
author: "Your Name"
|
|
email: "your.email@example.com"
|
|
license: "MIT"
|
|
description: "A brief description of your project"
|
|
|
|
defaults:
|
|
language: "python"
|
|
ci: "github"
|
|
template: null
|
|
|
|
template_vars:
|
|
python:
|
|
version: "3.8+"
|
|
nodejs:
|
|
version: "16+"
|
|
'''
|
|
|
|
output_path = Path(output)
|
|
if output_path.exists():
|
|
click.echo(
|
|
click.style(
|
|
f"File '{output}' already exists. Use --force to overwrite.",
|
|
fg="yellow",
|
|
)
|
|
)
|
|
return
|
|
|
|
try:
|
|
output_path.write_text(config_content)
|
|
click.echo(
|
|
click.style(
|
|
f"Configuration file created at {output_path}",
|
|
fg="green",
|
|
)
|
|
)
|
|
except Exception as e:
|
|
click.echo(
|
|
click.style(f"Error creating configuration file: {e}", fg="red")
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command()
|
|
@click.argument("shells", nargs=-1, type=click.Choice(get_installed_shells()))
|
|
def completions(shells):
|
|
"""Install shell completion for psc."""
|
|
if not shells:
|
|
shells = get_installed_shells()
|
|
|
|
for shell in shells:
|
|
init(shell)
|
|
click.echo(f"Completion for {shell} installed.")
|
|
|
|
|
|
def _validate_project_name(name: str) -> bool:
|
|
"""Validate project name format."""
|
|
if not name:
|
|
return False
|
|
import re
|
|
|
|
return bool(re.match(r"^[a-z][a-z0-9-]*$", name))
|
|
|
|
|
|
def _to_kebab_case(name: str) -> str:
|
|
"""Convert project name to kebab-case."""
|
|
import re
|
|
|
|
name = name.lower().strip()
|
|
name = re.sub(r"[^a-z0-9]+", "-", name)
|
|
return name.strip("-")
|
|
|
|
|
|
def get_project_root():
|
|
"""Get the project root directory (where templates are stored)."""
|
|
return Path(__file__).parent.parent / "templates"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|