ghostbuild 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ghostbuild/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """GhostBuild: reproducible build verification. Build it twice. Know what changed."""
2
+
3
+ __version__ = "0.1.0"
ghostbuild/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m ghostbuild``."""
2
+
3
+ from ghostbuild.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,19 @@
1
+ """Registry of ecosystem adapters.
2
+
3
+ To add an ecosystem, implement ``BuildAdapter`` and append an instance here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from ghostbuild.adapters.base import BuildAdapter
9
+ from ghostbuild.adapters.python import PythonAdapter
10
+
11
+ ADAPTERS: tuple[BuildAdapter, ...] = (PythonAdapter(),)
12
+
13
+
14
+ def get_adapters() -> tuple[BuildAdapter, ...]:
15
+ """Return all registered adapters, in detection order."""
16
+ return ADAPTERS
17
+
18
+
19
+ __all__ = ["ADAPTERS", "BuildAdapter", "PythonAdapter", "get_adapters"]
@@ -0,0 +1,68 @@
1
+ """The interface an ecosystem adapter implements.
2
+
3
+ The engine only talks to this interface. To add an ecosystem (Node.js, Cargo,
4
+ Go, ...), subclass ``BuildAdapter`` and register it in ``adapters/__init__.py``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from pathlib import Path
11
+
12
+ from ghostbuild.config import Config
13
+ from ghostbuild.models import ProjectInfo
14
+
15
+
16
+ class BuildAdapter(ABC):
17
+ """Knows how to recognise, inspect and build one kind of project."""
18
+
19
+ #: Short identifier used in reports and configuration, e.g. ``"python"``.
20
+ id: str
21
+ #: Human-readable language name.
22
+ language: str
23
+
24
+ @abstractmethod
25
+ def detect(self, project_dir: Path) -> bool:
26
+ """Return True if this adapter can handle the project. Must not run project code."""
27
+
28
+ @abstractmethod
29
+ def inspect(self, project_dir: Path) -> ProjectInfo:
30
+ """Read project metadata from files. Must not run project code."""
31
+
32
+ @abstractmethod
33
+ def available_tools(self) -> dict[str, str | None]:
34
+ """Map tool name to a version/path, or ``None`` if it is not available."""
35
+
36
+ @abstractmethod
37
+ def missing_prerequisites(self, config: Config) -> list[str]:
38
+ """Return human-readable problems that would stop a local build."""
39
+
40
+ @abstractmethod
41
+ def build_command(self, source_dir: Path, output_dir: Path, config: Config) -> list[str]:
42
+ """Return the argument list (never a shell string) for a local build."""
43
+
44
+ @abstractmethod
45
+ def has_custom_build_command(self, config: Config) -> bool:
46
+ """Return True if the user overrode the default build command."""
47
+
48
+ @abstractmethod
49
+ def container_image(self, config: Config) -> str:
50
+ """Return the container image used for an independent rebuild."""
51
+
52
+ @abstractmethod
53
+ def container_script(self, config: Config) -> str:
54
+ """Return the ``sh`` script run inside the container.
55
+
56
+ The container mounts the prepared source read-only at ``/src`` and a
57
+ writable output directory at ``/out``. The script must write artifacts
58
+ to ``/out`` and print one ``GHOSTBUILD_ENV <json>`` line describing the
59
+ container environment.
60
+ """
61
+
62
+ def describe_build_tool(self) -> str | None:
63
+ """Return the local build tool and version, if known."""
64
+ return None
65
+
66
+ def collect_warnings(self, project: ProjectInfo) -> list[str]:
67
+ """Return reproducibility risks visible from project metadata."""
68
+ return []
@@ -0,0 +1,232 @@
1
+ """Python build adapter: PEP 517 projects built with ``python -m build``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ import importlib.metadata
7
+ import importlib.util
8
+ import platform
9
+ import re
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ghostbuild.adapters.base import BuildAdapter
16
+ from ghostbuild.config import Config, load_toml
17
+ from ghostbuild.errors import ConfigError
18
+ from ghostbuild.models import ProjectInfo
19
+
20
+ # build-backend -> friendly build system name
21
+ _BACKENDS = {
22
+ "setuptools.build_meta": "setuptools",
23
+ "setuptools.build_meta:__legacy__": "setuptools",
24
+ "hatchling.build": "hatch",
25
+ "poetry.core.masonry.api": "poetry",
26
+ "pdm.backend": "pdm",
27
+ "pdm.pep517.api": "pdm",
28
+ "flit_core.buildapi": "flit",
29
+ "maturin": "maturin",
30
+ "scikit_build_core.build": "scikit-build-core",
31
+ }
32
+ _LEGACY_BACKEND = "setuptools.build_meta:__legacy__"
33
+ _LEGACY_REQUIRES = ["setuptools>=40.8.0", "wheel"]
34
+ _CONFIG_FILES = (
35
+ "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "hatch.toml",
36
+ "poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "tox.ini", "ghostbuild.toml",
37
+ ) # fmt: skip
38
+ _LOCK_FILES = ("poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock")
39
+ _TOOLS_ON_PATH = ("hatch", "poetry", "pdm", "uv")
40
+
41
+ _CONTAINER_SCRIPT = """\
42
+ set -eu
43
+ export HOME=/tmp/ghostbuild-home
44
+ mkdir -p "$HOME" /tmp/work
45
+ cp -a /src/. /tmp/work/
46
+ cd /tmp/work
47
+ python -m pip install --quiet --disable-pip-version-check --user '{requirement}'
48
+ python -c 'import importlib.metadata as m, json, os, platform, time; print("GHOSTBUILD_ENV " + json.dumps({"python_version": platform.python_version(), "python_implementation": platform.python_implementation(), "os": platform.platform(), "architecture": platform.machine(), "build_tool": "build " + m.version("build"), "locale": os.environ.get("LC_ALL") or os.environ.get("LANG"), "timezone": os.environ.get("TZ") or "/".join(time.tzname)}))'
49
+ python -m build --sdist --wheel --outdir /out .
50
+ """ # noqa: E501
51
+
52
+
53
+ def _is_pinned(requirement: str) -> bool:
54
+ """Return True if a PEP 508 requirement pins an exact version or URL."""
55
+ return bool(re.search(r"(===?|@)", requirement))
56
+
57
+
58
+ class PythonAdapter(BuildAdapter):
59
+ """Builds Python projects through the standard ``build`` frontend."""
60
+
61
+ id = "python"
62
+ language = "Python"
63
+
64
+ def detect(self, project_dir: Path) -> bool:
65
+ if (project_dir / "setup.py").is_file() or (project_dir / "setup.cfg").is_file():
66
+ return True
67
+ pyproject = project_dir / "pyproject.toml"
68
+ if not pyproject.is_file():
69
+ return False
70
+ data = load_toml(pyproject)
71
+ return any(k in data for k in ("build-system", "project")) or "poetry" in data.get(
72
+ "tool", {}
73
+ )
74
+
75
+ def inspect(self, project_dir: Path) -> ProjectInfo:
76
+ data: dict[str, Any] = {}
77
+ pyproject = project_dir / "pyproject.toml"
78
+ if pyproject.is_file():
79
+ data = load_toml(pyproject)
80
+ project = data.get("project", {})
81
+ poetry = data.get("tool", {}).get("poetry", {})
82
+ notes: list[str] = []
83
+
84
+ name, version = self._name_and_version(project_dir, project, poetry, notes)
85
+
86
+ build_system_table = data.get("build-system")
87
+ if build_system_table is not None:
88
+ backend = build_system_table.get("build-backend")
89
+ requires = list(build_system_table.get("requires", []))
90
+ if backend is None:
91
+ backend = _LEGACY_BACKEND
92
+ notes.append("[build-system] has no build-backend; pip would use setuptools.")
93
+ else:
94
+ backend, requires = _LEGACY_BACKEND, list(_LEGACY_REQUIRES)
95
+ notes.append("No [build-system] table; the default setuptools backend would be used.")
96
+ if poetry:
97
+ notes.append(
98
+ "Found [tool.poetry] without [build-system]; add one (poetry-core) so the "
99
+ "project builds with Poetry's backend."
100
+ )
101
+ build_system = _BACKENDS.get(backend, backend)
102
+ if build_system_table is None and not pyproject.is_file():
103
+ build_system = "setuptools (legacy setup.py/setup.cfg)"
104
+
105
+ dependencies = list(project.get("dependencies", []))
106
+ if not dependencies and poetry:
107
+ dependencies = [
108
+ f"{dep} {spec if isinstance(spec, str) else ''}".strip()
109
+ for dep, spec in poetry.get("dependencies", {}).items()
110
+ if dep.lower() != "python"
111
+ ]
112
+ requires_python = project.get("requires-python")
113
+ if requires_python is None and isinstance(poetry.get("dependencies", {}).get("python"), str):
114
+ requires_python = poetry["dependencies"]["python"]
115
+
116
+ config_files = [f for f in _CONFIG_FILES if (project_dir / f).is_file()]
117
+ config_files += sorted(p.name for p in project_dir.glob("requirements*.txt") if p.is_file())
118
+
119
+ return ProjectInfo(
120
+ path=str(project_dir),
121
+ name=name,
122
+ version=version,
123
+ language=self.language,
124
+ build_system=build_system,
125
+ build_backend=backend,
126
+ adapter=self.id,
127
+ config_files=config_files,
128
+ build_requires=requires,
129
+ dependencies=dependencies,
130
+ requires_python=requires_python,
131
+ lock_files=[f for f in _LOCK_FILES if (project_dir / f).is_file()],
132
+ notes=notes,
133
+ )
134
+
135
+ @staticmethod
136
+ def _name_and_version(
137
+ project_dir: Path, project: dict[str, Any], poetry: dict[str, Any], notes: list[str]
138
+ ) -> tuple[str | None, str | None]:
139
+ name = project.get("name") or poetry.get("name")
140
+ version = project.get("version") or poetry.get("version")
141
+ if version is None and "version" in project.get("dynamic", []):
142
+ version = "dynamic"
143
+ setup_cfg = project_dir / "setup.cfg"
144
+ if (name is None or version is None) and setup_cfg.is_file():
145
+ parser = configparser.ConfigParser(interpolation=None)
146
+ try:
147
+ parser.read(setup_cfg, encoding="utf-8")
148
+ except configparser.Error as exc:
149
+ notes.append(f"setup.cfg could not be parsed: {exc}")
150
+ else:
151
+ name = name or parser.get("metadata", "name", fallback=None)
152
+ cfg_version = parser.get("metadata", "version", fallback=None)
153
+ if version is None and cfg_version:
154
+ version = "dynamic" if cfg_version.startswith(("attr:", "file:")) else cfg_version
155
+ if name is None and (project_dir / "setup.py").is_file():
156
+ notes.append("Name/version are defined in setup.py, which GhostBuild does not execute.")
157
+ if version == "dynamic":
158
+ notes.append("The version is computed at build time (dynamic).")
159
+ return name, version
160
+
161
+ def available_tools(self) -> dict[str, str | None]:
162
+ tools: dict[str, str | None] = {"python": f"{platform.python_version()} ({sys.executable})"}
163
+ for package in ("pip", "build", "setuptools", "wheel"):
164
+ tools[package] = self._package_version(package)
165
+ for executable in _TOOLS_ON_PATH:
166
+ tools[executable] = shutil.which(executable)
167
+ return tools
168
+
169
+ @staticmethod
170
+ def _package_version(package: str) -> str | None:
171
+ try:
172
+ return importlib.metadata.version(package)
173
+ except importlib.metadata.PackageNotFoundError:
174
+ return None
175
+
176
+ def describe_build_tool(self) -> str | None:
177
+ version = self._package_version("build")
178
+ return f"build {version}" if version else None
179
+
180
+ def missing_prerequisites(self, config: Config) -> list[str]:
181
+ if config.python.build_command:
182
+ return []
183
+ if importlib.util.find_spec("build") is None:
184
+ return [
185
+ "The 'build' package is not installed for this Python interpreter "
186
+ f"({sys.executable}). Install it with: python -m pip install build"
187
+ ]
188
+ return []
189
+
190
+ def has_custom_build_command(self, config: Config) -> bool:
191
+ return bool(config.python.build_command)
192
+
193
+ def build_command(self, source_dir: Path, output_dir: Path, config: Config) -> list[str]:
194
+ if config.python.build_command:
195
+ replacements = {
196
+ "{python}": sys.executable,
197
+ "{source_dir}": str(source_dir),
198
+ "{outdir}": str(output_dir),
199
+ }
200
+ command = []
201
+ for part in config.python.build_command:
202
+ for placeholder, value in replacements.items():
203
+ part = part.replace(placeholder, value)
204
+ command.append(part)
205
+ return command
206
+ return [
207
+ sys.executable, "-m", "build", "--sdist", "--wheel",
208
+ "--outdir", str(output_dir), str(source_dir),
209
+ ] # fmt: skip
210
+
211
+ def container_image(self, config: Config) -> str:
212
+ if config.python.docker_image:
213
+ return config.python.docker_image
214
+ return f"python:{sys.version_info.major}.{sys.version_info.minor}-slim"
215
+
216
+ def container_script(self, config: Config) -> str:
217
+ requirement = config.python.build_requirement
218
+ if "'" in requirement:
219
+ raise ConfigError("[python] build_requirement must not contain a single quote.")
220
+ # str.replace, not str.format: the script contains literal braces.
221
+ return _CONTAINER_SCRIPT.replace("{requirement}", requirement)
222
+
223
+ def collect_warnings(self, project: ProjectInfo) -> list[str]:
224
+ warnings = []
225
+ unpinned = [r for r in project.build_requires if not _is_pinned(r)]
226
+ if unpinned:
227
+ warnings.append(
228
+ "Build requirements are not pinned to exact versions "
229
+ f"({', '.join(unpinned)}). Builds in different environments may resolve "
230
+ "different versions, which is a common cause of differing artifacts."
231
+ )
232
+ return warnings
ghostbuild/cli.py ADDED
@@ -0,0 +1,341 @@
1
+ """Command-line interface (Typer + Rich)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import sys
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import Annotated, Optional
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+
16
+ from ghostbuild import __version__
17
+ from ghostbuild.config import (
18
+ CONFIG_FILENAME,
19
+ EXAMPLE_CONFIG,
20
+ REPORT_FORMATS,
21
+ load_config,
22
+ )
23
+ from ghostbuild.detection import detect_project
24
+ from ghostbuild.engine.isolation import (
25
+ ISOLATION_CHOICES,
26
+ DockerStatus,
27
+ detect_docker,
28
+ resolve_isolation,
29
+ )
30
+ from ghostbuild.engine.verifier import compare_directories, verify_project
31
+ from ghostbuild.errors import GhostBuildError, LocalFallbackNotApproved, PathError
32
+ from ghostbuild.models import VerificationResult, to_plain
33
+ from ghostbuild.reports import json_report
34
+ from ghostbuild.reports.html_report import write_html
35
+ from ghostbuild.reports.terminal import render_banner, render_result, render_scan
36
+ from ghostbuild.utils.filesystem import validate_directory
37
+
38
+ # Exit codes: 2 is reserved for usage errors (Typer/Click).
39
+ EXIT_REPRODUCIBLE = 0
40
+ EXIT_NON_REPRODUCIBLE = 1
41
+ EXIT_INCONCLUSIVE = 3
42
+ EXIT_ERROR = 4
43
+ EXIT_DECLINED = 5
44
+ _VERDICT_EXIT = {
45
+ "reproducible": EXIT_REPRODUCIBLE,
46
+ "non-reproducible": EXIT_NON_REPRODUCIBLE,
47
+ "inconclusive": EXIT_INCONCLUSIVE,
48
+ }
49
+
50
+ app = typer.Typer(
51
+ name="ghostbuild",
52
+ help="Build it twice. Know what changed. Reproducible build verification.",
53
+ add_completion=False,
54
+ no_args_is_help=False,
55
+ )
56
+
57
+ NoColorOption = Annotated[bool, typer.Option("--no-color", help="Disable coloured output.")]
58
+ VerboseOption = Annotated[bool, typer.Option("--verbose", help="Show full details and build logs.")]
59
+ ConfigOption = Annotated[
60
+ Optional[Path],
61
+ typer.Option("--config", "-c", help="Path to a ghostbuild.toml (default: <project>/ghostbuild.toml)."),
62
+ ]
63
+
64
+
65
+ def _consoles(no_color: bool) -> tuple[Console, Console]:
66
+ """Return (stdout console, stderr console). Progress and errors go to stderr."""
67
+ return Console(no_color=no_color), Console(stderr=True, no_color=no_color)
68
+
69
+
70
+ @contextmanager
71
+ def _handle_errors(err: Console) -> Iterator[None]:
72
+ """Turn expected GhostBuild errors into a clean message and exit code 4."""
73
+ try:
74
+ yield
75
+ except GhostBuildError as exc:
76
+ err.print(Text(f"Error: {exc}", style="bold red"))
77
+ raise typer.Exit(EXIT_ERROR) from exc
78
+
79
+
80
+ def _check_format(value: str | None) -> str | None:
81
+ if value is not None and value not in REPORT_FORMATS:
82
+ raise typer.BadParameter(f"must be one of: {', '.join(REPORT_FORMATS)}", param_hint="--format")
83
+ return value
84
+
85
+
86
+ def _version_callback(value: bool) -> None:
87
+ if value:
88
+ typer.echo(f"ghostbuild {__version__}")
89
+ raise typer.Exit()
90
+
91
+
92
+ @app.callback(invoke_without_command=True)
93
+ def main(
94
+ ctx: typer.Context,
95
+ version: Annotated[
96
+ bool,
97
+ typer.Option("--version", "-V", callback=_version_callback, is_eager=True,
98
+ help="Show the version and exit."),
99
+ ] = False,
100
+ ) -> None: # fmt: skip
101
+ """Build it twice. Know what changed."""
102
+ if ctx.invoked_subcommand is None:
103
+ render_banner(Console())
104
+
105
+
106
+ @app.command()
107
+ def version() -> None:
108
+ """Show the installed GhostBuild version."""
109
+ typer.echo(f"ghostbuild {__version__}")
110
+
111
+
112
+ @app.command()
113
+ def init(
114
+ path: Annotated[Path, typer.Argument(help="Where to write the example configuration.")] = Path(
115
+ CONFIG_FILENAME
116
+ ),
117
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
118
+ ) -> None:
119
+ """Write an example ghostbuild.toml containing all defaults."""
120
+ out, err = _consoles(False)
121
+ if path.exists() and not force:
122
+ err.print(Text(f"Error: {path} already exists. Use --force to overwrite it.", style="bold red"))
123
+ raise typer.Exit(EXIT_ERROR)
124
+ path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
125
+ out.print(Text(f"Wrote {path}"))
126
+
127
+
128
+ @app.command()
129
+ def scan(
130
+ project_dir: Annotated[Path, typer.Argument(help="Project directory to inspect.")] = Path("."),
131
+ config_path: ConfigOption = None,
132
+ no_color: NoColorOption = False,
133
+ ) -> None:
134
+ """Inspect a project: name, build system, requirements and available tools.
135
+
136
+ Reads project files only. No build is run.
137
+ """
138
+ out, err = _consoles(no_color)
139
+ with _handle_errors(err):
140
+ load_config(config_path, validate_directory(project_dir, "project directory"))
141
+ adapter, project = detect_project(project_dir)
142
+ docker = detect_docker()
143
+ render_scan(out, to_plain(project), adapter.available_tools(), to_plain(docker))
144
+
145
+
146
+ def _interactive() -> bool:
147
+ return sys.stdin.isatty()
148
+
149
+
150
+ def _choose_isolation(requested: str, docker: DockerStatus, err: Console) -> str:
151
+ """Resolve the isolation mode, asking before any local fallback."""
152
+ try:
153
+ return resolve_isolation(requested, docker, local_approved=False)
154
+ except LocalFallbackNotApproved as exc:
155
+ if not _interactive():
156
+ raise
157
+ err.print(Text(f"Docker is unavailable: {docker.reason}.", style="bold yellow"))
158
+ err.print(
159
+ "A local rebuild uses the same interpreter and tools as the original build, so it "
160
+ "is NOT equivalent to an independent clean-container build. It can only show "
161
+ "repeatability on this machine."
162
+ )
163
+ if not typer.confirm("Use the weaker local rebuild instead?", default=False):
164
+ raise typer.Exit(EXIT_DECLINED) from exc
165
+ return resolve_isolation(requested, docker, local_approved=True)
166
+
167
+
168
+ def _present_plan(err: Console, project_dir: Path, command: list[str], mode: str, image: str | None) -> None:
169
+ err.print(Text("\nBuild plan", style="bold"))
170
+ err.print(Text(f" Project: {project_dir}"))
171
+ err.print(Text(f" Original build: runs on this host in a temporary copy: {shlex.join(command)}"))
172
+ if mode == "docker":
173
+ err.print(Text(f" Independent rebuild: runs inside a Docker container ({image}), source mounted read-only"))
174
+ else:
175
+ err.print(Text(" Independent rebuild: runs on this host in a second temporary copy (LOCAL, weaker isolation)",
176
+ style="yellow")) # fmt: skip
177
+ err.print(Text(" Your project directory is only read, never modified.", style="dim"))
178
+ err.print(Text(" WARNING: building runs code from this project (build backend, setup.py, hooks).",
179
+ style="bold yellow")) # fmt: skip
180
+
181
+
182
+ @app.command()
183
+ def verify(
184
+ project_dir: Annotated[Path, typer.Argument(help="Project directory to verify.")] = Path("."),
185
+ output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Directory for reports.")] = None,
186
+ fmt: Annotated[
187
+ Optional[str],
188
+ typer.Option("--format", "-f", help="Output format: terminal, json or html."),
189
+ ] = None,
190
+ keep_artifacts: Annotated[
191
+ bool, typer.Option("--keep-artifacts", help="Keep both builds' artifacts in the output directory.")
192
+ ] = False,
193
+ isolation: Annotated[
194
+ str,
195
+ typer.Option(help="Rebuild isolation: auto (Docker if available), docker, or local."),
196
+ ] = "auto",
197
+ yes: Annotated[
198
+ bool, typer.Option("--yes", "-y", help="Approve running the project's build without prompting.")
199
+ ] = False,
200
+ timeout: Annotated[Optional[int], typer.Option(help="Per-build timeout in seconds.", min=1)] = None,
201
+ config_path: ConfigOption = None,
202
+ verbose: VerboseOption = False,
203
+ no_color: NoColorOption = False,
204
+ ) -> None:
205
+ """Build the project twice and compare the artifacts.
206
+
207
+ This executes the project's build scripts. GhostBuild asks for consent first
208
+ (or use --yes) and never silently weakens isolation.
209
+ """
210
+ out, err = _consoles(no_color)
211
+ if isolation not in ISOLATION_CHOICES:
212
+ raise typer.BadParameter(f"must be one of: {', '.join(ISOLATION_CHOICES)}", param_hint="--isolation")
213
+ _check_format(fmt)
214
+ result: VerificationResult
215
+ with _handle_errors(err):
216
+ root = validate_directory(project_dir, "project directory")
217
+ config = load_config(config_path, root)
218
+ fmt = fmt or config.report.format
219
+ output_dir = (output or Path(config.report.output_dir)).resolve()
220
+ _check_output_dir(output_dir, keep_artifacts)
221
+ adapter, project = detect_project(root)
222
+ err.print(Text(f"GhostBuild v{__version__}", style="bold"))
223
+ err.print(Text(f"Detected: {project.language} Build system: {project.build_system}"))
224
+
225
+ mode = _choose_isolation(isolation, detect_docker(), err)
226
+ image = adapter.container_image(config) if mode == "docker" else None
227
+ command = adapter.build_command(Path("<source>"), Path("<output>"), config)
228
+ _present_plan(err, root, command, mode, image)
229
+ if not yes:
230
+ if not _interactive():
231
+ raise GhostBuildError(
232
+ "Refusing to run build commands without consent in a non-interactive session. "
233
+ "Re-run with --yes to approve."
234
+ )
235
+ if not typer.confirm("Run these build commands now?", default=False):
236
+ err.print("Aborted. No build commands were run.")
237
+ raise typer.Exit(EXIT_DECLINED)
238
+
239
+ err.print()
240
+ with err.status("Starting...") as status:
241
+ def on_stage(index: int, total: int, label: str) -> None:
242
+ err.print(Text(f"[{index}/{total}] {label}", style="bold cyan"))
243
+ status.update(f"{label}...")
244
+
245
+ result = verify_project(
246
+ root, adapter, project, config,
247
+ mode=mode,
248
+ timeout=timeout or config.build.timeout_seconds,
249
+ keep_artifacts_dir=(output_dir / "artifacts") if keep_artifacts else None,
250
+ skip_paths=[output_dir],
251
+ on_stage=on_stage,
252
+ ) # fmt: skip
253
+ _emit(result, output_dir, fmt, out, err, verbose)
254
+ raise typer.Exit(_VERDICT_EXIT[result.verdict.value])
255
+
256
+
257
+ def _check_output_dir(output_dir: Path, keep_artifacts: bool) -> None:
258
+ if output_dir.exists() and not output_dir.is_dir():
259
+ raise PathError(f"The output path exists and is not a directory: {output_dir}")
260
+ kept = output_dir / "artifacts"
261
+ if keep_artifacts and kept.exists() and any(kept.iterdir()):
262
+ raise PathError(
263
+ f"{kept} already contains files. GhostBuild will not overwrite or delete them; "
264
+ "choose another --output directory or remove it yourself."
265
+ )
266
+
267
+
268
+ def _emit(
269
+ result: VerificationResult, output_dir: Path, fmt: str, out: Console, err: Console, verbose: bool
270
+ ) -> None:
271
+ """Write result.json (always) and print/write the requested format."""
272
+ saved = json_report.write_json(result, output_dir)
273
+ data = result.to_dict()
274
+ if fmt == "json":
275
+ typer.echo(json_report.to_json(data), nl=False)
276
+ else:
277
+ render_result(out, data, verbose)
278
+ if fmt == "html":
279
+ err.print(Text(f"HTML report saved to: {write_html(data, output_dir)}"))
280
+ err.print(Text(f"Report saved to: {saved.parent}/"))
281
+
282
+
283
+ @app.command()
284
+ def compare(
285
+ dir_a: Annotated[Path, typer.Argument(help="First (original) artifact directory.")],
286
+ dir_b: Annotated[Path, typer.Argument(help="Second (rebuilt) artifact directory.")],
287
+ pattern: Annotated[
288
+ Optional[list[str]], typer.Option("--pattern", "-p", help="Artifact glob (repeatable).")
289
+ ] = None,
290
+ output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Write reports to this directory.")] = None,
291
+ fmt: Annotated[Optional[str], typer.Option("--format", "-f", help="terminal, json or html.")] = None,
292
+ config_path: ConfigOption = None,
293
+ verbose: VerboseOption = False,
294
+ no_color: NoColorOption = False,
295
+ ) -> None:
296
+ """Compare two existing artifact directories. No builds are run."""
297
+ out, err = _consoles(no_color)
298
+ _check_format(fmt)
299
+ with _handle_errors(err):
300
+ first = validate_directory(dir_a, "first directory")
301
+ second = validate_directory(dir_b, "second directory")
302
+ config = load_config(config_path, None)
303
+ fmt = fmt or "terminal"
304
+ if fmt == "html" and output is None:
305
+ raise PathError("--format html needs --output to choose where to write the report.")
306
+ result = compare_directories(first, second, config, pattern or None)
307
+ if output is not None:
308
+ _emit(result, output.resolve(), fmt, out, err, verbose)
309
+ else:
310
+ data = result.to_dict()
311
+ if fmt == "json":
312
+ typer.echo(json_report.to_json(data), nl=False)
313
+ else:
314
+ render_result(out, data, verbose)
315
+ raise typer.Exit(_VERDICT_EXIT[result.verdict.value])
316
+
317
+
318
+ @app.command()
319
+ def report(
320
+ path: Annotated[Path, typer.Argument(help="A result.json file or a directory containing one.")] = Path(
321
+ "ghostbuild-results"
322
+ ),
323
+ fmt: Annotated[str, typer.Option("--format", "-f", help="terminal, json or html.")] = "terminal",
324
+ output: Annotated[
325
+ Optional[Path], typer.Option("--output", "-o", help="Directory to write an HTML report to.")
326
+ ] = None,
327
+ verbose: VerboseOption = False,
328
+ no_color: NoColorOption = False,
329
+ ) -> None:
330
+ """Display or regenerate a report from a stored result.json."""
331
+ out, err = _consoles(no_color)
332
+ _check_format(fmt)
333
+ with _handle_errors(err):
334
+ data = json_report.load_result(path)
335
+ if fmt == "json":
336
+ typer.echo(json_report.to_json(data), nl=False)
337
+ elif fmt == "html":
338
+ target = output or (path if path.is_dir() else path.parent)
339
+ err.print(Text(f"HTML report saved to: {write_html(data, target.resolve())}"))
340
+ else:
341
+ render_result(out, data, verbose)