storebound api: every App Store Connect and Google Play operation

- specs/: Apple's App Store Connect OpenAPI 4.5.1 and the Google discovery docs for androidpublisher v3 and
  playdeveloperreporting v1beta1, pinned with URL, date and SHA-256 (specs/SOURCES.json)
- tools/gen-api.ts: one row per operation (cli/api/apple.gen.ts 1303, cli/api/play.gen.ts 170), a scan of which
  operations the commands already call (cli/api/wrapped.gen.ts) and docs/api-coverage.md; CI checks freshness
- cli/api/client.ts + types.ts: generic URL / parameter building, retries with Retry-After, pagination, types derived
  from the rows; auth reuses ascJwt and the Play service account
- cli/api/gate.ts: GET runs, writes dry unless --yes in a TTY, owner-only operations need the id typed, nothing
  written under MCP or serve; MCP gets the read-only api_get
- storebound api list | coverage | apple|play <op|METHOD path>; skill store-api, README, CHANGELOG, tests
This commit is contained in:
CRTkafa 2026-10-11 23:37:09 +08:00
parent 346d80e07e
commit 77aa09235e
30 changed files with 281170 additions and 13 deletions

View file

@ -35,6 +35,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
@ -56,6 +57,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game - `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game
- `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way - `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way
- `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git - `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git
- `skills/store-api/SKILL.md`: Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list

View file

@ -35,6 +35,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
@ -58,6 +59,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game | | `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game |
| `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way | | `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way |
| `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git | | `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git |
| `store-api` | Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely |
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |

View file

@ -36,6 +36,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
@ -57,6 +58,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game - `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game
- `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way - `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way
- `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git - `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git
- `skills/store-api/SKILL.md`: Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list

View file

@ -2,6 +2,25 @@
## Unreleased ## Unreleased
- Every endpoint: `storebound api` calls any operation of the App Store Connect API (1303 operations, OpenAPI 4.5.1)
and the Google Play Developer APIs (Android Publisher v3: 145 methods, Play Developer Reporting v1beta1: 25; v1alpha1
has no methods of its own), from the official specs pinned in `specs/` (URLs, dates, SHA-256 in
`specs/SOURCES.json`). `tools/gen-api.ts` turns them into one row per operation (`cli/api/apple.gen.ts`,
`cli/api/play.gen.ts`) plus generic helpers (`cli/api/client.ts`: URL and parameter building, retries on 429 / 5xx
with Retry-After, `--all-pages` over Apple's `links.next` and Google's page tokens), typed from the rows
(`cli/api/types.ts`: path and required parameters, loosely typed JSON:API requests and responses). About 44 KB more
in the npm tarball. Same credentials as every other command (`ascJwt` is now shared from `cli/apple/asc.ts`).
- `storebound api apple|play <operationId | METHOD path> [--param k=v ...] [--body file.json] [--upload file]
[--out file] [--all-pages] [--json] [--dry] [--yes]`: GETs (and the read-only Reporting `:query` POSTs) run;
every write prints the exact request and is only sent with `--yes` in an interactive terminal; owner-only
operations (`cli/api/gate.ts`: deletes, submissions, releases, phased release changes, product page tests, prices,
testers and users, review replies, Play `edits.commit`, activation, refunds, app signing) also need the operation
id typed. Under MCP or the desktop app no write is sent at all.
- `storebound api list [--store] [--search]` and `storebound api coverage`: every operation, counts per API and
resource, and which ones storebound's commands already call (a scan of `cli/`, `cli/api/wrapped.gen.ts`); the full
table is `docs/api-coverage.md`. CI fails when the tables, the scan or the doc are stale against `specs/`.
- MCP: one new read-only tool, `api_get` (GET operations only; not in the desktop profile). New skill `store-api`.
- New logo: a glossy indigo box with an upward arrow (`brand/master/logo-mark.png`, `brand/README.md`). Every logo - New logo: a glossy indigo box with an upward arrow (`brand/master/logo-mark.png`, `brand/README.md`). Every logo
file comes from it through `tools/brand-assets.py`: desktop app icons (PNG sizes, `.icns`, a `.ico` with its own file comes from it through `tools/brand-assets.py`: desktop app icons (PNG sizes, `.icns`, a `.ico` with its own
image per size, Windows tiles), the site's favicon.ico, maskable web app icons with a manifest, apple-touch-icon, image per size, Windows tiles), the site's favicon.ico, maskable web app icons with a manifest, apple-touch-icon,

View file

@ -34,6 +34,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
@ -57,6 +58,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game | | `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game |
| `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way | | `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way |
| `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git | | `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git |
| `store-api` | Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely |
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |

View file

@ -212,6 +212,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left | | `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files | | `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests | | `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](#every-endpoint)); GET runs, writes are dry unless `--yes` in a terminal |
| `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) | | `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) |
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones | | `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons | | `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
@ -304,6 +305,36 @@ run in the background with a log (`storebound shots render > .storebound/render.
</details> </details>
## Every endpoint
The commands above cover the journey. For anything else, `storebound api` calls **every operation** of the App Store
Connect API (1303 operations, OpenAPI 4.5.1) and of the Google Play Developer APIs (Android Publisher v3: 145,
Play Developer Reporting v1beta1: 25), generated from the official specs pinned in `specs/` (source URLs, dates and
SHA-256 in `specs/SOURCES.json`). Same credentials, same retries on 429 / 5xx (Retry-After honoured), JSON:API
documents on the Apple side.
```sh
storebound api list --store apple --search "customerReviews" # find an operation
storebound api apple apps_getCollection --param 'filter[bundleId]=com.example.app'
storebound api apple GET /v1/apps/123/appStoreVersions --all-pages # METHOD path works too
storebound api play reviews.list --param packageName=com.example.app --all-pages --json
storebound api play POST /v1beta1/apps/com.example.app/crashRateMetricSet:query --body query.json
storebound api coverage # counts per API and resource, what commands wrap
```
- **GET runs right away** (so do the Reporting API `:query` calls and other POSTs that only read).
- **Every write is a dry run by default**: it prints the exact request (method, URL, body; the credential is never
shown) and is only sent with `--yes` from an interactive terminal.
- **Owner-only operations** (submit, release, delete, cancel, refunds, prices, testers and users, review replies,
product page tests, Play `edits.commit`, app signing; the list is in `cli/api/gate.ts`) also ask you to type the
operation id before anything is sent.
- **Agents read, never write**: MCP has one tool for this, `api_get` (GET operations only). Under MCP or the desktop
app no write goes out, whatever the flags.
- Small on purpose: one table row per operation plus generic helpers (`cli/api/`), typed from the rows (path and
required parameters, JSON:API request and response shapes). `bun tools/gen-api.ts` regenerates the tables and
[docs/api-coverage.md](docs/api-coverage.md) (every operation, its gate and the commands that already call it);
CI fails when they are stale.
## Store art ## Store art
Screenshots and previews are one step of the journey, but the one that takes the longest by hand, so storebound Screenshots and previews are one step of the journey, but the one that takes the longest by hand, so storebound
@ -378,6 +409,7 @@ navigation.
| `store-screenshots` | capture from any engine, copy, fonts, render, checks, gallery, variants, App Store product page tests, Play experiments, upload | | `store-screenshots` | capture from any engine, copy, fonts, render, checks, gallery, variants, App Store product page tests, Play experiments, upload |
| `app-previews` | app or gameplay clips, App Store previews, Play promo video, checks, upload | | `app-previews` | app or gameplay clips, App Store previews, Play promo video, checks, upload |
| `store-preflight` | run and read `check`, `check --aso`, `validate`, `privacy-manifest` before submitting | | `store-preflight` | run and read `check`, `check --aso`, `validate`, `privacy-manifest` before submitting |
| `store-api` | any App Store Connect / Google Play operation without a command: find it, read freely, writes dry until the owner sends them |
| `release-management` | updates: Apple phased + manual release, Play staged rollout, halt / pause (not a rollback), review wait | | `release-management` | updates: Apple phased + manual release, Play staged rollout, halt / pause (not a rollback), review wait |
| `store-health` | crash / ANR vitals vs Play's bad behavior thresholds, App Store diagnostics, weekly report | | `store-health` | crash / ANR vitals vs Play's bad behavior thresholds, App Store diagnostics, weekly report |
| `review-replies` | reply drafts, tone, no incentives, store limits, the owner approves and sends | | `review-replies` | reply drafts, tone, no incentives, store limits, the owner approves and sends |
@ -396,7 +428,8 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc
- One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`, - One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`,
`analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`, `analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`,
`play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `import_fastlane`, `whats_new`, `icons`, `play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`, `api_get` (any App Store Connect or
Google Play GET operation by id). Local writes: `import_fastlane`, `whats_new`, `icons`,
`health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`, `health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`,
`shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`, `shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`,
`play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`, `play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`,
@ -430,6 +463,8 @@ model and the Dependencies screen (tool sources and licenses): [docs/desktop.md]
storebound only talks to the official store APIs (plus Google Fonts, your own Play reports bucket and your own URLs); storebound only talks to the official store APIs (plus Google Fonts, your own Play reports bucket and your own URLs);
see [SECURITY.md](SECURITY.md). see [SECURITY.md](SECURITY.md).
- **Dry run everywhere.** Every write command takes `--dry`; every MCP write tool has `dry_run`, **default true**. - **Dry run everywhere.** Every write command takes `--dry`; every MCP write tool has `dry_run`, **default true**.
Raw API writes (`storebound api`) are dry unless `--yes` is given in an interactive terminal; owner-only operations
also need the operation id typed, and MCP only gets the read-only `api_get`.
- **Nothing public or irreversible through an agent.** Submitting for review (App Review and Beta App Review), - **Nothing public or irreversible through an agent.** Submitting for review (App Review and Beta App Review),
releasing a version, production releases, resuming or completing a rollout or phased release, canceling a releasing a version, production releases, resuming or completing a rollout or phased release, canceling a
submission, deleting, inviting or removing testers, sending review replies, applying prices and starting or submission, deleting, inviting or removing testers, sending review replies, applying prices and starting or

174
cli/api.ts Normal file
View file

@ -0,0 +1,174 @@
// storebound api: every App Store Connect and Google Play Developer API operation, from the pinned specs.
//
// storebound api list [--store apple|play] [--search text] [--json]
// storebound api coverage [--store apple|play] [--json]
// storebound api apple|play <operationId | METHOD path> [--param k=v ...] [--body file.json] [--upload file]
// [--out file] [--all-pages] [--json] [--dry] [--yes]
// storebound api get apple|play <operationId> ... GET only (what the MCP tool api_get runs)
//
// GETs run right away. Writes print the exact request and are only sent with --yes in an interactive terminal;
// owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments: cli/api/gate.ts)
// also need the operation id typed at the prompt. Under MCP or the desktop app no write is ever sent.
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client';
import { decide, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
import { APPLE_SPEC } from './api/apple.gen';
import { PLAY_APIS } from './api/play.gen';
import { WRAPPED } from './api/wrapped.gen';
import type { ParamValue } from './api/types';
const STORES: Store[] = ['apple', 'play'];
export const USAGE = `usage:
storebound api list [--store apple|play] [--search text] [--json]
storebound api coverage [--store apple|play] [--json]
storebound api apple|play <operationId | METHOD path> [--param k=v ...] [--body file.json] [--upload file]
[--out file] [--all-pages] [--json] [--dry] [--yes]
storebound api get apple|play <operationId> [--param k=v ...] [--all-pages] [--json] (GET only)
GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only
operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the
operation id. Full table: docs/api-coverage.md`;
export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string }
export function rows(store?: Store): Row[] {
return (store ? [store] : STORES).flatMap(s => allOps(s).map(o => ({
store: s, id: o.id, method: o.method, path: o.path, gate: gateOf(s, o.id, o.method), wrapped: WRAPPED[s][o.id] ?? '',
})));
}
export function search(list: Row[], text: string): Row[] {
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
return list.filter(r => words.every(w => `${r.id} ${r.method} ${r.path}`.toLowerCase().includes(w)));
}
export interface Coverage {
apis: { api: string; title: string; operations: number; get: number; write: number; owner: number; wrapped: number }[];
resources: { store: Store; resource: string; operations: number; wrapped: number }[];
}
export function coverage(store?: Store): Coverage {
const list = rows(store);
const groups: [string, string, Row[]][] = [];
if (!store || store === 'apple') groups.push(['appstoreconnect', `${APPLE_SPEC.title} ${APPLE_SPEC.version}`, list.filter(r => r.store === 'apple')]);
if (!store || store === 'play') for (const [name, a] of Object.entries(PLAY_APIS)) groups.push([name, `${a.title} (${a.revision})`, list.filter(r => r.store === 'play' && r.id.startsWith(name + '.'))]);
const res = new Map<string, { store: Store; resource: string; operations: number; wrapped: number }>();
for (const r of list) {
const k = `${r.store} ${resourceOf(r)}`;
const cur = res.get(k) ?? { store: r.store, resource: resourceOf(r), operations: 0, wrapped: 0 };
cur.operations++;
if (r.wrapped) cur.wrapped++;
res.set(k, cur);
}
return {
apis: groups.map(([api, title, rs]) => ({ api, title, operations: rs.length, get: rs.filter(r => r.method === 'GET').length,
write: rs.filter(r => r.gate === 'write').length, owner: rs.filter(r => r.gate === 'owner').length, wrapped: rs.filter(r => r.wrapped).length })),
resources: [...res.values()],
};
}
/** --param k=v (repeatable; a repeated name becomes a list). */
export function parseParams(values: string[]): Record<string, ParamValue> {
const out: Record<string, ParamValue> = {};
for (const kv of values) {
const eq = kv.indexOf('=');
if (eq <= 0) throw new Error(`--param ${kv}: expected name=value`);
const k = kv.slice(0, eq), v = kv.slice(eq + 1);
const cur = out[k];
out[k] = cur === undefined ? v : [...(Array.isArray(cur) ? cur : [String(cur)]), v];
}
return out;
}
const UPLOAD_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', apk: 'application/vnd.android.package-archive' };
/** The exact request as text, the credential replaced by a placeholder. */
export function describeRequest(req: BuiltRequest): string {
const lines = [`${req.method} ${req.url}`, `Authorization: Bearer <${req.op.store === 'apple' ? 'App Store Connect API key JWT' : 'Google service account token'}, not shown>`];
if (req.contentType) lines.push(`Content-Type: ${req.contentType}`);
if (typeof req.body === 'string') lines.push('', JSON.stringify(JSON.parse(req.body), null, 2));
else if (req.body) lines.push('', `<${req.body.length} bytes of file data>`);
return lines.join('\n');
}
async function readLine(): Promise<string> {
for await (const line of console) return line.trim();
return '';
}
export async function run(args: Args): Promise<void> {
const [sub, ...rest] = args._;
const json = args.has('json');
const store = args.get('store') as Store | undefined;
if (store && !STORES.includes(store)) throw new Error('--store: apple or play');
if (!sub || args.has('help')) { console.log(USAGE); return; }
if (sub === 'list') {
const list = args.get('search') ? search(rows(store), args.get('search')!) : rows(store);
if (json) { console.log(JSON.stringify(list)); return; }
for (const r of list) console.log(`${r.store.padEnd(5)} ${r.method.padEnd(6)} ${r.id.padEnd(60)} ${r.path}${r.gate === 'owner' ? ' [owner only]' : ''}${r.wrapped ? ` (wrapped: ${r.wrapped})` : ''}`);
console.error(`${list.length} operation(s)`);
return;
}
if (sub === 'coverage') {
const c = coverage(store);
if (json) { console.log(JSON.stringify(c)); return; }
for (const a of c.apis) console.log(`${a.title}: ${a.operations} operations (${a.get} GET, ${a.write} write, ${a.owner} owner only), ${a.wrapped} wrapped by storebound commands`);
console.log(`\n${'resource'.padEnd(62)} ops wrapped`);
for (const r of c.resources) console.log(`${(r.store + ' ' + r.resource).padEnd(62)} ${String(r.operations).padStart(3)} ${String(r.wrapped).padStart(7)}`);
const total = c.apis.reduce((n, a) => n + a.operations, 0);
console.log(`\n${total} operations, every one callable with storebound api; full table: docs/api-coverage.md`);
return;
}
const getOnly = sub === 'get';
const st = (getOnly ? rest.shift() : sub) as Store;
if (!STORES.includes(st)) { console.error(USAGE); throw new Error(`unknown api subcommand: ${sub}`); }
const [ref, maybePath] = rest;
if (!ref) throw new Error(`storebound api ${getOnly ? 'get ' : ''}${st} <operationId | METHOD path>`);
const { op, params: fromPath } = resolveOp(st, ref, maybePath);
const params = { ...fromPath, ...parseParams(args.many('param')) };
const body = args.get('body') ? JSON.parse(readFileSync(resolve(args.get('body')!), 'utf8')) as unknown : undefined;
const uploadFile = args.get('upload');
const upload = uploadFile ? { bytes: new Uint8Array(readFileSync(resolve(uploadFile))), contentType: UPLOAD_TYPES[uploadFile.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } : undefined;
const out = args.get('out');
const req = buildRequest(op, params, body, { upload, download: !!out && op.flags.includes('d') });
const kind = gateOf(st, op.id, op.method);
const decision = decide({
kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly,
tty: !!process.stdin.isTTY && !!process.stdout.isTTY,
agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1',
});
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
if (decision.action === 'dry') {
if (json) console.log(JSON.stringify({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } }));
else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`);
return;
}
if (decision.action === 'confirm') {
console.log(describeRequest(req));
process.stdout.write(`\nThis ${ownerReason(st, op.id, op.method)}. Type the operation id (${op.id}) to send it: `);
if ((await readLine()) !== op.id) { console.log('not sent'); process.exitCode = 1; return; }
}
const root = process.env.STOREBOUND_PROJECT || process.cwd();
const auth = await authFor(st, root);
const onRetry = (m: string) => console.error(`api: ${m}`);
const all = args.has('all-pages');
if (all && op.method !== 'GET' && !(op.store === 'play' && /[pt]/.test(op.flags))) throw new Error(`${op.id}: --all-pages is for paged reads`);
const r = all ? await sendAll(req, auth, { onRetry, onPage: n => { if (!json) console.error(`api: page ${n}`); } }) : { ...(await send(req, auth, { onRetry })), pages: 1 };
if (!json) console.error(`${req.method} ${req.url.replace(/\?.*$/, '')} -> ${r.status}${r.pages > 1 ? ` · ${r.pages} pages` : ''}${r.rateLimit ? ` · rate limit ${r.rateLimit}` : ''}`);
if (r.bytes) {
if (!out) throw new Error(`${op.id}: binary response (${r.bytes.length} bytes): pass --out <file>`);
writeFileSync(resolve(out), r.bytes);
console.log(json ? JSON.stringify({ status: r.status, out, bytes: r.bytes.length }) : `wrote ${out} (${r.bytes.length} bytes)`);
return;
}
if (out) { writeFileSync(resolve(out), JSON.stringify(r.json, null, 2) + '\n'); if (!json) console.log(`wrote ${out}`); return; }
console.log(json ? JSON.stringify(r.json) : JSON.stringify(r.json, null, 2));
}

