diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43184c3..d5ec02c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,9 @@ jobs: - os: ubuntu-24.04 name: linux arch: amd64 + - os: ubuntu-24.04-arm + name: linux + arch: arm64 - os: windows-latest name: windows arch: amd64 @@ -114,14 +117,22 @@ jobs: echo "Copied Windows AMD64 binary" fi if [ -d "artifacts/perfecto-mcp-macos-arm64" ]; then - cp artifacts/perfecto-mcp-macos-arm64/* dist/ - chmod +x dist/perfecto-mcp-macos-arm64 - echo "Copied macOS ARM64 binary" + cp -r artifacts/perfecto-mcp-macos-arm64/* dist/ + if [ -d "dist/perfecto-mcp-arm64.app" ]; then + echo "Found macOS ARM64 .app bundle" + else + chmod +x dist/perfecto-mcp-macos-arm64 2>/dev/null || true + echo "Copied macOS ARM64 binary" + fi fi if [ -d "artifacts/perfecto-mcp-macos-amd64" ]; then - cp artifacts/perfecto-mcp-macos-amd64/* dist/ - chmod +x dist/perfecto-mcp-macos-amd64 - echo "Copied macOS AMD64 binary" + cp -r artifacts/perfecto-mcp-macos-amd64/* dist/ + if [ -d "dist/perfecto-mcp-amd64.app" ]; then + echo "Found macOS AMD64 .app bundle" + else + chmod +x dist/perfecto-mcp-macos-amd64 2>/dev/null || true + echo "Copied macOS AMD64 binary" + fi fi echo "Final dist/ contents:" diff --git a/build.py b/build.py index 1d27f88..59a6809 100644 --- a/build.py +++ b/build.py @@ -2,6 +2,8 @@ """Build script for creating PyInstaller binary.""" import os import platform +import shutil +import subprocess import tomllib from datetime import date from pathlib import Path @@ -11,6 +13,12 @@ sep = os.pathsep +def clean_build(): + build_dir = Path('build') + if build_dir.exists(): + shutil.rmtree(build_dir) + + def build_version_file(): pyproject = Path(__file__).parent / "pyproject.toml" with open(pyproject, "rb") as f: @@ -57,31 +65,36 @@ def build_version_file(): f.write(TEMPLATE.strip()) -def build(): - """Build the binary using PyInstaller.""" - system = platform.system().lower() - suffix = '.exe' if system == 'windows' else '' - arch = platform.machine().lower() - - # Map architecture names to Docker-compatible format +def normalize_architecture(arch: str) -> str: if arch in ['x86_64', 'amd64']: - arch = 'amd64' + return 'amd64' elif arch in ['aarch64', 'arm64']: - arch = 'arm64' + return 'arm64' elif arch.startswith('arm'): - arch = 'arm64' # Assume ARM64 for Docker compatibility + return 'arm64' + return arch - system = "macos" if system == 'darwin' else system - name = f'perfecto-mcp-{system}-{arch}{suffix}' - icon = 'app.ics' if system == 'macos' else 'app.ico' +def normalize_system_name(system: str) -> str: + return "macos" if system == 'darwin' else system + + +def get_binary_name(system: str, arch: str) -> str: + suffix = '.exe' if system == 'windows' else '' + return f'perfecto-mcp-{system}-{arch}{suffix}' + +def get_icon_file(system: str) -> str: + return 'app.icns' if system == 'macos' else 'app.ico' + + +def run_pyinstaller(name: str, icon: str): PyInstaller.__main__.run([ 'main.py', '--onefile', '--version-file=version_info.txt', f'--add-data=pyproject.toml{sep}.', - f'--add-data=resources/app.png{sep}resources', + f'--add-data=resources{sep}resources', f'--name={name}', f'--icon={icon}', '--clean', @@ -89,6 +102,113 @@ def build(): ]) +def build(): + clean_build() + + system = normalize_system_name(platform.system().lower()) + arch = normalize_architecture(platform.machine().lower()) + name = get_binary_name(system, arch) + icon = get_icon_file(system) + + run_pyinstaller(name, icon) + clean_build() + + if system == "macos": + create_app_bundle(name, arch, dist_dir=Path("dist")) + elif system == "linux": + create_sha256_checksum(name, dist_dir=Path("dist")) + + +def create_app_directory_structure(app_path: Path) -> Path: + macos_path = app_path / "Contents" / "MacOS" + macos_path.mkdir(parents=True, exist_ok=True) + return macos_path + + +def copy_binary_to_app(binary_path: Path, target_path: Path): + if not binary_path.exists(): + raise FileNotFoundError(f"Binary not found: {binary_path}") + shutil.copy2(binary_path, target_path) + os.chmod(target_path, 0o755) + + +def create_launcher_script(launcher_path: Path): + launcher_content = """#!/bin/bash +set -e + +BIN_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN="$BIN_DIR/perfecto-mcp" + +if [ -t 1 ]; then + exec "$BIN" "$@" +else + open -a Terminal "$BIN" +fi +""" + with open(launcher_path, "w", encoding="utf-8") as f: + f.write(launcher_content) + os.chmod(launcher_path, 0o755) + + +def create_info_plist(plist_path: Path): + info_plist_content = """ + + + + CFBundleExecutable + launcher.sh + + CFBundleIdentifier + com.perfecto.mcp + + CFBundleName + Perfecto MCP + + CFBundlePackageType + APPL + + +""" + with open(plist_path, "w", encoding="utf-8") as f: + f.write(info_plist_content) + + +def create_app_bundle(binary_name: str, arch: str, dist_dir: Path): + app_name = f"perfecto-mcp-{arch}.app" + app_path = dist_dir / app_name + contents_path = app_path / "Contents" + + macos_path = create_app_directory_structure(app_path) + + binary_path = dist_dir / binary_name + copy_binary_to_app(binary_path, macos_path / "perfecto-mcp") + + create_launcher_script(macos_path / "launcher.sh") + create_info_plist(contents_path / "Info.plist") + + binary_path.unlink() + print(f"Created {app_name} in {dist_dir}") + + +def create_sha256_checksum(binary_name: str, dist_dir: Path): + binary_path = dist_dir / binary_name + checksum_path = dist_dir / f"{binary_name}.sha256" + + if not binary_path.exists(): + raise FileNotFoundError(f"Binary not found: {binary_path}") + + with open(checksum_path, "w") as f: + subprocess.run( + ["sha256sum", binary_name], + cwd=dist_dir, + stdout=f, + check=True, + ) + + print(f"Created {checksum_path.name} in {dist_dir}") + + if __name__ == "__main__": build_version_file() - build() \ No newline at end of file + build() diff --git a/config/version.py b/config/version.py index c7c748c..69b457d 100644 --- a/config/version.py +++ b/config/version.py @@ -1,5 +1,6 @@ import importlib.metadata import os +import subprocess import sys import tomllib from pathlib import Path @@ -27,9 +28,26 @@ def get_executable(): else: return os.path.join(os.path.abspath(Path(__file__).parent.parent), "main.py") + +def get_bundle_executable(): + executable_path = os.path.realpath(get_executable()) + if sys.platform == "darwin": + translocated_path = executable_path + result = subprocess.check_output( + ['/usr/bin/security', 'translocate-original-path', translocated_path], + stderr=subprocess.STDOUT + ) + original_path = result.decode('utf-8').split('\n')[-2].strip() + + return os.path.realpath(os.path.join(original_path, "..", "..", "..")) + else: + return executable_path + + def is_uvx(): return "\\uv\\cache\\" in sys.prefix __version__ = get_version() __executable__ = get_executable() +__bundle__ = get_bundle_executable() __uvx__ = is_uvx() diff --git a/main.py b/main.py index 6e68418..e4fbfc7 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from config.perfecto import SECURITY_TOKEN_FILE_ENV_NAME, SECURITY_TOKEN_ENV_NAME, PERFECTO_CLOUD_NAME_ENV_NAME, \ GITHUB from config.token import PerfectoToken, PerfectoTokenError -from config.version import __version__, __executable__, __uvx__, get_version +from config.version import __version__, __executable__, __bundle__, __uvx__, get_version from server import register_tools PERFECTO_SECURITY_TOKEN_FILE_NAME = "perfecto-security-token.txt" @@ -38,7 +38,10 @@ def get_token() -> PerfectoToken: is_docker = os.getenv('MCP_DOCKER', 'false').lower() == 'true' token = None - local_security_token_file = os.path.join(os.path.dirname(__executable__), PERFECTO_SECURITY_TOKEN_FILE_NAME) + if sys.platform == "darwin" and __bundle__.endswith(".app"): + local_security_token_file = os.path.join(os.path.dirname(__bundle__), PERFECTO_SECURITY_TOKEN_FILE_NAME) + else: + local_security_token_file = os.path.join(os.path.dirname(__executable__), PERFECTO_SECURITY_TOKEN_FILE_NAME) if not PERFECTO_SECURITY_TOKEN_FILE_PATH and os.path.exists(local_security_token_file): PERFECTO_SECURITY_TOKEN_FILE_PATH = local_security_token_file @@ -119,7 +122,11 @@ def main(): else: perfecto_environment_str = f"{PERFECTO_CLOUD_NAME}" - command = "uvx" if __uvx__ else __executable__ + if sys.platform == "darwin" and __bundle__.endswith(".app"): + command_path = os.path.join(__bundle__, "Contents", "MacOS", "perfecto-mcp") + else: + command_path = __executable__ + command = "uvx" if __uvx__ else command_path args = ["--mcp"] if __uvx__: args = [