#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import plistlib import subprocess import sys import shutil import tempfile from uuid import uuid4 from pathlib import Path EXCLUDED_DIRS = { '.git', '.hg', '.svn', 'DerivedData', 'build', '.build', 'node_modules', 'Pods', 'Carthage', 'SourcePackages', } def fail(message: str) -> int: print(message, file=sys.stderr) return 1 def run(command: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]: result = subprocess.run(command, cwd=str(cwd), text=True, capture_output=True) if check and result.returncode != 0: output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part) tail = "\n".join(output.splitlines()[-80:]) raise RuntimeError(tail or f"command failed: {' '.join(command)}") return result def discover_containers(root: Path) -> list[tuple[str, Path]]: found: list[tuple[str, Path]] = [] for dirpath, dirnames, _ in os.walk(root): current = Path(dirpath) kept = [] for name in dirnames: if name in EXCLUDED_DIRS: continue child = current / name if name.endswith('.xcworkspace'): found.append(('workspace', child)) continue if name.endswith('.xcodeproj'): found.append(('project', child)) continue kept.append(name) dirnames[:] = kept return sorted(found, key=lambda item: (0 if item[0] == 'workspace' else 1, len(item[1].parts), str(item[1]))) def maybe_run_xcodegen(root: Path) -> None: project_yml = root / 'project.yml' if not project_yml.exists(): return run(['xcodegen', 'generate'], cwd=root) def choose_container(args: argparse.Namespace, root: Path) -> tuple[str, Path]: if args.workspace and args.project: raise RuntimeError('pass either --workspace or --project, not both') if args.workspace: path = Path(args.workspace).expanduser().resolve() if not path.exists() or path.suffix != '.xcworkspace': raise RuntimeError(f'workspace not found: {path}') return 'workspace', path if args.project: path = Path(args.project).expanduser().resolve() if not path.exists() or path.suffix != '.xcodeproj': raise RuntimeError(f'project not found: {path}') return 'project', path candidates = discover_containers(root) if not candidates and args.run_xcodegen: maybe_run_xcodegen(root) candidates = discover_containers(root) if not candidates: if (root / 'project.yml').exists(): raise RuntimeError('no Xcode container found; try rerunning with --run-xcodegen') raise RuntimeError('no .xcworkspace or .xcodeproj found under the project root') workspaces = [path for kind, path in candidates if kind == 'workspace'] projects = [path for kind, path in candidates if kind == 'project'] if len(workspaces) == 1: return 'workspace', workspaces[0] if len(workspaces) > 1: options = "\n".join(f'- {path}' for path in workspaces) raise RuntimeError(f'multiple workspaces found; choose one with --workspace:\n{options}') if len(projects) == 1: return 'project', projects[0] if len(projects) > 1: options = "\n".join(f'- {path}' for path in projects) raise RuntimeError(f'multiple projects found; choose one with --project:\n{options}') raise RuntimeError('unable to resolve a build container') def load_schemes(container_kind: str, container_path: Path) -> list[str]: flag = '-workspace' if container_kind == 'workspace' else '-project' result = run(['xcodebuild', flag, str(container_path), '-list', '-json'], cwd=container_path.parent) try: payload = json.loads(result.stdout) except json.JSONDecodeError as exc: raise RuntimeError(f'failed to parse xcodebuild scheme list: {exc}') from exc container_key = 'workspace' if container_kind == 'workspace' else 'project' section = payload.get(container_key, {}) schemes = section.get('schemes', []) if not schemes: raise RuntimeError(f'no schemes found in {container_path.name}') return [str(item) for item in schemes] def choose_scheme(requested: str | None, container_path: Path, schemes: list[str]) -> str: if requested: if requested not in schemes: options = ', '.join(schemes) raise RuntimeError(f'scheme {requested!r} not found; available schemes: {options}') return requested if len(schemes) == 1: return schemes[0] container_stem = container_path.stem if container_stem in schemes: return container_stem options = "\n".join(f'- {scheme}' for scheme in schemes) raise RuntimeError(f'multiple schemes found; choose one with --scheme:\n{options}') def sanitize_for_path(value: str) -> str: return ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in value).strip('-') or 'app' def build_app(args: argparse.Namespace, root: Path, container_kind: str, container_path: Path, scheme: str) -> Path: derived_data = Path(args.derived_data).expanduser() if args.derived_data else Path(tempfile.mkdtemp(prefix=f'codex-mac-{sanitize_for_path(root.name)}-{sanitize_for_path(scheme)}-')) derived_data.mkdir(parents=True, exist_ok=True) flag = '-workspace' if container_kind == 'workspace' else '-project' command = [ 'xcodebuild', flag, str(container_path), '-scheme', scheme, '-configuration', args.configuration, '-derivedDataPath', str(derived_data), 'build', ] run(command, cwd=root) products_dir = derived_data / 'Build' / 'Products' / args.configuration apps = sorted(products_dir.glob('*.app')) if not apps: raise RuntimeError(f'no .app bundle found under {products_dir}') if len(apps) == 1: return apps[0] preferred_names = [f'{scheme}.app', f'{container_path.stem}.app'] for preferred in preferred_names: matches = [path for path in apps if path.name == preferred] if len(matches) == 1: return matches[0] options = "\n".join(f'- {path}' for path in apps) raise RuntimeError(f'multiple .app bundles found; refine the selection:\n{options}') def install_app(source_app: Path, install_dir: Path) -> Path: source_id = read_bundle_id(source_app) try: install_dir.mkdir(parents=True, exist_ok=True) except PermissionError as exc: raise RuntimeError( f'install dir is not writable: {install_dir}; rerun with --install-dir ~/Applications ' 'for a per-user install' ) from exc destination = install_dir / source_app.name if source_app.is_symlink() or destination.is_symlink(): raise RuntimeError('Source and destination app bundles must not be symlinks.') if source_app.resolve() == destination.resolve(): raise RuntimeError('Build product and install destination must differ.') original_stat = destination.stat() if destination.exists() else None if original_stat and read_bundle_id(destination) != source_id: raise RuntimeError('Existing app has a different bundle identifier; refusing to replace it.') staging = Path(tempfile.mkdtemp(prefix='.codex-app-install-', dir=install_dir)) prepared = staging / source_app.name backup = install_dir / '.codex-app-backups' / f'{source_app.stem}-{uuid4()}.app' moved = False try: result = subprocess.run(['ditto', str(source_app), str(prepared)], text=True, capture_output=True) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or result.stdout.strip() or 'Failed to copy build product.') if read_bundle_id(prepared) != source_id: raise RuntimeError('Prepared app identity differs from the build product.') latest_stat = destination.stat() if destination.exists() else None if latest_stat != original_stat or destination.is_symlink(): raise RuntimeError('Destination changed during installation; retry after reviewing it.') if original_stat: backup.parent.mkdir(exist_ok=True) if backup.parent.is_symlink(): raise RuntimeError('Backup directory must not be a symlink.') destination.rename(backup) moved = True try: prepared.rename(destination) except OSError: if moved: backup.rename(destination) moved = False raise if moved: print(f'previous_app_backup={backup}') return destination except OSError as exc: raise RuntimeError(f'Installation failed: {exc}') from exc finally: shutil.rmtree(staging) def read_bundle_id(app_path: Path) -> str: plist_path = app_path / 'Contents' / 'Info.plist' if not plist_path.exists(): raise RuntimeError(f'Built app has no Info.plist: {app_path}') with plist_path.open('rb') as handle: info = plistlib.load(handle) bundle_id = info.get('CFBundleIdentifier') if not isinstance(bundle_id, str) or not bundle_id.strip(): raise RuntimeError(f'App has no valid bundle identifier: {app_path}') return bundle_id def main() -> int: parser = argparse.ArgumentParser(description='Build and install a local Xcode-based macOS app.') parser.add_argument('--project-root', required=True, help='Repository root to inspect for the macOS app project.') parser.add_argument('--workspace', help='Explicit .xcworkspace path to use.') parser.add_argument('--project', help='Explicit .xcodeproj path to use.') parser.add_argument('--scheme', help='Explicit build scheme to use.') parser.add_argument('--configuration', default='Release', help='Build configuration to use. Defaults to Release.') parser.add_argument('--install-dir', default='/Applications', help='Directory that should receive the installed .app bundle.') parser.add_argument('--derived-data', help='Optional derived-data path override.') parser.add_argument('--list-schemes', action='store_true', help='List schemes for the resolved container and exit.') parser.add_argument('--run-xcodegen', action='store_true', help='Run xcodegen generate when project.yml exists but no Xcode container is present.') parser.add_argument('--open', action='store_true', help='Open the installed app after a successful install.') args = parser.parse_args() root = Path(args.project_root).expanduser().resolve() if not root.exists(): return fail(f'project root does not exist: {root}') try: container_kind, container_path = choose_container(args, root) schemes = load_schemes(container_kind, container_path) if args.list_schemes: print(f'container={container_path}') for scheme in schemes: print(scheme) return 0 scheme = choose_scheme(args.scheme, container_path, schemes) built_app = build_app(args, root, container_kind, container_path, scheme) installed_app = install_app(built_app, Path(args.install_dir).expanduser()) bundle_id = read_bundle_id(installed_app) print(f'container={container_path}') print(f'scheme={scheme}') print(f'configuration={args.configuration}') print(f'bundle_id={bundle_id}') print(f'built_app={built_app}') print(f'installed_app={installed_app}') if args.open: run(['open', str(installed_app)], cwd=root) print('opened=true') else: print('opened=false') return 0 except RuntimeError as exc: return fail(str(exc)) if __name__ == '__main__': raise SystemExit(main())