1311
cli/api/apple.gen.ts Normal file

File diff suppressed because it is too large Load diff

341
cli/api/client.ts Normal file
View file

@ -0,0 +1,341 @@
// Generic client for every App Store Connect and Google Play operation: the generated rows (apple.gen.ts, play.gen.ts)
// plus a few helpers instead of a function per endpoint. Request building is pure (tests cover URLs and parameters);
// sending retries 429 / 5xx with backoff (Retry-After honoured) and follows pages.
// Auth: the App Store Connect team key JWT from cli/apple/asc.ts, the Play service account token from
// cli/lib/google-auth.ts (one scope per Google API). Credentials only come from env or .storebound/.
import { APPLE_OPS, APPLE_SPEC } from './apple.gen';
import { PLAY_APIS, PLAY_OPS } from './play.gen';
import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types';
import type { Store } from './gate';
import { errorBody } from '../lib/http';
export interface QueryParam { name: string; required: boolean; repeated: boolean }
export interface Op {
store: Store;
id: string;
method: string;
/** Path template: Apple "/v1/apps/{id}", Google "androidpublisher/v3/applications/{packageName}/edits" */
path: string;
query: QueryParam[];
/** Apple: resource type ("type", "type[]", "json", ""). Google: request schema name ("" for none). */
request: string;
/** Apple: "many:type" | "one:type" | "json" | "raw:<media type>" | "none". Google: response schema name. */
response: string;
/** Google: m media upload, d media download, p pageToken paging, t token paging. Apple: "". */
flags: string;
/** Google API name (androidpublisher, playdeveloperreporting); "" for Apple. */
api: string;
/** Google: the shape of each {+param}, e.g. name -> "apps/* /crashRateMetricSet" (no space; * is one segment). */
shapes: Record<string, string>;
}
/** Query parameters every Google API accepts on top of a method's own. */
export const GOOGLE_GLOBAL = ['fields', 'prettyPrint', 'quotaUser'];
const parseQuery = (s: string): QueryParam[] => (s ? s.split(' ') : []).map(t => ({ name: t.replace(/[!*]/g, ''), required: t.includes('!'), repeated: t.includes('*') }));
const cache: Partial<Record<Store, Op[]>> = {};
export function allOps(store: Store): Op[] {
if (cache[store]) return cache[store]!;
const rows = store === 'apple' ? APPLE_OPS as Record<string, readonly string[]> : PLAY_OPS as Record<string, readonly string[]>;
return (cache[store] = Object.entries(rows).map(([id, r]) => ({
store, id, method: r[0]!, path: r[1]!, query: parseQuery(r[2]!), request: r[3]!, response: r[4]!, flags: r[5] ?? '',
api: store === 'play' ? id.split('.')[0]! : '',
shapes: Object.fromEntries((r[6] ?? '').split(' ').filter(Boolean).map(s => [s.slice(0, s.indexOf('=')), s.slice(s.indexOf('=') + 1)])),
})));
}
export function getOp(store: Store, id: string): Op | undefined {
const ops = allOps(store);
return ops.find(o => o.id === id) ?? (store === 'play' ? ops.find(o => o.id.slice(o.api.length + 1) === id) : undefined);
}
const esc = (s: string) => s.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
/** "apps/* /crashRateMetricSet" (no space) -> a regex source; * is one path segment. */
const shapeSource = (shape: string) => shape.split('*').map(esc).join('[^/]+');
/** Path template -> RegExp with one group per path parameter ({+param}s narrowed by their shape). */
function templateRe(op: Op): { re: RegExp; names: string[]; fixed: number } {
const names: string[] = [];
const body = op.path.replace(/^\//, '').split(/(\{\+?[^}]+\})/).map(part => {
const m = /^\{(\+?)([^}]+)\}$/.exec(part);
if (!m) return esc(part);
names.push(m[2]!);
if (!m[1]) return '([^/:]+)';
const shape = op.shapes[m[2]!];
return shape ? `(${shapeSource(shape)})` : '(.+)';
}).join('');
return { re: new RegExp('^' + body + '$'), names, fixed: op.path.split('{')[0]!.length };
}
/**
* An operation from what the user typed: an operation id (Google ids also without the API prefix), or "METHOD path"
* with a concrete path (and query string), whose values become parameters.
*/
export function resolveOp(store: Store, ref: string, path?: string): { op: Op; params: Record<string, string> } {
if (path === undefined) {
const op = getOp(store, ref);
if (!op) throw new Error(`${store}: no operation "${ref}" (storebound api list --store ${store} --search <text>)`);
return { op, params: {} };
}
const method = ref.toUpperCase();
if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(method)) throw new Error(`"${ref}": expected an operation id or METHOD path`);
let p = path.replace(/^https:\/\/[^/]+/, '');
if (store === 'play') p = p.replace(/^\/?upload\//, '/');
const [rawPath, qs = ''] = p.split('?', 2) as [string, string?];
const clean = decodeURI(rawPath).replace(/^\//, '');
const hits = allOps(store).filter(o => o.method === method).map(o => ({ o, t: templateRe(o) }))
.map(x => ({ ...x, m: x.t.re.exec(clean) })).filter(x => x.m).sort((a, b) => b.t.fixed - a.t.fixed);
const best = hits[0];
if (!best) throw new Error(`${store}: no ${method} operation matches ${rawPath}`);
const params: Record<string, string> = {};
best.t.names.forEach((n, i) => { params[n] = best.m![i + 1]!; });
for (const [k, v] of new URLSearchParams(qs)) params[k] = v;
return { op: best.o, params };
}
export interface BuiltRequest {
op: Op;
method: string;
url: string;
/** JSON text or file bytes */
body?: string | Uint8Array;
contentType?: string;
/** Apple vendor media types (perfPowerMetrics: application/vnd.apple.xcode-metrics+json, reports: application/a-gzip) */
accept?: string;
}
const encSeg = (v: string) => encodeURIComponent(v);
// Apple writes brackets and commas plainly in its docs and links (filter[app]=1,2); keep them readable
const encName = (k: string) => encodeURIComponent(k).replace(/%5B/gi, '[').replace(/%5D/gi, ']');
const encVal = (v: string) => encodeURIComponent(v).replace(/%2C/gi, ',');
const str = (v: string | number | boolean) => String(v);
/** Builds the URL and body of one call. Throws on a missing path / required parameter or an unknown parameter. Pure. */
export function buildRequest(op: Op, params: Record<string, ParamValue | undefined>, body?: unknown, o: { upload?: { bytes: Uint8Array; contentType: string }; download?: boolean } = {}): BuiltRequest {
const left = new Map(Object.entries(params).filter(([, v]) => v !== undefined) as [string, ParamValue][]);
const path = op.path.replace(/\{(\+?)([^}]+)\}/g, (_, plus: string, name: string) => {
const v = left.get(name);
if (v === undefined || v === '') throw new Error(`${op.id}: path parameter "${name}" is required`);
left.delete(name);
const s = Array.isArray(v) ? v.join(',') : str(v as string);
const shape = op.shapes[name];
if (shape && !new RegExp(`^${shapeSource(shape)}$`).test(s)) throw new Error(`${op.id}: "${name}" must look like ${shape} (* is one segment), got "${s}"`);
return plus ? s.split('/').map(encSeg).join('/') : encSeg(s);
});
const known = new Map(op.query.map(q => [q.name, q] as const));
for (const q of op.query) if (q.required && !left.has(q.name)) throw new Error(`${op.id}: query parameter "${q.name}" is required`);
const parts: string[] = [];
for (const [k, v] of left) {
const q = known.get(k);
if (!q && !(op.store === 'play' && GOOGLE_GLOBAL.includes(k))) {
const names = op.query.map(x => x.name);
throw new Error(`${op.id}: unknown parameter "${k}"${names.length ? ` (takes: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''})` : ' (takes no query parameters)'}`);
}
const values = Array.isArray(v) ? v.map(str) : [str(v as string)];
if (q?.repeated) for (const x of values) parts.push(`${encName(k)}=${encVal(x)}`);
else parts.push(`${encName(k)}=${encVal(values.join(','))}`);
}
let base: string;
if (op.store === 'apple') base = APPLE_SPEC.base + path;
else {
const api = PLAY_APIS[op.api as keyof typeof PLAY_APIS];
if (!api) throw new Error(`${op.id}: unknown Google API ${op.api}`);
const upload = !!o.upload && op.flags.includes('m');
if (o.upload && !upload) throw new Error(`${op.id}: takes no file upload`);
base = api.root + (upload ? 'upload/' : '') + path;
if (upload) parts.unshift('uploadType=media');
if (o.download) {
if (!op.flags.includes('d')) throw new Error(`${op.id}: has no media download`);
parts.push('alt=media');
}
}
const req: BuiltRequest = { op, method: op.method, url: base + (parts.length ? '?' + parts.join('&') : '') };
if (op.response.startsWith('raw:')) req.accept = op.response.slice(4);
if (o.upload) { req.body = o.upload.bytes; req.contentType = o.upload.contentType; }
else if (body !== undefined) {
if (op.method === 'GET' || op.method === 'DELETE' && op.store === 'play') throw new Error(`${op.id}: ${op.method} takes no body`);
req.body = JSON.stringify(body);
req.contentType = 'application/json';
}
return req;
}
// ---------------------------------------------------------------------------------------------------- sending
/** Authorization header value for a request ("Bearer ..."). */
export type Auth = (req: BuiltRequest) => Promise<string> | string;
export interface SendOptions {
fetch?: (url: string, init: RequestInit) => Promise<Response>;
sleep?: (ms: number) => Promise<unknown>;
/** attempts for 429 / 5xx / network errors (default 5) */
attempts?: number;
/** each retry is logged here (stderr in the CLI) */
onRetry?: (msg: string) => void;
}
export interface ApiResult {
status: number;
/** parsed JSON, or null for an empty body */
json?: unknown;
/** non-JSON body (gzip reports, media downloads) */
bytes?: Uint8Array;
/** Apple's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") */
rateLimit?: string;
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) { super(message); }
}
/** Wait before retry `attempt` (1-based): Retry-After when the server sent one (max 60 s), else 1, 2, 4, 8 ... s (max 30 s). */
export function backoffMs(attempt: number, retryAfter?: string | null): number {
const s = retryAfter ? Number(retryAfter) : NaN;
if (Number.isFinite(s) && s >= 0) return Math.min(s, 60) * 1000;
return Math.min(1000 * 2 ** (attempt - 1), 30_000);
}
const short = (url: string) => url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*$/, '');
export async function send(req: BuiltRequest, auth: Auth, o: SendOptions = {}): Promise<ApiResult> {
const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i));
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
const attempts = o.attempts ?? 5;
for (let attempt = 1; ; attempt++) {
const headers: Record<string, string> = { Authorization: await auth(req) };
if (req.contentType) headers['Content-Type'] = req.contentType;
if (req.accept) headers.Accept = req.accept;
let r: Response;
try {
r = await doFetch(req.url, { method: req.method, headers, body: req.body as BodyInit | undefined });
} catch (e) {
if (attempt >= attempts) throw e;
o.onRetry?.(`${req.method} ${short(req.url)}: ${(e as Error).message}, retry ${attempt}/${attempts - 1}`);
await sleep(backoffMs(attempt));
continue;
}
if ((r.status === 429 || r.status >= 500) && attempt < attempts) {
const wait = backoffMs(attempt, r.headers.get('retry-after'));
await r.body?.cancel();
o.onRetry?.(`${req.method} ${short(req.url)} -> ${r.status}, retry ${attempt}/${attempts - 1} in ${Math.round(wait / 1000)} s`);
await sleep(wait);
continue;
}
const rateLimit = r.headers.get('x-rate-limit') ?? undefined;
const type = r.headers.get('content-type') ?? '';
if (!r.ok) throw new ApiError(r.status, `${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}`);
if (/json/.test(type) || (!type && r.status !== 204)) {
const t = await r.text();
return { status: r.status, json: t ? JSON.parse(t) : null, rateLimit };
}
if (r.status === 204) return { status: 204, json: null, rateLimit };
return { status: r.status, bytes: new Uint8Array(await r.arrayBuffer()), rateLimit };
}
}
/** Merges two pages: arrays are concatenated (Apple's included de-duplicated), the page tokens dropped. Pure. */
export function mergePages(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...a };
for (const [k, v] of Object.entries(b)) {
const cur = out[k];
if (Array.isArray(cur) && Array.isArray(v)) {
if (k === 'included') {
const seen = new Set(cur.map(x => `${(x as { type?: string }).type}/${(x as { id?: string }).id}`));
out[k] = [...cur, ...v.filter(x => !seen.has(`${(x as { type?: string }).type}/${(x as { id?: string }).id}`))];
} else out[k] = [...cur, ...v];
} else if (cur === undefined) out[k] = v;
}
delete out.nextPageToken;
if (out.tokenPagination) delete out.tokenPagination;
if (out.links && typeof out.links === 'object') out.links = { ...(out.links as Record<string, unknown>), next: undefined };
return out;
}
/** The next page of a result, or null. Apple: links.next (same host only, the token never leaves it); Google: the page token. */
export function nextRequest(req: BuiltRequest, json: unknown): BuiltRequest | null {
const j = (json ?? {}) as { links?: { next?: string }; nextPageToken?: string; tokenPagination?: { nextPageToken?: string } };
if (req.op.store === 'apple') {
const next = j.links?.next;
if (!next) return null;
if (!next.startsWith(APPLE_SPEC.base + '/')) throw new Error(`refusing to follow a next link to another host: ${next.slice(0, 80)}`);
return { ...req, url: next };
}
const [name, token] = req.op.flags.includes('p') ? ['pageToken', j.nextPageToken] : req.op.flags.includes('t') ? ['token', j.tokenPagination?.nextPageToken] : ['', undefined];
if (!token) return null;
const u = new URL(req.url);
u.searchParams.set(name, token);
return { ...req, url: u.toString() };
}
/** Every page of a GET (or a paged query), merged. `maxPages` guards against a server that never stops. */
export async function sendAll(req: BuiltRequest, auth: Auth, o: SendOptions & { maxPages?: number; onPage?: (n: number) => void } = {}): Promise<ApiResult & { pages: number }> {
let first = await send(req, auth, o);
let pages = 1;
let cur: BuiltRequest | null = req;
let last = first.json;
while ((cur = nextRequest(cur, last))) {
if (pages >= (o.maxPages ?? 1000)) throw new Error(`more than ${o.maxPages ?? 1000} pages: stopped`);
const r = await send(cur, auth, o);
pages++;
o.onPage?.(pages);
last = r.json;
first = { ...first, json: mergePages(first.json as Record<string, unknown>, r.json as Record<string, unknown>), rateLimit: r.rateLimit ?? first.rateLimit };
}
return { ...first, pages };
}
// ---------------------------------------------------------------------------------------------------- typed facades
/** Typed App Store Connect client: `api.call('apps_getCollection', { 'filter[bundleId]': 'com.example.app' })`. */
export function appleApi(auth: Auth, o: SendOptions = {}) {
const build = <O extends AppleOp>(op: O, params: AppleParams<O>, body?: AppleBody<O>) =>
buildRequest(getOp('apple', op)!, params as Record<string, ParamValue>, body);
const out = (r: ApiResult) => (r.bytes ?? r.json ?? null);
return {
build,
async call<O extends AppleOp>(op: O, params: AppleParams<O>, body?: AppleBody<O>): Promise<AppleResponse<O>> {
return out(await send(build(op, params, body), auth, o)) as AppleResponse<O>;
},
async all<O extends AppleOp>(op: O, params: AppleParams<O>): Promise<AppleResponse<O>> {
return out(await sendAll(build(op, params), auth, o)) as AppleResponse<O>;
},
};
}
/** Typed Google Play client: `api.call('androidpublisher.edits.tracks.list', { packageName, editId })`. */
export function playApi(auth: Auth, o: SendOptions = {}) {
const build = <O extends PlayOp>(op: O, params: PlayParams<O>, body?: PlayBody<O>) =>
body instanceof Uint8Array
? buildRequest(getOp('play', op)!, params as Record<string, ParamValue>, undefined, { upload: { bytes: body, contentType: 'application/octet-stream' } })
: buildRequest(getOp('play', op)!, params as Record<string, ParamValue>, body);
const out = (r: ApiResult) => (r.bytes ?? r.json ?? {});
return {
build,
async call<O extends PlayOp>(op: O, params: PlayParams<O>, body?: PlayBody<O>): Promise<PlayResponse<O>> {
return out(await send(build(op, params, body), auth, o)) as PlayResponse<O>;
},
async all<O extends PlayOp>(op: O, params: PlayParams<O>): Promise<PlayResponse<O>> {
return out(await sendAll(build(op, params), auth, o)) as PlayResponse<O>;
},
};
}
/** Auth from the project's credentials (env or .storebound/): Apple team key JWT, or a Play token for the op's API scope. */
export async function authFor(store: Store, root: string): Promise<Auth> {
if (store === 'apple') {
const { ascJwt } = await import('../apple/asc');
const { jwt } = ascJwt(root);
return () => 'Bearer ' + jwt();
}
const { playServiceAccount } = await import('../lib/credentials');
const { tokenSource } = await import('../lib/google-auth');
const sa = playServiceAccount(root);
const sources = new Map<string, () => Promise<string>>();
return async req => {
const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope;
if (!sources.has(scope)) sources.set(scope, tokenSource(sa, scope));
return 'Bearer ' + (await sources.get(scope)!());
};
}

99
cli/api/gate.ts Normal file
View file

