| 1 | # AGENTS.md |
| 2 | |
| 3 | This file provides guidance to AI coding agents (siGit Code, Claude Code, etc.) when working with code in this repository. It is the canonical agent doc, symlinked from the repo root as both `AGENTS.md` and `CLAUDE.md`. |
| 4 | |
| 5 | Guidance for AI coding agents working in this repository. |
| 6 | |
| 7 | ## What this is |
| 8 | |
| 9 | `smbcloud-ascapi` is a Rust workspace wrapping the App Store Connect API. It |
| 10 | ships two front ends over one shared core: |
| 11 | |
| 12 | - the `asc` CLI (`crates/cli`), and |
| 13 | - an MCP stdio server (`crates/mcp`, reachable via `asc --mcp`). |
| 14 | |
| 15 | Both go through `smbcloud-ascapi-frontend` so they can't drift. See `README.md` |
| 16 | for the full crate table. |
| 17 | |
| 18 | ## Workspace layout |
| 19 | |
| 20 | Domain crates (`aso`, `pricing`, `signing`, `reports`) know nothing about each |
| 21 | other or the front ends. Each extends `smbcloud_ascapi_core::Client` with an |
| 22 | **extension trait** (Rust only allows inherent impls in the crate that owns the |
| 23 | type). Import a domain's `prelude::*` to pull its traits in at once. |
| 24 | |
| 25 | ## Conventions |
| 26 | |
| 27 | - JSON:API responses deserialize through `ListDocument<T>` / the core |
| 28 | envelopes; `Resource.id` is the ASC id. |
| 29 | - CLI `ValueEnum` wrappers (`CliPlatform`, `CliBundleIdPlatform`, |
| 30 | `CliProfileType`, …) wrap the domain enums so clap owns the arg parsing; |
| 31 | map them to the domain type in the match arm (e.g. `.map(Platform::from)`). |
| 32 | - Tests live in-module: `#[cfg(test)] mod tests { use super::*; … }` with plain |
| 33 | `#[test]` fns (see `crates/aso/src/bundle_id.rs`, `crates/aso/src/build.rs`). |
| 34 | - Pin exact ASC filter keys in assertions — e.g. |
| 35 | `filter[preReleaseVersion.version]` vs `filter[version]`; the wrong key |
| 36 | silently returns the wrong rows. |
| 37 | |
| 38 | ## Builds API (CLI) |
| 39 | |
| 40 | All are subcommands of `apps` (clap kebab-cases the command names): |
| 41 | |
| 42 | ```sh |
| 43 | # GET /v1/builds?filter[app]={id} — newest first. Check processingState |
| 44 | # (VALID / INVALID / PROCESSING / FAILED). |
| 45 | asc apps builds <APP_ID> \ |
| 46 | [--version <BUILD_NUMBER>] \ # filter[version], e.g. 42 |
| 47 | [--pre-release-version <MARKETING>] \ # filter[preReleaseVersion.version], e.g. 1.1.0 |
| 48 | [--processing-state <STATE>] # filter[processingState] |
| 49 | |
| 50 | # GET /v1/builds/{id} — a single build by its ASC id. |
| 51 | asc apps build <BUILD_ID> |
| 52 | |
| 53 | # PATCH /v1/builds/{id} usesNonExemptEncryption — answer export compliance on a |
| 54 | # build showing "Missing Compliance". Without an answer, review-submissions |
| 55 | # submit fails with 409 STATE_ERROR.ENTITY_STATE_INVALID. false = "None of the |
| 56 | # algorithms mentioned above" (HTTPS through Apple's OS only). |
| 57 | asc apps set-encryption <BUILD_ID> --uses-non-exempt-encryption false |
| 58 | |
| 59 | # GET /v1/preReleaseVersions?filter[app]={id} — the marketing version strings |
| 60 | # TestFlight groups builds under. |
| 61 | asc apps pre-release-versions <APP_ID> [--platform <ios|mac-os|tv-os|vision-os>] |
| 62 | |
| 63 | # xcrun altool --upload-package, authenticated with the same key id, issuer |
| 64 | # and .p8 (--p8-file-path) as every other command. --dry-run prints the argv. |
| 65 | asc apps upload <PATH.ipa|PATH.pkg> [--wait] |
| 66 | ``` |
| 67 | |
| 68 | The underlying API lives in `crates/aso/src/build.rs`: `BuildsApi` (in the |
| 69 | `aso` prelude) plus `BuildFilter`, whose private `query_pairs` maps each set |
| 70 | field to its ASC filter key. The upload lives in |
| 71 | `crates/frontend/src/upload.rs`. It always passes `--p8-file-path` and never |
| 72 | `--auth-string`: letting altool search its key directories can pick up a |
| 73 | stale `AuthKey_<id>.p8` and fail with a 401, and `--auth-string` would put |
| 74 | the key in the process table. Upload is CLI-only, because a tool call that |
| 75 | blocks for minutes and publishes a build doesn't belong in MCP. |
| 76 | |
| 77 | ## Git branching workflow |
| 78 | |
| 79 | Branches flow in one direction: `main` ← `development` ← feature branch. |
| 80 | |
| 81 | - `main` is the release branch. Never commit to it directly; it only advances |
| 82 | by merging `development`. |
| 83 | - `development` is the integration/base branch. Never commit to it directly |
| 84 | either — every change lands through a pull request. |
| 85 | - Do the work on a short-lived **feature branch** cut from `development` |
| 86 | (`feat/…`, `fix/…`, `docs/…`), then **open a PR into `development`**. Let CI |
| 87 | (fmt / clippy `-D warnings` / test / doc / lower-crate solo builds) pass |
| 88 | before merge. |
| 89 | - When `development` is ready to release, it is merged into `main` via its own |
| 90 | PR — not by pushing commits onto `main`. |
| 91 | |
| 92 | So: branch off `development`, push the feature branch, open a PR → `development`. |
| 93 | Do not commit straight to `development` or `main`. |
| 94 | |
| 95 | ## Verify before committing |
| 96 | |
| 97 | CI (`.github/workflows/ci.yml`, ubuntu + macOS) runs these, and warnings are |
| 98 | errors, so run the strict forms locally: |
| 99 | |
| 100 | ```sh |
| 101 | cargo fmt --all -- --check |
| 102 | cargo clippy --workspace --all-targets --locked -- -D warnings |
| 103 | cargo test --workspace --locked |
| 104 | RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --locked |
| 105 | cargo check -p smbcloud-ascapi-core --locked # also -aso, -play, -signing: each lower crate must build alone |
| 106 | ``` |
| 107 | |
| 108 | Single test: `cargo test -p smbcloud-ascapi-aso build::tests::<name>`. |
| 109 | Intra-doc links are load-bearing (a broken one fails CI), and the CSR tests |
| 110 | shell out to `openssl`, whose output differs between OpenSSL 3 and macOS |
| 111 | LibreSSL. |
| 112 | |
| 113 | ## Verify before relying on a freshly built `asc` |
| 114 | |
| 115 | The released/installed `asc` binary can silently lag `development`. This |
| 116 | hides because `asc apps upload` only shells out to `xcrun altool` and |
| 117 | doesn't touch newer code paths, so an upload succeeds against a stale |
| 118 | binary that lacks e.g. the `testflight` subcommand. Before relying on a |
| 119 | newly-added command, rebuild from `development`: |
| 120 | |
| 121 | ```sh |
| 122 | cargo build --release |
| 123 | # or, to be sure you're on the current branch's binary: |
| 124 | cargo install --path crates/cli --locked |
| 125 | ``` |
| 126 | |
| 127 | `unrecognized subcommand` on a command that's been merged is the symptom; |
| 128 | the cure is a rebuild, not a workaround. |
| 129 | |
| 130 | ## Architecture notes |
| 131 | |
| 132 | - Layering is `core` → `aso`/`pricing`/`signing`/`reports` → `frontend` → |
| 133 | `mcp` / `cli`. Don't add a dependency that points up or sideways; CI checks |
| 134 | the lower crates build alone. |
| 135 | - The MCP tool set is declared by the `ascapi_mcp_tools!` macro |
| 136 | (`crates/mcp/src/lib.rs`), which takes every tool name as a literal; |
| 137 | `crates/mcp/src/server.rs` supplies them. Adding a tool means touching the |
| 138 | macro, the invocation, and `requests.rs`. |
| 139 | - stdout belongs to the MCP JSON-RPC stream: nothing under a tool call may |
| 140 | `println!`. Results sent to a model never contain private keys or |
| 141 | certificate/profile bodies; return the path written instead. That is |
| 142 | enforced by the result types in `crates/frontend`, so keep new result types |
| 143 | free of key material rather than filtering at the edge. |
| 144 | - Destructive or irreversible operations stay CLI-only (certificate revoke, |
| 145 | review submission, upload). Recreatable deletes may be MCP tools, annotated |
| 146 | destructive. |
| 147 | - `.agents/plans/` holds design plans from past release sessions; check them |
| 148 | before reworking upload or signing-identity behaviour. |
| 149 | |
| 150 | ## Credentials |
| 151 | |
| 152 | The CLI needs an ASC API key: `--key-id`/`ASC_API_KEY`, |
| 153 | `--issuer-id`/`ASC_ISSUER_ID`, and the `.p8` at |
| 154 | `--private-key-path`/`ASC_PRIVATE_KEY_PATH` (defaults to |
| 155 | `~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`). `--mcp` resolves |
| 156 | credentials per tool call, so the server can list tools unconfigured. |
| 157 | |
| 158 | ### App Store Connect key location |
| 159 | |
| 160 | `ascapi` itself defaults to `~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`. |
| 161 | Some sibling repos use a different default and can drift when two copies of |
| 162 | the same key id are on disk: |
| 163 | |
| 164 | - `onde-store/scripts/build-ios.sh` and `smbcloud-browser/scripts/build-ios.sh` |
| 165 | default to `~/private_keys/AuthKey_<id>.p8`. |
| 166 | |
| 167 | A stale `AuthKey_<id>.p8` with the same id surfaces as an indistinguishable |
| 168 | 401 on ASC API calls — ASC says "valid key id, signature doesn't verify." |
| 169 | `ascapi apps upload` dodges the half of the drift that goes through altool |
| 170 | by passing `--p8-file-path` explicitly (so altool can't search for a stale |
| 171 | key). For every other command the resolution rule is the one above; if a |
| 172 | 401 appears where it shouldn't, the first thing to check is which `.p8` |
| 173 | both sides are actually reading. If you need the scripts to use a single |
| 174 | key file, set `ASC_PRIVATE_KEY_PATH` explicitly in the environment |
| 175 | instead of relying on either default. |
| 176 | |
| 177 | ## Finish-the-release recipe (TestFlight) |
| 178 | |
| 179 | The shortest path from a signed `.pkg` to a TestFlight group: |
| 180 | |
| 181 | 1. **Upload:** `asc apps upload <PKG> --wait` (always passes |
| 182 | `--p8-file-path`; never call `altool` by hand — its key search can pick a |
| 183 | stale `AuthKey_<id>.p8` and fail with a 401). |
| 184 | 2. **Poll to VALID:** `asc apps builds <APP_ID> --version <BUILD_NUMBER>` |
| 185 | until `processingState` is `VALID`, then read the build id off the |
| 186 | response. (`asc apps await-processing` is the non-blocking alternative |
| 187 | to `--wait` if you're driving this from CI.) |
| 188 | 3. **Beta-review contact details** (one-time per app): |
| 189 | `asc testflight app-review-detail update ...`. |
| 190 | 4. **Submit:** find the Public Link group with |
| 191 | `asc testflight groups list <APP_ID>`, then either |
| 192 | `groups add-build --build-id <BUILD_ID> <GROUP_ID>` or |
| 193 | `app-review-submissions submit <BUILD_ID>`. Both are CLI-only — adding |
| 194 | to an external group / submitting to Beta App Review can trigger review, |
| 195 | which an MCP tool call shouldn't do unattended. |
| 196 | |
| 197 | ## Review submissions (CLI) |
| 198 | |
| 199 | ```sh |
| 200 | # GET /v1/appStoreVersions/{id}/build — the build attached to a version, or |
| 201 | # null (with "no build attached" on stderr). Run it before submitting. |
| 202 | asc app-store-versions build <APP_STORE_VERSION_ID> |
| 203 | |
| 204 | # PATCH the version's build relationship, then read the build back and fail if |
| 205 | # it isn't the one requested. Prints the build's id, version, processingState |
| 206 | # and usesNonExemptEncryption. --no-verify skips the read-back; --dry-run |
| 207 | # stays offline. |
| 208 | asc app-store-versions set-build <APP_STORE_VERSION_ID> --build-id <BUILD_ID> [--no-verify] |
| 209 | |
| 210 | # GET /v1/reviewSubmissions?filter[app]={id} |
| 211 | asc review-submissions list <APP_ID> [--platform <...>] [--state READY_FOR_REVIEW] |
| 212 | |
| 213 | # Reuse the platform's open (READY_FOR_REVIEW) submission or create one, add |
| 214 | # the version as an item, then PATCH submitted: true. --dry-run reads and |
| 215 | # prints the plan. Refuses a version that is already WAITING_FOR_REVIEW, |
| 216 | # IN_REVIEW or live, before any write. |
| 217 | asc review-submissions submit <APP_ID> --version-id <APP_STORE_VERSION_ID> |
| 218 | |
| 219 | # PATCH canceled: true, for a submission App Review hasn't picked up. |
| 220 | asc review-submissions cancel <SUBMISSION_ID> |
| 221 | ``` |
| 222 | |
| 223 | The API is `ReviewSubmissionsApi` in `crates/aso/src/review_submission.rs`; |
| 224 | the plan/submit orchestration is `crates/frontend/src/review.rs`. Submitting is |
| 225 | CLI-only on purpose: it sends a build to App Review, which an MCP tool call |
| 226 | shouldn't do on a model's say-so. |
| 227 | |
| 228 | ## Google Play (CLI) |
| 229 | |
| 230 | `crates/play` is the one non-Apple store backend. It does not depend on |
| 231 | `core`: Play authenticates with a service account (RS256 JWT exchanged at |
| 232 | `oauth2.googleapis.com/token`, scope `androidpublisher`) and has its own error |
| 233 | envelope. The key comes from `--play-service-account`, |
| 234 | `PLAY_SERVICE_ACCOUNT_JSON_PATH`, or the contents in `PLAY_SERVICE_ACCOUNT_JSON`. |
| 235 | `asc play` does not need the App Store Connect key. |
| 236 | |
| 237 | Everything in Play happens inside an *edit*. Each command opens one, applies, |
| 238 | `validate`s, and commits only without `--dry-run`; a dry run prints the body |
| 239 | and deletes the edit. Writes read back after commit and fail on any difference. |
| 240 | |
| 241 | ```sh |
| 242 | asc play listings list <PACKAGE> |
| 243 | asc play listings update <PACKAGE> --locale en-US \ |
| 244 | --title-file t.txt --short-description-file s.txt --full-description-file d.txt |
| 245 | asc play images list <PACKAGE> --locale en-US --type phone-screenshots |
| 246 | asc play images replace <PACKAGE> --locale en-US --type phone-screenshots --file a.png --file b.png |
| 247 | asc play tracks get <PACKAGE> --track internal |
| 248 | asc play tracks set-notes <PACKAGE> --track internal --version-code 120 --locale en-US --notes-file n.txt |
| 249 | ``` |
| 250 | |
| 251 | Limits linted before sending: title 30, short description 80, full description |
| 252 | 4000, release notes 500 characters per language; feature graphic exactly |
| 253 | 1024x500, icon 512x512, screenshots 320 to 3840 px with at most 2:1 aspect. |
| 254 | `images replace` deletes the bucket and uploads the files in one edit, so a |
| 255 | failed upload keeps the old set. MCP exposes only the reads |
| 256 | (`play_listing_list`, `play_images_list`, `play_track_get`); every write stays |
| 257 | CLI-only, like review submission. The operations live in |
| 258 | `crates/frontend/src/play.rs`. |