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")