@ -0,0 +1,99 @@
// Write gating for `storebound api`: which operations read, which write, and which only the owner may run.
//
// read GET (and a few POSTs that only compute or query, listed below): runs right away.
// write any other POST / PATCH / PUT / DELETE: prints the exact request; sent only with --yes in an interactive
// terminal, never under MCP (`storebound mcp`) or the desktop app (`storebound serve`).
// owner writes that submit, release, delete, cancel, change prices, invite or remove testers, send review replies,
// start or stop experiments, refund or rotate keys (AGENTS.md rule 1): a write that also needs the operation id
// typed at the prompt. Never reachable through MCP or serve.
// Pure; tests/api.test.ts covers every rule.
export type Store = 'apple' | 'play';
export type GateKind = 'read' | 'write' | 'owner';
/** POSTs that change nothing (vitals queries, price conversion, batch reads). */
export const READ_POSTS: RegExp[] = [
/^playdeveloperreporting\..*\.query$/,
/^androidpublisher\.monetization\.convertRegionPrices$/,
/^androidpublisher\.monetization\..*\.batchGet$/,
];
/** [pattern over "METHOD operationId", why]: the curated owner-only list. */
export const OWNER_ONLY: Record<Store, [RegExp, string][]> = {
apple: [
[/^DELETE /, 'deletes'],
[/^(POST|PATCH) \w*Submissions?(V\d)?_(create|update)Instance$/, 'submits for review (or cancels a submission)'],
[/^POST (?!appStoreVersionPhasedReleases)\w*(Releases|ReleaseRequests)_createInstance$/, 'releases'],
[/^PATCH appStoreVersionPhasedReleases_updateInstance$/, 'pauses, resumes or completes a phased release'],
[/^PATCH appStoreVersionExperiments(V2)?_updateInstance$/, 'starts or stops a product page test'],
[/^POST (appPriceSchedules|inAppPurchasePriceSchedules|subscriptionPrices)_createInstance$/, 'changes prices'],
[/^POST customerReviewResponses_createInstance$/, 'sends a public review reply'],
[/^POST (betaTesterInvitations|betaTesters|userInvitations)_createInstance$/, 'invites testers or users'],
[/^POST (betaGroups_betaTesters|betaTesters_betaGroups|betaTesters_builds|builds_individualTesters)_createToManyRelationship$/, 'adds testers'],
[/^(POST|PATCH) users_(updateInstance|visibleApps_\w+)$/, 'changes a team member\'s access'],
],
play: [
[/^DELETE /, 'deletes'],
[/^POST \S+\.(batchDelete|deleteall)$/, 'deletes'],
[/^POST androidpublisher\.edits\.commit$/, 'publishes the edit (releases, listing and graphics go live)'],
[/^POST \S+\.(cancel|refund|refundexternaltransaction|reviewrefund|revoke)$/, 'cancels, refunds or revokes'],
[/^POST androidpublisher\.apprecovery\.deploy$/, 'deploys an app recovery action'],
[/^POST \S+\.(activate|deactivate|archive|batchUpdateStates)$/, 'makes products or offers live (or takes them down)'],
[/^POST androidpublisher\.appstoreappsreview\.updateappstorehostedapppublishstatus$/, 'changes a publish status'],
[/^POST \S+\.(migratePrices|batchMigratePrices)$/, 'changes prices for existing subscribers'],
[/^POST androidpublisher\.reviews\.reply$/, 'sends a public review reply'],
[/^(PUT|PATCH) androidpublisher\.edits\.testers\.(update|patch)$/, 'changes the testers of a track'],
[/^(POST|PATCH) androidpublisher\.(users|grants)\.(create|patch)$/, 'invites users or changes their permissions'],
[/^POST androidpublisher\.appsigning\.(enrollApp|rotateAppSigningKey)$/, 'changes app signing (irreversible)'],
],
};
/** Why an operation is owner-only, or null. */
export function ownerReason(store: Store, id: string, method: string): string | null {
const key = `${method.toUpperCase()} ${id}`;
return OWNER_ONLY[store].find(([re]) => re.test(key))?.[1] ?? null;
}
/** Resource of an operation, for counts: Apple's first path segment (/v1/<apps>/...), Google's id without the method. */
export const resourceOf = (r: { store: Store; id: string; path: string }): string =>
r.store === 'apple' ? r.path.split('/')[2]! : r.id.split('.').slice(0, -1).join('.');
export function gateOf(store: Store, id: string, method: string): GateKind {
const m = method.toUpperCase();
if (m === 'GET') return 'read';
if (store === 'play' && m === 'POST' && READ_POSTS.some(re => re.test(id))) return 'read';
return ownerReason(store, id, m) ? 'owner' : 'write';
}
export interface GateInput {
kind: GateKind;
method: string;
/** --dry */
dry: boolean;
/** --yes */
yes: boolean;
/** stdin and stdout are a terminal */
tty: boolean;
/** under `storebound mcp` or `storebound serve` (STOREBOUND_MCP / STOREBOUND_SERVE) */
agent: boolean;
/** `storebound api get` (the MCP api_get tool): plain GETs only */
getOnly: boolean;
}
export type Decision =
| { action: 'send' }
| { action: 'confirm'; typed: true }
| { action: 'dry'; why: string }
| { action: 'refuse'; why: string };
/** What to do with one call. Pure. */
export function decide(g: GateInput): Decision {
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
if (g.kind === 'read') return g.dry ? { action: 'dry', why: '--dry' } : { action: 'send' };
if (g.agent) return { action: 'refuse', why: g.kind === 'owner'
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: writes through `storebound api` are only sent from one' };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}

181
cli/api/play.gen.ts Normal file
View file

@ -0,0 +1,181 @@
// GENERATED by tools/gen-api.ts from specs/androidpublisher_v3.discovery.json (Google Play Android Developer API v3, revision 20261008, https://www.googleapis.com/discovery/v1/apis/androidpublisher/v3/rest, downloaded 2026-10-11) and specs/playdeveloperreporting_v1beta1.discovery.json (Google Play Developer Reporting API v1beta1, revision 20261008, https://playdeveloperreporting.googleapis.com/$discovery/rest?version=v1beta1, downloaded 2026-10-11). Do not edit: run `bun tools/gen-api.ts` (CI checks it is fresh).
// One row per method: [HTTP method, path ({p} one segment, {+p} may hold slashes), query parameters ("name", "name!"
// required, "name*" repeated), request schema, response schema, flags (m media upload, d media download, p pageToken
// paging, t token paging)]. The API is the first part of the id.
export const PLAY_APIS = {
androidpublisher: { title: 'Google Play Android Developer API v3', version: 'v3', revision: '20261008', root: 'https://androidpublisher.googleapis.com/', scope: 'https://www.googleapis.com/auth/androidpublisher', sha256: '668757f25cbe47e97f782758e818f1e18bf7da4e2049180ef0e49b3d0e465efc' },
playdeveloperreporting: { title: 'Google Play Developer Reporting API v1beta1', version: 'v1beta1', revision: '20261008', root: 'https://playdeveloperreporting.googleapis.com/', scope: 'https://www.googleapis.com/auth/playdeveloperreporting', sha256: '277afdf1c026c13d91d0b3d6d5fd10a46ecaa9b21bbc2f085c104193e254a448' },
} as const;
export const PLAY_OPS = {
'androidpublisher.applications.dataSafety': ['POST', 'androidpublisher/v3/applications/{packageName}/dataSafety', '', 'SafetyLabelsUpdateRequest', 'SafetyLabelsUpdateResponse', '', ''],
'androidpublisher.applications.deviceTierConfigs.create': ['POST', 'androidpublisher/v3/applications/{packageName}/deviceTierConfigs', 'allowUnknownDevices', 'DeviceTierConfig', 'DeviceTierConfig', '', ''],
'androidpublisher.applications.deviceTierConfigs.get': ['GET', 'androidpublisher/v3/applications/{packageName}/deviceTierConfigs/{deviceTierConfigId}', '', '', 'DeviceTierConfig', '', ''],
'androidpublisher.applications.deviceTierConfigs.list': ['GET', 'androidpublisher/v3/applications/{packageName}/deviceTierConfigs', 'pageSize pageToken', '', 'ListDeviceTierConfigsResponse', 'p', ''],
'androidpublisher.applications.tracks.releases.list': ['GET', 'androidpublisher/v3/{+parent}/releases', '', '', 'ListReleaseSummariesResponse', '', 'parent=applications/*/tracks/*'],
'androidpublisher.apprecovery.addTargeting': ['POST', 'androidpublisher/v3/applications/{packageName}/appRecoveries/{appRecoveryId}:addTargeting', '', 'AddTargetingRequest', 'AddTargetingResponse', '', ''],
'androidpublisher.apprecovery.cancel': ['POST', 'androidpublisher/v3/applications/{packageName}/appRecoveries/{appRecoveryId}:cancel', '', 'CancelAppRecoveryRequest', 'CancelAppRecoveryResponse', '', ''],
'androidpublisher.apprecovery.create': ['POST', 'androidpublisher/v3/applications/{packageName}/appRecoveries', '', 'CreateDraftAppRecoveryRequest', 'AppRecoveryAction', '', ''],
'androidpublisher.apprecovery.deploy': ['POST', 'androidpublisher/v3/applications/{packageName}/appRecoveries/{appRecoveryId}:deploy', '', 'DeployAppRecoveryRequest', 'DeployAppRecoveryResponse', '', ''],
'androidpublisher.apprecovery.list': ['GET', 'androidpublisher/v3/applications/{packageName}/appRecoveries', 'versionCode', '', 'ListAppRecoveriesResponse', '', ''],
'androidpublisher.appsigning.enrollApp': ['POST', 'androidpublisher/v3/applications/{name}/appSigning:enrollApp', '', 'EnrollAppRequest', 'EnrollAppResponse', '', ''],
'androidpublisher.appsigning.rotateAppSigningKey': ['POST', 'androidpublisher/v3/applications/{name}/appSigning:rotateAppSigningKey', '', 'RotateAppSigningKeyRequest', 'RotateAppSigningKeyResponse', '', ''],
'androidpublisher.appstoreappsreview.createappstorehostedapp': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps:create', '', 'CreateAppStoreHostedAppRequest', 'CreateAppStoreHostedAppResponse', '', ''],
'androidpublisher.appstoreappsreview.updateappstorehostedapp': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps:update', '', 'UpdateAppStoreHostedAppRequest', 'UpdateAppStoreHostedAppResponse', '', ''],
'androidpublisher.appstoreappsreview.updateappstorehostedapppublishstatus': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps/{packageName}:updateAppStoreHostedAppPublishStatus', '', 'UpdateAppStoreHostedAppPublishStatusRequest', 'UpdateAppStoreHostedAppPublishStatusResponse', '', ''],
'androidpublisher.appstoreappsreview.uploadapk': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps/{packageName}/apks:upload', '', 'UploadApkRequest', 'UploadApkResponse', 'm', ''],
'androidpublisher.appstoreappsreview.uploadappstoreapppolicydeclarationfile': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps/{packageName}/policyDeclarationFiles:upload', '', 'UploadAppStoreAppPolicyDeclarationFileRequest', 'UploadAppStoreAppPolicyDeclarationFileResponse', 'm', ''],
'androidpublisher.appstoreappsreview.uploadimage': ['POST', 'androidpublisher/v3/appstore/{appStorePackageName}/apps/{packageName}/images:upload', '', 'UploadImageRequest', 'UploadImageResponse', 'm', ''],
'androidpublisher.appstorecatalog.recentappviews.get': ['GET', 'androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentAppViews/{playAppPackageName}', '', '', 'RecentAppView', '', ''],
'androidpublisher.appstorecatalog.recentupdateevents.list': ['GET', 'androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentUpdateEvents', 'endTime pageSize pageToken startTime', '', 'ListRecentUpdateEventsResponse', 'p', ''],
'androidpublisher.edits.apks.addexternallyhosted': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/externallyHosted', '', 'ApksAddExternallyHostedRequest', 'ApksAddExternallyHostedResponse', '', ''],
'androidpublisher.edits.apks.list': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks', '', '', 'ApksListResponse', '', ''],
'androidpublisher.edits.apks.upload': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks', '', '', 'Apk', 'm', ''],
'androidpublisher.edits.bundles.list': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/bundles', '', '', 'BundlesListResponse', '', ''],
'androidpublisher.edits.bundles.upload': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/bundles', 'ackBundleInstallationWarning deviceTierConfigId', '', 'Bundle', 'm', ''],
'androidpublisher.edits.commit': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}:commit', 'changesInReviewBehavior changesNotSentForReview', '', 'AppEdit', '', ''],
'androidpublisher.edits.countryavailability.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/countryAvailability/{track}', '', '', 'TrackCountryAvailability', '', ''],
'androidpublisher.edits.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/edits/{editId}', '', '', '', '', ''],
'androidpublisher.edits.deobfuscationfiles.upload': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}', '', '', 'DeobfuscationFilesUploadResponse', 'm', ''],
'androidpublisher.edits.details.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/details', '', '', 'AppDetails', '', ''],
'androidpublisher.edits.details.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/details', '', 'AppDetails', 'AppDetails', '', ''],
'androidpublisher.edits.details.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/details', '', 'AppDetails', 'AppDetails', '', ''],
'androidpublisher.edits.expansionfiles.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/expansionFiles/{expansionFileType}', '', '', 'ExpansionFile', '', ''],
'androidpublisher.edits.expansionfiles.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/expansionFiles/{expansionFileType}', '', 'ExpansionFile', 'ExpansionFile', '', ''],
'androidpublisher.edits.expansionfiles.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/expansionFiles/{expansionFileType}', '', 'ExpansionFile', 'ExpansionFile', '', ''],
'androidpublisher.edits.expansionfiles.upload': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/expansionFiles/{expansionFileType}', '', '', 'ExpansionFilesUploadResponse', 'm', ''],
'androidpublisher.edits.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}', '', '', 'AppEdit', '', ''],
'androidpublisher.edits.images.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}/{imageId}', '', '', '', '', ''],
'androidpublisher.edits.images.deleteall': ['DELETE', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}', '', '', 'ImagesDeleteAllResponse', '', ''],
'androidpublisher.edits.images.list': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}', '', '', 'ImagesListResponse', '', ''],
'androidpublisher.edits.images.upload': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}', 'aiGeneratedState', '', 'ImagesUploadResponse', 'm', ''],
'androidpublisher.edits.insert': ['POST', 'androidpublisher/v3/applications/{packageName}/edits', '', 'AppEdit', 'AppEdit', '', ''],
'androidpublisher.edits.listings.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}', '', '', '', '', ''],
'androidpublisher.edits.listings.deleteall': ['DELETE', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings', '', '', '', '', ''],
'androidpublisher.edits.listings.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}', '', '', 'Listing', '', ''],
'androidpublisher.edits.listings.list': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings', '', '', 'ListingsListResponse', '', ''],
'androidpublisher.edits.listings.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}', '', 'Listing', 'Listing', '', ''],
'androidpublisher.edits.listings.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}', '', 'Listing', 'Listing', '', ''],
'androidpublisher.edits.testers.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}', '', '', 'Testers', '', ''],
'androidpublisher.edits.testers.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}', '', 'Testers', 'Testers', '', ''],
'androidpublisher.edits.testers.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}', '', 'Testers', 'Testers', '', ''],
'androidpublisher.edits.tracks.create': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks', '', 'TrackConfig', 'Track', '', ''],
'androidpublisher.edits.tracks.get': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}', '', '', 'Track', '', ''],
'androidpublisher.edits.tracks.list': ['GET', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks', '', '', 'TracksListResponse', '', ''],
'androidpublisher.edits.tracks.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}', '', 'Track', 'Track', '', ''],
'androidpublisher.edits.tracks.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}', '', 'Track', 'Track', '', ''],
'androidpublisher.edits.validate': ['POST', 'androidpublisher/v3/applications/{packageName}/edits/{editId}:validate', '', '', 'AppEdit', '', ''],
'androidpublisher.externaltransactions.createexternaltransaction': ['POST', 'androidpublisher/v3/{+parent}/externalTransactions', 'externalTransactionId', 'ExternalTransaction', 'ExternalTransaction', '', 'parent=applications/*'],
'androidpublisher.externaltransactions.getexternaltransaction': ['GET', 'androidpublisher/v3/{+name}', '', '', 'ExternalTransaction', '', 'name=applications/*/externalTransactions/*'],
'androidpublisher.externaltransactions.refundexternaltransaction': ['POST', 'androidpublisher/v3/{+name}:refund', '', 'RefundExternalTransactionRequest', 'ExternalTransaction', '', 'name=applications/*/externalTransactions/*'],
'androidpublisher.generatedapks.download': ['GET', 'androidpublisher/v3/applications/{packageName}/generatedApks/{versionCode}/downloads/{downloadId}:download', '', '', '', 'd', ''],
'androidpublisher.generatedapks.list': ['GET', 'androidpublisher/v3/applications/{packageName}/generatedApks/{versionCode}', '', '', 'GeneratedApksListResponse', '', ''],
'androidpublisher.grants.create': ['POST', 'androidpublisher/v3/{+parent}/grants', '', 'Grant', 'Grant', '', 'parent=developers/*/users/*'],
'androidpublisher.grants.delete': ['DELETE', 'androidpublisher/v3/{+name}', '', '', '', '', 'name=developers/*/users/*/grants/*'],
'androidpublisher.grants.patch': ['PATCH', 'androidpublisher/v3/{+name}', 'updateMask', 'Grant', 'Grant', '', 'name=developers/*/users/*/grants/*'],
'androidpublisher.inappproducts.batchDelete': ['POST', 'androidpublisher/v3/applications/{packageName}/inappproducts:batchDelete', '', 'InappproductsBatchDeleteRequest', '', '', ''],
'androidpublisher.inappproducts.batchGet': ['GET', 'androidpublisher/v3/applications/{packageName}/inappproducts:batchGet', 'sku*', '', 'InappproductsBatchGetResponse', '', ''],
'androidpublisher.inappproducts.batchUpdate': ['POST', 'androidpublisher/v3/applications/{packageName}/inappproducts:batchUpdate', '', 'InappproductsBatchUpdateRequest', 'InappproductsBatchUpdateResponse', '', ''],
'androidpublisher.inappproducts.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/inappproducts/{sku}', 'latencyTolerance', '', '', '', ''],
'androidpublisher.inappproducts.get': ['GET', 'androidpublisher/v3/applications/{packageName}/inappproducts/{sku}', '', '', 'InAppProduct', '', ''],
'androidpublisher.inappproducts.insert': ['POST', 'androidpublisher/v3/applications/{packageName}/inappproducts', 'autoConvertMissingPrices', 'InAppProduct', 'InAppProduct', '', ''],
'androidpublisher.inappproducts.list': ['GET', 'androidpublisher/v3/applications/{packageName}/inappproducts', 'maxResults startIndex token', '', 'InappproductsListResponse', 't', ''],
'androidpublisher.inappproducts.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/inappproducts/{sku}', 'autoConvertMissingPrices latencyTolerance', 'InAppProduct', 'InAppProduct', '', ''],
'androidpublisher.inappproducts.update': ['PUT', 'androidpublisher/v3/applications/{packageName}/inappproducts/{sku}', 'allowMissing autoConvertMissingPrices latencyTolerance', 'InAppProduct', 'InAppProduct', '', ''],
'androidpublisher.internalappsharingartifacts.uploadapk': ['POST', 'androidpublisher/v3/applications/internalappsharing/{packageName}/artifacts/apk', '', '', 'InternalAppSharingArtifact', 'm', ''],
'androidpublisher.internalappsharingartifacts.uploadbundle': ['POST', 'androidpublisher/v3/applications/internalappsharing/{packageName}/artifacts/bundle', '', '', 'InternalAppSharingArtifact', 'm', ''],
'androidpublisher.monetization.convertRegionPrices': ['POST', 'androidpublisher/v3/applications/{packageName}/pricing:convertRegionPrices', '', 'ConvertRegionPricesRequest', 'ConvertRegionPricesResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.batchDelete': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts:batchDelete', '', 'BatchDeleteOneTimeProductsRequest', '', '', ''],
'androidpublisher.monetization.onetimeproducts.batchGet': ['GET', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts:batchGet', 'productIds*', '', 'BatchGetOneTimeProductsResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.batchUpdate': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts:batchUpdate', '', 'BatchUpdateOneTimeProductsRequest', 'BatchUpdateOneTimeProductsResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}', 'latencyTolerance', '', '', '', ''],
'androidpublisher.monetization.onetimeproducts.get': ['GET', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}', '', '', 'OneTimeProduct', '', ''],
'androidpublisher.monetization.onetimeproducts.list': ['GET', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts', 'pageSize pageToken', '', 'ListOneTimeProductsResponse', 'p', ''],
'androidpublisher.monetization.onetimeproducts.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/onetimeproducts/{productId}', 'allowMissing latencyTolerance regionsVersion.version updateMask', 'OneTimeProduct', 'OneTimeProduct', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.batchDelete': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions:batchDelete', '', 'BatchDeletePurchaseOptionsRequest', '', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.batchUpdateStates': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions:batchUpdateStates', '', 'BatchUpdatePurchaseOptionStatesRequest', 'BatchUpdatePurchaseOptionStatesResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.activate': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers/{offerId}:activate', '', 'ActivateOneTimeProductOfferRequest', 'OneTimeProductOffer', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.batchDelete': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers:batchDelete', '', 'BatchDeleteOneTimeProductOffersRequest', '', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.batchGet': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers:batchGet', '', 'BatchGetOneTimeProductOffersRequest', 'BatchGetOneTimeProductOffersResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.batchUpdate': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers:batchUpdate', '', 'BatchUpdateOneTimeProductOffersRequest', 'BatchUpdateOneTimeProductOffersResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.batchUpdateStates': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers:batchUpdateStates', '', 'BatchUpdateOneTimeProductOfferStatesRequest', 'BatchUpdateOneTimeProductOfferStatesResponse', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.cancel': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers/{offerId}:cancel', '', 'CancelOneTimeProductOfferRequest', 'OneTimeProductOffer', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.deactivate': ['POST', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers/{offerId}:deactivate', '', 'DeactivateOneTimeProductOfferRequest', 'OneTimeProductOffer', '', ''],
'androidpublisher.monetization.onetimeproducts.purchaseOptions.offers.list': ['GET', 'androidpublisher/v3/applications/{packageName}/oneTimeProducts/{productId}/purchaseOptions/{purchaseOptionId}/offers', 'pageSize pageToken', '', 'ListOneTimeProductOffersResponse', 'p', ''],
'androidpublisher.monetization.subscriptions.archive': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}:archive', '', 'ArchiveSubscriptionRequest', 'Subscription', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.activate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}:activate', '', 'ActivateBasePlanRequest', 'Subscription', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.batchMigratePrices': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans:batchMigratePrices', '', 'BatchMigrateBasePlanPricesRequest', 'BatchMigrateBasePlanPricesResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.batchUpdateStates': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans:batchUpdateStates', '', 'BatchUpdateBasePlanStatesRequest', 'BatchUpdateBasePlanStatesResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.deactivate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}:deactivate', '', 'DeactivateBasePlanRequest', 'Subscription', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}', '', '', '', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.migratePrices': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}:migratePrices', '', 'MigrateBasePlanPricesRequest', 'MigrateBasePlanPricesResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.activate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:activate', '', 'ActivateSubscriptionOfferRequest', 'SubscriptionOffer', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.batchGet': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchGet', '', 'BatchGetSubscriptionOffersRequest', 'BatchGetSubscriptionOffersResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdate', '', 'BatchUpdateSubscriptionOffersRequest', 'BatchUpdateSubscriptionOffersResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdateStates': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdateStates', '', 'BatchUpdateSubscriptionOfferStatesRequest', 'BatchUpdateSubscriptionOfferStatesResponse', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.create': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers', 'offerId regionsVersion.version', 'SubscriptionOffer', 'SubscriptionOffer', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.deactivate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:deactivate', '', 'DeactivateSubscriptionOfferRequest', 'SubscriptionOffer', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}', '', '', '', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.get': ['GET', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}', '', '', 'SubscriptionOffer', '', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.list': ['GET', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers', 'pageSize pageToken', '', 'ListSubscriptionOffersResponse', 'p', ''],
'androidpublisher.monetization.subscriptions.basePlans.offers.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}', 'allowMissing latencyTolerance regionsVersion.version updateMask', 'SubscriptionOffer', 'SubscriptionOffer', '', ''],
'androidpublisher.monetization.subscriptions.batchGet': ['GET', 'androidpublisher/v3/applications/{packageName}/subscriptions:batchGet', 'productIds*', '', 'BatchGetSubscriptionsResponse', '', ''],
'androidpublisher.monetization.subscriptions.batchUpdate': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions:batchUpdate', '', 'BatchUpdateSubscriptionsRequest', 'BatchUpdateSubscriptionsResponse', '', ''],
'androidpublisher.monetization.subscriptions.create': ['POST', 'androidpublisher/v3/applications/{packageName}/subscriptions', 'productId regionsVersion.version', 'Subscription', 'Subscription', '', ''],
'androidpublisher.monetization.subscriptions.delete': ['DELETE', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}', '', '', '', '', ''],
'androidpublisher.monetization.subscriptions.get': ['GET', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}', '', '', 'Subscription', '', ''],
'androidpublisher.monetization.subscriptions.list': ['GET', 'androidpublisher/v3/applications/{packageName}/subscriptions', 'pageSize pageToken showArchived', '', 'ListSubscriptionsResponse', 'p', ''],
'androidpublisher.monetization.subscriptions.patch': ['PATCH', 'androidpublisher/v3/applications/{packageName}/subscriptions/{productId}', 'allowMissing latencyTolerance regionsVersion.version updateMask', 'Subscription', 'Subscription', '', ''],
'androidpublisher.orders.batchget': ['GET', 'androidpublisher/v3/applications/{packageName}/orders:batchGet', 'orderIds*', '', 'BatchGetOrdersResponse', '', ''],
'androidpublisher.orders.get': ['GET', 'androidpublisher/v3/applications/{packageName}/orders/{orderId}', '', '', 'Order', '', ''],
'androidpublisher.orders.refund': ['POST', 'androidpublisher/v3/applications/{packageName}/orders/{orderId}:refund', 'revoke', '', '', '', ''],
'androidpublisher.orders.reviewrefund': ['POST', 'androidpublisher/v3/applications/{packageName}/orders/{orderId}:reviewrefund', '', 'OrdersReviewRefundRequest', '', '', ''],
'androidpublisher.purchases.products.acknowledge': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}:acknowledge', '', 'ProductPurchasesAcknowledgeRequest', '', '', ''],
'androidpublisher.purchases.products.consume': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}:consume', '', '', '', '', ''],
'androidpublisher.purchases.products.get': ['GET', 'androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}', '', '', 'ProductPurchase', '', ''],
'androidpublisher.purchases.productsv2.getproductpurchasev2': ['GET', 'androidpublisher/v3/applications/{packageName}/purchases/productsv2/tokens/{token}', '', '', 'ProductPurchaseV2', '', ''],
'androidpublisher.purchases.subscriptions.acknowledge': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}:acknowledge', '', 'SubscriptionPurchasesAcknowledgeRequest', '', '', ''],
'androidpublisher.purchases.subscriptions.cancel': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}:cancel', '', '', '', '', ''],
'androidpublisher.purchases.subscriptions.defer': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}:defer', '', 'SubscriptionPurchasesDeferRequest', 'SubscriptionPurchasesDeferResponse', '', ''],
'androidpublisher.purchases.subscriptionsv2.cancel': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}:cancel', '', 'CancelSubscriptionPurchaseRequest', 'CancelSubscriptionPurchaseResponse', '', ''],
'androidpublisher.purchases.subscriptionsv2.defer': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}:defer', '', 'DeferSubscriptionPurchaseRequest', 'DeferSubscriptionPurchaseResponse', '', ''],
'androidpublisher.purchases.subscriptionsv2.get': ['GET', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}', '', '', 'SubscriptionPurchaseV2', '', ''],
'androidpublisher.purchases.subscriptionsv2.revoke': ['POST', 'androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}:revoke', '', 'RevokeSubscriptionPurchaseRequest', 'RevokeSubscriptionPurchaseResponse', '', ''],
'androidpublisher.purchases.voidedpurchases.list': ['GET', 'androidpublisher/v3/applications/{packageName}/purchases/voidedpurchases', 'endTime includeQuantityBasedPartialRefund maxResults startIndex startTime token type', '', 'VoidedPurchasesListResponse', 't', ''],
'androidpublisher.reviews.get': ['GET', 'androidpublisher/v3/applications/{packageName}/reviews/{reviewId}', 'translationLanguage', '', 'Review', '', ''],
'androidpublisher.reviews.list': ['GET', 'androidpublisher/v3/applications/{packageName}/reviews', 'maxResults startIndex token translationLanguage', '', 'ReviewsListResponse', 't', ''],
'androidpublisher.reviews.reply': ['POST', 'androidpublisher/v3/applications/{packageName}/reviews/{reviewId}:reply', '', 'ReviewsReplyRequest', 'ReviewsReplyResponse', '', ''],
'androidpublisher.systemapks.variants.create': ['POST', 'androidpublisher/v3/applications/{packageName}/systemApks/{versionCode}/variants', '', 'Variant', 'Variant', '', ''],
'androidpublisher.systemapks.variants.download': ['GET', 'androidpublisher/v3/applications/{packageName}/systemApks/{versionCode}/variants/{variantId}:download', '', '', '', 'd', ''],
'androidpublisher.systemapks.variants.get': ['GET', 'androidpublisher/v3/applications/{packageName}/systemApks/{versionCode}/variants/{variantId}', '', '', 'Variant', '', ''],
'androidpublisher.systemapks.variants.list': ['GET', 'androidpublisher/v3/applications/{packageName}/systemApks/{versionCode}/variants', '', '', 'SystemApksListResponse', '', ''],
'androidpublisher.users.create': ['POST', 'androidpublisher/v3/{+parent}/users', '', 'User', 'User', '', 'parent=developers/*'],
'androidpublisher.users.delete': ['DELETE', 'androidpublisher/v3/{+name}', '', '', '', '', 'name=developers/*/users/*'],
'androidpublisher.users.list': ['GET', 'androidpublisher/v3/{+parent}/users', 'pageSize pageToken', '', 'ListUsersResponse', 'p', 'parent=developers/*'],
'androidpublisher.users.patch': ['PATCH', 'androidpublisher/v3/{+name}', 'updateMask', 'User', 'User', '', 'name=developers/*/users/*'],
'playdeveloperreporting.anomalies.list': ['GET', 'v1beta1/{+parent}/anomalies', 'filter pageSize pageToken', '', 'GooglePlayDeveloperReportingV1beta1ListAnomaliesResponse', 'p', 'parent=apps/*'],
'playdeveloperreporting.apps.fetchReleaseFilterOptions': ['GET', 'v1beta1/{+name}:fetchReleaseFilterOptions', '', '', 'GooglePlayDeveloperReportingV1beta1ReleaseFilterOptions', '', 'name=apps/*'],
'playdeveloperreporting.apps.search': ['GET', 'v1beta1/apps:search', 'pageSize pageToken', '', 'GooglePlayDeveloperReportingV1beta1SearchAccessibleAppsResponse', 'p', ''],
'playdeveloperreporting.vitals.anonrssandswapmemoryusage.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1AnonRssAndSwapMemoryUsageMetricSet', '', 'name=apps/*/anonRssAndSwapMemoryUsageMetricSet'],
'playdeveloperreporting.vitals.anonrssandswapmemoryusage.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryAnonRssAndSwapMemoryUsageMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryAnonRssAndSwapMemoryUsageMetricSetResponse', '', 'name=apps/*/anonRssAndSwapMemoryUsageMetricSet'],
'playdeveloperreporting.vitals.anrrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1AnrRateMetricSet', '', 'name=apps/*/anrRateMetricSet'],
'playdeveloperreporting.vitals.anrrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetResponse', '', 'name=apps/*/anrRateMetricSet'],
'playdeveloperreporting.vitals.bitmapmemoryusage.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1BitmapMemoryUsageMetricSet', '', 'name=apps/*/bitmapMemoryUsageMetricSet'],
'playdeveloperreporting.vitals.bitmapmemoryusage.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryBitmapMemoryUsageMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryBitmapMemoryUsageMetricSetResponse', '', 'name=apps/*/bitmapMemoryUsageMetricSet'],
'playdeveloperreporting.vitals.crashrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1CrashRateMetricSet', '', 'name=apps/*/crashRateMetricSet'],
'playdeveloperreporting.vitals.crashrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryCrashRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryCrashRateMetricSetResponse', '', 'name=apps/*/crashRateMetricSet'],
'playdeveloperreporting.vitals.errors.counts.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1ErrorCountMetricSet', '', 'name=apps/*/errorCountMetricSet'],
'playdeveloperreporting.vitals.errors.counts.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryErrorCountMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryErrorCountMetricSetResponse', '', 'name=apps/*/errorCountMetricSet'],
'playdeveloperreporting.vitals.errors.issues.search': ['GET', 'v1beta1/{+parent}/errorIssues:search', 'filter interval.endTime.day interval.endTime.hours interval.endTime.minutes interval.endTime.month interval.endTime.nanos interval.endTime.seconds interval.endTime.timeZone.id interval.endTime.timeZone.version interval.endTime.utcOffset interval.endTime.year interval.startTime.day interval.startTime.hours interval.startTime.minutes interval.startTime.month interval.startTime.nanos interval.startTime.seconds interval.startTime.timeZone.id interval.startTime.timeZone.version interval.startTime.utcOffset interval.startTime.year orderBy pageSize pageToken sampleErrorReportLimit', '', 'GooglePlayDeveloperReportingV1beta1SearchErrorIssuesResponse', 'p', 'parent=apps/*'],
'playdeveloperreporting.vitals.errors.reports.search': ['GET', 'v1beta1/{+parent}/errorReports:search', 'filter interval.endTime.day interval.endTime.hours interval.endTime.minutes interval.endTime.month interval.endTime.nanos interval.endTime.seconds interval.endTime.timeZone.id interval.endTime.timeZone.version interval.endTime.utcOffset interval.endTime.year interval.startTime.day interval.startTime.hours interval.startTime.minutes interval.startTime.month interval.startTime.nanos interval.startTime.seconds interval.startTime.timeZone.id interval.startTime.timeZone.version interval.startTime.utcOffset interval.startTime.year pageSize pageToken', '', 'GooglePlayDeveloperReportingV1beta1SearchErrorReportsResponse', 'p', 'parent=apps/*'],
'playdeveloperreporting.vitals.excessivewakeuprate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1ExcessiveWakeupRateMetricSet', '', 'name=apps/*/excessiveWakeupRateMetricSet'],
'playdeveloperreporting.vitals.excessivewakeuprate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryExcessiveWakeupRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryExcessiveWakeupRateMetricSetResponse', '', 'name=apps/*/excessiveWakeupRateMetricSet'],
'playdeveloperreporting.vitals.lmkrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1LmkRateMetricSet', '', 'name=apps/*/lmkRateMetricSet'],
'playdeveloperreporting.vitals.lmkrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryLmkRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryLmkRateMetricSetResponse', '', 'name=apps/*/lmkRateMetricSet'],
'playdeveloperreporting.vitals.slowrenderingrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1SlowRenderingRateMetricSet', '', 'name=apps/*/slowRenderingRateMetricSet'],
'playdeveloperreporting.vitals.slowrenderingrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QuerySlowRenderingRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QuerySlowRenderingRateMetricSetResponse', '', 'name=apps/*/slowRenderingRateMetricSet'],
'playdeveloperreporting.vitals.slowstartrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1SlowStartRateMetricSet', '', 'name=apps/*/slowStartRateMetricSet'],
'playdeveloperreporting.vitals.slowstartrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QuerySlowStartRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QuerySlowStartRateMetricSetResponse', '', 'name=apps/*/slowStartRateMetricSet'],
'playdeveloperreporting.vitals.stuckbackgroundwakelockrate.get': ['GET', 'v1beta1/{+name}', '', '', 'GooglePlayDeveloperReportingV1beta1StuckBackgroundWakelockRateMetricSet', '', 'name=apps/*/stuckBackgroundWakelockRateMetricSet'],
'playdeveloperreporting.vitals.stuckbackgroundwakelockrate.query': ['POST', 'v1beta1/{+name}:query', '', 'GooglePlayDeveloperReportingV1beta1QueryStuckBackgroundWakelockRateMetricSetRequest', 'GooglePlayDeveloperReportingV1beta1QueryStuckBackgroundWakelockRateMetricSetResponse', '', 'name=apps/*/stuckBackgroundWakelockRateMetricSet'],
} as const;

