Plain is headed towards 1.0! Subscribe for development updates →

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