v0.146.0
  1import random
  2import re
  3import shutil
  4import subprocess
  5from pathlib import Path
  6
  7import click
  8
  9STARTER_REPOS = {
 10    "app": "https://github.com/dropseed/plain-starter-app",
 11    "bare": "https://github.com/dropseed/plain-starter-bare",
 12}
 13
 14# Placeholder values used in the starter templates
 15TEMPLATE_CONTAINER_NAME = "app-postgres"
 16TEMPLATE_DB_PORT = "54321"
 17
 18
 19@click.command()
 20@click.argument("project_name")
 21@click.option(
 22    "--type",
 23    "starter_type",
 24    type=click.Choice(["app", "bare"]),
 25    default="app",
 26    help="Type of starter template to use",
 27)
 28@click.option(
 29    "--no-install",
 30    is_flag=True,
 31    help="Skip running ./scripts/install after setup",
 32)
 33def cli(project_name: str, starter_type: str, no_install: bool) -> None:
 34    """Bootstrap a new Plain project from starter templates"""
 35    if project_name == ".":
 36        project_path = Path.cwd()
 37        display_name = project_path.name
 38    else:
 39        project_path = Path.cwd() / project_name
 40        display_name = project_name
 41
 42    if project_name == ".":
 43        if any(project_path.iterdir()):
 44            click.secho(
 45                "Error: Current directory is not empty",
 46                fg="red",
 47                err=True,
 48            )
 49            raise click.Abort()
 50    else:
 51        if project_path.exists():
 52            click.secho(
 53                f"Error: Directory '{project_name}' already exists",
 54                fg="red",
 55                err=True,
 56            )
 57            raise click.Abort()
 58
 59    # Clone the starter repository
 60    repo_url = STARTER_REPOS[starter_type]
 61    click.secho(f"Cloning {starter_type} starter template...", dim=True)
 62
 63    try:
 64        subprocess.run(
 65            ["git", "clone", "--depth", "1", repo_url, str(project_path)],
 66            check=True,
 67            capture_output=True,
 68        )
 69    except subprocess.CalledProcessError as e:
 70        click.secho(
 71            f"Error cloning repository: {e.stderr.decode()}", fg="red", err=True
 72        )
 73        raise click.Abort()
 74
 75    # Remove .git directory and reinitialize
 76    click.secho("Initializing new git repository...", dim=True)
 77    git_dir = project_path / ".git"
 78    if git_dir.exists():
 79        shutil.rmtree(git_dir)
 80
 81    subprocess.run(
 82        ["git", "init"],
 83        cwd=project_path,
 84        check=True,
 85        capture_output=True,
 86    )
 87
 88    # Configure project-specific names and ports
 89    click.secho("Configuring project...", dim=True)
 90    db_port = str(random.randint(50000, 59999))
 91
 92    pyproject_path = project_path / "pyproject.toml"
 93    if pyproject_path.exists():
 94        content = pyproject_path.read_text()
 95        content = re.sub(
 96            r'^name\s*=\s*["\'].*?["\']',
 97            f'name = "{display_name}"',
 98            content,
 99            count=1,
100            flags=re.MULTILINE,
101        )
102        content = content.replace(TEMPLATE_CONTAINER_NAME, f"{display_name}-postgres")
103        content = content.replace(TEMPLATE_DB_PORT, db_port)
104        pyproject_path.write_text(content)
105
106    for env_file in [project_path / ".env", project_path / ".env.example"]:
107        if env_file.exists():
108            content = env_file.read_text()
109            content = content.replace(TEMPLATE_DB_PORT, db_port)
110            env_file.write_text(content)
111
112    # Run install script unless --no-install
113    if not no_install:
114        install_script = project_path / "scripts" / "install"
115        if install_script.exists():
116            click.echo(
117                click.style("Running installation:", bold=True)
118                + click.style(" ./scripts/install", dim=True)
119            )
120            try:
121                subprocess.run(
122                    ["./scripts/install"],
123                    cwd=project_path,
124                    check=True,
125                )
126            except subprocess.CalledProcessError as e:
127                click.secho(
128                    f"Warning: Installation script failed with exit code {e.returncode}",
129                    fg="yellow",
130                    err=True,
131                )
132                click.secho(
133                    "You may need to run './scripts/install' manually.",
134                    fg="yellow",
135                    err=True,
136                )
137
138    # Success message
139    click.echo()
140    click.secho(
141        f"✓ Project '{display_name}' created successfully!", fg="green", bold=True
142    )
143    click.echo()
144    click.secho("Next steps:", bold=True)
145    if project_name != ".":
146        click.secho(f"  cd {project_name}")
147    if no_install:
148        click.secho("  ./scripts/install")
149    click.secho("  uv run plain dev")