68
cli/api/types.ts Normal file
View file

@ -0,0 +1,68 @@
// Types of every App Store Connect / Google Play operation, derived from the generated rows (apple.gen.ts, play.gen.ts)
// instead of one generated interface per endpoint: path parameters from the path template, query parameters from the
// row's parameter list ("!" required), request and response shapes from the row's body / response columns.
import type { APPLE_OPS } from './apple.gen';
import type { PLAY_OPS } from './play.gen';
export type AppleOp = keyof typeof APPLE_OPS;
export type PlayOp = keyof typeof PLAY_OPS;
/** A parameter value: arrays are comma-joined (Apple) or repeated (Google, "name*" parameters). */
export type ParamValue = string | number | boolean | readonly (string | number)[];
/** "a b c" -> "a" | "b" | "c" (tail-recursive, so rows with 70+ parameters stay under the depth limit). */
export type Split<S extends string, Acc = never> = S extends `${infer H} ${infer T}` ? Split<T, Acc | H> : S extends '' ? Acc : Acc | S;
/** "/v1/apps/{id}/builds" -> "id"; "{+name}" -> "name". */
export type PathParams<P extends string, Acc = never> = P extends `${string}{${infer N}}${infer R}` ? PathParams<R, Acc | (N extends `+${infer M}` ? M : N)> : Acc;
type Required<T> = T extends `${infer N}!${string}` ? N : never;
type Optional<T> = T extends `${string}!${string}` ? never : T extends `${infer N}*` ? N : T;
/** Parameters of one row: path parameters and required query parameters must be given, the rest may. */
export type RowParams<R extends readonly string[]> =
{ [K in PathParams<R[1]> | Required<Split<R[2]>>]: ParamValue } & { [K in Optional<Split<R[2]>>]?: ParamValue };
export type AppleParams<O extends AppleOp> = RowParams<(typeof APPLE_OPS)[O]>;
export type PlayParams<O extends PlayOp> = RowParams<(typeof PLAY_OPS)[O]> & { fields?: string; prettyPrint?: boolean; quotaUser?: string };
// ---------------------------------------------------------------- JSON:API (App Store Connect), loosely typed
export interface Resource<T extends string = string> {
type: T;
id: string;
attributes?: Record<string, unknown>;
relationships?: Record<string, { data?: Linkage | Linkage[] | null; links?: Record<string, string>; meta?: Record<string, unknown> }>;
links?: Record<string, string>;
}
export interface Linkage { type: string; id: string }
export interface Document<D> {
data: D;
included?: Resource[];
links?: { self?: string; next?: string; first?: string; related?: string };
meta?: { paging?: { total?: number; limit?: number; nextCursor?: string } } & Record<string, unknown>;
}
type AppleRow<O extends AppleOp> = (typeof APPLE_OPS)[O];
/** Response of an Apple operation: a JSON:API document of the row's resource type, raw bytes, or nothing. */
export type AppleResponse<O extends AppleOp> =
AppleRow<O>[4] extends `many:${infer T}` ? Document<Resource<T>[]>
: AppleRow<O>[4] extends `one:${infer T}` ? Document<Resource<T>>
: AppleRow<O>[4] extends `raw:${string}json` ? Record<string, unknown>
: AppleRow<O>[4] extends `raw:${string}` ? Uint8Array
: AppleRow<O>[4] extends 'none' ? null
: Record<string, unknown>;
/** Request body of an Apple operation (undefined when it takes none). */
export type AppleBody<O extends AppleOp> =
AppleRow<O>[3] extends '' ? undefined
: AppleRow<O>[3] extends 'json' ? Record<string, unknown>
: AppleRow<O>[3] extends `${infer T}[]` ? { data: { type: T; id: string }[] }
: { data: { type: AppleRow<O>[3]; id?: string; attributes?: Record<string, unknown>; relationships?: Record<string, unknown> } };
type PlayRow<O extends PlayOp> = (typeof PLAY_OPS)[O];
/** Google responses are plain JSON objects (schema name in the row); media downloads are bytes. */
export type PlayResponse<O extends PlayOp> = PlayRow<O>[5] extends `${string}d${string}` ? Record<string, unknown> | Uint8Array : Record<string, unknown>;
/** Request body: the JSON object of the row's schema, file bytes for a media upload, else nothing. */
export type PlayBody<O extends PlayOp> =
PlayRow<O>[3] extends '' ? (PlayRow<O>[5] extends `${string}m${string}` ? Uint8Array : undefined) : Record<string, unknown>;

