diff --git a/skills/tophat-build-install/SKILL.md b/skills/tophat-build-install/SKILL.md new file mode 100644 index 00000000..008086c1 --- /dev/null +++ b/skills/tophat-build-install/SKILL.md @@ -0,0 +1,87 @@ +--- +name: tophat-build-install +description: Find, build, and install mobile artifacts for the current repository through GitHub Actions and Tophat. Use it for requests like "install the latest build from main", "install PR #123 on my simulator", or "build this branch and install it". Default to the current GitHub repository, use `gh` for lookup and workflow dispatch, and use the local helper scripts for artifact listing and Tophat install. +--- + +# Tophat Build Install + +Use this skill to turn a user request into a concrete GitHub Actions artifact install through Tophat. + +This skill is GitHub Actions only. Require: + +- `gh` +- Tophat at `/Applications/Tophat.app/Contents/MacOS/tophatctl` with the `gha` provider installed +- a repository that already publishes GitHub Actions artifacts + +Do not switch to another artifact provider from this skill. + +## Inputs + +Collect only the missing inputs. + +- Platform: `ios` or `android`. +- Repository: derive it from the current checkout first. Ask only if there is no usable GitHub remote. +- Source selector: branch, PR number, workflow run ID, or explicit artifact ID. +- Workflow: only when a new build must be triggered. +- Destination: `simulator` or `device` when it matters. +- Launch arguments: only if the app needs them. + +If the user says "latest build", prefer the latest successful existing run for the requested branch instead of forcing a rebuild. If the user says "build", "rebuild", or "trigger", dispatch a new workflow run first. + +## Default Flow + +Use this order: + +1. Infer the current repository. +2. Resolve the target from the user request. +3. If the user gave an explicit artifact ID, install it directly. +4. Otherwise, look for an existing artifact first. +5. Trigger a workflow only when no suitable artifact exists or the user explicitly asked for a rebuild. + +## Build Resolution + +Resolve the user's target into a concrete artifact. Use `gh` when you need to inspect PRs, workflows, runs, or artifacts. Use `scripts/gha.py list-artifacts` when you want the local helper to list non-expired artifacts for the current repo or a chosen selector. + +The helper prefers the repository-wide artifacts endpoint, but if GitHub returns `404` there it should fall back to recent workflow runs instead of stopping immediately. + +Prefer this order: + +1. If the user gave an explicit artifact ID, install it directly. +2. If the user gave a workflow run ID, list artifacts and match `artifact.workflow_run.id`. +3. If the user gave a PR number, resolve the PR head branch and head SHA, then list artifacts for that head. +4. If the user gave a branch or commit SHA, list artifacts and match branch and-or SHA. +5. Trigger a workflow only when no matching non-expired artifact exists or the user explicitly asked for a rebuild. + +`scripts/gha.py list-artifacts` supports these selectors: + +- `--ref ` +- `--sha ` +- `--pr ` +- `--run-id ` +- `--platform ios|android` + +If multiple artifacts exist, match by platform or ask the user which artifact to install. + +## Install + +Prefer `scripts/install_artifact.py` for the normal install flow. It creates a temporary recipe, calls Tophat, prints a short status line, and removes the recipe unless `--keep-recipe` is set. + +Use `/Applications/Tophat.app/Contents/MacOS/tophatctl` as the Tophat CLI path. Keep that path explicit when reporting missing local tooling so agents do not guess. + +Use `scripts/make_recipe.py` directly only when you need the raw recipe JSON. + +For `gha`, the recipe must include: + +- `owner` +- `repo` +- `artifact_id` + +If `owner` and `repo` are omitted, infer them from Git. + +Treat the known `tophatctl` timeout as "install may still be in progress in Tophat", not as a hard failure. + +For `destination=device`, use only workflows that already produce signed device builds. If that path does not exist, stop and explain it. + +## Reference Notes + +Read `references/public-contract.md` when you need the public Tophat schema, the GitHub Actions provider details, or command examples. diff --git a/skills/tophat-build-install/references/public-contract.md b/skills/tophat-build-install/references/public-contract.md new file mode 100644 index 00000000..6e8333a1 --- /dev/null +++ b/skills/tophat-build-install/references/public-contract.md @@ -0,0 +1,74 @@ +# Public Contract + +This skill is anchored to the public `Shopify/tophat` repo, not a private internal fork. + +## Public `tophatctl` Recipe Schema + +When specifying the path to a JSON configuration file for `tophatctl install`, use a JSON array of recipes. + +General form: + +```json +[ + { + "artifactProviderID": "", + "artifactProviderParameters": {}, + "launchArguments": [], + "platformHint": "ios", + "destinationHint": "simulator" + } +] +``` + +To target a specific device by name and runtime version: + +```json +[ + { + "artifactProviderID": "", + "artifactProviderParameters": {}, + "launchArguments": [], + "device": { + "name": "iPhone 16 Pro", + "platform": "ios", + "runtimeVersion": "18.2" + } + } +] +``` + +Relevant public fields: + +- `artifactProviderID` +- `artifactProviderParameters` +- `launchArguments` +- `platformHint` +- `destinationHint` +- `device.name` +- `device.platform` +- `device.runtimeVersion` + +## Public GitHub Actions Provider + +The checked-out public repo includes `TophatGitHubActionsExtension`. + +- Provider ID: `gha` +- Provider title: `GitHub Actions` +- Required parameters: + - `owner` + - `repo` + - `artifact_id` + +The public implementation downloads an artifact archive from: + +```text +GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip +``` + +The extension requires a GitHub personal access token in Tophat settings. + +For this skill, owner and repo should normally come from the current checkout via `git remote get-url origin` or `git remote -v`. Only ask the user for repository details when the local checkout does not expose a usable GitHub remote. + +This skill is intentionally limited to GitHub Actions artifacts through `gh` and Tophat's `gha` provider. Do not add fallback behavior to GitHub MCP, direct REST calls, or other providers. + +Use `/Applications/Tophat.app/Contents/MacOS/tophatctl` as the CLI path for this skill. diff --git a/skills/tophat-build-install/scripts/gha.py b/skills/tophat-build-install/scripts/gha.py new file mode 100644 index 00000000..e3450216 --- /dev/null +++ b/skills/tophat-build-install/scripts/gha.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +import subprocess +import sys +from typing import Any + + +GITHUB_REMOTE_PATTERNS = ( + r"^git@github\.com:(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", + r"^https://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", + r"^ssh://git@github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", +) + + +def parse_github_owner_repo(remote_url: str) -> tuple[str, str] | None: + remote_url = remote_url.strip() + for pattern in GITHUB_REMOTE_PATTERNS: + match = re.match(pattern, remote_url) + if match: + return match.group("owner"), match.group("repo") + return None + + +def infer_owner_repo_from_git() -> tuple[str, str] | None: + for remote in ("origin", "upstream"): + result = subprocess.run( + ["git", "remote", "get-url", remote], + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 0: + parsed = parse_github_owner_repo(result.stdout) + if parsed: + return parsed + + result = subprocess.run( + ["git", "remote", "-v"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + parsed = parse_github_owner_repo(parts[1]) + if parsed: + return parsed + + return None + + +def run_gh_json(args: list[str]) -> Any: + result = subprocess.run( + ["gh", *args], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "gh command failed" + raise SystemExit(message) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise SystemExit(f"Failed to parse gh JSON output: {exc}") from exc + + +def is_not_found_error(message: str) -> bool: + return "Not Found (HTTP 404)" in message + + +def canonicalize_repo(repo: str) -> str: + payload = run_gh_json(["repo", "view", repo, "--json", "nameWithOwner"]) + canonical_repo = payload.get("nameWithOwner") or repo + if canonical_repo != repo: + print(f"Using canonical GitHub repository: {canonical_repo}", file=sys.stderr) + return canonical_repo + + +def normalize_sha(value: str) -> str: + return value.strip().lower() + + +def sha_matches(actual_sha: str, requested_sha: str) -> bool: + if not requested_sha: + return True + return normalize_sha(actual_sha).startswith(normalize_sha(requested_sha)) + + +def workflow_run_matches_ref(workflow_run: dict[str, Any], ref: str, sha: str) -> bool: + head_branch = workflow_run.get("head_branch") or workflow_run.get("headBranch") or "" + head_sha = workflow_run.get("head_sha") or workflow_run.get("headSha") or "" + if ref and head_branch != ref: + return False + return sha_matches(head_sha, sha) + + +def list_repo_artifacts(repo: str) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + page = 1 + + while True: + try: + payload = run_gh_json( + [ + "api", + f"repos/{repo}/actions/artifacts", + "-F", + "per_page=100", + "-F", + f"page={page}", + ] + ) + except SystemExit as exc: + if page == 1 and is_not_found_error(str(exc)): + print( + "GitHub repository artifact API returned 404. " + "Falling back to recent workflow runs.", + file=sys.stderr, + ) + return list_all_run_artifacts(repo) + raise + page_artifacts = payload.get("artifacts", []) + if not page_artifacts: + return artifacts + artifacts.extend(page_artifacts) + if len(page_artifacts) < 100: + return artifacts + page += 1 + + +def list_runs(repo: str, ref: str) -> list[dict[str, Any]]: + args = [ + "run", + "list", + "--repo", + repo, + "--limit", + "100", + "--json", + "databaseId,headBranch,headSha", + ] + if ref: + args.extend(["--branch", ref]) + payload = run_gh_json(args) + if not isinstance(payload, list): + raise SystemExit("Failed to parse gh run list output.") + return payload + + +def list_run_artifacts(repo: str, run_id: int) -> list[dict[str, Any]]: + payload = run_gh_json(["api", f"repos/{repo}/actions/runs/{run_id}/artifacts"]) + artifacts = payload.get("artifacts", []) + for artifact in artifacts: + workflow_run = artifact.get("workflow_run") or {} + if not workflow_run: + artifact["workflow_run"] = {"id": run_id} + return artifacts + + +def list_matching_run_artifacts(repo: str, ref: str, sha: str) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + + for run in list_runs(repo, ref=ref): + if not workflow_run_matches_ref(run, ref=ref, sha=sha): + continue + + run_id = run.get("databaseId") + if not run_id: + continue + + for artifact in list_run_artifacts(repo, int(run_id)): + workflow_run = artifact.get("workflow_run") or {} + if "head_branch" not in workflow_run and run.get("headBranch"): + workflow_run["head_branch"] = run.get("headBranch") + if "head_sha" not in workflow_run and run.get("headSha"): + workflow_run["head_sha"] = run.get("headSha") + artifact["workflow_run"] = workflow_run + artifacts.append(artifact) + + return artifacts + + +def list_all_run_artifacts(repo: str) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + + for run in list_runs(repo, ref=""): + run_id = run.get("databaseId") + if not run_id: + continue + + for artifact in list_run_artifacts(repo, int(run_id)): + workflow_run = artifact.get("workflow_run") or {} + if "head_branch" not in workflow_run and run.get("headBranch"): + workflow_run["head_branch"] = run.get("headBranch") + if "head_sha" not in workflow_run and run.get("headSha"): + workflow_run["head_sha"] = run.get("headSha") + artifact["workflow_run"] = workflow_run + artifacts.append(artifact) + + return artifacts + + +def resolve_pr_ref(repo: str, pr: int) -> tuple[str, str]: + payload = run_gh_json(["api", f"repos/{repo}/pulls/{pr}"]) + head = payload.get("head") or {} + head_ref = head.get("ref") or "" + head_sha = head.get("sha") or "" + if not head_ref or not head_sha: + raise SystemExit(f"Could not resolve PR #{pr} to a head ref and SHA.") + return head_ref, head_sha + + +def artifact_matches( + artifact: dict[str, Any], + ref: str, + sha: str, + run_id: int | None, + platform: str, +) -> bool: + if artifact.get("expired"): + return False + + workflow_run = artifact.get("workflow_run") or {} + if run_id is not None and workflow_run.get("id") != run_id: + return False + if not workflow_run_matches_ref(workflow_run, ref=ref, sha=sha): + return False + if platform and platform.lower() not in (artifact.get("name") or "").lower(): + return False + return True + + +def print_artifacts(artifacts: list[dict[str, Any]]) -> None: + for artifact in sorted( + artifacts, + key=lambda item: item.get("created_at") or "", + reverse=True, + ): + workflow_run = artifact.get("workflow_run") or {} + print( + "\t".join( + [ + str(artifact.get("id", "")), + artifact.get("name") or "", + workflow_run.get("head_branch") or "", + (workflow_run.get("head_sha") or "")[:7], + artifact.get("created_at") or "", + ] + ) + ) + + +def list_artifacts( + repo: str, + ref: str, + sha: str, + pr: int | None, + run_id: int | None, + platform: str, +) -> int: + repo = canonicalize_repo(repo) + + if pr is not None: + pr_ref, pr_sha = resolve_pr_ref(repo, pr) + ref = ref or pr_ref + sha = sha or pr_sha + + if run_id is not None: + artifacts = list_run_artifacts(repo, run_id) + elif ref or sha: + artifacts = list_matching_run_artifacts(repo, ref=ref, sha=sha) + else: + artifacts = list_repo_artifacts(repo) + + print_artifacts( + [ + artifact + for artifact in artifacts + if artifact_matches( + artifact, + ref=ref, + sha=sha, + run_id=run_id, + platform=platform, + ) + ] + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="GitHub Actions helpers for the tophat-build-install skill." + ) + subparsers = parser.add_subparsers(dest="command") + + list_parser = subparsers.add_parser( + "list-artifacts", + help="List non-expired GitHub Actions artifacts for the current repo.", + ) + list_parser.add_argument("--repo", help="Repository in owner/repo format.") + list_parser.add_argument("--ref", default="", help="Branch name to match.") + list_parser.add_argument( + "--sha", + default="", + help="Commit SHA or prefix to match against the artifact workflow run.", + ) + list_parser.add_argument("--pr", type=int, help="PR number to resolve.") + list_parser.add_argument("--run-id", type=int, help="Workflow run database ID.") + list_parser.add_argument( + "--platform", + default="", + choices=("ios", "android", ""), + help="Platform substring to match in artifact names.", + ) + + args = parser.parse_args() + + if args.command != "list-artifacts": + parser.print_help(sys.stderr) + return 1 + + repo = args.repo + if not repo: + inferred = infer_owner_repo_from_git() + if not inferred: + raise SystemExit("Could not infer GitHub repository from git remotes.") + repo = "/".join(inferred) + + return list_artifacts( + repo=repo, + ref=args.ref, + sha=args.sha, + pr=args.pr, + run_id=args.run_id, + platform=args.platform, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/tophat-build-install/scripts/install_artifact.py b/skills/tophat-build-install/scripts/install_artifact.py new file mode 100644 index 00000000..7111458f --- /dev/null +++ b/skills/tophat-build-install/scripts/install_artifact.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 + +import argparse +import sys +from pathlib import Path + +from install_with_tophat import ( + STATUS_INSTALLED, + STATUS_PENDING, + install_recipe, +) +from make_recipe import build_provider_parameters, build_recipe, parse_key_value, write_recipe + + +GREEN = "\033[32m" +YELLOW = "\033[33m" +RESET = "\033[0m" + + +def colorize(message: str, color: str) -> str: + return f"{color}{message}{RESET}" + + +def format_status_message( + prefix: str, + source: str, + artifact_id: str, + platform: str, + destination: str | None, +) -> str: + parts = [ + f"{prefix}: {source}", + f"artifact {artifact_id}", + platform, + ] + if destination: + parts.append(destination) + return " ยท ".join(parts) + + +def recipe_path_for_artifact(tmp_dir: Path, artifact_id: str) -> Path: + return tmp_dir / f"tophat-recipe-{artifact_id}.json" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Create a Tophat recipe, install it, and print user-facing status." + ) + parser.add_argument("--provider", default="gha", help="Tophat artifact provider ID.") + parser.add_argument("--owner", help="GitHub owner for the public gha provider.") + parser.add_argument("--repo", help="GitHub repository for the public gha provider.") + parser.add_argument( + "--artifact-id", + required=True, + help="GitHub Actions artifact ID for the public gha provider.", + ) + parser.add_argument( + "--platform", + required=True, + choices=("ios", "android"), + help="Recipe platformHint.", + ) + parser.add_argument( + "--destination", + choices=("simulator", "device"), + help="Recipe destinationHint.", + ) + parser.add_argument( + "--source", + required=True, + help="Short source label used in the user-facing status message.", + ) + parser.add_argument( + "--launch-arg", + action="append", + default=[], + help="Launch argument to pass through to the app. Repeat as needed.", + ) + parser.add_argument( + "--param", + action="append", + default=[], + type=parse_key_value, + help="Additional artifactProviderParameters as key=value. Repeat as needed.", + ) + parser.add_argument( + "--tmp-dir", + default="tmp", + help="Directory for the temporary recipe file.", + ) + parser.add_argument( + "--keep-recipe", + action="store_true", + help="Keep the generated recipe file for debugging.", + ) + args = parser.parse_args() + + try: + provider_parameters = build_provider_parameters( + provider=args.provider, + owner=args.owner, + repo=args.repo, + artifact_id=args.artifact_id, + extra_parameters=dict(args.param), + ) + except ValueError as exc: + parser.error(str(exc)) + + recipe = build_recipe( + provider=args.provider, + platform=args.platform, + destination=args.destination, + provider_parameters=provider_parameters, + launch_arguments=args.launch_arg, + ) + + tmp_dir = Path(args.tmp_dir) + tmp_dir.mkdir(parents=True, exist_ok=True) + recipe_path = recipe_path_for_artifact(tmp_dir, args.artifact_id) + write_recipe(recipe, recipe_path) + + try: + result = install_recipe(recipe_path) + finally: + if not args.keep_recipe and recipe_path.exists(): + recipe_path.unlink() + + if result.stdout: + print(result.stdout) + if result.status == STATUS_INSTALLED: + print( + colorize( + format_status_message( + prefix="Installed build successfully", + source=args.source, + artifact_id=args.artifact_id, + platform=args.platform, + destination=args.destination, + ), + GREEN, + ) + ) + return 0 + + if result.status == STATUS_PENDING: + if result.stderr: + print(result.stderr, file=sys.stderr) + print( + colorize( + format_status_message( + prefix="Install is still in progress in Tophat", + source=args.source, + artifact_id=args.artifact_id, + platform=args.platform, + destination=args.destination, + ), + YELLOW, + ) + ) + return 0 + + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/tophat-build-install/scripts/install_with_tophat.py b/skills/tophat-build-install/scripts/install_with_tophat.py new file mode 100644 index 00000000..79c261d3 --- /dev/null +++ b/skills/tophat-build-install/scripts/install_with_tophat.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +TOPHATCTL_PATH = "/Applications/Tophat.app/Contents/MacOS/tophatctl" +TIMEOUT_MESSAGE = "Error: The operation timed out." +STATUS_INSTALLED = "installed" +STATUS_PENDING = "pending" +STATUS_FAILED = "failed" + + +@dataclass +class InstallResult: + status: str + returncode: int + stdout: str + stderr: str + + @property + def combined_output(self) -> str: + return "\n".join(part for part in (self.stdout, self.stderr) if part) + + +def install_recipe(recipe_path: Path) -> InstallResult: + if not Path(TOPHATCTL_PATH).exists(): + return InstallResult( + status=STATUS_FAILED, + returncode=1, + stdout="", + stderr=f"Tophat CLI not found at {TOPHATCTL_PATH}", + ) + + result = subprocess.run( + [TOPHATCTL_PATH, "install", str(recipe_path)], + check=False, + capture_output=True, + text=True, + ) + + stdout = result.stdout.strip() + stderr = result.stderr.strip() + combined = "\n".join(part for part in (stdout, stderr) if part) + + if result.returncode == 0: + return InstallResult( + status=STATUS_INSTALLED, + returncode=0, + stdout=stdout, + stderr=stderr, + ) + + if TIMEOUT_MESSAGE in combined: + pending_stderr = ( + "Tophat CLI timed out while waiting for completion. " + "The install request was sent and installation may still be in progress in the Tophat app." + ) + return InstallResult( + status=STATUS_PENDING, + returncode=0, + stdout=stdout, + stderr=pending_stderr, + ) + + return InstallResult( + status=STATUS_FAILED, + returncode=result.returncode, + stdout=stdout, + stderr=stderr, + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Install a Tophat recipe file with " + f"{TOPHATCTL_PATH} and treat the known CLI timeout as in-progress." + ) + ) + parser.add_argument("recipe", help="Path to a Tophat recipe JSON file.") + args = parser.parse_args() + + recipe_path = Path(args.recipe).resolve() + if not recipe_path.exists(): + parser.error(f"Recipe file not found: {recipe_path}") + + result = install_recipe(recipe_path) + + if result.status == STATUS_INSTALLED: + if result.combined_output: + print(result.combined_output) + return 0 + + if result.status == STATUS_PENDING: + if result.stdout: + print(result.stdout) + print(result.stderr, file=sys.stderr) + return 0 + + if result.combined_output: + print(result.combined_output, file=sys.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/tophat-build-install/scripts/make_recipe.py b/skills/tophat-build-install/scripts/make_recipe.py new file mode 100755 index 00000000..cea8d26f --- /dev/null +++ b/skills/tophat-build-install/scripts/make_recipe.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from gha import infer_owner_repo_from_git + + +def parse_key_value(item: str) -> tuple[str, str]: + if "=" not in item: + raise argparse.ArgumentTypeError(f"expected key=value, got: {item}") + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + raise argparse.ArgumentTypeError(f"missing key in: {item}") + return key, value + + +def build_provider_parameters( + provider: str, + owner: str | None, + repo: str | None, + artifact_id: str | None, + extra_parameters: dict[str, str] | None = None, +) -> dict[str, str]: + provider_parameters = dict(extra_parameters or {}) + + if provider != "gha": + return provider_parameters + + if not owner or not repo: + inferred = infer_owner_repo_from_git() + if inferred: + inferred_owner, inferred_repo = inferred + owner = owner or inferred_owner + repo = repo or inferred_repo + + missing = [ + name + for name, value in ( + ("owner", owner), + ("repo", repo), + ("artifact-id", artifact_id), + ) + if not value + ] + if missing: + missing_list = ", ".join(missing) + raise ValueError( + f"provider 'gha' requires --owner, --repo, and --artifact-id; missing: {missing_list}" + ) + + return { + "owner": owner, + "repo": repo, + "artifact_id": artifact_id, + **provider_parameters, + } + + +def build_recipe( + provider: str, + platform: str, + destination: str | None, + provider_parameters: dict[str, str], + launch_arguments: list[str] | None = None, +) -> list[dict[str, Any]]: + return [ + { + "artifactProviderID": provider, + "artifactProviderParameters": provider_parameters, + "launchArguments": list(launch_arguments or []), + "platformHint": platform, + "destinationHint": destination, + } + ] + + +def write_recipe(recipe: list[dict[str, Any]], output_path: Path) -> None: + output = json.dumps(recipe, indent=2) + "\n" + output_path.write_text(output) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Create a public tophatctl recipe JSON file." + ) + parser.add_argument("--provider", default="gha", help="Tophat artifact provider ID.") + parser.add_argument("--owner", help="GitHub owner for the public gha provider.") + parser.add_argument("--repo", help="GitHub repository for the public gha provider.") + parser.add_argument( + "--artifact-id", + help="GitHub Actions artifact ID for the public gha provider.", + ) + parser.add_argument( + "--platform", + required=True, + choices=("ios", "android"), + help="Recipe platformHint.", + ) + parser.add_argument( + "--destination", + choices=("simulator", "device"), + help="Recipe destinationHint.", + ) + parser.add_argument( + "--launch-arg", + action="append", + default=[], + help="Launch argument to pass through to the app. Repeat as needed.", + ) + parser.add_argument( + "--param", + action="append", + default=[], + type=parse_key_value, + help="Additional artifactProviderParameters as key=value. Repeat as needed.", + ) + parser.add_argument( + "--output", + help="Write JSON to this path instead of stdout.", + ) + args = parser.parse_args() + + try: + provider_parameters = build_provider_parameters( + provider=args.provider, + owner=args.owner, + repo=args.repo, + artifact_id=args.artifact_id, + extra_parameters=dict(args.param), + ) + except ValueError as exc: + parser.error(str(exc)) + + recipe = build_recipe( + provider=args.provider, + platform=args.platform, + destination=args.destination, + provider_parameters=provider_parameters, + launch_arguments=args.launch_arg, + ) + + if args.output: + write_recipe(recipe, Path(args.output)) + else: + sys.stdout.write(json.dumps(recipe, indent=2) + "\n") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())