BoilerWorks

boilerworks / boilerworks / generator.py
Blame History Raw 355 lines
1
"""Project generator — clone → render → rename → git init."""
2
3
from __future__ import annotations
4
5
import shutil
6
import subprocess
7
import sys
8
from pathlib import Path
9
10
from rich.progress import Progress, SpinnerColumn, TextColumn
11
12
from boilerworks.console import console, print_error, print_info, print_success
13
from boilerworks.manifest import BoilerworksManifest
14
from boilerworks.registry import Registry
15
from boilerworks.renderer import build_replacements, rename_boilerworks_paths, render_directory
16
17
_OPS_REPO = "ConflictHQ/boilerworks-opscode"
18
19
_NEXT_STEPS_TEMPLATE = """[bold]Project created at:[/bold] {project_dir}
20
21
[bold]Next steps:[/bold]
22
cd {project}
23
docker compose up -d
24
# Visit http://localhost:3000
25
26
[bold]Documentation:[/bold]
27
bootstrap.md — conventions
28
CLAUDE.md — AI agent guide
29
"""
30
31
_NEXT_STEPS_OPS_STANDARD = """[bold]Infrastructure repo:[/bold] {ops_dir}
32
33
[bold]Infrastructure next steps:[/bold]
34
cd {ops_name}
35
# Edit {cloud}/config.env to review settings
36
./run.sh bootstrap {cloud} dev
37
./run.sh plan {cloud} dev
38
39
[bold]Documentation:[/bold]
40
bootstrap.md — Terraform conventions and setup
41
"""
42
43
_NEXT_STEPS_OPS_OMNI = """[bold]Infrastructure (ops/) is inside the app repo[/bold]
44
45
[bold]Infrastructure next steps:[/bold]
46
cd {project}/ops
47
# Edit {cloud}/config.env to review settings
48
./run.sh bootstrap {cloud} dev
49
./run.sh plan {cloud} dev
50
51
[bold]Documentation:[/bold]
52
ops/bootstrap.md — Terraform conventions and setup
53
"""
54
55
56
def _clone_repo(repo: str, dest: Path) -> None:
57
"""Clone repo to dest. Tries SSH first, falls back to HTTPS."""
58
ssh_url = f"[email protected]:{repo}.git"
59
https_url = f"https://github.com/{repo}.git"
60
61
result = subprocess.run(
62
["git", "clone", "--depth", "1", ssh_url, str(dest)],
63
capture_output=True,
64
text=True,
65
)
66
if result.returncode != 0:
67
# SSH failed — try HTTPS
68
result = subprocess.run(
69
["git", "clone", "--depth", "1", https_url, str(dest)],
70
capture_output=True,
71
text=True,
72
)
73
if result.returncode != 0:
74
raise RuntimeError(
75
f"Failed to clone {repo}.\n"
76
f"SSH error: {result.stderr.strip()}\n"
77
"Ensure you have GitHub access (SSH key or gh auth login)."
78
)
79
80
81
def _remove_git_dir(project_dir: Path) -> None:
82
git_dir = project_dir / ".git"
83
if git_dir.exists():
84
shutil.rmtree(git_dir)
85
86
87
def _git_init(project_dir: Path, family: str) -> None:
88
"""Initialise a fresh git repo and make the initial commit."""
89
subprocess.run(["git", "init"], cwd=project_dir, capture_output=True)
90
subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
91
subprocess.run(
92
["git", "commit", "-m", f"Initial project from boilerworks-{family}"],
93
cwd=project_dir,
94
capture_output=True,
95
)
96
97
98
def _git_add_commit(project_dir: Path, message: str) -> None:
99
"""Stage all and commit in an existing repo."""
100
subprocess.run(["git", "add", "."], cwd=project_dir, capture_output=True)
101
subprocess.run(
102
["git", "commit", "-m", message],
103
cwd=project_dir,
104
capture_output=True,
105
)
106
107
108
def _write_ops_config(ops_dir: Path, cloud: str, project: str, region: str | None, domain: str | None) -> None:
109
"""Write {cloud}/config.env with project-specific values."""
110
config_path = ops_dir / cloud / "config.env"
111
if not config_path.exists():
112
return
113
114
region_default = {"aws": "us-east-1", "gcp": "us-central1", "azure": "eastus"}.get(cloud, "us-east-1")
115
effective_region = region or region_default
116
117
lines = [
118
"# -----------------------------------------------------------------------------",
119
f"# {project} — {cloud.upper()} Configuration",
120
"#",
121
"# Shared configuration for all environments. Sourced by run.sh and bootstrap.sh.",
122
"# Review and update before running bootstrap.",
123
"# -----------------------------------------------------------------------------",
124
"",
125
"# Project slug — used in all resource naming",
126
f'PROJECT="{project}"',
127
"",
128
f"# {cloud.upper()} region",
129
]
130
131
if cloud == "aws":
132
lines.append(f'AWS_REGION="{effective_region}"')
133
elif cloud == "gcp":
134
lines.append(f'GCP_REGION="{effective_region}"')
135
elif cloud == "azure":
136
lines.append(f'AZURE_REGION="{effective_region}"')
137
138
lines += [
139
"",
140
"# Owner tag",
141
f'OWNER="{project}"',
142
]
143
144
if domain:
145
lines += [
146
"",
147
"# Domain",
148
f'DOMAIN="{domain}"',
149
]
150
151
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
152
153
154
def _clone_and_render_ops(
155
project: str,
156
cloud: str,
157
region: str | None,
158
domain: str | None,
159
dest: Path,
160
progress: Progress,
161
) -> None:
162
"""Clone boilerworks-opscode, render, rename, and configure for this project."""
163
task = progress.add_task(f"Cloning {_OPS_REPO}…", total=None)
164
try:
165
_clone_repo(_OPS_REPO, dest)
166
except RuntimeError as exc:
167
progress.stop()
168
print_error(str(exc))
169
sys.exit(1)
170
progress.remove_task(task)
171
172
task = progress.add_task("Removing .git/ from ops…", total=None)
173
_remove_git_dir(dest)
174
progress.remove_task(task)
175
176
task = progress.add_task("Applying ops substitutions…", total=None)
177
replacements = build_replacements(project)
178
render_directory(dest, replacements)
179
progress.remove_task(task)
180
181
task = progress.add_task("Renaming ops paths…", total=None)
182
rename_boilerworks_paths(dest, project)
183
progress.remove_task(task)
184
185
task = progress.add_task("Configuring ops for your project…", total=None)
186
_write_ops_config(dest, cloud, project, region, domain)
187
progress.remove_task(task)
188
189
190
def _dry_run_plan(manifest: BoilerworksManifest, output_dir: Path) -> None:
191
"""Print what would happen without doing it."""
192
registry = Registry()
193
template = registry.get_by_name(manifest.family)
194
repo = template.repo if template else f"ConflictHQ/boilerworks-{manifest.family}"
195
project_dir = output_dir / manifest.project
196
197
console.print("\n[bold]Dry run — no files will be written[/bold]\n")
198
steps = [
199
f"[dim]1.[/dim] Clone [cyan]{repo}[/cyan]",
200
"[dim]2.[/dim] Remove .git/ from cloned directory",
201
f"[dim]3.[/dim] Replace all 'boilerworks' → '[bold]{manifest.project}[/bold]' (case-variant)",
202
"[dim]4.[/dim] Rename files/dirs containing 'boilerworks'",
203
"[dim]5.[/dim] Update CLAUDE.md and README.md headers",
204
f"[dim]6.[/dim] git init + initial commit in [bold]{project_dir}[/bold]",
205
]
206
if manifest.ops and manifest.cloud:
207
ops_dest = f"{project_dir}/ops/" if manifest.topology == "omni" else str(output_dir / f"{manifest.project}-ops")
208
steps += [
209
f"[dim]7.[/dim] Clone [cyan]{_OPS_REPO}[/cyan] → [bold]{ops_dest}[/bold]",
210
f"[dim]8.[/dim] Render + rename ops files (boilerworks → {manifest.project})",
211
f"[dim]9.[/dim] Write [cyan]{manifest.cloud}/config.env[/cyan] (project, region, domain)",
212
]
213
if manifest.topology == "omni":
214
steps.append("[dim]10.[/dim] Recommit app repo to include ops/")
215
else:
216
steps.append(f"[dim]10.[/dim] git init ops repo in [bold]{ops_dest}[/bold]")
217
218
if manifest.mobile:
219
steps.append(f"[dim]{len(steps) + 1}.[/dim] Clone mobile template")
220
221
for step in steps:
222
console.print(f" {step}")
223
console.print(f"\n[dim]Output directory:[/dim] {project_dir}")
224
225
226
def generate_from_manifest(
227
manifest_path: str | None,
228
output_dir: str = ".",
229
dry_run: bool = False,
230
) -> None:
231
"""Entry point called from the CLI."""
232
manifest_file = Path(manifest_path) if manifest_path else Path("boilerworks.yaml")
233
234
if not manifest_file.exists():
235
print_error(f"Manifest not found: {manifest_file}")
236
print_info("Run [bold]boilerworks setup[/bold] first to create boilerworks.yaml")
237
sys.exit(1)
238
239
try:
240
manifest = BoilerworksManifest.from_file(manifest_file)
241
except Exception as exc:
242
print_error(f"Invalid manifest: {exc}")
243
sys.exit(1)
244
245
out = Path(output_dir).resolve()
246
247
if dry_run:
248
_dry_run_plan(manifest, out)
249
return
250
251
_generate(manifest, out)
252
253
254
def _generate(manifest: BoilerworksManifest, output_dir: Path) -> None:
255
registry = Registry()
256
template = registry.get_by_name(manifest.family)
257
if template is None:
258
print_error(f"Unknown template family: {manifest.family}")
259
sys.exit(1)
260
261
project_dir = output_dir / manifest.project
262
263
if project_dir.exists():
264
print_error(f"Directory already exists: {project_dir}")
265
print_info("Delete it first or choose a different output directory.")
266
sys.exit(1)
267
268
replacements = build_replacements(manifest.project)
269
270
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
271
# ── App template ──────────────────────────────────────────────────────
272
task = progress.add_task(f"Cloning {template.repo}…", total=None)
273
try:
274
_clone_repo(template.repo, project_dir)
275
except RuntimeError as exc:
276
progress.stop()
277
print_error(str(exc))
278
sys.exit(1)
279
progress.remove_task(task)
280
281
task = progress.add_task("Removing .git/…", total=None)
282
_remove_git_dir(project_dir)
283
progress.remove_task(task)
284
285
task = progress.add_task("Applying template substitutions…", total=None)
286
render_directory(project_dir, replacements)
287
progress.remove_task(task)
288
289
task = progress.add_task("Renaming paths…", total=None)
290
rename_boilerworks_paths(project_dir, manifest.project)
291
progress.remove_task(task)
292
293
# ── Ops (infra-as-code) ───────────────────────────────────────────────
294
ops_dir: Path | None = None
295
if manifest.ops and manifest.cloud:
296
if manifest.topology == "omni":
297
# Ops lives inside the app repo
298
ops_dir = project_dir / "ops"
299
_clone_and_render_ops(
300
manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
301
)
302
# git init + commit includes ops/
303
task = progress.add_task("Initialising git repository (app + ops)…", total=None)
304
_git_init(project_dir, manifest.family)
305
progress.remove_task(task)
306
else:
307
# Standard: app and ops are sibling repos
308
task = progress.add_task("Initialising app git repository…", total=None)
309
_git_init(project_dir, manifest.family)
310
progress.remove_task(task)
311
312
ops_dir = output_dir / f"{manifest.project}-ops"
313
if ops_dir.exists():
314
print_error(f"Ops directory already exists: {ops_dir}")
315
print_info("Delete it first or choose a different output directory.")
316
sys.exit(1)
317
_clone_and_render_ops(
318
manifest.project, manifest.cloud, manifest.region, manifest.domain, ops_dir, progress
319
)
320
task = progress.add_task("Initialising ops git repository…", total=None)
321
_git_init(ops_dir, "opscode")
322
progress.remove_task(task)
323
else:
324
# No ops — just init app
325
task = progress.add_task("Initialising git repository…", total=None)
326
_git_init(project_dir, manifest.family)
327
progress.remove_task(task)
328
329
print_success(f"Project [bold]{manifest.project}[/bold] created at [bold]{project_dir}[/bold]")
330
331
from rich.panel import Panel
332
333
next_steps = _NEXT_STEPS_TEMPLATE.format(project=manifest.project, project_dir=project_dir)
334
335
if manifest.ops and manifest.cloud and ops_dir is not None:
336
if manifest.topology == "omni":
337
next_steps += _NEXT_STEPS_OPS_OMNI.format(
338
project=manifest.project,
339
cloud=manifest.cloud,
340
)
341
else:
342
next_steps += _NEXT_STEPS_OPS_STANDARD.format(
343
ops_dir=ops_dir,
344
ops_name=f"{manifest.project}-ops",
345
cloud=manifest.cloud,
346
)
347
348
console.print(
349
Panel(
350
next_steps.strip(),
351
title="[bold green]Done![/bold green]",
352
border_style="green",
353
)
354
)
355

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button