171
cli/api/wrapped.gen.ts Normal file
View file

@ -0,0 +1,171 @@
// GENERATED by tools/gen-api.ts from a scan of the API calls in cli/ (outside cli/api). Do not edit: run `bun tools/gen-api.ts` (CI checks it is fresh).
// Operation id -> the source modules (cli/<module>.ts) of storebound commands that already call it.
export const WRAPPED: { apple: Record<string, string>; play: Record<string, string> } = {
apple: {
'ageRatingDeclarations_updateInstance': 'apple/appinfo',
'analyticsReportInstances_segments_getToManyRelated': 'analytics',
'analyticsReportRequests_createInstance': 'analytics',
'analyticsReportRequests_reports_getToManyRelated': 'analytics',
'analyticsReports_instances_getToManyRelated': 'analytics',
'appAvailabilitiesV2_createInstance': 'apple/appinfo',
'appEncryptionDeclarations_getCollection': 'apple/encryption',
'appInfos_ageRatingDeclaration_getToOneRelated': 'validate',
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
'appInfos_updateInstance': 'apple/appinfo',
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
'appPreviewSets_createInstance': 'apple/previews',
'appPreviews_createInstance': 'apple/previews',
'appPreviews_deleteInstance': 'apple/previews',
'appPreviews_updateInstance': 'apple/previews',
'appPriceSchedules_createInstance': 'apple/appinfo',
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots validate',
'appScreenshotSets_createInstance': 'apple/screenshots',
'appScreenshots_createInstance': 'apple/screenshots',
'appScreenshots_deleteInstance': 'apple/screenshots',
'appScreenshots_updateInstance': 'apple/screenshots',
'appStoreReviewDetails_createInstance': 'apple/review-details',
'appStoreReviewDetails_updateInstance': 'apple/review-details',
'appStoreVersionExperimentTreatmentLocalizations_appScreenshotSets_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentTreatmentLocalizations_createInstance': 'lib/ppo',
'appStoreVersionExperimentTreatments_appStoreVersionExperimentTreatmentLocalizations_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentTreatments_createInstance': 'lib/ppo',
'appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentsV2_createInstance': 'lib/ppo',
'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo',
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews',
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate',
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate',
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
'appStoreVersions_build_getToOneRelated': 'validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing',
'appStoreVersions_createInstance': 'apple/listing',
'appStoreVersions_updateInstance': 'apple/listing',
'apps_analyticsReportRequests_getToManyRelated': 'analytics',
'apps_appAvailabilityV2_getToOneRelated': 'apple/appinfo validate',
'apps_appInfos_getToManyRelated': 'apple/asc',
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/release apple/review-details apple/status lib/store-apps validate',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
'apps_betaGroups_getToManyRelated': 'apple/testflight',
'apps_customerReviews_getToManyRelated': 'reviews',
'apps_getCollection': 'apple/asc apple/bundle apps lib/store-apps',
'apps_getInstance': 'apple/status',
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/status catalog prices validate',
'apps_perfPowerMetrics_getToManyRelated': 'health',
'apps_subscriptionGroups_getToManyRelated': 'apple/subscriptions catalog validate',
'apps_updateInstance': 'apple/appinfo',
'betaAppLocalizations_createInstance': 'lib/testflight',
'betaAppLocalizations_updateInstance': 'lib/testflight',
'betaAppReviewDetails_updateInstance': 'lib/testflight',
'betaAppReviewSubmissions_createInstance': 'lib/testflight',
'betaBuildLocalizations_createInstance': 'lib/testflight',
'betaBuildLocalizations_updateInstance': 'lib/testflight',
'betaGroups_betaTesters_createToManyRelationship': 'lib/testflight',
'betaGroups_betaTesters_deleteToManyRelationship': 'lib/testflight',
'betaGroups_betaTesters_getToManyRelated': 'apple/testflight',
'betaGroups_builds_createToManyRelationship': 'lib/testflight',
'betaGroups_builds_getToManyRelated': 'apple/testflight',
'betaGroups_createInstance': 'lib/testflight',
'betaGroups_getCollection': 'apple/testflight',
'betaTesterInvitations_createInstance': 'lib/testflight',
'betaTesters_createInstance': 'lib/testflight',
'betaTesters_getCollection': 'apple/testflight',
'builds_appEncryptionDeclaration_getToOneRelated': 'validate',
'builds_betaAppReviewSubmission_getToOneRelated': 'apple/testflight',
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
'builds_diagnosticSignatures_getToManyRelated': 'health',
'builds_getCollection': 'apple/encryption apple/listing apple/next-build apple/status apple/testflight health lib/store-apps',
'builds_updateInstance': 'apple/encryption',
'bundleIds_createInstance': 'apple/bundle',
'bundleIds_getCollection': 'apple/bundle',
'customerReviewResponses_createInstance': 'lib/reviews',
'inAppPurchaseAppStoreReviewScreenshots_createInstance': 'apple/iap-review',
'inAppPurchaseAppStoreReviewScreenshots_deleteInstance': 'apple/iap-review',
'inAppPurchaseAppStoreReviewScreenshots_updateInstance': 'apple/iap-review',
'inAppPurchaseAvailabilities_createInstance': 'apple/iap',
'inAppPurchaseLocalizations_createInstance': 'apple/iap',
'inAppPurchasePriceSchedules_automaticPrices_getToManyRelated': 'prices',
'inAppPurchasePriceSchedules_baseTerritory_getToOneRelated': 'prices',
'inAppPurchasePriceSchedules_createInstance': 'apple/iap prices',
'inAppPurchasePriceSchedules_manualPrices_getToManyRelated': 'prices',
'inAppPurchasesV2_appStoreReviewScreenshot_getToOneRelated': 'apple/iap-review validate',
'inAppPurchasesV2_createInstance': 'apple/iap',
'inAppPurchasesV2_iapPriceSchedule_getToOneRelated': 'apple/iap catalog',
'inAppPurchasesV2_inAppPurchaseAvailability_getToOneRelated': 'apple/iap',
'inAppPurchasesV2_inAppPurchaseLocalizations_getToManyRelated': 'apple/iap catalog',
'inAppPurchasesV2_pricePoints_getToManyRelated': 'apple/iap prices',
'reviewSubmissions_getCollection': 'apple/status',
'reviewSubmissions_updateInstance': 'apple/status',
'subscriptionAppStoreReviewScreenshots_createInstance': 'apple/subscriptions',
'subscriptionAppStoreReviewScreenshots_updateInstance': 'apple/subscriptions',
'subscriptionAvailabilities_createInstance': 'apple/subscriptions',
'subscriptionGroupLocalizations_createInstance': 'apple/subscriptions',
'subscriptionGroups_createInstance': 'apple/subscriptions',
'subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated': 'apple/subscriptions validate',
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/subscriptions catalog validate',
'subscriptionLocalizations_createInstance': 'apple/subscriptions',
'subscriptionOfferCodeCustomCodes_createInstance': 'apple/subscriptions',
'subscriptionOfferCodes_createInstance': 'apple/subscriptions',
'subscriptionOfferCodes_customCodes_getToManyRelated': 'apple/subscriptions',
'subscriptionPricePoints_equalizations_getToManyRelated': 'apple/subscriptions',
'subscriptionPromotionalOffers_createInstance': 'apple/subscriptions',
'subscriptions_appStoreReviewScreenshot_getToOneRelated': 'apple/subscriptions validate',
'subscriptions_createInstance': 'apple/subscriptions',
'subscriptions_introductoryOffers_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_offerCodes_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_pricePoints_getToManyRelated': 'apple/subscriptions',
'subscriptions_prices_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_promotionalOffers_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_subscriptionAvailability_getToOneRelated': 'apple/subscriptions',
'subscriptions_subscriptionLocalizations_getToManyRelated': 'apple/subscriptions catalog validate',
'subscriptions_updateInstance': 'apple/subscriptions',
'territories_getCollection': 'apple/appinfo apple/iap apple/subscriptions',
},
play: {
'androidpublisher.applications.dataSafety': 'play/data-safety',
'androidpublisher.edits.apks.list': 'play/next-version-code',
'androidpublisher.edits.bundles.list': 'play/next-version-code',
'androidpublisher.edits.bundles.upload': 'play/release',
'androidpublisher.edits.commit': 'play/client',
'androidpublisher.edits.delete': 'apps play/client validate',
'androidpublisher.edits.details.get': 'apps play/listing play/status validate',
'androidpublisher.edits.details.update': 'play/listing',
'androidpublisher.edits.images.deleteall': 'play/images',
'androidpublisher.edits.images.list': 'apps play/images validate',
'androidpublisher.edits.images.upload': 'play/images',
'androidpublisher.edits.insert': 'apps play/client validate',
'androidpublisher.edits.listings.list': 'play/listing play/status validate',
'androidpublisher.edits.listings.update': 'play/listing',
'androidpublisher.edits.tracks.get': 'play/rollout',
'androidpublisher.edits.tracks.list': 'play/next-version-code play/status validate',
'androidpublisher.edits.tracks.update': 'play/release play/rollout',
'androidpublisher.edits.validate': 'play/client',
'androidpublisher.monetization.convertRegionPrices': 'play/products play/subscriptions prices',
'androidpublisher.monetization.onetimeproducts.get': 'prices',
'androidpublisher.monetization.onetimeproducts.list': 'play/products',
'androidpublisher.monetization.onetimeproducts.patch': 'play/products prices',
'androidpublisher.monetization.onetimeproducts.purchaseOptions.batchUpdateStates': 'play/products',
'androidpublisher.monetization.subscriptions.basePlans.activate': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.activate': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.create': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.list': 'catalog play/subscriptions',
'androidpublisher.monetization.subscriptions.create': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.list': 'catalog play/subscriptions validate',
'androidpublisher.monetization.subscriptions.patch': 'play/subscriptions',
'androidpublisher.reviews.list': 'reviews',
'androidpublisher.reviews.reply': 'lib/reviews',
'playdeveloperreporting.apps.search': 'apps lib/store-apps',
'playdeveloperreporting.vitals.anrrate.query': 'health',
'playdeveloperreporting.vitals.crashrate.get': 'health',
'playdeveloperreporting.vitals.crashrate.query': 'health',
'playdeveloperreporting.vitals.errors.issues.search': 'health',
},
};

View file

@ -24,7 +24,9 @@ export function setUpToDate(existing: { checksum?: string | null; state?: string
return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? '')); return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? ''));
} }
export async function ascClient(root: string, bundleId: string, appIdHint?: string) { /** Cached ES256 token getter for the team API key (env or .storebound/): valid 18 minutes, renewed after 15. Shared by
* ascClient and the generic `storebound api apple` client (cli/api/client.ts). */
export function ascJwt(root: string): { jwt: () => string; keyId: string } {
const k = ascKey(root); const k = ascKey(root);
const key = createPrivateKey(k.pem); const key = createPrivateKey(k.pem);
let cached = { jwt: '', until: 0 }; let cached = { jwt: '', until: 0 };
@ -36,6 +38,12 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
cached = { jwt: `${h}.${p}.${b64(sign('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }))}`, until: now + 900 }; cached = { jwt: `${h}.${p}.${b64(sign('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }))}`, until: now + 900 };
return cached.jwt; return cached.jwt;
} }
return { jwt, keyId: k.keyId };
}
export async function ascClient(root: string, bundleId: string, appIdHint?: string) {
const k = ascJwt(root);
const jwt = k.jwt;
async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise<J> { async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise<J> {
const r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, { const r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, {

View file

@ -18,6 +18,7 @@ const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'], 'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, --download saves it to ~/Downloads after checking its signature (never installs)'], 'desktop': [() => import('./desktop'), 'open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, --download saves it to ~/Downloads after checking its signature (never installs)'],
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'], 'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios <path>)'], 'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios <path>)'],
}; };
const COMMANDS: Record<string, [() => Promise<Mod>, string]> = { const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {

View file

@ -6,21 +6,25 @@ export interface Args {
str(name: string, d: string): string; str(name: string, d: string): string;
num(name: string, d: number): number; num(name: string, d: number): number;
list(name: string): string[]; list(name: string): string[];
/** Every value of a flag given more than once (--param a=1 --param b=2), in order. */
many(name: string): string[];
} }
export function parseArgs(argv: string[], booleans: string[] = []): Args { export function parseArgs(argv: string[], booleans: string[] = []): Args {
const flags = new Map<string, string>(); const flags = new Map<string, string>();
const repeated = new Map<string, string[]>();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = []; const _: string[] = [];
const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', ...booleans]); const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', ...booleans]);
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]!; const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; } if (!a.startsWith('--')) { _.push(a); continue; }
const eq = a.indexOf('='); const eq = a.indexOf('=');
if (eq > 0) { flags.set(a.slice(2, eq), a.slice(eq + 1)); continue; } if (eq > 0) { set(a.slice(2, eq), a.slice(eq + 1)); continue; }
const name = a.slice(2); const name = a.slice(2);
const next = argv[i + 1]; const next = argv[i + 1];
if (bool.has(name) || name.startsWith('no-') || next === undefined || next.startsWith('--')) flags.set(name, 'true'); if (bool.has(name) || name.startsWith('no-') || next === undefined || next.startsWith('--')) set(name, 'true');
else { flags.set(name, next); i++; } else { set(name, next); i++; }
} }
const get = (n: string) => flags.get(n); const get = (n: string) => flags.get(n);
return { return {
@ -30,5 +34,6 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
str: (n, d) => get(n) ?? d, str: (n, d) => get(n) ?? d,
num: (n, d) => { const v = get(n); const x = v === undefined ? d : Number(v); if (Number.isNaN(x)) throw new Error(`--${n}: not a number`); return x; }, num: (n, d) => { const v = get(n); const x = v === undefined ? d : Number(v); if (Number.isNaN(x)) throw new Error(`--${n}: not a number`); return x; },
list: n => (get(n) ?? '').split(',').map(s => s.trim()).filter(Boolean), list: n => (get(n) ?? '').split(',').map(s => s.trim()).filter(Boolean),
many: n => repeated.get(n) ?? [],
}; };
} }

View file

