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:
parent
346d80e07e
commit
77aa09235e
30 changed files with 281170 additions and 13 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -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
174
cli/api.ts
Normal 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
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
341
cli/api/client.ts
Normal 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
99
cli/api/gate.ts
Normal 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
181
cli/api/play.gen.ts
Normal 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
68
cli/api/types.ts
Normal 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
171
cli/api/wrapped.gen.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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]> = {
|
||||||
|
|
|
||||||
|
|
@ -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) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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
55
skills/store-api/SKILL.md
Normal 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
36
specs/SOURCES.json
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
14995
specs/androidpublisher_v3.discovery.json
Normal file
14995
specs/androidpublisher_v3.discovery.json
Normal file
File diff suppressed because it is too large
Load diff
258516
specs/app-store-connect.oas.json
Normal file
258516
specs/app-store-connect.oas.json
Normal file
File diff suppressed because it is too large
Load diff
2678
specs/playdeveloperreporting_v1beta1.discovery.json
Normal file
2678
specs/playdeveloperreporting_v1beta1.discovery.json
Normal file
File diff suppressed because it is too large
Load diff
333
tests/api.test.ts
Normal file
333
tests/api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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']],
|
||||||
|
|
|
||||||
|
|
@ -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
341
tools/gen-api.ts
Normal 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');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue