fix: correct pyproject.toml for project-scaffold-cli
- Fixed package name from auto-readme-cli to project-scaffold-cli - Fixed dependencies to match project-scaffold-cli requirements - Fixed linting import sorting issues in test files
This commit is contained in:
3
project_scaffold_cli/__init__.py
Normal file
3
project_scaffold_cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Project Scaffold CLI - Generate standardized project scaffolding."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
365
project_scaffold_cli/cli.py
Normal file
365
project_scaffold_cli/cli.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Main CLI entry point for Project Scaffold CLI."""
|
||||
|
||||
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()
|
||||
152
project_scaffold_cli/config.py
Normal file
152
project_scaffold_cli/config.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Configuration handling for Project Scaffold CLI."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration management for project scaffold CLI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
author: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
license: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
default_language: Optional[str] = None,
|
||||
default_ci: Optional[str] = None,
|
||||
default_template: Optional[str] = None,
|
||||
template_vars: Optional[Dict[str, Any]] = None,
|
||||
custom_templates_dir: Optional[str] = None,
|
||||
config_path: Optional[str] = None,
|
||||
):
|
||||
self.author = author
|
||||
self.email = email
|
||||
self.license = license
|
||||
self.description = description
|
||||
self.default_language = default_language
|
||||
self.default_ci = default_ci
|
||||
self.default_template = default_template
|
||||
self.template_vars = template_vars or {}
|
||||
self.custom_templates_dir = custom_templates_dir
|
||||
self.config_path = config_path
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Optional[str] = None) -> "Config":
|
||||
"""Load configuration from file."""
|
||||
if config_path:
|
||||
config_file = Path(config_path)
|
||||
if config_file.exists():
|
||||
return cls._from_file(config_file)
|
||||
|
||||
search_paths = [
|
||||
Path("project.yaml"),
|
||||
Path(".project-scaffoldrc"),
|
||||
Path.cwd() / "project.yaml",
|
||||
Path.cwd() / ".project-scaffoldrc",
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
return cls._from_file(path)
|
||||
|
||||
home_config = Path.home() / ".config" / "project-scaffold" / "config.yaml"
|
||||
if home_config.exists():
|
||||
return cls._from_file(home_config)
|
||||
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def _from_file(cls, path: Path) -> "Config":
|
||||
"""Load configuration from a YAML file."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML in {path}: {e}")
|
||||
|
||||
project_config = data.get("project", {})
|
||||
defaults = data.get("defaults", {})
|
||||
template_vars = data.get("template_vars", {})
|
||||
custom_templates_dir = data.get("custom_templates_dir")
|
||||
|
||||
return cls(
|
||||
author=project_config.get("author"),
|
||||
email=project_config.get("email"),
|
||||
license=project_config.get("license"),
|
||||
description=project_config.get("description"),
|
||||
default_language=defaults.get("language"),
|
||||
default_ci=defaults.get("ci"),
|
||||
default_template=defaults.get("template"),
|
||||
template_vars=template_vars,
|
||||
custom_templates_dir=custom_templates_dir,
|
||||
config_path=str(path),
|
||||
)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Save configuration to a YAML file."""
|
||||
data = {
|
||||
"project": {
|
||||
"author": self.author,
|
||||
"email": self.email,
|
||||
"license": self.license,
|
||||
"description": self.description,
|
||||
},
|
||||
"defaults": {
|
||||
"language": self.default_language,
|
||||
"ci": self.default_ci,
|
||||
"template": self.default_template,
|
||||
},
|
||||
"template_vars": self.template_vars,
|
||||
}
|
||||
|
||||
if self.custom_templates_dir:
|
||||
data["custom_templates_dir"] = self.custom_templates_dir
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
def get_template_dirs(self) -> List[str]:
|
||||
"""Get list of template directories to search."""
|
||||
dirs = []
|
||||
|
||||
if self.custom_templates_dir:
|
||||
dirs.append(self.custom_templates_dir)
|
||||
|
||||
home_templates = (
|
||||
Path.home()
|
||||
/ ".local"
|
||||
/ "share"
|
||||
/ "project-scaffold"
|
||||
/ "templates"
|
||||
)
|
||||
dirs.append(str(home_templates))
|
||||
|
||||
return dirs
|
||||
|
||||
def get_custom_templates_dir(self) -> str:
|
||||
"""Get the directory for custom templates."""
|
||||
if self.custom_templates_dir:
|
||||
return self.custom_templates_dir
|
||||
|
||||
custom_dir = (
|
||||
Path.home() / ".config" / "project-scaffold" / "templates"
|
||||
)
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(custom_dir)
|
||||
|
||||
def get_template_vars(self, language: str) -> Dict[str, Any]:
|
||||
"""Get template variables for a specific language."""
|
||||
return self.template_vars.get(language, {})
|
||||
|
||||
@property
|
||||
def ci(self) -> Optional[str]:
|
||||
"""Get default CI provider."""
|
||||
return self.default_ci
|
||||
|
||||
@property
|
||||
def template(self) -> Optional[str]:
|
||||
"""Get default template."""
|
||||
return self.default_template
|
||||
202
project_scaffold_cli/gitignore.py
Normal file
202
project_scaffold_cli/gitignore.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Gitignore generator for Project Scaffold CLI."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Set
|
||||
|
||||
|
||||
class GitignoreGenerator:
|
||||
"""Generate language-specific .gitignore files."""
|
||||
|
||||
SUPPORTED_LANGUAGES = ["python", "nodejs", "go", "rust"]
|
||||
|
||||
GITIGNORE_TEMPLATES = {
|
||||
"python": """__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
*.manifest
|
||||
*.spec
|
||||
""",
|
||||
"nodejs": """node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
build/
|
||||
.nyc_output/
|
||||
coverage/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
""",
|
||||
"go": """# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of go coverage
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# Vendor directory
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
""",
|
||||
"rust": """# Cargo
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Backup files
|
||||
*~
|
||||
#*#
|
||||
#*
|
||||
.backup
|
||||
|
||||
# Build artifacts
|
||||
*.rlib
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
""",
|
||||
}
|
||||
|
||||
ADDITIONAL_PATTERNS = {
|
||||
"python": """*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.local
|
||||
.vscode/
|
||||
.idea/
|
||||
""",
|
||||
"nodejs": """package-lock.json
|
||||
yarn.lock
|
||||
.env
|
||||
.env.local
|
||||
""",
|
||||
"go": """*.out
|
||||
*.test
|
||||
coverage.txt
|
||||
""",
|
||||
"rust": """**/*.rs.bk
|
||||
Cargo.lock
|
||||
""",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = self._get_templates_dir()
|
||||
|
||||
def _get_templates_dir(self) -> Path:
|
||||
"""Get the directory containing gitignore templates."""
|
||||
return Path(__file__).parent / "templates" / "gitignore"
|
||||
|
||||
def generate(
|
||||
self, language: str, output_path: Path, extra_patterns: Optional[Set[str]] = None
|
||||
) -> None:
|
||||
"""Generate a .gitignore file for the specified language."""
|
||||
if language not in self.SUPPORTED_LANGUAGES:
|
||||
raise ValueError(
|
||||
f"Unsupported language: {language}. "
|
||||
f"Supported: {', '.join(self.SUPPORTED_LANGUAGES)}"
|
||||
)
|
||||
|
||||
content = self.GITIGNORE_TEMPLATES.get(language, "")
|
||||
|
||||
extra = self.ADDITIONAL_PATTERNS.get(language, "")
|
||||
if extra:
|
||||
content += extra
|
||||
|
||||
if extra_patterns:
|
||||
content += "\n".join(sorted(extra_patterns)) + "\n"
|
||||
|
||||
content += "\n# Editor directories\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n"
|
||||
|
||||
output_path.write_text(content)
|
||||
|
||||
def generate_from_template(self, template_name: str, output_path: Path) -> None:
|
||||
"""Generate .gitignore from a template file."""
|
||||
template_path = self.templates_dir / template_name
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Gitignore template not found: {template_path}"
|
||||
)
|
||||
|
||||
content = template_path.read_text()
|
||||
output_path.write_text(content)
|
||||
|
||||
def list_available_templates(self) -> list[str]:
|
||||
"""List available gitignore templates."""
|
||||
if not self.templates_dir.exists():
|
||||
return []
|
||||
|
||||
return [
|
||||
f.stem
|
||||
for f in self.templates_dir.iterdir()
|
||||
if f.is_file() and f.suffix in (".gitignore", ".txt", "")
|
||||
]
|
||||
|
||||
def get_template_content(self, language: str) -> str:
|
||||
"""Get the raw template content for a language."""
|
||||
return self.GITIGNORE_TEMPLATES.get(language, "")
|
||||
|
||||
def append_patterns(self, gitignore_path: Path, patterns: Set[str]) -> None:
|
||||
"""Append additional patterns to an existing .gitignore file."""
|
||||
if gitignore_path.exists():
|
||||
content = gitignore_path.read_text()
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
else:
|
||||
content = ""
|
||||
|
||||
content += "\n" + "\n".join(sorted(patterns)) + "\n"
|
||||
gitignore_path.write_text(content)
|
||||
164
project_scaffold_cli/prompts.py
Normal file
164
project_scaffold_cli/prompts.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Interactive prompts for Project Scaffold CLI."""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import click
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class ProjectPrompts:
|
||||
"""Interactive prompt collection for project configuration."""
|
||||
|
||||
LICENSE_CHOICES = ["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "None"]
|
||||
|
||||
LANGUAGE_CHOICES = ["python", "nodejs", "go", "rust"]
|
||||
|
||||
CI_CHOICES = ["github", "gitlab", "none"]
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def collect_inputs(
|
||||
self,
|
||||
project_name: Optional[str],
|
||||
language: Optional[str],
|
||||
author: Optional[str],
|
||||
email: Optional[str],
|
||||
description: Optional[str],
|
||||
license: Optional[str],
|
||||
ci: Optional[str],
|
||||
template: Optional[str],
|
||||
) -> Tuple[
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
Optional[str],
|
||||
]:
|
||||
"""Collect all project configuration inputs interactively."""
|
||||
project_name = self._prompt_project_name(project_name)
|
||||
language = self._prompt_language(language)
|
||||
author = self._prompt_author(author)
|
||||
email = self._prompt_email(email)
|
||||
description = self._prompt_description(description)
|
||||
license = self._prompt_license(license)
|
||||
ci = self._prompt_ci(ci)
|
||||
|
||||
return (project_name, language, author, email, description, license, ci, template)
|
||||
|
||||
def _prompt_project_name(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for project name."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
return click.prompt(
|
||||
"Project name",
|
||||
type=str,
|
||||
default=default,
|
||||
value_proc=lambda x: x.strip() if x else x,
|
||||
)
|
||||
|
||||
def _prompt_language(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for programming language."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
return click.prompt(
|
||||
"Project language",
|
||||
type=click.Choice(self.LANGUAGE_CHOICES),
|
||||
default=self.config.default_language or "python",
|
||||
)
|
||||
|
||||
def _prompt_author(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for author name."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
if self.config.author:
|
||||
use_default = click.confirm(
|
||||
f"Use '{self.config.author}' as author?", default=True
|
||||
)
|
||||
if use_default:
|
||||
return self.config.author
|
||||
|
||||
return click.prompt(
|
||||
"Author name",
|
||||
type=str,
|
||||
default=self.config.author or "Your Name",
|
||||
)
|
||||
|
||||
def _prompt_email(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for author email."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
if self.config.email:
|
||||
use_default = click.confirm(
|
||||
f"Use '{self.config.email}' as email?", default=True
|
||||
)
|
||||
if use_default:
|
||||
return self.config.email
|
||||
|
||||
return click.prompt(
|
||||
"Author email",
|
||||
type=str,
|
||||
default=self.config.email or "your.email@example.com",
|
||||
)
|
||||
|
||||
def _prompt_description(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for project description."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
if self.config.description:
|
||||
use_default = click.confirm(
|
||||
f"Use '{self.config.description}' as description?", default=True
|
||||
)
|
||||
if use_default:
|
||||
return self.config.description
|
||||
|
||||
return click.prompt(
|
||||
"Project description",
|
||||
type=str,
|
||||
default=self.config.description or "A new project",
|
||||
)
|
||||
|
||||
def _prompt_license(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for license type."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
if self.config.license:
|
||||
use_default = click.confirm(
|
||||
f"Use '{self.config.license}' as license?", default=True
|
||||
)
|
||||
if use_default:
|
||||
return self.config.license
|
||||
|
||||
return click.prompt(
|
||||
"License",
|
||||
type=click.Choice(self.LICENSE_CHOICES),
|
||||
default=self.config.license or "MIT",
|
||||
)
|
||||
|
||||
def _prompt_ci(self, default: Optional[str]) -> Optional[str]:
|
||||
"""Prompt for CI/CD provider."""
|
||||
if default:
|
||||
return default
|
||||
|
||||
if self.config.default_ci:
|
||||
use_default = click.confirm(
|
||||
f"Use '{self.config.default_ci}' for CI/CD?", default=True
|
||||
)
|
||||
if use_default:
|
||||
return self.config.default_ci
|
||||
|
||||
return click.prompt(
|
||||
"CI/CD provider",
|
||||
type=click.Choice(self.CI_CHOICES),
|
||||
default=self.config.default_ci or "none",
|
||||
)
|
||||
199
project_scaffold_cli/template_engine.py
Normal file
199
project_scaffold_cli/template_engine.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Template engine using Jinja2 for project scaffolding."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jinja2 import (
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
TemplateSyntaxError,
|
||||
)
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class TemplateEngine:
|
||||
"""Jinja2 template rendering engine for project scaffolding."""
|
||||
|
||||
SUPPORTED_LANGUAGES = ["python", "nodejs", "go", "rust"]
|
||||
|
||||
def __init__(self, template_path: Optional[str] = None, config: Optional[Config] = None):
|
||||
self.template_path = template_path
|
||||
self.config = config or Config()
|
||||
self._env: Optional[Environment] = None
|
||||
|
||||
def _get_environment(self) -> Environment:
|
||||
"""Get or create the Jinja2 environment."""
|
||||
if self._env is not None:
|
||||
return self._env
|
||||
|
||||
if self.template_path and not os.path.isabs(self.template_path):
|
||||
search_dirs = self.config.get_template_dirs()
|
||||
search_dirs.insert(0, str(Path.cwd()))
|
||||
self._env = Environment(
|
||||
loader=FileSystemLoader(search_dirs),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
elif self.template_path:
|
||||
template_dir = Path(self.template_path).parent
|
||||
self._env = Environment(
|
||||
loader=FileSystemLoader([str(template_dir)]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
else:
|
||||
project_root = self._get_project_root()
|
||||
self._env = Environment(
|
||||
loader=FileSystemLoader(str(project_root)),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
return self._env
|
||||
|
||||
def _get_project_root(self) -> Path:
|
||||
"""Get the project root directory for built-in templates."""
|
||||
return Path(__file__).parent / "templates"
|
||||
|
||||
def _validate_template(self, template_name: str) -> bool:
|
||||
"""Validate that a template exists."""
|
||||
env = self._get_environment()
|
||||
try:
|
||||
env.get_template(template_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _render_template(
|
||||
self, template_name: str, context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Render a single template with context."""
|
||||
env = self._get_environment()
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**context)
|
||||
|
||||
def render_language_template(
|
||||
self, language: str, context: Dict[str, Any], output_dir: Path
|
||||
) -> None:
|
||||
"""Render all templates for a language."""
|
||||
if language not in self.SUPPORTED_LANGUAGES:
|
||||
raise ValueError(
|
||||
f"Unsupported language: {language}. "
|
||||
f"Supported: {', '.join(self.SUPPORTED_LANGUAGES)}"
|
||||
)
|
||||
|
||||
self._get_environment()
|
||||
language_dir = language
|
||||
|
||||
try:
|
||||
project_root = self._get_project_root() / language_dir
|
||||
for root, dirs, files in os.walk(project_root):
|
||||
rel_path = Path(root).relative_to(project_root)
|
||||
|
||||
rel_path_str = rel_path.as_posix()
|
||||
rel_path_str = rel_path_str.replace("{{project_slug}}", context.get("project_slug", ""))
|
||||
rel_path_str = rel_path_str.replace("{{ project_name }}", context.get("project_name", ""))
|
||||
|
||||
dest_dir = output_dir / rel_path_str
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file in files:
|
||||
if file.endswith((".j2", ".jinja2")):
|
||||
template_path = (
|
||||
Path(root) / file
|
||||
).relative_to(project_root).as_posix()
|
||||
dest_file = Path(dest_dir) / file[:-3]
|
||||
|
||||
try:
|
||||
content = self._render_template(
|
||||
language_dir + "/" + template_path, context
|
||||
)
|
||||
dest_file.write_text(content)
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValueError(
|
||||
f"Template syntax error in {template_path}: {e}"
|
||||
)
|
||||
else:
|
||||
src_file = Path(root) / file
|
||||
dest_file = Path(dest_dir) / file
|
||||
dest_file.write_bytes(src_file.read_bytes())
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error rendering language template: {e}")
|
||||
|
||||
def render_project(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
output_dir: Path,
|
||||
template: str,
|
||||
) -> None:
|
||||
"""Render a custom project template."""
|
||||
if not self._validate_template(template):
|
||||
raise ValueError(f"Template not found: {template}")
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(
|
||||
self._get_environment().loader.searchpath[0]
|
||||
):
|
||||
for pattern in self._get_environment().loader.get_files(
|
||||
template
|
||||
):
|
||||
pass
|
||||
|
||||
env = self._get_environment()
|
||||
base_template = env.get_template(template)
|
||||
|
||||
for item in base_template.list_templates():
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error rendering project template: {e}")
|
||||
|
||||
def render_ci_template(
|
||||
self,
|
||||
ci_provider: str,
|
||||
context: Dict[str, Any],
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Render CI/CD template for a provider."""
|
||||
ci_templates = {
|
||||
"github": "ci/.github/workflows/ci.yml.j2",
|
||||
"gitlab": "ci/.gitlab-ci.yml.j2",
|
||||
}
|
||||
|
||||
if ci_provider not in ci_templates:
|
||||
raise ValueError(
|
||||
f"Unsupported CI provider: {ci_provider}. "
|
||||
f"Supported: {', '.join(ci_templates.keys())}"
|
||||
)
|
||||
|
||||
self._get_environment()
|
||||
template_name = ci_templates[ci_provider]
|
||||
|
||||
try:
|
||||
content = self._render_template(template_name, context)
|
||||
|
||||
if ci_provider == "github":
|
||||
workflow_dir = output_dir / ".github" / "workflows"
|
||||
workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = workflow_dir / "ci.yml"
|
||||
else:
|
||||
output_file = output_dir / ".gitlab-ci.yml"
|
||||
|
||||
output_file.write_text(content)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error rendering CI template: {e}")
|
||||
|
||||
def get_template_list(self) -> list[str]:
|
||||
"""Get list of available templates."""
|
||||
env = self._get_environment()
|
||||
return env.list_templates()
|
||||
|
||||
def validate_context(self, context: Dict[str, Any]) -> list[str]:
|
||||
"""Validate that all required context variables are provided."""
|
||||
required = ["project_name", "author"]
|
||||
missing = [var for var in required if var not in context or not context[var]]
|
||||
return missing
|
||||
85
project_scaffold_cli/templates/ci/.github/workflows/ci.yml.j2
vendored
Normal file
85
project_scaffold_cli/templates/ci/.github/workflows/ci.yml.j2
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 black
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 .
|
||||
|
||||
- name: Format with black
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -v --cov=.{% raw %}{{ project_slug }}{% endraw %} --cov-report=xml
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: {% raw %}${{ secrets.PYPI_API_TOKEN }}{% endraw %}
|
||||
43
project_scaffold_cli/templates/ci/.gitlab-ci.yml.j2
Normal file
43
project_scaffold_cli/templates/ci/.gitlab-ci.yml.j2
Normal file
@@ -0,0 +1,43 @@
|
||||
stages:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
image: python:3.11
|
||||
script:
|
||||
- pip install flake8 black
|
||||
- flake8 .
|
||||
- black --check .
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: python:3.11
|
||||
script:
|
||||
- pip install -e ".[dev]"
|
||||
- pytest -v --cov=.{{ project_slug }} --cov-report=html
|
||||
artifacts:
|
||||
reports:
|
||||
junit: test-results.xml
|
||||
paths:
|
||||
- htmlcov/
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: python:3.11
|
||||
script:
|
||||
- pip install build
|
||||
- python -m build
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
only:
|
||||
- main
|
||||
- tags
|
||||
27
project_scaffold_cli/templates/go/README.md.j2
Normal file
27
project_scaffold_cli/templates/go/README.md.j2
Normal file
@@ -0,0 +1,27 @@
|
||||
# {{ project_name }}
|
||||
|
||||
{{ description }}
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/{{ author|replace(' ', '-') }}/{{ project_slug }}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"{{ author|replace(' ', '-') }}/{{ project_slug }}"
|
||||
)
|
||||
|
||||
func main() {
|
||||
{{ project_slug }}.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
{{ license }}
|
||||
5
project_scaffold_cli/templates/go/go.mod.j2
Normal file
5
project_scaffold_cli/templates/go/go.mod.j2
Normal file
@@ -0,0 +1,5 @@
|
||||
module {{ project_slug }}
|
||||
|
||||
go 1.20
|
||||
|
||||
require github.com/{{ author|replace(' ', '-') }}/{{ project_slug }} v1.0.0
|
||||
9
project_scaffold_cli/templates/go/main.go.j2
Normal file
9
project_scaffold_cli/templates/go/main.go.j2
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Welcome to {{ project_name }}!")
|
||||
}
|
||||
21
project_scaffold_cli/templates/nodejs/README.md.j2
Normal file
21
project_scaffold_cli/templates/nodejs/README.md.j2
Normal file
@@ -0,0 +1,21 @@
|
||||
# {{ project_name }}
|
||||
|
||||
{{ description }}
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
const {{ project_slug }} = require('./index.js');
|
||||
|
||||
{{ project_slug }}();
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
{{ license }}
|
||||
12
project_scaffold_cli/templates/nodejs/index.js.j2
Normal file
12
project_scaffold_cli/templates/nodejs/index.js.j2
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* {{ project_name }}
|
||||
* {{ description }}
|
||||
*/
|
||||
|
||||
function main() {
|
||||
console.log('Welcome to {{ project_name }}!');
|
||||
}
|
||||
|
||||
main();
|
||||
1
project_scaffold_cli/templates/python/README.md.j2
Normal file
1
project_scaffold_cli/templates/python/README.md.j2
Normal file
@@ -0,0 +1 @@
|
||||
{{ description }}
|
||||
48
project_scaffold_cli/templates/python/setup.py.j2
Normal file
48
project_scaffold_cli/templates/python/setup.py.j2
Normal file
@@ -0,0 +1,48 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
with open("README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="{{ project_slug }}",
|
||||
version="1.0.0",
|
||||
author="{{ author }}",
|
||||
author_email="{{ email }}",
|
||||
description="{{ description }}",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/{{ author|replace(' ', '-') }}/{{ project_slug }}",
|
||||
packages=find_packages(),
|
||||
python_requires=">=3.8",
|
||||
install_requires=[
|
||||
{% if template_vars and template_vars.python %}
|
||||
{% for dep in template_vars.python.get('dependencies', []) %}
|
||||
"{{ dep }}",
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% endif %}
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"black>=23.0",
|
||||
"flake8>=6.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"{{ project_slug }}={{ project_slug }}.cli:main",
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
{{ project_slug }}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Main CLI entry point."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
def main():
|
||||
"""Main entry point for {{ project_name }}."""
|
||||
click.echo("Welcome to {{ project_name }}!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Test module for {{ project_name }}."""
|
||||
|
||||
import pytest
|
||||
|
||||
from {{ project_slug }} import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
"""Test version is a string."""
|
||||
assert isinstance(__version__, str)
|
||||
|
||||
|
||||
def test_example():
|
||||
"""Example test."""
|
||||
assert True
|
||||
9
project_scaffold_cli/templates/rust/Cargo.toml.j2
Normal file
9
project_scaffold_cli/templates/rust/Cargo.toml.j2
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "{{ project_slug }}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["{{ author }} <{{ email }}>"]
|
||||
description = "{{ description }}"
|
||||
license = "{{ license }}"
|
||||
|
||||
[dependencies]
|
||||
23
project_scaffold_cli/templates/rust/README.md.j2
Normal file
23
project_scaffold_cli/templates/rust/README.md.j2
Normal file
@@ -0,0 +1,23 @@
|
||||
# {{ project_name }}
|
||||
|
||||
{{ description }}
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cargo add {{ project_slug }}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use {{ project_slug }}::run;
|
||||
|
||||
fn main() {
|
||||
run();
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
{{ license }}
|
||||
3
project_scaffold_cli/templates/rust/src/main.rs.j2
Normal file
3
project_scaffold_cli/templates/rust/src/main.rs.j2
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Welcome to {{ project_name }}!");
|
||||
}
|
||||
Reference in New Issue
Block a user