@ -3,7 +3,7 @@
// submits for review, releases to the public, resumes / completes a rollout, cancels a submission or deletes is // submits for review, releases to the public, resumes / completes a rollout, cancels a submission or deletes is
// exposed. Those stay terminal commands the owner runs (they also ask for confirmation there). // exposed. Those stay terminal commands the owner runs (they also ask for confirmation there).
export interface JsonSchema { type: string; description?: string; enum?: (string | number)[]; default?: unknown; items?: JsonSchema; minimum?: number; maximum?: number; pattern?: string } export interface JsonSchema { type: string; description?: string; enum?: (string | number)[]; default?: unknown; items?: JsonSchema; minimum?: number; maximum?: number; pattern?: string; additionalProperties?: JsonSchema | boolean }
export interface ToolDef { export interface ToolDef {
name: string; name: string;
@ -204,6 +204,14 @@ export const TOOLS: ToolDef[] = [
description: 'Sync the screenshots of one shots variant (renders/<variant>/<lang>/...) into the matching treatment of the editable Product Page Optimization test, per locale (MD5-idempotent; changed sets are replaced). Nothing is shown to players until the owner starts the test.', description: 'Sync the screenshots of one shots variant (renders/<variant>/<lang>/...) into the matching treatment of the editable Product Page Optimization test, per locale (MD5-idempotent; changed sets are replaced). Nothing is shown to players until the owner starts the test.',
props: { variant, experiment: { type: 'string', description: 'Test name (default: the one editable test)' }, only }, props: { variant, experiment: { type: 'string', description: 'Test name (default: the one editable test)' }, only },
required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] }, required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] },
{ name: 'api_get', title: 'Any store API read', command: ['api', 'get'], write: false, network: true,
description: 'Read-only call of any App Store Connect API or Google Play Developer API GET operation by its operation id (every one is listed by `storebound api list` and in docs/api-coverage.md), e.g. store apple, operation apps_getCollection, params {"filter[bundleId]": "com.example.app"}. GET only: writes, and anything that submits, releases, deletes or cancels, are never available here.',
props: { store: { type: 'string', enum: ['apple', 'play'] },
operation: { type: 'string', pattern: '^[A-Za-z][A-Za-z0-9_.]{0,200}$', description: 'Operation id: Apple e.g. apps_getCollection, Google e.g. androidpublisher.reviews.list (the API prefix may be left out)' },
params: { type: 'object', additionalProperties: { type: 'string' }, description: 'Path and query parameters by name (string, number or boolean values; lists comma-separated)' },
all_pages: { type: 'boolean', description: 'Follow every page and merge them' } },
required: ['store', 'operation'],
args: i => [String(i.store), String(i.operation), ...Object.entries((i.params ?? {}) as Record<string, unknown>).map(([k, v]) => `--param=${k}=${String(v)}`), ...flag('all-pages', i.all_pages), '--json'] },
{ name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true, { name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true,
description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' }, description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' },
{ name: 'import_fastlane', title: 'Import a fastlane setup', command: ['import', 'fastlane'], write: true, network: false, idempotent: true, { name: 'import_fastlane', title: 'Import a fastlane setup', command: ['import', 'fastlane'], write: true, network: false, idempotent: true,
@ -248,8 +256,10 @@ export function validateInput(t: ToolDef, input: unknown): string[] {
for (const [k, v] of Object.entries(i)) { for (const [k, v] of Object.entries(i)) {
const p = s.properties[k]; const p = s.properties[k];
if (!p) { e.push(`${k}: unknown argument`); continue; } if (!p) { e.push(`${k}: unknown argument`); continue; }
const ok = p.type === 'integer' ? Number.isInteger(v) : p.type === 'boolean' ? typeof v === 'boolean' : p.type === 'string' ? typeof v === 'string' : true; const ok = p.type === 'integer' ? Number.isInteger(v) : p.type === 'boolean' ? typeof v === 'boolean' : p.type === 'string' ? typeof v === 'string'
if (!ok) { e.push(`${k}: must be ${p.type}`); continue; } : p.type === 'object' ? typeof v === 'object' && v !== null && !Array.isArray(v) && Object.entries(v).every(([pk, pv]) => /^[A-Za-z][A-Za-z0-9_.[\]+-]{0,100}$/.test(pk) && ['string', 'number', 'boolean'].includes(typeof pv) && !/[\n\r]/.test(String(pv)))
: true;
if (!ok) { e.push(`${k}: must be ${p.type === 'object' ? 'an object of name -> string / number / boolean' : p.type}`); continue; }
if (p.enum && !p.enum.includes(v as string)) e.push(`${k}: one of ${p.enum.join(', ')}`); if (p.enum && !p.enum.includes(v as string)) e.push(`${k}: one of ${p.enum.join(', ')}`);
if (p.pattern && typeof v === 'string' && !new RegExp(p.pattern).test(v)) e.push(`${k}: does not match ${p.pattern}`); if (p.pattern && typeof v === 'string' && !new RegExp(p.pattern).test(v)) e.push(`${k}: does not match ${p.pattern}`);
if (p.minimum !== undefined && typeof v === 'number' && v < p.minimum) e.push(`${k}: >= ${p.minimum}`); if (p.minimum !== undefined && typeof v === 'number' && v < p.minimum) e.push(`${k}: >= ${p.minimum}`);

View file

@ -120,7 +120,8 @@ const running = new Map<Id, Running>();
function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); } function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); }
async function runCli(argv: string[], id: Id): Promise<{ code: number; out: string }> { async function runCli(argv: string[], id: Id): Promise<{ code: number; out: string }> {
const r = runCommand(argv, { cwd: process.env.STOREBOUND_PROJECT || process.cwd() }); // STOREBOUND_MCP: `storebound api` never sends a write from an MCP call, whatever the arguments
const r = runCommand(argv, { cwd: process.env.STOREBOUND_PROJECT || process.cwd(), env: { STOREBOUND_MCP: '1' } });
running.set(id, r); running.set(id, r);
try { const { code, out } = await r.result; return { code, out }; } finally { running.delete(id); } try { const { code, out } = await r.result; return { code, out }; } finally { running.delete(id); }
} }

View file

@ -39,8 +39,8 @@ const lang = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,20}(,[A-Za-z0-9_-]{1,2
export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/; export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/;
/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has /** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has
* the owner actions for those). */ * the owner actions for those; the raw API reader api_get, which the window has no use for). */
export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release']); export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release', 'api_get']);
/** Words no desktop method name may contain. */ /** Words no desktop method name may contain. */
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i; export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;

1768
docs/api-coverage.md Normal file

File diff suppressed because it is too large Load diff

55
skills/store-api/SKILL.md Normal file
View file

@ -0,0 +1,55 @@
---
name: store-api
description: Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely - `storebound api list / coverage` to find it, `storebound api apple|play <operationId>` (or `METHOD path`) to call it, with GETs run freely, writes dry until the owner sends them with --yes in a terminal, and owner-only operations (submit, release, delete, cancel, prices, testers, replies) needing the operation id typed. Use when the user needs a store feature with no storebound command (custom product pages, Game Center, app clips, users, purchases, sales reports, edit-level Play reads) or asks whether an endpoint is supported.
---
# Store API (every endpoint)
storebound covers the release journey with high-level commands. Everything else in the App Store Connect API
(1303 operations) and the Google Play Developer APIs (Android Publisher v3: 145, Reporting v1beta1: 25) is reachable
through `storebound api`, generated from the official specs pinned in `specs/`. Prefer a high-level command whenever
one exists (`storebound api coverage` and docs/api-coverage.md show which operations a command already wraps): those
are idempotent, check the config and know the store's rules.
## Find the operation
- `storebound api list --store apple --search "appCustomProductPages"` (words match the id, method and path; `--json`).
- `storebound api coverage`: counts per API and per resource, and how many a command already wraps.
- The full table with gates: docs/api-coverage.md in the storebound repository.
- Apple ids look like `apps_getCollection`, `appStoreVersions_updateInstance`, `apps_customerReviews_getToManyRelated`.
Google ids look like `androidpublisher.edits.tracks.list` (the `androidpublisher.` prefix may be left out).
## Read (runs right away; MCP tool `api_get`)
```sh
storebound api apple apps_getCollection --param 'filter[bundleId]=com.example.app' --param 'fields[apps]=name,sku'
storebound api apple GET '/v1/apps/<id>/appStoreVersions?filter[platform]=IOS' --all-pages
storebound api play reviews.list --param packageName=com.example.app --all-pages --json
storebound api play POST /v1beta1/apps/com.example.app/crashRateMetricSet:query --body query.json # a read
storebound api apple salesReports_getCollection --param ... --out report.gz # non-JSON bodies need --out
```
- Parameters: `--param name=value`, repeatable; lists are comma-separated (Apple) or repeated (Google). Missing
required parameters and unknown names are errors before anything is sent.
- `--all-pages` follows Apple's `links.next` and Google's page tokens and merges the pages.
- Play edit-scoped reads (`edits.*.get/list`) need an edit id, and opening an edit (`edits.insert`) is a write: use
`storebound play status` for those reads instead of opening edits by hand.
- MCP: `api_get` with `store`, `operation`, `params` (object) and `all_pages`. It runs GET operations only.
## Write (only the owner sends)
1. Run the write without `--yes`: it prints the exact request (method, URL, body; the key is never shown) and sends
nothing. Show that to the owner.
2. The owner runs it in their own terminal with `--yes`. Outside an interactive terminal, under MCP or in the desktop
app, a write is never sent, whatever the flags.
3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased
release changes, product page test start / stop, price schedules and price migrations, review replies, tester and
user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for
the operation id to be typed at the prompt. Never try to get around this: tell the owner what the call does and
let them run it (AGENTS.md rule 1).
Bodies: `--body file.json` (Apple: a JSON:API document `{ "data": { "type": ..., "attributes": ..., "relationships": ... } }`),
`--upload file` for Google media uploads (bundles, APKs, images).
## Rules
- Credentials only from env or `.storebound/` (same keys as every other command); never ask for them in chat.
- Never answer compliance questions through raw calls (age rating, App Privacy, Data safety, export compliance): the
matching skill and the owner decide.
- Rate limits: storebound retries 429 and 5xx with backoff (Retry-After honoured) and prints Apple's remaining hourly
quota; long `--all-pages` runs go to the background with a log (`> .storebound/api.log 2>&1 &`).

36
specs/SOURCES.json Normal file
View file

