Archived
216 lines
6.4 KiB
Python
Executable File
216 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError as exc: # pragma: no cover - hard failure on old Python
|
|
raise SystemExit("Python 3.11+ is required to run this packager") from exc
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_OUTPUT_DIR = REPO_ROOT / "target" / "debian"
|
|
DEFAULT_DESCRIPTION = (
|
|
"Rust TUI/CLI wrapper for Claude, Antigravity, Codex, and Copilot with SQLite history"
|
|
)
|
|
DEFAULT_MAINTAINER = "Secret Agent Maintainers <noreply@example.com>"
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
manifest = load_manifest(REPO_ROOT / "Cargo.toml")
|
|
package_name = manifest["package_name"]
|
|
binary_name = manifest["binary_name"]
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Package an existing Rust build as a Debian .deb archive."
|
|
)
|
|
parser.add_argument(
|
|
"--binary",
|
|
type=Path,
|
|
default=REPO_ROOT / "target" / "release" / binary_name,
|
|
help="Path to the built Rust binary to package.",
|
|
)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
default=DEFAULT_OUTPUT_DIR,
|
|
help="Directory where the staging tree and resulting .deb will be written.",
|
|
)
|
|
parser.add_argument(
|
|
"--package-name",
|
|
default=package_name,
|
|
help="Debian package name to use in the control file.",
|
|
)
|
|
parser.add_argument(
|
|
"--install-name",
|
|
default=binary_name,
|
|
help="File name to install under /usr/bin inside the package.",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
default=manifest["version"],
|
|
help="Debian package version.",
|
|
)
|
|
parser.add_argument(
|
|
"--architecture",
|
|
default=detect_architecture(),
|
|
help="Debian architecture string, for example amd64.",
|
|
)
|
|
parser.add_argument(
|
|
"--maintainer",
|
|
default=detect_maintainer(manifest.get("authors", [])),
|
|
help="Maintainer value for the Debian control file.",
|
|
)
|
|
parser.add_argument(
|
|
"--description",
|
|
default=manifest["description"],
|
|
help="Short package description for the Debian control file.",
|
|
)
|
|
parser.add_argument(
|
|
"--section",
|
|
default="utils",
|
|
help="Debian package section.",
|
|
)
|
|
parser.add_argument(
|
|
"--priority",
|
|
default="optional",
|
|
help="Debian package priority.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_manifest(path: Path) -> dict[str, object]:
|
|
with path.open("rb") as handle:
|
|
data = tomllib.load(handle)
|
|
|
|
package = data.get("package", {})
|
|
package_name = package.get("name")
|
|
version = package.get("version")
|
|
if not package_name or not version:
|
|
raise SystemExit(f"missing package name or version in {path}")
|
|
|
|
binaries = data.get("bin", [])
|
|
if binaries and isinstance(binaries, list):
|
|
binary_name = binaries[0].get("name", package_name)
|
|
else:
|
|
binary_name = package_name
|
|
|
|
description = package.get("description") or DEFAULT_DESCRIPTION
|
|
authors = package.get("authors", [])
|
|
return {
|
|
"package_name": str(package_name),
|
|
"binary_name": str(binary_name),
|
|
"version": str(version),
|
|
"description": str(description),
|
|
"authors": authors if isinstance(authors, list) else [],
|
|
}
|
|
|
|
|
|
def detect_architecture() -> str:
|
|
dpkg = shutil.which("dpkg")
|
|
if not dpkg:
|
|
raise SystemExit("dpkg is required to determine the Debian architecture")
|
|
|
|
result = subprocess.run(
|
|
[dpkg, "--print-architecture"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
architecture = result.stdout.strip()
|
|
if not architecture:
|
|
raise SystemExit("dpkg returned an empty architecture string")
|
|
return architecture
|
|
|
|
|
|
def detect_maintainer(authors: list[object]) -> str:
|
|
full_name = os.environ.get("DEBFULLNAME")
|
|
email = os.environ.get("DEBEMAIL")
|
|
if full_name and email:
|
|
return f"{full_name} <{email}>"
|
|
|
|
if authors:
|
|
first_author = str(authors[0]).strip()
|
|
if first_author:
|
|
return first_author
|
|
|
|
return DEFAULT_MAINTAINER
|
|
|
|
|
|
def format_control(description: str, args: argparse.Namespace, binary_size: int) -> str:
|
|
lines = [
|
|
f"Package: {args.package_name}",
|
|
f"Version: {args.version}",
|
|
f"Section: {args.section}",
|
|
f"Priority: {args.priority}",
|
|
f"Architecture: {args.architecture}",
|
|
f"Maintainer: {args.maintainer}",
|
|
f"Installed-Size: {max(1, math.ceil(binary_size / 1024))}",
|
|
f"Description: {description.strip()}",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def ensure_built_binary(binary_path: Path) -> None:
|
|
if not binary_path.is_file():
|
|
raise SystemExit(f"built binary not found: {binary_path}")
|
|
if not os.access(binary_path, os.X_OK):
|
|
raise SystemExit(f"binary is not executable: {binary_path}")
|
|
|
|
|
|
def build_package(args: argparse.Namespace) -> Path:
|
|
dpkg_deb = shutil.which("dpkg-deb")
|
|
if not dpkg_deb:
|
|
raise SystemExit("dpkg-deb is required to build Debian packages")
|
|
|
|
binary_path = args.binary.resolve()
|
|
ensure_built_binary(binary_path)
|
|
|
|
output_dir = args.output_dir.resolve()
|
|
staging_dir = output_dir / f"{args.package_name}_{args.version}_{args.architecture}"
|
|
deb_path = output_dir / f"{args.package_name}_{args.version}_{args.architecture}.deb"
|
|
binary_destination = staging_dir / "usr" / "bin" / args.install_name
|
|
control_path = staging_dir / "DEBIAN" / "control"
|
|
|
|
if staging_dir.exists():
|
|
shutil.rmtree(staging_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
(staging_dir / "DEBIAN").mkdir(parents=True)
|
|
binary_destination.parent.mkdir(parents=True)
|
|
|
|
shutil.copy2(binary_path, binary_destination)
|
|
binary_destination.chmod(0o755)
|
|
control_path.write_text(
|
|
format_control(args.description, args, binary_path.stat().st_size),
|
|
encoding="utf-8",
|
|
)
|
|
control_path.chmod(0o644)
|
|
|
|
if deb_path.exists():
|
|
deb_path.unlink()
|
|
|
|
subprocess.run(
|
|
[dpkg_deb, "--build", "--root-owner-group", str(staging_dir), str(deb_path)],
|
|
check=True,
|
|
)
|
|
return deb_path
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
deb_path = build_package(args)
|
|
print(deb_path)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|