@ -0,0 +1,36 @@
{
"note": "Pinned API descriptions for tools/gen-api.ts. Refresh: download again, update this file (url, date, sha256), run bun tools/gen-api.ts, commit specs + generated files together.",
"apple": {
"file": "app-store-connect.oas.json",
"title": "App Store Connect API",
"version": "4.5.1",
"url": "https://developer.apple.com/sample-code/app-store-connect/app-store-connect-openapi-specification.zip",
"zipLastModified": "2026-10-06",
"zipSha256": "7b826ae9ca3e45b0e7df2643cbb811c9930632640944db11a8af4586fab9db46",
"downloaded": "2026-10-11",
"sha256": "7518d3a94a8bd701ac25c1c601b95b8f53aad92091affb15a2a9d331713dac1a"
},
"play": [
{
"file": "androidpublisher_v3.discovery.json",
"title": "Google Play Android Developer API v3",
"revision": "20261008",
"url": "https://www.googleapis.com/discovery/v1/apis/androidpublisher/v3/rest",
"downloaded": "2026-10-11",
"sha256": "668757f25cbe47e97f782758e818f1e18bf7da4e2049180ef0e49b3d0e465efc"
},
{
"file": "playdeveloperreporting_v1beta1.discovery.json",
"title": "Google Play Developer Reporting API v1beta1",
"revision": "20261008",
"url": "https://playdeveloperreporting.googleapis.com/$discovery/rest?version=v1beta1",
"downloaded": "2026-10-11",
"sha256": "277afdf1c026c13d91d0b3d6d5fd10a46ecaa9b21bbc2f085c104193e254a448"
}
],
"notIncluded": {
"playdeveloperreporting v1alpha1": "revision 20261008 has the same 25 methods as v1beta1 (no unique methods), so only v1beta1 is generated",
"games v1 / gamesConfiguration / gamesManagement": "storebound does not call the Play Games Services APIs",
"playintegrity v1": "storebound does not call the Play Integrity API (it is called by an app's own backend)"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

333
tests/api.test.ts Normal file
View file

@ -0,0 +1,333 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { appleRows, generate, playRows, scanSource, templateRegex, wrappedOps } from '../tools/gen-api';
import { APPLE_OPS } from '../cli/api/apple.gen';
import { PLAY_APIS, PLAY_OPS } from '../cli/api/play.gen';
import { allOps, appleApi, backoffMs, buildRequest, getOp, mergePages, nextRequest, type Op, playApi, resolveOp, send, sendAll } from '../cli/api/client';
import { decide, gateOf, type GateInput, OWNER_ONLY, ownerReason } from '../cli/api/gate';
import { coverage, describeRequest, parseParams, rows, search } from '../cli/api';
import { parseArgs } from '../cli/lib/args';
import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
import { DESKTOP_TOOLS } from '../cli/serve';
const ROOT = join(import.meta.dir, '..');
const op = (store: 'apple' | 'play', id: string): Op => getOp(store, id)!;
describe('generator', () => {
test('deterministic and committed files are fresh', () => {
const a = generate(ROOT), b = generate(ROOT);
expect(a.map(g => g.text)).toEqual(b.map(g => g.text));
for (const g of a) expect({ path: g.path, fresh: readFileSync(join(ROOT, g.path), 'utf8') === g.text }).toEqual({ path: g.path, fresh: true });
});
test('every operation of the pinned specs is in the tables', () => {
const spec = JSON.parse(readFileSync(join(ROOT, 'specs/app-store-connect.oas.json'), 'utf8'));
let n = 0;
for (const item of Object.values<Record<string, unknown>>(spec.paths)) for (const m of ['get', 'post', 'put', 'patch', 'delete']) if (item[m]) n++;
expect(Object.keys(APPLE_OPS).length).toBe(n);
expect(n).toBeGreaterThan(1200);
const ap = playRows(JSON.parse(readFileSync(join(ROOT, 'specs/androidpublisher_v3.discovery.json'), 'utf8')));
const rep = playRows(JSON.parse(readFileSync(join(ROOT, 'specs/playdeveloperreporting_v1beta1.discovery.json'), 'utf8')));
expect(Object.keys(PLAY_OPS).length).toBe(Object.keys(ap).length + Object.keys(rep).length);
expect(Object.keys(PLAY_APIS).sort()).toEqual(['androidpublisher', 'playdeveloperreporting']);
expect(PLAY_APIS.androidpublisher.scope).toBe('https://www.googleapis.com/auth/androidpublisher');
expect(PLAY_APIS.playdeveloperreporting.scope).toBe('https://www.googleapis.com/auth/playdeveloperreporting');
});
test('rows are sorted and the spec order does not matter', () => {
const spec = { paths: { '/v1/b': { get: { operationId: 'b_get', responses: {} } }, '/v1/a': { get: { operationId: 'a_get', responses: {} } } } };
const rev = { paths: { '/v1/a': spec.paths['/v1/a'], '/v1/b': spec.paths['/v1/b'] } };
expect(JSON.stringify(appleRows(spec))).toBe(JSON.stringify(appleRows(rev)));
expect(Object.keys(appleRows(spec))).toEqual(['a_get', 'b_get']);
const ids = Object.keys(APPLE_OPS);
expect([...ids].sort()).toEqual(ids);
});
test('JSON:API shapes and vendor media types', () => {
expect(APPLE_OPS.apps_getCollection[4]).toBe('many:apps');
expect(APPLE_OPS.apps_getInstance[4]).toBe('one:apps');
expect(APPLE_OPS.appStoreVersions_createInstance[3]).toBe('appStoreVersions');
expect(APPLE_OPS.betaGroups_betaTesters_createToManyRelationship[3]).toBe('betaTesters[]');
expect(APPLE_OPS.appScreenshots_deleteInstance[4]).toBe('none');
expect(APPLE_OPS.apps_perfPowerMetrics_getToManyRelated[4]).toBe('raw:application/vnd.apple.xcode-metrics+json');
expect(APPLE_OPS.salesReports_getCollection[4]).toBe('raw:application/a-gzip');
});
});
describe('request building', () => {
test('Apple: path, comma-joined arrays, readable brackets', () => {
const r = buildRequest(op('apple', 'apps_getCollection'), { 'filter[bundleId]': 'com.example.app', 'fields[apps]': ['name', 'bundleId'], limit: 200 });
expect(r.method).toBe('GET');
expect(r.url).toBe('https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=com.example.app&fields[apps]=name,bundleId&limit=200');
expect(r.body).toBeUndefined();
const v = buildRequest(op('apple', 'apps_appStoreVersions_getToManyRelated'), { id: 'a/b c', 'filter[platform]': 'IOS' });
expect(v.url).toBe('https://api.appstoreconnect.apple.com/v1/apps/a%2Fb%20c/appStoreVersions?filter[platform]=IOS');
});
test('Apple: body, vendor Accept, missing and unknown parameters', () => {
const body = { data: { type: 'appStoreVersions', id: '1', attributes: { versionString: '1.1' } } };
const p = buildRequest(op('apple', 'appStoreVersions_updateInstance'), { id: '1' }, body);
expect(p).toMatchObject({ method: 'PATCH', url: 'https://api.appstoreconnect.apple.com/v1/appStoreVersions/1', contentType: 'application/json' });
expect(JSON.parse(p.body as string)).toEqual(body);
expect(buildRequest(op('apple', 'apps_perfPowerMetrics_getToManyRelated'), { id: '9' }).accept).toBe('application/vnd.apple.xcode-metrics+json');
expect(() => buildRequest(op('apple', 'apps_getInstance'), {})).toThrow('path parameter "id" is required');
expect(() => buildRequest(op('apple', 'betaAppReviewDetails_getCollection'), {})).toThrow('query parameter "filter[app]" is required');
expect(() => buildRequest(op('apple', 'apps_getInstance'), { id: '1', 'filter[nope]': 'x' })).toThrow('unknown parameter "filter[nope]"');
expect(() => buildRequest(op('apple', 'apps_getCollection'), {}, { x: 1 })).toThrow('GET takes no body');
});
test('Play: API roots, uploads, downloads, {+name} shapes, global parameters', () => {
expect(buildRequest(op('play', 'androidpublisher.edits.tracks.update'), { packageName: 'com.example.app', editId: 'e1', track: 'beta' }, { track: 'beta' }).url)
.toBe('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/e1/tracks/beta');
const up = buildRequest(op('play', 'androidpublisher.edits.bundles.upload'), { packageName: 'com.example.app', editId: 'e1' }, undefined,
{ upload: { bytes: new Uint8Array([1, 2]), contentType: 'application/octet-stream' } });
expect(up.url).toBe('https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.app/edits/e1/bundles?uploadType=media');
expect(up.body).toEqual(new Uint8Array([1, 2]));
expect(() => buildRequest(op('play', 'androidpublisher.edits.tracks.list'), { packageName: 'p', editId: 'e' }, undefined, { upload: { bytes: new Uint8Array(), contentType: 'x' } })).toThrow('takes no file upload');
expect(buildRequest(op('play', 'androidpublisher.generatedapks.download'), { packageName: 'p', versionCode: 3, downloadId: 'd' }, undefined, { download: true }).url).toEndWith('?alt=media');
const q = buildRequest(op('play', 'playdeveloperreporting.vitals.crashrate.query'), { name: 'apps/com.example.app/crashRateMetricSet' }, { metrics: ['crashRate'] });
expect(q.url).toBe('https://playdeveloperreporting.googleapis.com/v1beta1/apps/com.example.app/crashRateMetricSet:query');
expect(() => buildRequest(op('play', 'playdeveloperreporting.vitals.crashrate.query'), { name: 'apps/com.example.app/anrRateMetricSet' })).toThrow('must look like apps/*/crashRateMetricSet');
expect(buildRequest(op('play', 'androidpublisher.reviews.list'), { packageName: 'p', fields: 'reviews(reviewId)', maxResults: 10 }).url)
.toBe('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/p/reviews?fields=reviews(reviewId)&maxResults=10');
});
test('repeated Google parameters are sent once per value', () => {
const synthetic: Op = { ...op('play', 'androidpublisher.reviews.list'), query: [{ name: 'x', required: false, repeated: true }] };
expect(buildRequest(synthetic, { packageName: 'p', x: ['a', 'b'] }).url).toEndWith('/reviews?x=a&x=b');
});
test('METHOD path resolves to the operation and its parameters', () => {
const a = resolveOp('apple', 'GET', '/v1/apps/123/appStoreVersions?filter[platform]=IOS&limit=5');
expect(a.op.id).toBe('apps_appStoreVersions_getToManyRelated');
expect(a.params).toEqual({ id: '123', 'filter[platform]': 'IOS', limit: '5' });
expect(resolveOp('apple', 'get', 'https://api.appstoreconnect.apple.com/v1/apps/9').op.id).toBe('apps_getInstance');
const crash = resolveOp('play', 'POST', '/v1beta1/apps/com.example.app/crashRateMetricSet:query');
expect(crash.op.id).toBe('playdeveloperreporting.vitals.crashrate.query');
expect(crash.params).toEqual({ name: 'apps/com.example.app/crashRateMetricSet' });
expect(resolveOp('play', 'POST', '/v1beta1/apps/com.example.app/anrRateMetricSet:query').op.id).toBe('playdeveloperreporting.vitals.anrrate.query');
expect(resolveOp('play', 'POST', '/upload/androidpublisher/v3/applications/p/edits/e/bundles').op.id).toBe('androidpublisher.edits.bundles.upload');
expect(resolveOp('play', 'reviews.list').op.id).toBe('androidpublisher.reviews.list');
expect(() => resolveOp('apple', 'apps_nope')).toThrow('no operation');
expect(() => resolveOp('apple', 'GET', '/v9/nothing')).toThrow('no GET operation');
});
test('typed facades (checked by tsc)', () => {
const api = appleApi(() => 'Bearer t');
expect(api.build('apps_getInstance', { id: '1', 'fields[apps]': 'name' }).url).toContain('/v1/apps/1?fields[apps]=name');
// @ts-expect-error id is a required path parameter
expect(() => api.build('apps_getInstance', {})).toThrow();
// @ts-expect-error not a parameter of this operation
expect(() => api.build('apps_getInstance', { id: '1', nope: 'x' })).toThrow();
// @ts-expect-error filter[app] is required
expect(() => api.build('betaAppReviewDetails_getCollection', {})).toThrow();
api.build('appStoreVersions_updateInstance', { id: '1' }, { data: { type: 'appStoreVersions', id: '1', attributes: { copyright: 'x' } } });
// @ts-expect-error the body's resource type is appStoreVersions
api.build('appStoreVersions_updateInstance', { id: '1' }, { data: { type: 'apps', id: '1' } });
const play = playApi(() => 'Bearer t');
expect(play.build('androidpublisher.edits.tracks.list', { packageName: 'p', editId: 'e' }).method).toBe('GET');
// @ts-expect-error editId is required
expect(() => play.build('androidpublisher.edits.tracks.list', { packageName: 'p' })).toThrow();
});
});
describe('write gating', () => {
const base: GateInput = { kind: 'read', method: 'GET', dry: false, yes: false, tty: true, agent: false, getOnly: false };
test('gate kinds', () => {
expect(gateOf('apple', 'apps_getCollection', 'GET')).toBe('read');
expect(gateOf('apple', 'apps_updateInstance', 'PATCH')).toBe('write');
expect(gateOf('apple', 'reviewSubmissions_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'reviewSubmissions_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'betaAppReviewSubmissions_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionReleaseRequests_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionPhasedReleases_createInstance', 'POST')).toBe('write');
expect(gateOf('apple', 'appStoreVersionPhasedReleases_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'subscriptionPrices_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'customerReviewResponses_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'betaTesterInvitations_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionExperimentsV2_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'reviewSubmissionItems_createInstance', 'POST')).toBe('write');
expect(gateOf('play', 'androidpublisher.edits.insert', 'POST')).toBe('write');
expect(gateOf('play', 'androidpublisher.edits.commit', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.reviews.reply', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.purchases.subscriptionsv2.cancel', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.orders.refund', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.monetization.subscriptions.basePlans.migratePrices', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.inappproducts.batchDelete', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.edits.testers.update', 'PUT')).toBe('owner');
expect(gateOf('play', 'androidpublisher.appsigning.rotateAppSigningKey', 'POST')).toBe('owner');
expect(gateOf('play', 'playdeveloperreporting.vitals.crashrate.query', 'POST')).toBe('read');
expect(gateOf('play', 'androidpublisher.monetization.convertRegionPrices', 'POST')).toBe('read');
expect(ownerReason('apple', 'apps_getCollection', 'GET')).toBeNull();
});
test('every delete, submit, release and cancel operation is owner-only', () => {
for (const s of ['apple', 'play'] as const) for (const o of allOps(s)) {
const word = /delete|submi|release(?!s?_get)|cancel|refund|revoke|commit|reply|deploy/i.test(o.id.split('.').pop()!.replace(/_get\w+$/, ''));
if (o.method === 'DELETE' || (o.method !== 'GET' && word && !/PhasedReleases_createInstance|reviewSubmissionItems_|SubmissionItems|appClipDefaultExperiences_releaseWithAppStoreVersion/.test(o.id)))
expect({ id: o.id, gate: gateOf(s, o.id, o.method) }).toEqual({ id: o.id, gate: 'owner' });
}
expect(OWNER_ONLY.apple.length).toBeGreaterThan(5);
});
test('decisions: GET runs, writes are dry until --yes in a terminal, owner-only asks for the typed id', () => {
expect(decide(base)).toEqual({ action: 'send' });
expect(decide({ ...base, dry: true }).action).toBe('dry');
const w = { ...base, kind: 'write' as const, method: 'PATCH' };
expect(decide(w)).toMatchObject({ action: 'dry', why: expect.stringContaining('--yes') });
expect(decide({ ...w, yes: true, dry: true }).action).toBe('dry');
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'dry', why: expect.stringContaining('not an interactive terminal') });
expect(decide({ ...w, yes: true })).toEqual({ action: 'send' });
const o = { ...w, kind: 'owner' as const };
expect(decide({ ...o, yes: true })).toEqual({ action: 'confirm', typed: true });
expect(decide({ ...o, yes: true, tty: false }).action).toBe('dry');
// MCP / desktop: never a write, whatever the flags
expect(decide({ ...w, yes: true, agent: true }).action).toBe('refuse');
expect(decide({ ...o, yes: true, agent: true })).toMatchObject({ action: 'refuse', why: expect.stringContaining('never through MCP') });
expect(decide({ ...base, agent: true })).toEqual({ action: 'send' });
// api get: GET only, even the read-only POST queries
expect(decide({ ...base, getOnly: true, method: 'POST' }).action).toBe('refuse');
expect(decide({ ...base, getOnly: true })).toEqual({ action: 'send' });
});
test('the dry run shows the exact request without the credential', () => {
const r = buildRequest(op('apple', 'appStoreVersions_updateInstance'), { id: '1' }, { data: { type: 'appStoreVersions', id: '1' } });
const text = describeRequest(r);
expect(text).toStartWith('PATCH https://api.appstoreconnect.apple.com/v1/appStoreVersions/1\n');
expect(text).toContain('Bearer <App Store Connect API key JWT, not shown>');
expect(text).toContain('"type": "appStoreVersions"');
});
test('MCP exposes api_get only, read-only, with every parameter as one --param= token', () => {
const apiTools = TOOLS.filter(t => t.command[0] === 'api');
expect(apiTools.map(t => t.name)).toEqual(['api_get']);
const t = apiTools[0]!;
expect(t.write).toBe(false);
const argv = toArgv(t, { store: 'apple', operation: 'apps_getCollection', params: { 'filter[bundleId]': 'com.example.app', limit: 5 }, all_pages: true }).argv;
expect(argv).toEqual(['api', 'get', 'apple', 'apps_getCollection', '--param=filter[bundleId]=com.example.app', '--param=limit=5', '--all-pages', '--json']);
expect(argv).not.toContain('--yes');
expect(validateInput(t, { store: 'apple', operation: '--yes' })[0]).toContain('does not match');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: ['b'] } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { '--yes': 'x' } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: 'b\nc' } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: 'b', n: 1, f: true } })).toEqual([]);
expect(DESKTOP_TOOLS.some(d => d.command[0] === 'api')).toBe(false);
});
test('repeated --param values', () => {
const a = parseArgs(['apple', 'x', '--param', 'a=1', '--param=b=2', '--param', 'a=3', '--all-pages', 'y']);
expect(a.many('param')).toEqual(['a=1', 'b=2', 'a=3']);
expect(a.has('all-pages')).toBe(true);
expect(a._).toEqual(['apple', 'x', 'y']);
expect(parseParams(a.many('param'))).toEqual({ a: ['1', '3'], b: '2' });
expect(() => parseParams(['nope'])).toThrow('name=value');
});
});
describe('the CLI never sends a write it should not', () => {
const cli = (args: string[], env: Record<string, string> = {}) => {
const r = Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), 'api', ...args], { cwd: ROOT, env: { ...process.env, ASC_KEY_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, stdin: 'ignore' });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
};
test('a write without --yes prints the request (no credentials needed, nothing sent)', () => {
const r = cli(['play', 'edits.tracks.update', '--param', 'packageName=com.example.app', '--param', 'editId=1', '--param', 'track=beta', '--json']);
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ dryRun: true, gate: 'write', request: { method: 'PUT', url: 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/1/tracks/beta' } });
});
test('--yes outside a terminal still only prints', () => {
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1', '--yes']);
expect(r.code).toBe(0);
expect(r.out).toContain('not an interactive terminal');
expect(r.out).toContain('owner only: this deletes');
});
test('under MCP and in api get, writes are refused', () => {
const m = cli(['play', 'edits.commit', '--param', 'packageName=p', '--param', 'editId=1', '--yes'], { STOREBOUND_MCP: '1' });
expect(m.code).toBe(1);
expect(m.err).toContain('never through MCP');
const g = cli(['get', 'apple', 'apps_updateInstance', '--param', 'id=1']);
expect(g.code).toBe(1);
expect(g.err).toContain('GET operations only');
});
test('list and coverage', () => {
const l = JSON.parse(cli(['list', '--store', 'play', '--search', 'tracks update', '--json']).out) as { id: string }[];
expect(l.map(x => x.id)).toEqual(['androidpublisher.edits.tracks.update']);
const c = JSON.parse(cli(['coverage', '--json']).out) as ReturnType<typeof coverage>;
expect(c.apis.map(a => a.api)).toEqual(['appstoreconnect', 'androidpublisher', 'playdeveloperreporting']);
expect(c.apis[0]!.operations).toBe(Object.keys(APPLE_OPS).length);
expect(c.resources.reduce((n, r) => n + r.operations, 0)).toBe(Object.keys(APPLE_OPS).length + Object.keys(PLAY_OPS).length);
});
});
describe('coverage scan', () => {
test('call sites map to operations, the most specific template wins', () => {
const src = [
"const v = await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, body);",
'const all = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);',
"await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, t);",
"await g.json('POST', `${app}/crashRateMetricSet:query`, q); // androidpublisher",
"await play.call('POST', `${API}/edits/${e.id}:commit${o.commitQuery ?? ''}`);",
"const x = { method: 'POST', path: '/v1/betaTesterInvitations' };",
"await c.uploadAsset('appScreenshots', rel, f);",
"await c.asc('POST', `/v1/${type}`, body);",
].join('\n');
const sites = scanSource('demo', src);
const w = wrappedOps(sites, APPLE_OPS as never, PLAY_OPS as never);
expect(Object.keys(w.apple).sort()).toEqual(['appScreenshots_createInstance', 'appScreenshots_updateInstance', 'appStoreVersions_updateInstance',
'apps_appStoreVersions_getToManyRelated', 'betaTesterInvitations_createInstance']);
expect(Object.keys(w.play).sort()).toEqual(['androidpublisher.edits.commit', 'androidpublisher.edits.tracks.update', 'playdeveloperreporting.vitals.crashrate.query']);
expect(templateRegex('v1beta1/{+name}:query', 'name=apps/*/anrRateMetricSet').test('v1beta1/apps/X/crashRateMetricSet:query')).toBe(false);
});
test('another ${base} in a file that never talks to Google is not a Play call', () => {
expect(scanSource('shots/x', 'const u = `${base}/frames/${n}.png`;')).toEqual([]);
});
test('storebound commands already wrap a good share, reported per operation', () => {
const r = rows();
expect(r.filter(x => x.wrapped).length).toBeGreaterThan(100);
expect(r.find(x => x.id === 'apps_getCollection')!.wrapped).toContain('apple/bundle');
expect(search(r, 'crashrate query').map(x => x.id)).toEqual(['playdeveloperreporting.vitals.crashrate.query']);
});
});
describe('sending: retries and pagination', () => {
const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
test('429 with Retry-After and 5xx are retried with backoff, then the error has the API text', async () => {
const waits: number[] = [];
const answers = [json({}, 429, { 'retry-after': '3' }), json({}, 503), json({ data: [] }, 200, { 'x-rate-limit': 'user-hour-lim:3600;user-hour-rem:3599;' })];
const seen: Record<string, string>[] = [];
const r = await send(buildRequest(op('apple', 'apps_getCollection'), {}), () => 'Bearer t',
{ fetch: async (_u, i) => { seen.push(i.headers as Record<string, string>); return answers.shift()!; }, sleep: async ms => { waits.push(ms); } });
expect(waits).toEqual([3000, 2000]);
expect(r).toEqual({ status: 200, json: { data: [] }, rateLimit: 'user-hour-lim:3600;user-hour-rem:3599;' });
expect(seen[0]!.Authorization).toBe('Bearer t');
expect(backoffMs(1)).toBe(1000);
expect(backoffMs(10)).toBe(30_000);
expect(backoffMs(1, '120')).toBe(60_000);
const fail = send(buildRequest(op('apple', 'apps_getInstance'), { id: '1' }), () => 'Bearer t',
{ fetch: async () => json({ errors: [{ status: '404', detail: 'not\nfound' }] }, 404), sleep: async () => {} });
expect(fail).rejects.toThrow('GET /v1/apps/1 -> 404 {"errors":[{"status":"404","detail":"not\\nfound"}]}');
});
test('Apple pages follow links.next on the same host; included is de-duplicated', async () => {
const B = 'https://api.appstoreconnect.apple.com/v1/apps';
const pages: Record<string, unknown> = {
[B + '?limit=2']: { data: [{ type: 'apps', id: '1' }, { type: 'apps', id: '2' }], included: [{ type: 'builds', id: 'b' }], links: { self: 's', next: B + '?cursor=2' } },
[B + '?cursor=2']: { data: [{ type: 'apps', id: '3' }], included: [{ type: 'builds', id: 'b' }, { type: 'builds', id: 'c' }], links: { self: 's2' } },
};
const urls: string[] = [];
const r = await sendAll(buildRequest(op('apple', 'apps_getCollection'), { limit: 2 }), () => 'Bearer t', { fetch: async u => { urls.push(u); return json(pages[u]); } });
expect(urls).toEqual([B + '?limit=2', B + '?cursor=2']);
expect(r.pages).toBe(2);
const j = r.json as { data: { id: string }[]; included: { id: string }[] };
expect(j.data.map(d => d.id)).toEqual(['1', '2', '3']);
expect(j.included.map(d => d.id)).toEqual(['b', 'c']);
const req = buildRequest(op('apple', 'apps_getCollection'), {});
expect(() => nextRequest(req, { links: { next: 'https://evil.example.com/v1/apps?cursor=1' } })).toThrow('another host');
});
test('Google pageToken and token paging', async () => {
const urls: string[] = [];
const tracks = [{ a: [1], nextPageToken: 'n1' }, { a: [2], nextPageToken: 'n2' }, { a: [3] }];
const r = await sendAll(buildRequest(op('play', 'playdeveloperreporting.apps.search'), { pageSize: 1 }), () => 'Bearer t', { fetch: async u => { urls.push(u); return json(tracks.shift()); } });
expect(urls[1]).toBe('https://playdeveloperreporting.googleapis.com/v1beta1/apps:search?pageSize=1&pageToken=n1');
expect(r.json).toEqual({ a: [1, 2, 3] });
const reviews = [{ reviews: [{ reviewId: 'r1' }], tokenPagination: { nextPageToken: 't1' } }, { reviews: [{ reviewId: 'r2' }], tokenPagination: {} }];
const ru: string[] = [];
const rv = await sendAll(buildRequest(op('play', 'androidpublisher.reviews.list'), { packageName: 'p' }), () => 'Bearer t', { fetch: async u => { ru.push(u); return json(reviews.shift()); } });
expect(ru[1]).toEndWith('/reviews?token=t1');
expect(rv.json).toEqual({ reviews: [{ reviewId: 'r1' }, { reviewId: 'r2' }] });
expect(mergePages({ x: [1], k: 'a' }, { x: [2], k: 'b', nextPageToken: 'z' })).toEqual({ x: [1, 2], k: 'a' });
});
test('a server that never stops paging is cut off', async () => {
const r = sendAll(buildRequest(op('play', 'playdeveloperreporting.apps.search'), {}), () => 'Bearer t',
{ maxPages: 3, fetch: async () => json({ apps: [1], nextPageToken: 'again' }) });
expect(r).rejects.toThrow('more than 3 pages');
});
});

View file

@ -150,7 +150,7 @@ describe('full CLI coverage: new methods', () => {
describe('the owner flag', () => { describe('the owner flag', () => {
const saved = { ...process.env }; const saved = { ...process.env };
afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], _: [] } }; const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } };
test('under serve: no by default, even with --yes; yes only with the owner flag', async () => { test('under serve: no by default, even with --yes; yes only with the owner flag', async () => {
process.env.STOREBOUND_SERVE = '1'; process.env.STOREBOUND_SERVE = '1';
delete process.env.STOREBOUND_OWNER_CONFIRMED; delete process.env.STOREBOUND_OWNER_CONFIRMED;

View file

@ -12,6 +12,8 @@ const tauri = join(root, 'apps/desktop/src-tauri');
const cargo = process.env.STOREBOUND_CI_RUST === '0' ? null : Bun.which('cargo'); const cargo = process.env.STOREBOUND_CI_RUST === '0' ? null : Bun.which('cargo');
const steps: [string, string[], string?][] = [ const steps: [string, string[], string?][] = [
['adapters', ['bun', 'tools/gen-adapters.ts', '--check']], ['adapters', ['bun', 'tools/gen-adapters.ts', '--check']],
// the App Store Connect / Google Play operation tables, the wrapped-by scan and docs/api-coverage.md match specs/ and cli/
['api tables', ['bun', 'tools/gen-api.ts', '--check']],
// one desktop version: package.json desktopVersion = apps/desktop/package.json = tauri.conf.json = Cargo.toml / .lock // one desktop version: package.json desktopVersion = apps/desktop/package.json = tauri.conf.json = Cargo.toml / .lock
['desktop version', ['bun', 'tools/desktop-version.ts']], ['desktop version', ['bun', 'tools/desktop-version.ts']],
['tests', ['bun', 'test']], ['tests', ['bun', 'test']],

View file

@ -74,6 +74,7 @@ export function generate(root: string): Map<string, string> {
'- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.', '- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`.',
'- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.', '- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.',
'- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).', '- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores).',
'- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.',
].join('\n'); ].join('\n');
// 4. AGENTS.md (Codex, Cursor, Copilot, Windsurf, OpenCode, ...) // 4. AGENTS.md (Codex, Cursor, Copilot, Windsurf, OpenCode, ...)

341
tools/gen-api.ts Normal file
View file

@ -0,0 +1,341 @@
#!/usr/bin/env bun
// Generates the operation tables of every App Store Connect and Google Play API endpoint from the pinned specs/:
// cli/api/apple.gen.ts App Store Connect API (Apple's OpenAPI document)
// cli/api/play.gen.ts Google Play Android Developer API v3 + Play Developer Reporting API v1beta1 (discovery docs)
// cli/api/wrapped.gen.ts which operations storebound's own commands already call (a scan of cli/)
// docs/api-coverage.md the full table: every operation, its gate (read / write / owner-only) and its wrapper
//
// bun tools/gen-api.ts write the files
// bun tools/gen-api.ts --check exit 1 when a committed file is stale (CI)
//
// Compact on purpose: one row of strings per operation and generic call helpers in cli/api/client.ts, no function per
// endpoint, so the npm package stays small. Types come from the rows (cli/api/types.ts derives path / query params,
// request and response shapes from the literal strings).
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { gateOf, resourceOf, type GateKind } from '../cli/api/gate';
const ROOT = join(import.meta.dir, '..');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type J = any;
export interface Sources {
apple: { file: string; title: string; version: string; url: string; downloaded: string; sha256: string };
play: { file: string; title: string; revision: string; url: string; downloaded: string; sha256: string }[];
}
const sha256 = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex');
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
// ---------------------------------------------------------------------------------------------------------- Apple
export type AppleRow = [method: string, path: string, query: string, request: string, response: string];
/** The resource `type` a JSON:API schema's `data` carries ("" when it has none), with [] when data is a list. */
function dataType(spec: J, schema: J): string {
const s = deref(spec, schema);
const data = deref(spec, s?.properties?.data);
if (!data) return '';
if (data.type === 'array') {
const item = deref(spec, data.items);
return (item?.properties?.type?.enum?.[0] ?? '') + '[]';
}
return data.properties?.type?.enum?.[0] ?? '';
}
function deref(spec: J, s: J): J {
for (let i = 0; s && s.$ref && i < 10; i++) {
const parts = String(s.$ref).replace(/^#\//, '').split('/');
s = parts.reduce((o: J, k) => o?.[k], spec);
}
return s;
}
export function appleRows(spec: J): Record<string, AppleRow> {
const rows: [string, AppleRow][] = [];
for (const [path, item] of Object.entries<J>(spec.paths)) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const op = item[method];
if (!op) continue;
const params = [...(item.parameters ?? []), ...(op.parameters ?? [])].map(p => deref(spec, p));
const query = params.filter(p => p.in === 'query').map(p => p.name + (p.required ? '!' : ''));
let request = '';
const reqSchema = op.requestBody?.content?.['application/json']?.schema;
if (reqSchema) request = dataType(spec, reqSchema) || 'json';
let response = 'none';
const ok = Object.keys(op.responses ?? {}).filter(c => /^2\d\d$/.test(c)).sort()[0];
const content = ok ? op.responses[ok].content ?? deref(spec, op.responses[ok]).content : undefined;
if (content) {
const json = content['application/json'];
if (json) {
const t = dataType(spec, json.schema);
response = !t ? 'json' : t.endsWith('[]') ? `many:${t.slice(0, -2)}` : `one:${t}`;
} else response = 'raw:' + Object.keys(content).sort()[0];
}
rows.push([op.operationId, [method.toUpperCase(), path, query.join(' '), request, response]]);
}
}
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
return Object.fromEntries(rows);
}
// ---------------------------------------------------------------------------------------------------------- Google
/** [method, path (with {param} / {+param}), query params ("name", "name!" required, "name*" repeated), request schema,
* response schema, flags: m = media upload, d = media download, p = pageToken paging, t = token paging, the shape of
* each {+param} ("name=apps/* /crashRateMetricSet" without the space, * one segment; several joined by spaces)]. */
export type PlayRow = [method: string, path: string, query: string, request: string, response: string, flags: string, shapes: string];
export function playRows(doc: J): Record<string, PlayRow> {
const rows: [string, PlayRow][] = [];
const walk = (r: J) => {
for (const m of Object.values<J>(r.methods ?? {})) {
const query = Object.entries<J>(m.parameters ?? {}).filter(([, p]) => p.location === 'query')
.map(([n, p]) => n + (p.required ? '!' : '') + (p.repeated ? '*' : '')).sort();
const resp = m.response?.$ref ? doc.schemas?.[m.response.$ref] : undefined;
let flags = '';
if (m.mediaUpload) flags += 'm';
if (m.supportsMediaDownload) flags += 'd';
if (m.parameters?.pageToken?.location === 'query' && resp?.properties?.nextPageToken) flags += 'p';
if (m.parameters?.token?.location === 'query' && resp?.properties?.tokenPagination) flags += 't';
// {+name} holds slashes; its pattern (^apps/[^/]+/crashRateMetricSet$) tells the vitals metric sets apart
const shapes = Object.entries<J>(m.parameters ?? {}).filter(([n, p]) => p.location === 'path' && p.pattern && m.path.includes(`{+${n}}`))
.map(([n, p]) => `${n}=${String(p.pattern).replace(/^\^|\$$/g, '').split('[^/]+').join('*')}`).sort();
rows.push([m.id, [m.httpMethod, doc.servicePath + m.path, query.join(' '), m.request?.$ref ?? '', m.response?.$ref ?? '', flags, shapes.join(' ')]]);
}
for (const s of Object.values<J>(r.resources ?? {})) walk(s);
};
walk(doc);
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
return Object.fromEntries(rows);
}
// ---------------------------------------------------------------------------------------------------------- wrapped
/** Literal prefixes the commands use for Google URLs, mapped to the path form of the Play tables. */
const PLAY_PREFIXES: [RegExp, string][] = [
[/^\$\{play\.UPLOAD\}|^\$\{UPLOAD\}/, 'androidpublisher/v3/applications/X'],
[/^\$\{play\.API\}|^\$\{API\}|^\$\{base\}/, 'androidpublisher/v3/applications/X'],
[/^https:\/\/androidpublisher\.googleapis\.com\/(upload\/)?/, ''],
[/^\$\{app\}/, 'v1beta1/apps/X'],
[/^\$\{REPORTING_API\}/, 'v1beta1'],
[/^https:\/\/playdeveloperreporting\.googleapis\.com\//, ''],
];
export interface CallSite { store: 'apple' | 'play'; method: string; path: string; file: string }
/** API calls in one source file: path literals with the HTTP method next to them (GET for the read helpers). */
export function scanSource(file: string, text: string): CallSite[] {
const out: CallSite[] = [];
const start = String.raw`(?:\/v\d\/|\$\{(?:play\.)?(?:API|UPLOAD)\}|\$\{base\}|\$\{app\}|\$\{REPORTING_API\}|https:\/\/(?:androidpublisher|playdeveloperreporting)\.googleapis\.com\/|\/apps:search)`;
// a template literal may hold ${...} with quotes inside (`${x ?? ''}`); a plain string may not
const lit = new RegExp(String.raw`\x60(${start}(?:\$\{[^}]*\}|[^\x60$]|\$(?!\{))*)\x60|'(${start}[^'\n]*)'`, 'g');
for (const m of text.matchAll(lit)) {
const before = text.slice(Math.max(0, m.index - 120), m.index);
const meth = /'(GET|POST|PATCH|PUT|DELETE)'[^'`]*$/.exec(before.split('\n').slice(-2).join('\n'));
const method = meth ? meth[1]! : 'GET';
// a trailing ${...} glued to a word is a query suffix (`:commit${o.commitQuery ?? ''}`), not part of the path
let raw = (m[1] ?? m[2]!).replace(/(?<=[A-Za-z])\$\{[^}]*\}$/, '').replace(/\$\{[^}]*\}/g, s => (PLAY_PREFIXES.some(([re]) => re.test(s)) ? s : 'X'));
raw = raw.replace(/[?].*$/, '').replace(/\$\{[^}]*$/, '');
if (raw.startsWith('/v')) {
if (/^\/v\d\/X/.test(raw)) continue; // `/v1/${type}`: resource not known statically
out.push({ store: 'apple', method, path: raw, file });
continue;
}
if (raw.startsWith('/apps:search')) { out.push({ store: 'play', method, path: 'v1beta1' + raw, file }); continue; }
if (!/androidpublisher|playdeveloperreporting|play\.API|REPORTING_API/.test(text)) continue; // another `${base}`
for (const [re, rep] of PLAY_PREFIXES) {
if (!re.test(raw)) continue;
const rest = raw.replace(re, '');
if (rest.replace(/^\//, '')) out.push({ store: 'play', method, path: (rep + (rep && !rest.startsWith('/') && !rest.startsWith(':') ? '/' : '') + rest).replace(/^\//, ''), file });
break;
}
}
// c.uploadAsset('appScreenshots', ...): reserve (POST /v1/<type>) and commit (PATCH /v1/<type>/{id})
for (const m of text.matchAll(/uploadAsset\(\s*'([A-Za-z]+)'/g)) {
out.push({ store: 'apple', method: 'POST', path: `/v1/${m[1]}`, file }, { store: 'apple', method: 'PATCH', path: `/v1/${m[1]}/X`, file });
}
return out;
}
const esc = (s: string) => s.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
/** Path template -> regex over a scanned path (X for any expression); `shapes` narrows {+param}s. */
export function templateRegex(path: string, shapes = ''): RegExp {
const shape = new Map(shapes.split(' ').filter(Boolean).map(s => [s.slice(0, s.indexOf('=')), s.slice(s.indexOf('=') + 1)] as const));
const body = path.replace(/^\//, '').split(/(\{\+?[^}]+\})/).map(part => {
const m = /^\{(\+?)([^}]+)\}$/.exec(part);
if (!m) return esc(part);
if (!m[1]) return '[^/:]+';
const s = shape.get(m[2]!);
return s ? s.split('*').map(esc).join('[^/:]+') : '.+';
}).join('');
return new RegExp('^' + body + '$');
}
export function wrappedOps(sites: CallSite[], apple: Record<string, AppleRow>, play: Record<string, PlayRow>): { apple: Record<string, string[]>; play: Record<string, string[]> } {
const res = { apple: {} as Record<string, string[]>, play: {} as Record<string, string[]> };
const index = (rows: Record<string, AppleRow | PlayRow>) => Object.entries(rows).map(([id, r]) => ({ id, method: r[0], re: templateRegex(r[1], (r as string[])[6] ?? ''), fixed: r[1].split('{')[0]!.length }));
const tables = { apple: index(apple), play: index(play) };
for (const s of sites) {
const path = s.path.replace(/^\//, '');
// the most specific template wins (longest fixed prefix): /v1/apps/X matches apps_getInstance, not a {+name} catch-all
const hits = tables[s.store].filter(o => o.method === s.method && o.re.test(path)).sort((a, b) => b.fixed - a.fixed);
const best = hits[0];
if (!best) continue;
const list = (res[s.store][best.id] ??= []);
if (!list.includes(s.file)) list.push(s.file);
}
for (const store of ['apple', 'play'] as const) {
res[store] = Object.fromEntries(Object.entries(res[store]).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, v.sort()]));
}
return res;
}
function listTs(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
if (statSync(p).isDirectory()) { if (name !== 'api') out.push(...listTs(p)); }
else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(p);
}
return out;
}
export function scanCli(root = ROOT): CallSite[] {
return listTs(join(root, 'cli')).flatMap(p => scanSource(relative(join(root, 'cli'), p).replace(/\.ts$/, ''), readFileSync(p, 'utf8')));
}
// ---------------------------------------------------------------------------------------------------------- output
const HEAD = (what: string) => `// GENERATED by tools/gen-api.ts from ${what}. Do not edit: run \`bun tools/gen-api.ts\` (CI checks it is fresh).\n`;
export function appleFile(src: Sources['apple'], rows: Record<string, AppleRow>): string {
const lines = Object.entries(rows).map(([id, r]) => ` ${id}: [${r.map(q).join(', ')}],`);
return HEAD(`specs/${src.file} (${src.title} ${src.version}, ${src.url}, downloaded ${src.downloaded})`) +
`// One row per operation: [method, path, query parameters ("name", "name!" when required; arrays are comma-joined),
// request body resource type ("type", "type[]" for a relationship list, "json" for another body, "" for none),
// response ("many:type" | "one:type" | "json" | "raw:<media type>" for another body, sent as Accept | "none")].
export const APPLE_SPEC = { title: ${q(src.title)}, version: ${q(src.version)}, sha256: ${q(src.sha256)}, base: 'https://api.appstoreconnect.apple.com' } as const;
export const APPLE_OPS = {
${lines.join('\n')}
} as const;
`;
}
export function playFile(srcs: Sources['play'], docs: J[], rows: Record<string, PlayRow>): string {
const apis = docs.map((d, i) => ` ${d.name}: { title: ${q(srcs[i]!.title)}, version: ${q(d.version)}, revision: ${q(d.revision)}, root: ${q(d.rootUrl)}, scope: ${q(Object.keys(d.auth?.oauth2?.scopes ?? {})[0] ?? '')}, sha256: ${q(srcs[i]!.sha256)} },`);
const lines = Object.entries(rows).map(([id, r]) => ` ${q(id)}: [${r.map(q).join(', ')}],`);
return HEAD(srcs.map(s => `specs/${s.file} (${s.title}, revision ${s.revision}, ${s.url}, downloaded ${s.downloaded})`).join(' and ')) +
`// One row per method: [HTTP method, path ({p} one segment, {+p} may hold slashes), query parameters ("name", "name!"
// required, "name*" repeated), request schema, response schema, flags (m media upload, d media download, p pageToken
// paging, t token paging)]. The API is the first part of the id.
export const PLAY_APIS = {
${apis.join('\n')}
} as const;
export const PLAY_OPS = {
${lines.join('\n')}
} as const;
`;
}
export function wrappedFile(w: ReturnType<typeof wrappedOps>): string {
const block = (o: Record<string, string[]>) => Object.entries(o).map(([k, v]) => ` ${q(k)}: ${q(v.join(' '))},`).join('\n');
return HEAD('a scan of the API calls in cli/ (outside cli/api)') +
`// Operation id -> the source modules (cli/<module>.ts) of storebound commands that already call it.
export const WRAPPED: { apple: Record<string, string>; play: Record<string, string> } = {
apple: {
${block(w.apple)}
},
play: {
${block(w.play)}
},
};
`;
}
export function coverageDoc(srcs: Sources, apple: Record<string, AppleRow>, play: Record<string, PlayRow>, w: ReturnType<typeof wrappedOps>): string {
const label: Record<GateKind, string> = { read: 'read', write: 'write (dry by default)', owner: 'owner only (typed confirm)' };
const all = [
...Object.entries(apple).map(([id, r]) => ({ store: 'apple' as const, id, method: r[0], path: r[1] })),
...Object.entries(play).map(([id, r]) => ({ store: 'play' as const, id, method: r[0], path: r[1] })),
];
const sections: string[] = [];
const summary: string[] = ['| API | operations | GET | other | wrapped by a command | owner only |', '|---|---:|---:|---:|---:|---:|'];
const groups: [string, typeof all][] = [
[`App Store Connect API ${srcs.apple.version}`, all.filter(o => o.store === 'apple')],
...srcs.play.map(s => [`${s.title} (revision ${s.revision})`, all.filter(o => o.store === 'play' && o.id.startsWith(s.file.split('_')[0]! + '.'))] as [string, typeof all]),
];
for (const [title, ops] of groups) {
const wrappedSet = (o: (typeof all)[number]) => w[o.store][o.id];
summary.push(`| ${title} | ${ops.length} | ${ops.filter(o => o.method === 'GET').length} | ${ops.filter(o => o.method !== 'GET').length} | ${ops.filter(wrappedSet).length} | ${ops.filter(o => gateOf(o.store, o.id, o.method) === 'owner').length} |`);
const byRes = new Map<string, typeof all>();
for (const o of ops) { const r = resourceOf(o); byRes.set(r, [...(byRes.get(r) ?? []), o]); }
const rows = [...byRes.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).flatMap(([res, list]) => [
`| **${res}** (${list.length}) | | | | |`,
...list.map(o => `| \`${o.id}\` | ${o.method} | \`${o.path}\` | ${label[gateOf(o.store, o.id, o.method)]} | ${(wrappedSet(o) ?? []).join(', ')} |`),
]);
sections.push(`## ${title}\n\n${byRes.size} resources, ${ops.length} operations.\n\n| operation | method | path | gate | wrapped by (cli/…) |\n|---|---|---|---|---|\n${rows.join('\n')}\n`);
}
return `# API coverage
<!-- GENERATED by tools/gen-api.ts from the pinned specs/ and a scan of cli/. Do not edit. -->
Every operation of the App Store Connect API and the Google Play Developer APIs is callable with
\`storebound api apple|play <operationId> [--param k=v ...]\`. "Wrapped by" names the storebound modules whose
high-level commands already call the operation. Gates: **read** runs right away; **write** prints the exact request and
only sends with \`--yes\` in an interactive terminal; **owner only** (submit, release, delete, cancel, prices, testers,
review replies, experiments) also asks you to type the operation id, and is never reachable through MCP or the desktop app.
Specs: Apple [${srcs.apple.title} ${srcs.apple.version}](${srcs.apple.url}) (downloaded ${srcs.apple.downloaded}); ${srcs.play.map(s => `[${s.title}](${s.url}) revision ${s.revision}`).join('; ')}.
${summary.join('\n')}
${sections.join('\n')}`;
}
export interface Generated { path: string; text: string }
export function generate(root = ROOT): Generated[] {
const srcs = JSON.parse(readFileSync(join(root, 'specs/SOURCES.json'), 'utf8')) as Sources;
const read = (f: string) => {
const buf = readFileSync(join(root, 'specs', f));
return { buf, sum: sha256(buf) };
};
const a = read(srcs.apple.file);
if (a.sum !== srcs.apple.sha256) throw new Error(`specs/${srcs.apple.file}: sha256 ${a.sum} does not match specs/SOURCES.json`);
const appleSpec = JSON.parse(a.buf.toString('utf8'));
const docs = srcs.play.map(s => {
const r = read(s.file);
if (r.sum !== s.sha256) throw new Error(`specs/${s.file}: sha256 ${r.sum} does not match specs/SOURCES.json`);
return JSON.parse(r.buf.toString('utf8'));
});
const apple = appleRows(appleSpec);
const play = Object.assign({}, ...docs.map(playRows)) as Record<string, PlayRow>;
const w = wrappedOps(scanCli(root), apple, play);
return [
{ path: 'cli/api/apple.gen.ts', text: appleFile(srcs.apple, apple) },
{ path: 'cli/api/play.gen.ts', text: playFile(srcs.play, docs, play) },
{ path: 'cli/api/wrapped.gen.ts', text: wrappedFile(w) },
{ path: 'docs/api-coverage.md', text: coverageDoc(srcs, apple, play, w) },
];
}
if (import.meta.main) {
const check = process.argv.includes('--check');
let stale = 0;
for (const g of generate()) {
const p = join(ROOT, g.path);
let cur = '';
try { cur = readFileSync(p, 'utf8'); } catch { /* new */ }
if (cur === g.text) continue;
if (check) { console.error(`gen-api: ${g.path} is stale: run bun tools/gen-api.ts`); stale++; continue; }
writeFileSync(p, g.text);
console.log(`gen-api: wrote ${g.path} (${(g.text.length / 1024).toFixed(0)} KB)`);
}
if (stale) process.exit(1);
if (check) console.log('gen-api: generated API tables are fresh');
}