Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks) - apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app version (--with-version), --no-submit keeps the draft; read-only MCP plan - apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten) - whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits - apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API) - apple cpp and apple events: list, create, upload, submit (asks) - reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV - triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS - aso rank: iTunes Search API ranks and competitors, rate limited - ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks - tests against recorded, sanitized answers; skills, README, CHANGELOG
This commit is contained in:
parent
346d80e07e
commit
b63f2fdfbc
55 changed files with 4388 additions and 62 deletions
|
|
@ -43,6 +43,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
|
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
|
||||||
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
|
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
|
||||||
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
|
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
|
||||||
|
- `skills/app-store-promotion/SKILL.md`: App Store promotion tools for a mobile app or game through the App Store Connect API
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
|
|
@ -55,7 +56,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
|
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
|
||||||
- `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; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency
|
||||||
- `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
|
||||||
|
|
|
||||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -45,6 +45,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
|
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
|
||||||
| `app-icons` | Generate every icon a mobile app or game needs from one artwork |
|
| `app-icons` | Generate every icon a mobile app or game needs from one artwork |
|
||||||
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
|
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
|
||||||
|
| `app-store-promotion` | App Store promotion tools for a mobile app or game through the App Store Connect API |
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
|
|
@ -57,7 +58,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
|
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
|
||||||
| `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; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency |
|
||||||
| `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 |
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
|
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
|
||||||
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
|
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
|
||||||
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
|
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
|
||||||
|
- `skills/app-store-promotion/SKILL.md`: App Store promotion tools for a mobile app or game through the App Store Connect API
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
|
|
@ -56,7 +57,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
|
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
|
||||||
- `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; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency
|
||||||
- `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
|
||||||
|
|
|
||||||
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -2,6 +2,53 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Store operations. Every new command has `--dry` and `--json`; what customers can see, redeem or lose (submitting,
|
||||||
|
codes, offers, pre-orders, expiring builds, making a page visible) asks first in the terminal and is not an MCP
|
||||||
|
tool. API details come from the App Store Connect API 4.5.1 OpenAPI spec, Apple's and Google's reference pages and
|
||||||
|
App Store Connect Help (cited at the top of each `cli/lib/*.ts`); tests run the commands against recorded,
|
||||||
|
sanitized answers (`tests/store-ops-*.test.ts`, harness in `tests/helpers/store-mock.ts`).
|
||||||
|
- TestFlight: `apple testflight wait --build N` (polls until VALID / FAILED, progress lines, `--timeout`, exit 0 /
|
||||||
|
1 / 7) and `apple testflight expire --keep N | --older-than D` (newest, attached and processing builds stay;
|
||||||
|
`PATCH /v1/builds/{id}` `expired: true`).
|
||||||
|
- `apple iap-submit`: IAP, subscription and subscription group versions into a review submission
|
||||||
|
(`reviewSubmissionItems` with `inAppPurchaseVersion` / `subscriptionVersion` / `subscriptionGroupVersion`, API
|
||||||
|
4.4.1+; the deprecated `inAppPurchaseSubmissions` are not used). It applies Apple's rules: the first item of each
|
||||||
|
type only together with a new app version (`--with-version`), later ones alone once the app has an approved
|
||||||
|
version, a new subscription with its unapproved group, at most 200 items; `--no-submit` stops at the draft,
|
||||||
|
`--new-version` makes a new draft version of an approved or rejected product. MCP: the read-only plan
|
||||||
|
`apple_iap_review_plan`.
|
||||||
|
- Pulls: `apple listing pull`, `play listing pull` (live texts back into `store/*.md`, diff first, `--write` to
|
||||||
|
write) and `catalog pull` (IAP and subscription texts as a proposal diff; the config is never rewritten,
|
||||||
|
`--write` saves `.storebound/pull/catalog.json`).
|
||||||
|
- `whats-new --languages a,b,c`: one file per language in `store/whats-new/`, translated by your agent (skill) or a
|
||||||
|
`--translate "<command>"` hook of yours (no translation service or key in the CLI); `whats-new check` (Play 500,
|
||||||
|
App Store 4000, untranslated or identical to the source) and `whats-new apply`.
|
||||||
|
- Offers: `apple offer-codes` (offer codes for one-time purchases, `inAppPurchaseOfferCodes`; custom and one-time-use
|
||||||
|
codes only with `--codes`, saved to `.storebound/offer-codes/`), `apple win-back` (win-back offers,
|
||||||
|
`winBackOffers`), `apple promoted` (promoted purchases: create disabled, order, `--enable`), `apple preorder
|
||||||
|
status | enable | end` (territory availabilities, `endAppAvailabilityPreOrders`). Google Play pre-registration and
|
||||||
|
promo codes have no API: console guide in the new `app-store-promotion` skill.
|
||||||
|
- `apple cpp list | create | upload | submit | visible` (custom product pages, screenshots from a shots variant) and
|
||||||
|
`apple events list | create | upload | submit` (in-app events with schedule, localizations, card and details
|
||||||
|
images checked against Apple's sizes).
|
||||||
|
- `reports apple-sales | apple-finance | play-earnings | play-sales`: Apple `salesReports` / `financeReports`
|
||||||
|
(vendor number from `ASC_VENDOR_NUMBER` in env or `.storebound/credentials.json`) and the Play earnings / sales
|
||||||
|
zips from the reports bucket into `.storebound/reports/`, with a summary per product and currency and a CSV.
|
||||||
|
Zero dependencies: a small ZIP reader on `node:zlib`.
|
||||||
|
- `triage`: TestFlight crash reports (signature from each crash log, text and `.ips`), TestFlight screenshot feedback
|
||||||
|
and Play error reports of the top issues, grouped by signature with devices, OS versions and builds; tester emails
|
||||||
|
are never requested.
|
||||||
|
- `aso rank`: the app's rank per keyword and storefront in the public iTunes Search API results plus the first
|
||||||
|
competitors, one request every 3.5 s (Apple allows about 20 a minute), snapshots in `.storebound/aso/`. Subtitles
|
||||||
|
are not in any public API and are not shown.
|
||||||
|
- `ads scan --ios <path>`: AdAttributionKit / SKAdNetwork checks of Info.plist and sources (ad network ids and their
|
||||||
|
case, `AttributionCopyEndpoint`, re-engagement copies, `NSAdvertisingAttributionReportEndpoint`, attribution
|
||||||
|
windows and cooldown ranges, conversion value updates); the ads-mediation-checks skill explains each finding.
|
||||||
|
- New MCP tools (dry by default where they write local files): `apple_testflight_wait`, `apple_iap_review_plan`,
|
||||||
|
`apple_listing_pull`, `play_listing_pull`, `catalog_pull`, `whats_new_languages`, `whats_new_check`,
|
||||||
|
`whats_new_apply`, `reports`, `triage`, `aso_rank`, `ads_scan`, `apple_cpp_list | create | upload`,
|
||||||
|
`apple_events_list | create | upload`, `apple_preorder_status`.
|
||||||
|
|
||||||
- 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,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
|
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
|
||||||
| `app-icons` | Generate every icon a mobile app or game needs from one artwork |
|
| `app-icons` | Generate every icon a mobile app or game needs from one artwork |
|
||||||
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
|
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
|
||||||
|
| `app-store-promotion` | App Store promotion tools for a mobile app or game through the App Store Connect API |
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
|
|
@ -56,7 +57,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
|
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
|
||||||
| `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; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency |
|
||||||
| `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 |
|
||||||
|
|
|
||||||
65
README.md
65
README.md
|
|
@ -76,17 +76,20 @@ New personal Play developer accounts also need a closed test with 12+ testers fo
|
||||||
every language, graphics, screenshots, app previews, in-app products and prices (also from one CSV), Play Data
|
every language, graphics, screenshots, app previews, in-app products and prices (also from one CSV), Play Data
|
||||||
safety, Apple age rating, categories, content rights, price and availability, export compliance answer, App Review
|
safety, Apple age rating, categories, content rights, price and availability, export compliance answer, App Review
|
||||||
details with the demo account, subscriptions (Apple groups, levels, prices, introductory and promotional offers,
|
details with the demo account, subscriptions (Apple groups, levels, prices, introductory and promotional offers,
|
||||||
offer codes; Play base plans, regional prices, offers), phased release and staged rollout, TestFlight groups / testers / What to Test / Test Information, crash and
|
offer codes; Play base plans, regional prices, offers), phased release and staged rollout, TestFlight groups / testers / What to Test / Test Information / build
|
||||||
ANR vitals and App Store diagnostics, review replies, App Store analytics reports, App Store Product Page
|
processing / expiring builds, crash and ANR vitals and App Store diagnostics, TestFlight feedback, review replies,
|
||||||
Optimization tests.
|
App Store analytics reports, sales and finance reports, App Store Product Page Optimization tests, IAP and
|
||||||
|
subscription review submissions, offer codes for one-time purchases, win-back offers, promoted IAPs, pre-orders,
|
||||||
|
custom product pages and in-app events, and pulling live listing texts back.
|
||||||
- **Console only** (no public API; the skill + your agent's browser, or you): creating the App Store Connect app record
|
- **Console only** (no public API; the skill + your agent's browser, or you): creating the App Store Connect app record
|
||||||
and the Play app, Play App content (IARC content rating, target audience, ads, advertising ID, app access,
|
and the Play app, Play App content (IARC content rating, target audience, ads, advertising ID, app access,
|
||||||
government / financial / health / news, app access with the demo account), Play category and tags, Play countries,
|
government / financial / health / news, app access with the demo account), Play category and tags, Play countries,
|
||||||
App Privacy labels and their Publish, adding first IAPs and subscriptions to the submission, Play promo codes, Play store listing experiments, test results of both stores' experiments.
|
App Privacy labels and their Publish, Play promo codes, Play pre-registration, Play store listing experiments, test results of both stores' experiments, promotional images of promoted IAPs.
|
||||||
Play store statistics have no API either: storebound reads the monthly exports from your Play reports bucket.
|
Play store statistics have no API either: storebound reads the monthly exports from your Play reports bucket.
|
||||||
- **Only you**: agreements, banking and tax, and the final Send for review / Submit for Review. Releases and
|
- **Only you**: agreements, banking and tax, and the final Send for review / Submit for Review (apps, IAPs, custom
|
||||||
rollouts after approval, Beta App Review, tester invitations, sending review replies, applying price changes and
|
product pages, in-app events). Releases and rollouts after approval, Beta App Review, tester invitations, sending
|
||||||
starting / stopping a product page test too. None of these is an MCP tool; the terminal commands ask first.
|
review replies, applying price changes, starting / stopping a product page test, creating offer codes, win-back
|
||||||
|
offers, switching on promoted IAPs, pre-orders and expiring builds too. None of these is an MCP tool; the terminal commands ask first.
|
||||||
|
|
||||||
## 5-minute quick start
|
## 5-minute quick start
|
||||||
|
|
||||||
|
|
@ -211,6 +214,9 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |
|
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |
|
||||||
| `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 |
|
||||||
|
| `whats-new --languages en,de,tr` / `check` / `apply` | local | one file per language (`store/whats-new/`) for your agent (or a `--translate` hook of yours) to translate, per-store limits (Play 500, App Store 4000), then into both listing files |
|
||||||
|
| `catalog pull [--write]` | API, read-only + local | IAP and subscription texts on the stores vs the config: a proposal diff (`.storebound/pull/catalog.json`); the config is never rewritten |
|
||||||
|
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
||||||
| `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 |
|
||||||
| `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 |
|
||||||
|
|
@ -224,6 +230,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| command | kind | what |
|
| command | kind | what |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `play listing` | API | app details, title / short / full description per language, promo video URL (`play.video`) |
|
| `play listing` | API | app details, title / short / full description per language, promo video URL (`play.video`) |
|
||||||
|
| `play listing pull [--write]` | API, read-only + local | live listings back into `store/listing.md`: diff first, writes only with `--write` |
|
||||||
| `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
|
| `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
|
||||||
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
|
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
|
||||||
| `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) |
|
| `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) |
|
||||||
|
|
@ -242,9 +249,17 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `apple bundle` | API | register the bundle id, find the app record |
|
| `apple bundle` | API | register the bundle id, find the app record |
|
||||||
| `apple listing --version x.y.z [--build N]` | API | version (manual release), the build with exactly that number, texts per locale |
|
| `apple listing --version x.y.z [--build N]` | API | version (manual release), the build with exactly that number, texts per locale |
|
||||||
|
| `apple listing pull [--version x.y.z] [--write]` | API, read-only + local | live texts back into `store/appstore.md`: diff first, writes only with `--write` |
|
||||||
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
|
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
|
||||||
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
|
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
|
||||||
| `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots |
|
| `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots |
|
||||||
|
| `apple iap-submit [--with-version] [--no-submit]` | API | IAP / subscription / group versions into a review submission (the first of a type together with the app version); asks before adding and before submitting |
|
||||||
|
| `apple offer-codes [--codes]` | API | offer code campaigns for one-time purchases; custom and one-time-use codes only with `--codes` (asks; codes saved to `.storebound/offer-codes/`) |
|
||||||
|
| `apple win-back` | API | win-back offers for lapsed subscribers (asks: they go live on their start date) |
|
||||||
|
| `apple promoted [--enable]` | API | promoted in-app purchases: create (disabled), order, switch on (asks) |
|
||||||
|
| `apple preorder status \| enable --release-date \| end` | API | pre-orders per territory (enable / end ask first) |
|
||||||
|
| `apple cpp list \| create \| upload \| submit \| visible` | API | custom product pages from the config and a shots variant; submit / visible ask first |
|
||||||
|
| `apple events list \| create \| upload \| submit` | API | in-app events with schedule, localizations, card and details images; submit asks first |
|
||||||
| `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) |
|
| `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) |
|
||||||
| `apple appinfo` | API | categories, age rating, content rights, Free, territories |
|
| `apple appinfo` | API | categories, age rating, content rights, Free, territories |
|
||||||
| `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) |
|
| `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) |
|
||||||
|
|
@ -268,6 +283,8 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| `apple testflight what-to-test --build N` | API | "What to Test" per locale from `store/appstore.md` (`whatToTest`, else `whatsNew`) |
|
| `apple testflight what-to-test --build N` | API | "What to Test" per locale from `store/appstore.md` (`whatToTest`, else `whatsNew`) |
|
||||||
| `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes |
|
| `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes |
|
||||||
| `apple testflight distribute --build N --group a,b [--submit-review]` | API | the build to groups; external groups and Beta App Review ask first |
|
| `apple testflight distribute --build N --group a,b [--submit-review]` | API | the build to groups; external groups and Beta App Review ask first |
|
||||||
|
| `apple testflight wait --build N [--timeout 60m]` | API, read-only | until processing ends, with progress; exit 0 VALID / 1 FAILED / 7 timeout |
|
||||||
|
| `apple testflight expire --keep N \| --older-than D` | API | expire old builds (never attached or processing ones); dry run, then asks |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -285,6 +302,9 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| `analytics setup` | API | App Store analytics report request (once, Admin key) |
|
| `analytics setup` | API | App Store analytics report request (once, Admin key) |
|
||||||
| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` |
|
| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` |
|
||||||
| `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change |
|
| `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change |
|
||||||
|
| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export |
|
||||||
|
| `triage [--days 14]` | API, read-only | TestFlight crash and screenshot feedback, Play error reports: grouped by signature with devices, OS versions, builds |
|
||||||
|
| `aso rank [--storefronts us,de]` | network + local | App Store rank per keyword and storefront plus competitors from the public iTunes Search API (rate limited) -> `.storebound/aso/` |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -364,12 +384,13 @@ navigation.
|
||||||
| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out |
|
| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out |
|
||||||
| `play-first-release` | Google Play from zero to "In review" |
|
| `play-first-release` | Google Play from zero to "In review" |
|
||||||
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
||||||
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review |
|
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review, waiting for processing, expiring old builds |
|
||||||
| `store-iap-setup` | in-app products on both stores (consumable, non-consumable, non-renewing), prices and the price sheet CSV, catalog diff, testers, the first-IAP-with-version rule |
|
| `store-iap-setup` | in-app products on both stores (consumable, non-consumable, non-renewing), prices and the price sheet CSV, catalog diff, testers, review submissions through the API and the first-IAP-with-version rule |
|
||||||
| `store-subscriptions` | subscription groups, levels, base plans, prices, intro and promotional offers, offer codes, disclosure and Terms (3.1.2), first submission |
|
| `store-subscriptions` | subscription groups, levels, base plans, prices, intro and promotional offers, offer codes, disclosure and Terms (3.1.2), first submission |
|
||||||
| `review-access` | demo account for reviewers (App Review fields, Play App access), never committed |
|
| `review-access` | demo account for reviewers (App Review fields, Play App access), never committed |
|
||||||
| `account-deletion` | Apple 5.1.1(v) in-app deletion, Play delete-account web page and Data safety |
|
| `account-deletion` | Apple 5.1.1(v) in-app deletion, Play delete-account web page and Data safety |
|
||||||
| `aso-basics` | keyword field, subtitle, cross-localization, Play placement without stuffing |
|
| `aso-basics` | keyword field, subtitle, cross-localization, Play placement without stuffing, keyword ranks via the iTunes Search API |
|
||||||
|
| `app-store-promotion` | IAP offer codes, win-back offers, promoted IAPs, pre-orders (Play pre-registration: console guide), custom product pages, in-app events |
|
||||||
| `store-declarations` | every questionnaire: Play Data safety (API) and App content (IARC, target audience, ads, advertising ID, app access, government / financial / health), Apple App Privacy labels and age rating |
|
| `store-declarations` | every questionnaire: Play Data safety (API) and App content (IARC, target audience, ads, advertising ID, app access, government / financial / health), Apple App Privacy labels and age rating |
|
||||||
| `export-compliance` | encryption answer, Info.plist key, declarations; never exempt by default |
|
| `export-compliance` | encryption answer, Info.plist key, declarations; never exempt by default |
|
||||||
| `privacy-policy` | one policy page for all your apps with a per-app services table (templates per profile) |
|
| `privacy-policy` | one policy page for all your apps with a per-app services table (templates per profile) |
|
||||||
|
|
@ -378,13 +399,13 @@ 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 |
|
||||||
| `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, pulling console edits back |
|
||||||
| `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, crash and feedback triage |
|
||||||
| `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 |
|
||||||
| `store-analytics` | App Store analytics reports, Play reports bucket, before / after listing changes |
|
| `store-analytics` | App Store analytics reports, Play reports bucket, before / after listing changes, sales / finance / earnings reports |
|
||||||
| `whats-new` | player-facing release notes from git, localized, within store limits |
|
| `whats-new` | player-facing release notes from git, one file per language for your agent to translate, store limits checked |
|
||||||
| `review-rejections` | triage a rejection (fix / clarify / push back / appeal), reply template, the guidelines apps and games hit |
|
| `review-rejections` | triage a rejection (fix / clarify / push back / appeal), reply template, the guidelines apps and games hit |
|
||||||
| `ads-mediation-checks` | AdMob, AppLovin MAX and LevelPlay: ids, debug tools, adapters, app-ads.txt, SKAdNetwork, no-fill (`ads-levelplay-checks` stays as a pointer) |
|
| `ads-mediation-checks` | AdMob, AppLovin MAX and LevelPlay: ids, debug tools, adapters, app-ads.txt, SKAdNetwork, AdAttributionKit (`ads scan`), no-fill (`ads-levelplay-checks` stays as a pointer) |
|
||||||
| `portfolio-originality` | Guideline 4.3 gate for developers shipping many similar apps or games |
|
| `portfolio-originality` | Guideline 4.3 gate for developers shipping many similar apps or games |
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
@ -498,9 +519,9 @@ in 0.4.0. 0.5.0 made storebound app-first (profiles, app screenshot style, accou
|
||||||
brought the full in-app catalog: subscriptions on both stores with offers and offer codes, non-renewing products and
|
brought the full in-app catalog: subscriptions on both stores with offers and offer codes, non-renewing products and
|
||||||
the cross-store `catalog diff`. Next, roughly in this order:
|
the cross-store `catalog diff`. Next, roughly in this order:
|
||||||
|
|
||||||
- Offer codes for one-time products (`inAppPurchaseOfferCodes`), Play prepaid base plans for `non_renewing`, win-back
|
- Play prepaid base plans for `non_renewing`, and a `prices export` that includes subscriptions.
|
||||||
offers, and a `prices export` that includes subscriptions.
|
- The App Asset Library (API 4.5.1) for screenshots, custom product pages and in-app events once the screenshot sets
|
||||||
- App Store custom product pages and in-app events from the same copy and renders.
|
it replaces stop working.
|
||||||
- Play store listing experiments and their results, as soon as Google offers an API.
|
- Play store listing experiments and their results, as soon as Google offers an API.
|
||||||
- App preview treatments and alternate icons in Product Page Optimization tests.
|
- App preview treatments and alternate icons in Product Page Optimization tests.
|
||||||
- The iPhone Duo screenshot classes Apple lists for apps built with the iOS 27.1 SDK (required from April 2027).
|
- The iPhone Duo screenshot classes Apple lists for apps built with the iOS 27.1 SDK (required from April 2027).
|
||||||
|
|
@ -541,7 +562,13 @@ Checked against each tool's own docs in October 2026:
|
||||||
pay-as-you-go offers as a `relativeDiscount`, Play "developer determined" offers as offers without targeting plus an
|
pay-as-you-go offers as a `relativeDiscount`, Play "developer determined" offers as offers without targeting plus an
|
||||||
offer tag, the ISO 8601 strings for Play billing periods (Google lists them only in words), the Play subscription
|
offer tag, the ISO 8601 strings for Play billing periods (Google lists them only in words), the Play subscription
|
||||||
benefit length (40, from Play Console), the Data safety sign-up method ids (`PSL_ACM_*`, Google publishes none:
|
benefit length (40, from Play Console), the Data safety sign-up method ids (`PSL_ACM_*`, Google publishes none:
|
||||||
compare with a Console export) and the delete-account URL question id (use `dataSafety.extra`).
|
compare with a Console export) and the delete-account URL question id (use `dataSafety.extra`). From the store
|
||||||
|
operations (built from the App Store Connect API 4.5.1 OpenAPI spec and the cited Help pages, tested with recorded
|
||||||
|
answers, not yet run live): the first IAP of a type sent through `reviewSubmissionItems` next to the app version
|
||||||
|
(`--no-submit` leaves the draft for the UI if Apple refuses it), free IAP offer code and win-back prices without a
|
||||||
|
price point, the order request for promoted purchases, the Play earnings / sales zip names beyond the documented
|
||||||
|
prefix (storebound lists the prefix), and where Xcode stores `AttributionCopyEndpoint` (top level or inside an
|
||||||
|
`AdAttributionKit` dictionary: both are read).
|
||||||
- **Apple IAP description**: 45 characters (App Store Connect Help, "In-app purchase information"); an older App Store
|
- **Apple IAP description**: 45 characters (App Store Connect Help, "In-app purchase information"); an older App Store
|
||||||
marketing page says 55. storebound checks 45.
|
marketing page says 55. storebound checks 45.
|
||||||
|
|
||||||
|
|
|
||||||
71
cli/ads.ts
Normal file
71
cli/ads.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Local AdAttributionKit / SKAdNetwork scan of an iOS project (no network, no credentials, no config needed).
|
||||||
|
// storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]
|
||||||
|
// Reads every app Info.plist (Pods, build output, tests and frameworks left out) plus Podfile.lock / Package.resolved
|
||||||
|
// and the Swift / Objective-C sources, then checks: ad network ids (SKAdNetworkItems, AdNetworkIdentifiers;
|
||||||
|
// lowercase, format, ad SDKs without ids), postback copy endpoints (AttributionCopyEndpoint, the re-engagement opt-in,
|
||||||
|
// NSAdvertisingAttributionReportEndpoint), AdAttributionKitConfigurations (attribution windows, cooldown) and that an
|
||||||
|
// advertised app updates its conversion value. Exit 1 on a blocker. --dry is accepted and changes nothing (read-only).
|
||||||
|
// Rules and Apple's documentation links: cli/lib/adattribution.ts; the ads-mediation-checks skill explains the fixes.
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, resolve } from 'node:path';
|
||||||
|
import type { Args } from './lib/args';
|
||||||
|
import { type AakFinding, evaluateAdAttribution, sourceSignals } from './lib/adattribution';
|
||||||
|
import { done, printJson } from './lib/log';
|
||||||
|
import { parsePlist, type PlistValue } from './lib/plist';
|
||||||
|
|
||||||
|
const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata', 'Pods', 'Carthage', 'fastlane']);
|
||||||
|
const SKIP_PLIST = /(Tests?|UITests|\.bundle|\.framework|\.xcframework|\.appex|Extension|Widget|Watch)(\/|$)/i;
|
||||||
|
|
||||||
|
function walk(dir: string, visit: (p: string) => void, depth = 0): void {
|
||||||
|
if (depth > 12) return;
|
||||||
|
let names: string[] = [];
|
||||||
|
try { names = readdirSync(dir); } catch { return; }
|
||||||
|
for (const n of names) {
|
||||||
|
if (SKIP.has(n)) continue;
|
||||||
|
const p = join(dir, n);
|
||||||
|
let st;
|
||||||
|
try { st = statSync(p); } catch { continue; }
|
||||||
|
if (st.isDirectory()) walk(p, visit, depth + 1); else visit(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(args: Args): Promise<void> {
|
||||||
|
if (args._[0] !== 'scan') throw new Error('usage: storebound ads scan --ios <path to the iOS project folder, e.g. ios/App> [--plist Info.plist] [--json]');
|
||||||
|
const ios = args.get('ios');
|
||||||
|
if (!ios) throw new Error('--ios <path to the iOS project folder>');
|
||||||
|
const root = resolve(ios);
|
||||||
|
if (!existsSync(root)) throw new Error(`${root} does not exist`);
|
||||||
|
const plists: string[] = [];
|
||||||
|
const files: { path: string; text: string }[] = [];
|
||||||
|
walk(root, p => {
|
||||||
|
const rel = relative(root, p);
|
||||||
|
if (p.endsWith('/Info.plist') && !SKIP_PLIST.test(rel)) plists.push(p);
|
||||||
|
if (/(\.swift|\.m|\.mm|Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(p)) {
|
||||||
|
try { if (statSync(p).size < 4_000_000) files.push({ path: rel, text: readFileSync(p, 'utf8') }); } catch { /* unreadable */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// lock files usually sit next to the project folder (ios/Podfile.lock for ios/App)
|
||||||
|
for (const extra of ['Podfile.lock', '../Podfile.lock', 'Package.resolved', '../Package.resolved']) {
|
||||||
|
const p = join(root, extra);
|
||||||
|
if (existsSync(p) && !files.some(f => resolve(root, f.path) === resolve(p))) files.push({ path: extra, text: readFileSync(p, 'utf8') });
|
||||||
|
}
|
||||||
|
const chosen = args.get('plist') ? [resolve(args.get('plist')!)] : plists;
|
||||||
|
const signals = sourceSignals(files);
|
||||||
|
const results: { plist: string; findings: AakFinding[] }[] = [];
|
||||||
|
for (const p of chosen) {
|
||||||
|
let parsed: PlistValue;
|
||||||
|
try { parsed = parsePlist(readFileSync(p, 'utf8')); } catch (e) { results.push({ plist: relative(process.cwd(), p), findings: [{ level: 'warn', code: 'plist-read', msg: (e as Error).message }] }); continue; }
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
||||||
|
results.push({ plist: relative(process.cwd(), p), findings: evaluateAdAttribution(parsed, signals) });
|
||||||
|
}
|
||||||
|
if (!chosen.length) results.push({ plist: '(none)', findings: [{ level: signals.adSdks.length ? 'blocker' : 'info', code: 'no-plist', msg: `no app Info.plist under ${root}${signals.adSdks.length ? ` but ad SDKs (${signals.adSdks.join(', ')}) need SKAdNetworkItems / AdNetworkIdentifiers in one` : ''}` }] });
|
||||||
|
const blockers = results.flatMap(r => r.findings).filter(f => f.level === 'blocker').length;
|
||||||
|
if (args.has('json')) printJson({ ios: root, signals, results, blockers });
|
||||||
|
else {
|
||||||
|
console.log(`ad SDKs: ${signals.adSdks.join(', ') || 'none found'} · attribution SDKs: ${signals.mmpSdks.join(', ') || 'none'} · import AdAttributionKit: ${signals.aakImport ? 'yes' : 'no'} · conversion value updates in code: ${signals.conversionUpdates ? 'yes' : 'no'}`);
|
||||||
|
const tag = { blocker: 'BLOCKER', warn: 'warn ', ok: 'ok ', info: 'info ' } as const;
|
||||||
|
for (const r of results) { console.log(`\n${r.plist}`); for (const f of r.findings) console.log(` ${tag[f.level]} ${f.msg}`); }
|
||||||
|
}
|
||||||
|
done('ads', blockers ? `${blockers} blocker(s)` : 'no blocker');
|
||||||
|
if (blockers) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,18 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET of a binary answer (sales / finance reports: application/a-gzip). null on 404 (Apple answers 404 when there
|
||||||
|
* is no report for that date: no sales, or not published yet). */
|
||||||
|
async function bytes(path: string, accept = 'application/a-gzip'): Promise<Uint8Array | null> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
|
||||||
|
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await Bun.sleep(2000 * attempt); continue; }
|
||||||
|
if (r.status === 404) { await r.body?.cancel(); return null; }
|
||||||
|
if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`);
|
||||||
|
return new Uint8Array(await r.arrayBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Every item of a paged GET. */
|
/** Every item of a paged GET. */
|
||||||
async function all(path: string): Promise<J[]> {
|
async function all(path: string): Promise<J[]> {
|
||||||
const out: J[] = [];
|
const out: J[] = [];
|
||||||
|
|
@ -107,5 +119,5 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
||||||
return res.id;
|
return res.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { asc, all, maybe, getAs, app, editableVersion, editableAppInfo, uploadAsset, keyId: k.keyId };
|
return { asc, all, maybe, getAs, bytes, app, editableVersion, editableAppInfo, uploadAsset, keyId: k.keyId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
145
cli/apple/cpp.ts
Normal file
145
cli/apple/cpp.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// App Store custom product pages from apple.customProductPages.
|
||||||
|
// storebound apple cpp list [--json]
|
||||||
|
// storebound apple cpp create [--name "..."] [--dry] [--json] pages missing on App Store Connect + localizations
|
||||||
|
// storebound apple cpp upload --name "..." [--only en-US,tr] [--src store/renders] [--dry] screenshots (the page's variant)
|
||||||
|
// storebound apple cpp submit --name "..." [--dry] [--yes] into a review submission (the owner's step; asks)
|
||||||
|
// storebound apple cpp visible --name "..." [--off] [--dry] [--yes] show / hide an approved page (asks)
|
||||||
|
// create and upload change nothing customers see (MCP tools, dry by default). A page's screenshots come from the
|
||||||
|
// renders of its shots variant (<renders>/<variant>/<lang>/ios69 ...), MD5-idempotent like apple screenshots.
|
||||||
|
// Pure parts and sources: cli/lib/cpp-events.ts.
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { byAppleLocale, confirm, need } from '../lib/ctx';
|
||||||
|
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppLocales, type CustomProductPageDef, planCppLocales, reviewItemReq } from '../lib/cpp-events';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { numberedImages } from '../play/images';
|
||||||
|
import { variantRoot } from '../shots/lib';
|
||||||
|
import { type Asc, ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute, one, type Req } from './jsonapi';
|
||||||
|
import { displaySets, syncScreenshotSet } from './screenshots';
|
||||||
|
|
||||||
|
const TAG = 'cpp';
|
||||||
|
const USAGE = 'usage: storebound apple cpp list | create [--name n] | upload --name n | submit --name n | visible --name n [--off]';
|
||||||
|
|
||||||
|
interface Page { id: string; name: string; url?: string; visible?: boolean; version?: { id: string; state: string; deepLink?: string | null } }
|
||||||
|
|
||||||
|
async function pagesOf(c: Asc, appId: string): Promise<Page[]> {
|
||||||
|
const out: Page[] = [];
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/appCustomProductPages?limit=200`)) {
|
||||||
|
const vs = await c.all(`/v1/appCustomProductPages/${p.id}/appCustomProductPageVersions?limit=50`);
|
||||||
|
const v = vs.find((x: J) => CPP_EDITABLE.has(x.attributes.state)) ?? vs.at(-1);
|
||||||
|
out.push({ id: p.id, name: p.attributes.name, url: p.attributes.url, visible: p.attributes.visible, ...(v ? { version: { id: v.id, state: v.attributes.state, deepLink: v.attributes.deepLink } } : {}) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A draft review submission of the app (READY_FOR_REVIEW), or the request that creates one. */
|
||||||
|
async function draftSubmission(c: Asc, appId: string): Promise<string | null> {
|
||||||
|
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const sub = a._[0];
|
||||||
|
if (!sub) throw new Error(USAGE);
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const defs = config.apple?.customProductPages ?? [];
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const pages = await pagesOf(c, appId);
|
||||||
|
const pick = (): { def: CustomProductPageDef; page: Page | undefined } => {
|
||||||
|
const name = need(a.get('name'), '--name "<page name>"');
|
||||||
|
const def = defs.find(d => d.name === name);
|
||||||
|
if (!def) throw new Error(`no custom product page "${name}" in apple.customProductPages`);
|
||||||
|
return { def, page: pages.find(p => p.name === name) };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const rows = [...pages.map(p => [p.name, p.visible ? 'visible' : 'hidden', p.version?.state ?? '', defs.some(d => d.name === p.name) ? 'config' : '', p.url ?? '']),
|
||||||
|
...defs.filter(d => !pages.some(p => p.name === d.name)).map(d => [d.name, '', 'not created', 'config', ''])];
|
||||||
|
if (a.has('json')) { printJson({ pages, missing: defs.filter(d => !pages.some(p => p.name === d.name)).map(d => d.name), limit: CPP_LIMITS.pages }); return; }
|
||||||
|
console.log(rows.length ? textTable(['page', 'visible', 'version', 'source', 'url'], rows) : 'no custom product pages');
|
||||||
|
done(TAG, `${pages.length} of ${CPP_LIMITS.pages} custom product pages used`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'create') {
|
||||||
|
const only = a.get('name');
|
||||||
|
const reqs: Req[] = [];
|
||||||
|
for (const d of defs.filter(x => !only || x.name === only)) {
|
||||||
|
const locs = cppLocales(d, config.languages);
|
||||||
|
const page = pages.find(p => p.name === d.name);
|
||||||
|
if (!page) { reqs.push({ method: 'POST', path: '/v1/appCustomProductPages', note: `page "${d.name}" with ${locs.length} locale(s)`, body: cppCreateBody(appId, d, locs) }); continue; }
|
||||||
|
if (!page.version || !CPP_EDITABLE.has(page.version.state)) { log(TAG, `"${d.name}": version ${page.version?.state ?? 'missing'}, not editable (a new version: App Store Connect)`); continue; }
|
||||||
|
const have = (await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, promotionalText: l.attributes.promotionalText }));
|
||||||
|
reqs.push(...planCppLocales(page.version.id, have, locs));
|
||||||
|
if ((d.deepLink ?? null) !== (page.version.deepLink ?? null) && d.deepLink) reqs.push({ method: 'PATCH', path: `/v1/appCustomProductPageVersions/${page.version.id}`, note: `"${d.name}" deep link`,
|
||||||
|
body: { data: { type: 'appCustomProductPageVersions', id: page.version.id, attributes: { deepLink: d.deepLink } } } });
|
||||||
|
}
|
||||||
|
if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`);
|
||||||
|
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });
|
||||||
|
if (!reqs.length) { done(TAG, 'custom product pages up to date'); return; }
|
||||||
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
||||||
|
done(TAG, dry ? `dry run: ${reqs.length} change(s)` : `${reqs.length} change(s); next: storebound apple cpp upload --name "<page>"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'upload') {
|
||||||
|
const { def, page } = pick();
|
||||||
|
if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`);
|
||||||
|
if (!CPP_EDITABLE.has(page.version.state)) throw new Error(`"${def.name}": version ${page.version.state}, screenshots can only change on an editable version`);
|
||||||
|
const src = variantRoot(resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), def.variant, config.shots?.variants);
|
||||||
|
const only = a.list('only');
|
||||||
|
const langOf = byAppleLocale(config.languages);
|
||||||
|
let sets = 0;
|
||||||
|
for (const loc of await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)) {
|
||||||
|
const locale: string = loc.attributes.locale;
|
||||||
|
if (only.length && !only.includes(locale)) continue;
|
||||||
|
const lang = langOf.get(locale);
|
||||||
|
if (!lang) { warn(TAG, `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
||||||
|
const existing = await c.all(`/v1/appCustomProductPageLocalizations/${loc.id}/appScreenshotSets?limit=50`);
|
||||||
|
for (const [dir, type] of Object.entries(displaySets(config.apple))) {
|
||||||
|
const d = join(src, lang.id, dir);
|
||||||
|
const files = existsSync(d) ? numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10) : [];
|
||||||
|
if (!files.length) { warn(TAG, `${locale}: nothing in ${d}, skipped`); continue; }
|
||||||
|
await syncScreenshotSet(c, { rel: 'appCustomProductPageLocalization', type: 'appCustomProductPageLocalizations', id: loc.id }, existing, type, files, `"${def.name}" ${locale} ${type}`, dry);
|
||||||
|
sets++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done(TAG, `${sets} screenshot set(s) ${dry ? 'checked (dry run)' : 'synced'} for "${def.name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'submit') {
|
||||||
|
const { def, page } = pick();
|
||||||
|
if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`);
|
||||||
|
if (!['PREPARE_FOR_SUBMISSION', 'REJECTED'].includes(page.version.state)) { done(TAG, `"${def.name}": version is ${page.version.state}, nothing to submit`); return; }
|
||||||
|
const draft = await draftSubmission(c, appId);
|
||||||
|
const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } };
|
||||||
|
const item = reviewItemReq(draft ?? '${submission}', 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`);
|
||||||
|
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), item, { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
|
||||||
|
if (!(await confirm(ctx, `Send custom product page "${def.name}" to App Review now?`))) return;
|
||||||
|
const sid = draft ?? ((await c.asc('POST', create.path, create.body)).data.id as string);
|
||||||
|
await c.asc('POST', item.path, reviewItemReq(sid, 'appCustomProductPageVersion', page.version.id, '').body);
|
||||||
|
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
|
||||||
|
done(TAG, `"${def.name}" submitted (review submission ${sid}); after approval: storebound apple cpp visible --name "${def.name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'visible') {
|
||||||
|
const { def, page } = pick();
|
||||||
|
if (!page) throw new Error(`"${def.name}" is not on App Store Connect yet`);
|
||||||
|
const visible = !a.has('off');
|
||||||
|
if (page.visible === visible) { done(TAG, `"${def.name}" is already ${visible ? 'visible' : 'hidden'}`); return; }
|
||||||
|
const req: Req = { method: 'PATCH', path: `/v1/appCustomProductPages/${page.id}`, note: `"${def.name}" ${visible ? 'visible' : 'hidden'}`, body: { data: { type: 'appCustomProductPages', id: page.id, attributes: { visible } } } };
|
||||||
|
if (dry) { await execute(c.asc, [req], { dry, tag: TAG }); return; }
|
||||||
|
if (!(await confirm(ctx, `${visible ? 'Show' : 'Hide'} custom product page "${def.name}" on the App Store?`))) return;
|
||||||
|
await execute(c.asc, [req], { dry: false, tag: TAG });
|
||||||
|
done(TAG, `"${def.name}" ${visible ? 'visible' : 'hidden'}${page.url ? ` (${page.url})` : ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`unknown cpp command "${sub}"\n${USAGE}`);
|
||||||
|
}
|
||||||
126
cli/apple/events.ts
Normal file
126
cli/apple/events.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// App Store in-app events from apple.events.
|
||||||
|
// storebound apple events list [--json]
|
||||||
|
// storebound apple events create [--name "<reference name>"] [--dry] [--json] missing events + localizations
|
||||||
|
// storebound apple events upload --name "..." [--dry] event card (16:9) + details page (9:16) images per locale
|
||||||
|
// storebound apple events submit --name "..." [--dry] [--yes] into a review submission (the owner's step; asks)
|
||||||
|
// An approved event is published by its own schedule (publishStart): sending it to review is the moment it is decided,
|
||||||
|
// so `submit` asks first and is not an MCP tool. Up to 10 published and 15 approved events per app.
|
||||||
|
// Pure parts and sources: cli/lib/cpp-events.ts.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventCreateBody, planEventLocales, reviewItemReq } from '../lib/cpp-events';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { imageInfo } from '../shots/spec';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute, one, type Req } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'events';
|
||||||
|
const USAGE = 'usage: storebound apple events list | create [--name n] | upload --name n | submit --name n';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const sub = a._[0];
|
||||||
|
if (!sub) throw new Error(USAGE);
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const defs = config.apple?.events ?? [];
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const events = await c.all(`/v1/apps/${appId}/appEvents?limit=200`);
|
||||||
|
const localeOf = (langId: string): string | undefined => config.languages.find(l => l.id === langId)?.apple;
|
||||||
|
const pick = (): { def: AppEventDef; ev: J | undefined } => {
|
||||||
|
const name = need(a.get('name'), '--name "<reference name>"');
|
||||||
|
const def = defs.find(d => d.referenceName === name);
|
||||||
|
if (!def) throw new Error(`no event "${name}" in apple.events`);
|
||||||
|
return { def, ev: events.find((e: J) => e.attributes.referenceName === name) };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const rows = events.map((e: J) => { const s = e.attributes.territorySchedules?.[0] ?? {}; return [e.attributes.referenceName, e.attributes.badge ?? '', e.attributes.eventState ?? '', (s.eventStart ?? '').slice(0, 16), (s.eventEnd ?? '').slice(0, 16), defs.some(d => d.referenceName === e.attributes.referenceName) ? 'config' : '']; });
|
||||||
|
for (const d of defs.filter(d => !events.some((e: J) => e.attributes.referenceName === d.referenceName))) rows.push([d.referenceName, d.badge, 'not created', d.schedule.eventStart.slice(0, 16), d.schedule.eventEnd.slice(0, 16), 'config']);
|
||||||
|
if (a.has('json')) { printJson({ events: events.map((e: J) => ({ id: e.id, ...e.attributes })), missing: defs.filter(d => !events.some((e: J) => e.attributes.referenceName === d.referenceName)).map(d => d.referenceName) }); return; }
|
||||||
|
console.log(rows.length ? textTable(['event', 'badge', 'state', 'start', 'end', 'source'], rows) : 'no in-app events');
|
||||||
|
const published = events.filter((e: J) => e.attributes.eventState === 'PUBLISHED').length, approved = events.filter((e: J) => ['APPROVED', 'ACCEPTED', 'PUBLISHED'].includes(e.attributes.eventState)).length;
|
||||||
|
done(TAG, `${published} of ${EVENT_LIMITS.published} published, ${approved} of ${EVENT_LIMITS.approved} approved`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'create') {
|
||||||
|
const only = a.get('name');
|
||||||
|
const territories = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id as string);
|
||||||
|
const reqs: Req[] = [];
|
||||||
|
const created: { def: AppEventDef; index: number }[] = [];
|
||||||
|
for (const d of defs.filter(x => !only || x.referenceName === only)) {
|
||||||
|
const want = Object.entries(d.listings).flatMap(([lang, x]) => { const locale = localeOf(lang); return locale ? [{ locale, ...x }] : []; });
|
||||||
|
const primary = localeOf(d.primaryLanguage ?? '') ?? want[0]?.locale ?? 'en-US';
|
||||||
|
const ev = events.find((e: J) => e.attributes.referenceName === d.referenceName);
|
||||||
|
if (!ev) { created.push({ def: d, index: reqs.length }); reqs.push({ method: 'POST', path: '/v1/appEvents', note: `event "${d.referenceName}" (${d.badge}, ${d.schedule.eventStart} to ${d.schedule.eventEnd})`, body: eventCreateBody(appId, d, primary, d.schedule.territories ?? territories) }); continue; }
|
||||||
|
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) { log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`); continue; }
|
||||||
|
const have = (await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
|
||||||
|
reqs.push(...planEventLocales(ev.id, have, want));
|
||||||
|
}
|
||||||
|
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });
|
||||||
|
if (!reqs.length) { done(TAG, 'in-app events up to date'); return; }
|
||||||
|
if (dry) {
|
||||||
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
||||||
|
for (const x of created) log(TAG, `then: ${Object.keys(x.def.listings).length} localization(s) for "${x.def.referenceName}"`);
|
||||||
|
done(TAG, 'dry run: nothing changed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const out = await execute(c.asc, reqs, { dry: false, tag: TAG }) as J[];
|
||||||
|
for (const x of created) {
|
||||||
|
const id = out[x.index].data.id as string;
|
||||||
|
const want = Object.entries(x.def.listings).flatMap(([lang, t]) => { const locale = localeOf(lang); return locale ? [{ locale, ...t }] : []; });
|
||||||
|
await execute(c.asc, planEventLocales(id, [], want), { dry: false, tag: TAG });
|
||||||
|
}
|
||||||
|
done(TAG, 'in-app events created / updated; next: storebound apple events upload --name "<event>"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'upload') {
|
||||||
|
const { def, ev } = pick();
|
||||||
|
if (!ev) throw new Error(`"${def.referenceName}" is not on App Store Connect yet (storebound apple events create)`);
|
||||||
|
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) throw new Error(`"${def.referenceName}": ${ev.attributes.eventState}, media can only change on a draft`);
|
||||||
|
let n = 0;
|
||||||
|
for (const loc of await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)) {
|
||||||
|
const lang = config.languages.find(l => l.apple === loc.attributes.locale);
|
||||||
|
if (!lang) continue;
|
||||||
|
const have = await c.all(`/v1/appEventLocalizations/${loc.id}/appEventScreenshots?limit=50`);
|
||||||
|
for (const [key, kind] of [['card', 'EVENT_CARD'], ['details', 'EVENT_DETAILS_PAGE']] as const) {
|
||||||
|
const tpl = def.media?.[key];
|
||||||
|
if (!tpl) continue;
|
||||||
|
const file = resolve(tpl.replaceAll('{lang}', lang.id));
|
||||||
|
if (!existsSync(file)) { warn(TAG, `${loc.attributes.locale} ${kind}: no ${file}`); continue; }
|
||||||
|
const info = imageInfo(readFileSync(file));
|
||||||
|
const problems = checkEventMedia(kind, info.w, info.h, info.format);
|
||||||
|
if (problems.length) throw new Error(`${file}: ${problems.join('; ')}`);
|
||||||
|
if (have.some((s: J) => s.attributes.appEventAssetType === kind && s.attributes.fileSize === info.bytes)) { log(TAG, `${loc.attributes.locale} ${kind}: up to date`); continue; }
|
||||||
|
if (dry) { log(TAG, `${loc.attributes.locale} ${kind}: would upload ${file} (${info.w}x${info.h})`); n++; continue; }
|
||||||
|
for (const s of have.filter((x: J) => x.attributes.appEventAssetType === kind)) await c.asc('DELETE', `/v1/appEventScreenshots/${s.id}`);
|
||||||
|
await c.uploadAsset('appEventScreenshots', { appEventLocalization: { data: { type: 'appEventLocalizations', id: loc.id } } }, file, { appEventAssetType: kind });
|
||||||
|
log(TAG, `${loc.attributes.locale} ${kind}: uploaded`);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done(TAG, `${n} image(s) ${dry ? 'to upload (dry run)' : 'uploaded'} for "${def.referenceName}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'submit') {
|
||||||
|
const { def, ev } = pick();
|
||||||
|
if (!ev) throw new Error(`"${def.referenceName}" is not on App Store Connect yet`);
|
||||||
|
if (!EVENT_EDITABLE.has(ev.attributes.eventState) && ev.attributes.eventState !== 'READY_FOR_REVIEW') { done(TAG, `"${def.referenceName}" is ${ev.attributes.eventState}, nothing to submit`); return; }
|
||||||
|
const draft = (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id as string | undefined;
|
||||||
|
const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } };
|
||||||
|
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), reviewItemReq(draft ?? '${submission}', 'appEvent', ev.id, `in-app event "${def.referenceName}"`), { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
|
||||||
|
if (!(await confirm(ctx, `Send in-app event "${def.referenceName}" to App Review? Once approved it is published on ${def.schedule.publishStart} by its schedule.`))) return;
|
||||||
|
const sid = draft ?? ((await c.asc('POST', create.path, create.body)).data.id as string);
|
||||||
|
await c.asc('POST', '/v1/reviewSubmissionItems', reviewItemReq(sid, 'appEvent', ev.id, '').body);
|
||||||
|
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
|
||||||
|
done(TAG, `"${def.referenceName}" submitted (review submission ${sid})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`unknown events command "${sub}"\n${USAGE}`);
|
||||||
|
}
|
||||||
105
cli/apple/iap-submit.ts
Normal file
105
cli/apple/iap-submit.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Send in-app purchases and subscriptions to App Review through a review submission (the owner's action).
|
||||||
|
// storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--no-submit] [--dry] [--yes] [--json]
|
||||||
|
// Default: every product and subscription of the config sold on the App Store. The plan (cli/lib/review-submit.ts)
|
||||||
|
// says which version of each goes in, which items are first of their type (those need an app version in the same
|
||||||
|
// submission: --with-version adds the editable one, and then submitting submits the app too), and what is left out.
|
||||||
|
// --new-version: approved or rejected products get a new draft version first (POST inAppPurchaseVersions /
|
||||||
|
// subscriptionVersions) so changed metadata can go to review. --no-submit: only fill the draft submission; the owner
|
||||||
|
// presses "Submit for Review" in App Store Connect. Without --no-submit the last step asks first (never under MCP).
|
||||||
|
import { sellsOn } from '../lib/config';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { allSubscriptions } from '../lib/subscriptions';
|
||||||
|
import { bindIds, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf } from '../lib/review-submit';
|
||||||
|
import { type Asc, ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'iap-submit';
|
||||||
|
|
||||||
|
export async function readSubmitState(c: Asc, appId: string, want: string[]): Promise<SubmitState> {
|
||||||
|
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
||||||
|
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
|
||||||
|
const iaps: PIap[] = [];
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||||
|
// every product counts for "first of its type"; versions are only read for the products we submit
|
||||||
|
const versions = want.includes(p.attributes.productId) ? versionsOf(await c.all(`/v2/inAppPurchases/${p.id}/versions?limit=200`)) : [];
|
||||||
|
iaps.push({ id: p.id, productId: p.attributes.productId, type: p.attributes.inAppPurchaseType, state: p.attributes.state, versions });
|
||||||
|
}
|
||||||
|
const subs: PSub[] = [];
|
||||||
|
const groups: SubmitState['groups'] = [];
|
||||||
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
||||||
|
const members = await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`);
|
||||||
|
const wanted = members.some((s: J) => want.includes(s.attributes.productId));
|
||||||
|
groups.push({ id: g.id, name: g.attributes.referenceName, versions: wanted ? versionsOf(await c.all(`/v1/subscriptionGroups/${g.id}/versions?limit=200`)) : [] });
|
||||||
|
for (const s of members) subs.push({ id: s.id, productId: s.attributes.productId, groupId: g.id, state: s.attributes.state,
|
||||||
|
versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
|
||||||
|
}
|
||||||
|
const drafts = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`);
|
||||||
|
let draft: SubmitState['draft'] = null;
|
||||||
|
if (drafts[0]) {
|
||||||
|
const items = await c.all(`/v1/reviewSubmissions/${drafts[0].id}/items?include=appStoreVersion,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion&limit=200`);
|
||||||
|
const ids = items.flatMap((it: J) => Object.values(it.relationships ?? {}).map((r: J) => r?.data?.id).filter(Boolean)) as string[];
|
||||||
|
draft = { id: drafts[0].id, itemVersionIds: ids };
|
||||||
|
}
|
||||||
|
return { appVersions, iaps, subs, groups, draft };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const only = a.list('only');
|
||||||
|
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)]
|
||||||
|
.filter(id => !only.length || only.includes(id));
|
||||||
|
for (const id of only) if (!ids.includes(id)) throw new Error(`--only ${id}: not a product or subscription of the config sold on the App Store`);
|
||||||
|
if (!ids.length) throw new Error('no in-app purchases or subscriptions in the config for the App Store');
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
let state = await readSubmitState(c, appId, ids);
|
||||||
|
|
||||||
|
if (a.has('new-version')) {
|
||||||
|
const reqs = [
|
||||||
|
...state.iaps.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('iap', p.id, p.productId)),
|
||||||
|
...state.subs.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('sub', p.id, p.productId)),
|
||||||
|
];
|
||||||
|
if (reqs.length) {
|
||||||
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
||||||
|
if (!dry) state = await readSubmitState(c, appId, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = planIapSubmission(state, ids, { withVersion: a.has('with-version') });
|
||||||
|
const prepare = plan.prepare.map(r => bindIds(r, { app: appId }));
|
||||||
|
if (a.has('json')) {
|
||||||
|
printJson({ dryRun: dry, items: plan.items, skipped: plan.skipped, firstOfType: plan.firstOfType, hasApprovedVersion: plan.hasApprovedVersion,
|
||||||
|
needsAppVersion: plan.needsAppVersion, blockers: plan.blockers, draft: state.draft?.id ?? null, requests: [...prepare, plan.submit].map(r => describeReq(r)) });
|
||||||
|
if (dry || plan.blockers.length) { if (plan.blockers.length) process.exitCode = 1; return; }
|
||||||
|
} else {
|
||||||
|
for (const it of plan.items) log(TAG, `submit: ${it.label}`);
|
||||||
|
for (const s of plan.skipped) log(TAG, `skip ${s.id}: ${s.why}`);
|
||||||
|
if (plan.firstOfType.length) log(TAG, `first ${plan.firstOfType.join(' / ')} of the app: needs an app version in the same submission`);
|
||||||
|
for (const b of plan.blockers) warn(TAG, b);
|
||||||
|
}
|
||||||
|
if (plan.blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
|
||||||
|
if (!plan.items.length) { done(TAG, 'nothing to submit'); return; }
|
||||||
|
if (dry) {
|
||||||
|
await execute(c.asc, [...prepare, plan.submit], { dry, tag: TAG });
|
||||||
|
done(TAG, `dry run: ${plan.items.length} item(s) would go into ${state.draft ? `draft ${state.draft.id}` : 'a new draft submission'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`;
|
||||||
|
if (!(await confirm(ctx, `Add ${what} to ${state.draft ? 'the draft' : 'a new draft'} review submission?`))) return;
|
||||||
|
let sid = state.draft?.id;
|
||||||
|
for (const r of prepare) {
|
||||||
|
const out = await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body);
|
||||||
|
if (r.path === '/v1/reviewSubmissions') sid = out.data.id;
|
||||||
|
log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
||||||
|
}
|
||||||
|
if (a.has('no-submit')) { done(TAG, `draft review submission ${sid} ready: press "Submit for Review" in App Store Connect (Apps > Distribution > App Review)`); return; }
|
||||||
|
if (!(await confirm(ctx, `Submit review submission ${sid} (${what}) to App Review now?${plan.appVersion ? ' This submits the app version too.' : ''}`))) {
|
||||||
|
done(TAG, `draft ${sid} kept; submit it in App Store Connect or re-run`); return;
|
||||||
|
}
|
||||||
|
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, bindIds(plan.submit, { submission: sid }).body);
|
||||||
|
done(TAG, `review submission ${sid} submitted (storebound apple status shows its state)`);
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,17 @@
|
||||||
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
|
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
|
||||||
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
|
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
|
||||||
// support + marketing URL per locale
|
// support + marketing URL per locale
|
||||||
import { readFileSync } from 'node:fs';
|
// And the other way round (read-only on App Store Connect):
|
||||||
import { parseAppStoreListing } from '../lib/copy';
|
// storebound apple listing pull [--version x.y.z] [--write] [--json]
|
||||||
|
// the live texts (the editable version if there is one, else the newest; --version picks one) into the App Store
|
||||||
|
// listing file: shows the diff, writes only with --write.
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { type AppleCopy, parseAppStoreListing } from '../lib/copy';
|
||||||
|
import { compactDiff } from '../lib/diff';
|
||||||
|
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
|
||||||
import type { Ctx } from '../lib/ctx';
|
import type { Ctx } from '../lib/ctx';
|
||||||
import { need } from '../lib/ctx';
|
import { need } from '../lib/ctx';
|
||||||
import { done, log } from '../lib/log';
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
import { ascClient, type J } from './asc';
|
import { ascClient, type J } from './asc';
|
||||||
|
|
||||||
export function readAppleCopy(ctx: Ctx) {
|
export function readAppleCopy(ctx: Ctx) {
|
||||||
|
|
@ -34,9 +40,51 @@ async function upsertLocs(c: Awaited<ReturnType<typeof ascClient>>, list: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** apple listing pull: live texts -> diff -> (with --write) the App Store listing file. */
|
||||||
|
async function pull(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||||
|
const want = a.get('version');
|
||||||
|
const sorted = [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')));
|
||||||
|
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion()) ?? sorted[0];
|
||||||
|
if (!v) throw new Error(want ? `no iOS version ${want} on App Store Connect` : 'no iOS version on App Store Connect yet');
|
||||||
|
const infos = await c.all(`/v1/apps/${appId}/appInfos`);
|
||||||
|
const live = v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||||
|
// the app info that belongs to the version: the editable one while a version is being prepared, else the live one
|
||||||
|
const info = ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(live)
|
||||||
|
? infos.find((i: J) => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]
|
||||||
|
: await c.editableAppInfo();
|
||||||
|
const remote: Record<string, AppleCopy> = {};
|
||||||
|
for (const l of await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)) remote[l.attributes.locale] = { name: l.attributes.name ?? undefined, subtitle: l.attributes.subtitle ?? undefined };
|
||||||
|
for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=200`)) {
|
||||||
|
const at = l.attributes;
|
||||||
|
remote[at.locale] = { ...remote[at.locale], description: at.description ?? undefined, keywords: at.keywords ?? undefined, promotionalText: at.promotionalText ?? undefined, whatsNew: at.whatsNew ?? undefined };
|
||||||
|
}
|
||||||
|
const file = resolve(config.apple?.listing ?? 'store/appstore.md');
|
||||||
|
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# App Store listing copy\n\nPulled from App Store Connect by `storebound apple listing pull`.\n';
|
||||||
|
const r = mergeAppleListing(before, remote);
|
||||||
|
const over = overLimits(r.changes, 'apple');
|
||||||
|
const diff = compactDiff(before, r.md);
|
||||||
|
if (a.has('json')) printJson({ version: v.attributes.versionString, state: live, file, write: a.has('write') && !a.has('dry'), changes: r.changes, added: r.added, overLimits: over, diff });
|
||||||
|
else {
|
||||||
|
log('apple', `pull: version ${v.attributes.versionString} (${live}), ${Object.keys(remote).length} locales`);
|
||||||
|
for (const ch of r.changes) log('apple', `${ch.section} ${ch.field}: ${ch.from === null ? 'new' : 'changed'}`);
|
||||||
|
if (diff.length) console.log(diff.join('\n'));
|
||||||
|
}
|
||||||
|
for (const o of over) warn('apple', `over the limit on the store: ${o}`);
|
||||||
|
if (!r.changes.length) { done('apple', `${file} already matches the store`); return; }
|
||||||
|
if (!a.has('write') || a.has('dry')) { done('apple', `${r.changes.length} change(s); nothing written (add --write to update ${file})`); return; }
|
||||||
|
writeFileSync(file, r.md);
|
||||||
|
done('apple', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new locales ${r.added.join(', ')}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root } = ctx.loaded;
|
const { config, root } = ctx.loaded;
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
|
if (a._[0] === 'pull') return pull(ctx);
|
||||||
const version = a.get('version');
|
const version = a.get('version');
|
||||||
if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--dry]');
|
if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--dry]');
|
||||||
const listing = readAppleCopy(ctx);
|
const listing = readAppleCopy(ctx);
|
||||||
|
|
|
||||||
92
cli/apple/offer-codes.ts
Normal file
92
cli/apple/offer-codes.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Offer codes for one-time purchases (consumable, non-consumable, non-renewing) from products[].offerCodes.
|
||||||
|
// storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]
|
||||||
|
// Creates the missing campaigns (by name) with their prices: free, or the USA price point of `usd` and its
|
||||||
|
// equalizations (every territory, or `territories`). A campaign alone lets nobody redeem anything. Codes are made only
|
||||||
|
// with --codes, after a confirmation: custom codes (anyone who has the code) and one-time-use batches; the one-time
|
||||||
|
// codes are saved to .storebound/offer-codes/ (gitignored; treat them like vouchers). Each app may have 10 active
|
||||||
|
// offers and 1 million codes per quarter (App Store Connect Help). Subscription offer codes: apple subscriptions.
|
||||||
|
// Pure parts and sources: cli/lib/offers.ts.
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { sellsOn } from '../lib/config';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { iapCustomCodeReq, iapOfferCodeBody, iapOneTimeCodesReq, type TerritoryPoint, validateIapOfferCodes } from '../lib/offers';
|
||||||
|
import { applePoints, pickPoint } from '../lib/subscriptions';
|
||||||
|
import { pages } from '../prices';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute, type Req } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'offer-codes';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const only = a.list('only');
|
||||||
|
const products = (config.products ?? []).filter(p => p.offerCodes?.length && sellsOn(config, p, 'apple') && (!only.length || only.includes(p.id)));
|
||||||
|
if (!products.length) throw new Error('no products with offerCodes for the App Store (products[].offerCodes in storebound.config.ts)');
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const problems = products.flatMap(p => validateIapOfferCodes(p.id, p.offerCodes, p.type, today));
|
||||||
|
if (problems.length) throw new Error(problems.join('; '));
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const report: Record<string, unknown>[] = [];
|
||||||
|
const codeReqs: { req: Req; product: string; campaign: string; oneTime: boolean }[] = [];
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
for (const p of products) {
|
||||||
|
const iap = (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data?.[0];
|
||||||
|
if (!iap) { warn(TAG, `${p.id}: not on App Store Connect (storebound apple iap)`); report.push({ product: p.id, missing: true }); continue; }
|
||||||
|
const existing = await c.all(`/v2/inAppPurchases/${iap.id}/offerCodes?include=customCodes,oneTimeUseCodes&limit=200`);
|
||||||
|
active += existing.filter((x: J) => x.attributes.active).length;
|
||||||
|
let usaPoints: ReturnType<typeof applePoints> | null = null;
|
||||||
|
const prices = async (oc: NonNullable<typeof p.offerCodes>[number]): Promise<TerritoryPoint[]> => {
|
||||||
|
if (oc.free) {
|
||||||
|
const all = oc.territories ?? (await c.all('/v1/territories?limit=200')).map((t: J) => t.id as string);
|
||||||
|
return all.map(territory => ({ territory }));
|
||||||
|
}
|
||||||
|
usaPoints ??= applePoints(await pages(c, `/v2/inAppPurchases/${iap.id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
|
||||||
|
const usa = pickPoint(usaPoints, 'USA', oc.usd!);
|
||||||
|
const eq = applePoints(await pages(c, `/v1/inAppPurchasePricePoints/${usa.id}/equalizations?include=territory&limit=200`));
|
||||||
|
const all = [{ territory: 'USA', pointId: usa.id }, ...eq.filter(x => x.territory !== 'USA').map(x => ({ territory: x.territory, pointId: x.id }))];
|
||||||
|
return oc.territories ? all.filter(x => oc.territories!.includes(x.territory)) : all;
|
||||||
|
};
|
||||||
|
for (const oc of p.offerCodes!) {
|
||||||
|
const cur = existing.find((x: J) => x.attributes.name === oc.name);
|
||||||
|
let id: string | undefined = cur?.id;
|
||||||
|
if (!cur) {
|
||||||
|
const body = iapOfferCodeBody(iap.id, oc, await prices(oc));
|
||||||
|
const req: Req = { method: 'POST', path: '/v1/inAppPurchaseOfferCodes', body, note: `${p.id}: campaign "${oc.name}" (${oc.free ? 'free' : `USD ${oc.usd}`}, ${body.included.length} territories)` };
|
||||||
|
if (dry) log(TAG, `would ${describeReq(req)}`);
|
||||||
|
else { id = ((await execute(c.asc, [req], { dry, tag: TAG }))[0] as J).data.id; active++; }
|
||||||
|
} else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
|
||||||
|
const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []);
|
||||||
|
for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false });
|
||||||
|
// one-time batches are not matched one by one: a batch is created when the campaign has fewer batches than configured
|
||||||
|
const batches = cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/oneTimeUseCodes?limit=200`)).length : 0;
|
||||||
|
for (const b of (oc.oneTimeCodes ?? []).slice(batches)) codeReqs.push({ req: iapOneTimeCodesReq(id ?? '${new campaign}', b), product: p.id, campaign: oc.name, oneTime: true });
|
||||||
|
report.push({ product: p.id, campaign: oc.name, exists: !!cur, codesToCreate: codeReqs.filter(x => x.campaign === oc.name && x.product === p.id).map(x => x.req.note) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active > 10) warn(TAG, `${active} active offer code campaigns: App Store Connect allows 10 active offers per app at a time`);
|
||||||
|
if (a.has('json')) printJson({ dryRun: dry, campaigns: report, codes: codeReqs.map(x => describeReq(x.req)) });
|
||||||
|
if (!codeReqs.length) { done(TAG, dry ? 'offer codes checked (dry run)' : 'offer codes done'); return; }
|
||||||
|
if (!a.has('codes')) { for (const x of codeReqs) log(TAG, `not created without --codes: ${x.req.note}`); done(TAG, `${codeReqs.length} code request(s) left; run with --codes (asks first)`); return; }
|
||||||
|
if (dry) { await execute(c.asc, codeReqs.map(x => x.req), { dry, tag: TAG }); done(TAG, 'dry run: no codes created'); return; }
|
||||||
|
if (!(await confirm(ctx, `Create ${codeReqs.length} code request(s) (${codeReqs.map(x => x.req.note).join('; ')})? Codes count against the 1 million per quarter and can be redeemed by anyone who gets them.`))) return;
|
||||||
|
const dir = resolve('.storebound/offer-codes');
|
||||||
|
for (const x of codeReqs) {
|
||||||
|
const out = (await execute(c.asc, [x.req], { dry: false, tag: TAG }))[0] as J;
|
||||||
|
if (!x.oneTime) continue;
|
||||||
|
// the codes of a one-time-use batch (text/csv); saved locally, never printed
|
||||||
|
const values = await c.bytes(`/v1/inAppPurchaseOfferCodeOneTimeUseCodes/${out.data.id}/values`, 'text/csv');
|
||||||
|
if (!values) { warn(TAG, `${x.product} "${x.campaign}": codes not ready yet; download them in App Store Connect`); continue; }
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const file = join(dir, `${x.product}-${x.campaign.replace(/[^A-Za-z0-9._-]+/g, '_')}-${out.data.id}.csv`);
|
||||||
|
writeFileSync(file, values);
|
||||||
|
log(TAG, `${x.product} "${x.campaign}": one-time codes saved to ${file}`);
|
||||||
|
}
|
||||||
|
done(TAG, `${codeReqs.length} code request(s) done`);
|
||||||
|
}
|
||||||
61
cli/apple/preorder.ts
Normal file
61
cli/apple/preorder.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// App Store pre-orders through the app availability API.
|
||||||
|
// storebound apple preorder status [--json]
|
||||||
|
// storebound apple preorder enable --release-date YYYY-MM-DD [--territories USA,DEU] [--dry] [--yes] [--json]
|
||||||
|
// storebound apple preorder end [--territories USA,DEU] [--dry] [--yes] [--json]
|
||||||
|
// status: per territory available, release date, pre-order on, publish date, content statuses. enable puts the
|
||||||
|
// territories (default: all the app is available in and not yet released) on pre-order with that release date; end
|
||||||
|
// stops the pre-order and releases the app there at once. Both are public, so they show the dry run and ask first;
|
||||||
|
// neither is an MCP tool. Apple requires an approved version first; the release date is 2-180 days ahead for a new
|
||||||
|
// app (up to 365 days for an existing app in a new region). Google Play pre-registration has no API: Play Console >
|
||||||
|
// Grow users > Pre-registration (see the app-store-promotion skill). Pure parts and sources: cli/lib/offers.ts.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, printJson, warn } from '../lib/log';
|
||||||
|
import { endPreOrderReq, planPreOrder, territoryRows } from '../lib/offers';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'preorder';
|
||||||
|
const USAGE = 'usage: storebound apple preorder status | enable --release-date YYYY-MM-DD [--territories USA,DEU] | end [--territories USA]';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const sub = a._[0];
|
||||||
|
if (!sub || !['status', 'enable', 'end'].includes(sub)) throw new Error(USAGE);
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const avail = (await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`))?.data;
|
||||||
|
if (!avail) throw new Error('the app has no availability yet: set territories first (storebound apple appinfo, or Pricing and Availability in App Store Connect)');
|
||||||
|
const pagesOut: J[] = [];
|
||||||
|
for (let url: string | null = `/v2/appAvailabilities/${avail.id}/territoryAvailabilities?include=territory&limit=200`; url;) { const p: J = await c.asc('GET', url); pagesOut.push(p); url = p.links?.next ?? null; }
|
||||||
|
const rows = territoryRows(pagesOut);
|
||||||
|
const territories = a.list('territories').length ? a.list('territories') : null;
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
|
const pre = rows.filter(r => r.preOrderEnabled);
|
||||||
|
if (a.has('json')) { printJson({ availableInNewTerritories: avail.attributes?.availableInNewTerritories ?? null, territories: rows }); return; }
|
||||||
|
console.log(textTable(['territory', 'available', 'release date', 'pre-order', 'pre-order published', 'status'],
|
||||||
|
rows.filter(r => !territories || territories.includes(r.territory)).map(r => [r.territory, r.available ? 'yes' : 'no', r.releaseDate ?? '', r.preOrderEnabled ? 'on' : '', r.preOrderPublishDate ?? '', (r.contentStatuses ?? []).join(' ')])));
|
||||||
|
done(TAG, `${rows.length} territories, ${pre.length} on pre-order`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = sub === 'enable'
|
||||||
|
? planPreOrder(rows, need(a.get('release-date'), '--release-date YYYY-MM-DD'), territories)
|
||||||
|
: { reqs: [endPreOrderReq(rows, territories)].filter(r => r !== null), problems: [] as string[], warnings: [] as string[] };
|
||||||
|
for (const w of plan.warnings) warn(TAG, w);
|
||||||
|
if (plan.problems.length) throw new Error(plan.problems.join('; '));
|
||||||
|
if (a.has('json') && dry) { printJson({ dryRun: true, requests: plan.reqs.map(r => describeReq(r)), warnings: plan.warnings }); return; }
|
||||||
|
if (!plan.reqs.length) { done(TAG, sub === 'enable' ? 'pre-order already set up like that' : 'no territory is on pre-order'); return; }
|
||||||
|
if (dry) { await execute(c.asc, plan.reqs, { dry, tag: TAG }); done(TAG, 'dry run: nothing changed'); return; }
|
||||||
|
const q = sub === 'enable'
|
||||||
|
? `Put the app on pre-order in ${plan.reqs.length} territor${plan.reqs.length === 1 ? 'y' : 'ies'} with release date ${a.get('release-date')}? The pre-order page goes public once you release an approved version.`
|
||||||
|
: `End the pre-order and release the app now (${plan.reqs[0]!.note})?`;
|
||||||
|
if (!(await confirm(ctx, q))) return;
|
||||||
|
await execute(c.asc, plan.reqs, { dry: false, tag: TAG });
|
||||||
|
if (a.has('json')) printJson({ dryRun: false, requests: plan.reqs.map(r => describeReq(r)) });
|
||||||
|
done(TAG, sub === 'enable' ? 'pre-order set (storebound apple preorder status)' : 'pre-order ended; the app is released in those territories');
|
||||||
|
}
|
||||||
62
cli/apple/promoted.ts
Normal file
62
cli/apple/promoted.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Promoted in-app purchases on the App Store product page, from apple.promoted.
|
||||||
|
// storebound apple promoted [--enable] [--dry] [--yes] [--json]
|
||||||
|
// Status (what is promoted, enabled, its review state), then: creates the missing promoted purchases disabled,
|
||||||
|
// updates visibleForAllUsers, sets the display order. --enable also switches them on, which shows them on the
|
||||||
|
// product page once approved: that asks first and is not an MCP tool. Each promoted purchase needs its promotional
|
||||||
|
// image (App Store Connect) and the app must handle purchases started on the App Store (StoreKit PurchaseIntent).
|
||||||
|
// At most 20 at a time. Pure parts and sources: cli/lib/offers.ts.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { planPromoted, type PromotedNow, promotedOrderReq } from '../lib/offers';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'promoted';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const cfg = config.apple?.promoted;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const products = new Map<string, { apiId: string; kind: 'iap' | 'sub' }>();
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) products.set(p.attributes.productId, { apiId: p.id, kind: 'iap' });
|
||||||
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
||||||
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) products.set(s.attributes.productId, { apiId: s.id, kind: 'sub' });
|
||||||
|
const byApiId = new Map([...products.entries()].map(([pid, v]) => [v.apiId, pid] as const));
|
||||||
|
const raw = await c.asc('GET', `/v1/apps/${appId}/promotedPurchases?include=inAppPurchaseV2,subscription&limit=50`);
|
||||||
|
const now: PromotedNow[] = (raw.data ?? []).map((p: J) => ({ id: p.id, enabled: !!p.attributes.enabled, visibleForAllUsers: !!p.attributes.visibleForAllUsers, state: p.attributes.state,
|
||||||
|
productId: byApiId.get(p.relationships?.inAppPurchaseV2?.data?.id ?? p.relationships?.subscription?.data?.id ?? '') ?? '?' }));
|
||||||
|
const status = now.map((p, i) => [i + 1, p.productId, p.enabled ? 'enabled' : 'off', p.visibleForAllUsers ? 'everyone' : 'app decides', p.state ?? '']);
|
||||||
|
if (!cfg) {
|
||||||
|
if (a.has('json')) printJson({ promoted: now });
|
||||||
|
else console.log(now.length ? textTable(['#', 'product', 'enabled', 'visible to', 'state'], status) : 'no promoted in-app purchases');
|
||||||
|
done(TAG, 'apple.promoted is not set: status only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plan = planPromoted(appId, cfg, now, products, { enable: a.has('enable') });
|
||||||
|
const order = promotedOrderReq(appId, cfg, now);
|
||||||
|
for (const u of plan.unknown) warn(TAG, `${u}: not on App Store Connect (storebound apple iap / apple subscriptions)`);
|
||||||
|
if (plan.extra.length) log(TAG, `promoted on the store but not in apple.promoted (left as they are): ${plan.extra.join(', ')}`);
|
||||||
|
if (now.length + plan.create.length > 20) throw new Error(`${now.length + plan.create.length} promoted purchases: at most 20`);
|
||||||
|
const reqs = [...plan.create, ...plan.update];
|
||||||
|
if (a.has('json')) printJson({ dryRun: dry, promoted: now, requests: [...reqs, ...(order ? [order] : [])].map(r => describeReq(r)), unknown: plan.unknown, extra: plan.extra });
|
||||||
|
else if (now.length) console.log(textTable(['#', 'product', 'enabled', 'visible to', 'state'], status));
|
||||||
|
if (!reqs.length && !order) { done(TAG, 'promoted purchases up to date'); return; }
|
||||||
|
if (dry) { await execute(c.asc, [...reqs, ...(order ? [order] : [])], { dry, tag: TAG }); done(TAG, 'dry run: nothing changed'); return; }
|
||||||
|
const enabling = reqs.some(r => JSON.stringify(r.body).includes('"enabled":true'));
|
||||||
|
if (enabling && !(await confirm(ctx, 'Switch promoted purchases on? Once approved they show on the App Store product page.'))) return;
|
||||||
|
await execute(c.asc, reqs, { dry: false, tag: TAG });
|
||||||
|
// the order covers the purchases that exist now (new ones included)
|
||||||
|
if (plan.create.length || order) {
|
||||||
|
const fresh = await c.asc('GET', `/v1/apps/${appId}/promotedPurchases?include=inAppPurchaseV2,subscription&limit=50`);
|
||||||
|
const after: PromotedNow[] = (fresh.data ?? []).map((p: J) => ({ id: p.id, enabled: !!p.attributes.enabled, visibleForAllUsers: !!p.attributes.visibleForAllUsers,
|
||||||
|
productId: byApiId.get(p.relationships?.inAppPurchaseV2?.data?.id ?? p.relationships?.subscription?.data?.id ?? '') ?? '?' }));
|
||||||
|
const o = promotedOrderReq(appId, cfg, after);
|
||||||
|
if (o) await execute(c.asc, [o], { dry: false, tag: TAG });
|
||||||
|
}
|
||||||
|
done(TAG, `promoted purchases updated${a.has('enable') ? '' : ' (not switched on: --enable when the images are approved)'}`);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
// storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry]
|
// storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry]
|
||||||
// storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes
|
// storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes
|
||||||
// storebound apple testflight distribute --build N --group a,b [--version x.y.z] [--internal-only] [--submit-review] [--dry] [--yes]
|
// storebound apple testflight distribute --build N --group a,b [--version x.y.z] [--internal-only] [--submit-review] [--dry] [--yes]
|
||||||
|
// storebound apple testflight wait --build N [--version x.y.z] [--timeout 60m] [--dry] [--json] until processing ends
|
||||||
|
// exit codes: 0 VALID, 1 FAILED / INVALID, 7 timeout; --dry checks once and does not wait
|
||||||
|
// storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]
|
||||||
|
// expires old builds (permanent; asks first); builds attached to an App Store version and processing builds stay
|
||||||
// Testers come from a CSV in .storebound/ (gitignored; never commit tester emails): email, first_name, last_name,
|
// Testers come from a CSV in .storebound/ (gitignored; never commit tester emails): email, first_name, last_name,
|
||||||
// groups (";"-separated). Emails are masked in every log line. Adding testers makes Apple email TestFlight invitations
|
// groups (";"-separated). Emails are masked in every log line. Adding testers makes Apple email TestFlight invitations
|
||||||
// (once the group has a build), so `testers` asks first; `--remove` takes testers out of the managed groups.
|
// (once the group has a build), so `testers` asks first; `--remove` takes testers out of the managed groups.
|
||||||
|
|
@ -16,6 +20,8 @@ import { confirm, need } from '../lib/ctx';
|
||||||
import { reviewContact } from '../lib/credentials';
|
import { reviewContact } from '../lib/credentials';
|
||||||
import { done, log, printJson, warn } from '../lib/log';
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
import { textTable } from '../lib/table';
|
import { textTable } from '../lib/table';
|
||||||
|
import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHint, waitDelay, waitOutcome } from '../lib/build-ops';
|
||||||
|
import { parseDuration } from '../lib/release';
|
||||||
import {
|
import {
|
||||||
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail,
|
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail,
|
||||||
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing,
|
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing,
|
||||||
|
|
@ -24,7 +30,26 @@ import { type Asc, ascClient, type J } from './asc';
|
||||||
import { execute } from './jsonapi';
|
import { execute } from './jsonapi';
|
||||||
|
|
||||||
const TAG = 'testflight';
|
const TAG = 'testflight';
|
||||||
const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names>';
|
const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
|
||||||
|
|
||||||
|
/** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */
|
||||||
|
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
|
||||||
|
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
||||||
|
if (marketing) q.set('filter[preReleaseVersion.version]', marketing);
|
||||||
|
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
||||||
|
const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing));
|
||||||
|
if (!hits.length) return { found: false };
|
||||||
|
const b = pickBuild(hits, number, marketing);
|
||||||
|
const d = (resp.included ?? []).find((x: J) => x.type === 'buildBetaDetails' && x.id === (b as J).relationships?.buildBetaDetail?.data?.id)?.attributes;
|
||||||
|
return { found: true, processingState: b.attributes.processingState, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, version: b.marketing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every page of a GET, with `included` kept (c.all keeps only data). */
|
||||||
|
async function pagesOf(c: Asc, path: string): Promise<J[]> {
|
||||||
|
const out: J[] = [];
|
||||||
|
for (let url: string | null = path; url;) { const page: J = await c.asc('GET', url); out.push(page); url = page.links?.next ?? null; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function groups(c: Asc, appId: string): Promise<Group[]> {
|
async function groups(c: Asc, appId: string): Promise<Group[]> {
|
||||||
return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi);
|
return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi);
|
||||||
|
|
@ -187,5 +212,51 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
done(TAG, `build ${number}: ${reqs.length ? `added to ${reqs.length} group(s)` : 'already in every group'}`);
|
done(TAG, `build ${number}: ${reqs.length ? `added to ${reqs.length} group(s)` : 'already in every group'}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (sub === 'wait') {
|
||||||
|
const number = need(a.get('build'), '--build N (the exact build number you uploaded)');
|
||||||
|
const marketing = a.get('version');
|
||||||
|
const timeoutMs = parseDuration(a.str('timeout', '60m'));
|
||||||
|
const started = Date.now();
|
||||||
|
let delay: number | null = null, last = '';
|
||||||
|
for (;;) {
|
||||||
|
const b = await seeBuild(c, appId, number, marketing);
|
||||||
|
const outcome = waitOutcome(b);
|
||||||
|
const mins = `${Math.round((Date.now() - started) / 60_000)} min`;
|
||||||
|
const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${b.processingState}${b.internal ? `, internal ${b.internal}` : ''}`;
|
||||||
|
if (line !== last) { log(TAG, `build ${number}${b.version ? ` (${b.version})` : ''}: ${line} [${mins}]`); last = line; }
|
||||||
|
const finish = (code: number, msg: string): never => {
|
||||||
|
if (a.has('json')) printJson({ build: number, version: b.version ?? marketing ?? null, outcome: code === BUILD_WAIT_EXIT.timeout ? 'timeout' : outcome, processingState: b.processingState ?? null,
|
||||||
|
internal: b.internal ?? null, external: b.external ?? null, waitedSeconds: Math.round((Date.now() - started) / 1000) });
|
||||||
|
log(TAG, msg);
|
||||||
|
process.exit(code);
|
||||||
|
};
|
||||||
|
if (outcome === 'valid') finish(BUILD_WAIT_EXIT.valid, `build ${number} is VALID; ${validHint(b).replace('N', number)}`);
|
||||||
|
if (outcome === 'failed') finish(BUILD_WAIT_EXIT.failed, `build ${number} is ${b.processingState}: read the email from App Store Connect (missing icons, wrong SDK, invalid signature), fix and upload a new build number`);
|
||||||
|
if (dry) finish(BUILD_WAIT_EXIT.timeout, 'dry run: checked once, not waiting');
|
||||||
|
delay = waitDelay(delay);
|
||||||
|
if (Date.now() + delay - started > timeoutMs) finish(BUILD_WAIT_EXIT.timeout, `timeout after ${mins}; still ${outcome} (re-run to keep waiting)`);
|
||||||
|
await Bun.sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'expire') {
|
||||||
|
const keep = a.get('keep') === undefined ? undefined : a.num('keep', 0);
|
||||||
|
const olderThanDays = a.get('older-than') === undefined ? undefined : a.num('older-than', 0);
|
||||||
|
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[expired]': 'false', 'filter[preReleaseVersion.platform]': 'IOS', sort: '-uploadedDate', include: 'preReleaseVersion,appStoreVersion', limit: '200' });
|
||||||
|
if (a.get('version')) q.set('filter[preReleaseVersion.version]', a.get('version')!);
|
||||||
|
const plan = planExpire(expireCandidates(await pagesOf(c, `/v1/builds?${q}`)), { keep, olderThanDays });
|
||||||
|
const row = (b: { build: string; version?: string; uploaded: string }, what: string) => [b.version ?? '', b.build, b.uploaded.slice(0, 10), what];
|
||||||
|
const summary = { expire: plan.expire.map(b => ({ build: b.build, version: b.version ?? null, uploaded: b.uploaded })), keep: plan.keep.length,
|
||||||
|
protected: plan.protected.map(p => ({ build: p.b.build, version: p.b.version ?? null, why: p.why })) };
|
||||||
|
if (a.has('json') && dry) { printJson({ dryRun: true, ...summary }); return; }
|
||||||
|
if (!a.has('json')) console.log(textTable(['version', 'build', 'uploaded', ''], [...plan.expire.map(b => row(b, 'expire')), ...plan.protected.map(p => row(p.b, `keep: ${p.why}`)), ...plan.keep.map(b => row(b, 'keep'))]));
|
||||||
|
if (!plan.reqs.length) { done(TAG, 'nothing to expire'); if (a.has('json')) printJson({ dryRun: dry, ...summary, expired: [] }); return; }
|
||||||
|
if (dry) { await execute(c.asc, plan.reqs, { dry, tag: TAG }); done(TAG, `dry run: ${plan.reqs.length} build(s) would be expired`); return; }
|
||||||
|
if (!(await confirm(ctx, `Expire ${plan.reqs.length} build(s) (${plan.expire.map(b => b.build).join(', ')})? Testers lose them; an expired build cannot be brought back.`))) return;
|
||||||
|
await execute(c.asc, plan.reqs, { dry: false, tag: TAG });
|
||||||
|
if (a.has('json')) printJson({ dryRun: false, ...summary, expired: plan.expire.map(b => b.build) });
|
||||||
|
done(TAG, `${plan.reqs.length} build(s) expired`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`unknown testflight command "${sub}"\n${USAGE}`);
|
throw new Error(`unknown testflight command "${sub}"\n${USAGE}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
cli/apple/win-back.ts
Normal file
70
cli/apple/win-back.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Win-back offers for lapsed subscribers from subscriptions[].subscriptions[].winBack.
|
||||||
|
// storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]
|
||||||
|
// Lists what each subscription has (GET /v1/subscriptions/{id}/winBackOffers) and creates the configured offers that
|
||||||
|
// are missing (by offer id). A win-back offer goes live by itself on its start date (Manage Subscriptions, the
|
||||||
|
// in-app offer sheet, the App Store when promoted), so creating one is the owner's step: dry run first, then a
|
||||||
|
// terminal confirmation; never an MCP tool. The subscription must be approved; reference name, offer id and price
|
||||||
|
// cannot be changed afterwards (App Store Connect Help). Pure parts and sources: cli/lib/offers.ts.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
|
import { type TerritoryPoint, validateWinBack, winBackBody } from '../lib/offers';
|
||||||
|
import { allSubscriptions, applePoints, pickPoint } from '../lib/subscriptions';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { pages } from '../prices';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
import { describeReq, execute, type Req } from './jsonapi';
|
||||||
|
|
||||||
|
const TAG = 'win-back';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const only = a.list('only');
|
||||||
|
const subs = allSubscriptions(config.subscriptions).map(x => x.sub).filter(s => s.winBack?.length && (!only.length || only.includes(s.id)));
|
||||||
|
if (!subs.length) throw new Error('no subscriptions with winBack offers (subscriptions[].subscriptions[].winBack in storebound.config.ts)');
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const onStore = new Map<string, J>();
|
||||||
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
||||||
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) onStore.set(s.attributes.productId, s);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const reqs: Req[] = [];
|
||||||
|
const rows: (string | number)[][] = [];
|
||||||
|
const skipped: { id: string; why: string }[] = [];
|
||||||
|
|
||||||
|
for (const s of subs) {
|
||||||
|
const remote = onStore.get(s.id);
|
||||||
|
if (!remote) { skipped.push({ id: s.id, why: 'not on App Store Connect (storebound apple subscriptions)' }); continue; }
|
||||||
|
const existing = await c.all(`/v1/subscriptions/${remote.id}/winBackOffers?limit=200`);
|
||||||
|
for (const o of existing) rows.push([s.id, o.attributes.offerId, o.attributes.offerMode, o.attributes.startDate ?? '', o.attributes.endDate ?? '', o.attributes.priority ?? '']);
|
||||||
|
const missing = s.winBack!.filter(o => !existing.some((x: J) => x.attributes.offerId === o.id));
|
||||||
|
if (!missing.length) continue;
|
||||||
|
if (remote.attributes.state !== 'APPROVED') { skipped.push({ id: s.id, why: `subscription is ${remote.attributes.state}: win-back offers need an approved subscription` }); continue; }
|
||||||
|
const problems = validateWinBack(s.id, missing, today);
|
||||||
|
if (problems.length) { skipped.push({ id: s.id, why: problems.join('; ') }); continue; }
|
||||||
|
let usa: ReturnType<typeof applePoints> | null = null;
|
||||||
|
for (const o of missing) {
|
||||||
|
let prices: TerritoryPoint[];
|
||||||
|
if (o.type === 'free') prices = (await c.all(`/v1/subscriptions/${remote.id}/prices?include=territory&limit=200`)).map((x: J) => ({ territory: x.relationships?.territory?.data?.id })).filter((x: TerritoryPoint) => !!x.territory);
|
||||||
|
else {
|
||||||
|
usa ??= applePoints(await pages(c, `/v1/subscriptions/${remote.id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
|
||||||
|
const point = pickPoint(usa, 'USA', o.usd!);
|
||||||
|
const eq = applePoints(await pages(c, `/v1/subscriptionPricePoints/${point.id}/equalizations?include=territory&limit=200`));
|
||||||
|
prices = [{ territory: 'USA', pointId: point.id }, ...eq.filter(x => x.territory !== 'USA').map(x => ({ territory: x.territory, pointId: x.id }))];
|
||||||
|
}
|
||||||
|
reqs.push({ method: 'POST', path: '/v1/winBackOffers', body: winBackBody(remote.id, s.period, o, prices),
|
||||||
|
note: `${s.id}: win-back "${o.id}" ${o.type}${o.usd ? ` USD ${o.usd}` : ''}, from ${o.start}${o.end ? ` to ${o.end}` : ''}, ${prices.length} territories` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a.has('json')) printJson({ dryRun: dry, existing: rows.map(r => ({ subscription: r[0], offerId: r[1], mode: r[2], start: r[3], end: r[4], priority: r[5] })), create: reqs.map(r => describeReq(r)), skipped });
|
||||||
|
else if (rows.length) console.log(textTable(['subscription', 'offer', 'mode', 'start', 'end', 'priority'], rows));
|
||||||
|
for (const s of skipped) warn(TAG, `${s.id}: ${s.why}`);
|
||||||
|
if (!reqs.length) { done(TAG, 'no win-back offer to create'); return; }
|
||||||
|
if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, `dry run: ${reqs.length} win-back offer(s) would be created`); return; }
|
||||||
|
if (!(await confirm(ctx, `Create ${reqs.length} win-back offer(s)? Each goes live by itself on its start date; reference name, offer id and price cannot be changed afterwards.`))) return;
|
||||||
|
await execute(c.asc, reqs, { dry: false, tag: TAG });
|
||||||
|
log(TAG, 'the redemption URL of each offer is in App Store Connect (Subscriptions > the subscription > Win-Back Offers)');
|
||||||
|
done(TAG, `${reqs.length} win-back offer(s) created`);
|
||||||
|
}
|
||||||
66
cli/aso.ts
Normal file
66
cli/aso.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// App Store keyword rank check through Apple's public iTunes Search API (no account, no scraping).
|
||||||
|
// storebound aso rank [--storefronts us,de=de-DE] [--keywords "a,b"] [--only-keywords] [--competitors 3] [--limit 200]
|
||||||
|
// [--delay 3500] [--dry] [--json]
|
||||||
|
// Keywords: the keyword field of each storefront's locale in store/appstore.md (us -> the primary locale unless
|
||||||
|
// "us=en-GB"), plus --keywords (--only-keywords: just those). One request per keyword and storefront, at most one
|
||||||
|
// every 3.5 s (Apple allows about 20 a minute); a 403 / 429 stops the run with what was read. Rank = position of the
|
||||||
|
// app (apple.appId, else the bundle id) in the first --limit results, "-" when not there; plus the first competitors
|
||||||
|
// (title, developer, rating). Subtitles are not in the public API and are not shown. Writes
|
||||||
|
// .storebound/aso/<date>.json (and compares with the previous one) unless --dry. Pure parts: cli/lib/aso-rank.ts.
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
import { done, log, printJson, warn } from './lib/log';
|
||||||
|
import { competitors, keywordList, MIN_DELAY_MS, parseStorefronts, rankDelta, rankOf, type RankRow, type SearchApp, searchUrl } from './lib/aso-rank';
|
||||||
|
import { appleSections } from './lib/project-files';
|
||||||
|
import { textTable } from './lib/table';
|
||||||
|
|
||||||
|
const TAG = 'aso';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
if (a._[0] !== 'rank') throw new Error('usage: storebound aso rank [--storefronts us,de=de-DE] [--keywords "a,b"] [--dry] [--json]');
|
||||||
|
if (!config.apple) throw new Error('aso rank checks App Store search: the config has no apple section');
|
||||||
|
const primary = config.apple.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? 'en-US';
|
||||||
|
const listingFile = resolve(config.apple.listing ?? 'store/appstore.md');
|
||||||
|
const listing = existsSync(listingFile) ? appleSections(readFileSync(listingFile, 'utf8')) : new Map<string, Record<string, string | undefined>>();
|
||||||
|
const extra = a.list('keywords');
|
||||||
|
const fronts = parseStorefronts(a.list('storefronts'), primary);
|
||||||
|
const n = a.num('competitors', 3);
|
||||||
|
const limit = a.num('limit', 200);
|
||||||
|
const delay = Math.max(MIN_DELAY_MS, a.num('delay', MIN_DELAY_MS));
|
||||||
|
const app = { appId: config.apple.appId, bundleId: config.apple.bundleId };
|
||||||
|
const plan = fronts.flatMap(f => keywordList(a.has('only-keywords') ? '' : listing.get(f.locale)?.keywords, extra).map(k => ({ ...f, keyword: k })));
|
||||||
|
if (!plan.length) throw new Error(`no keywords: ${listingFile} has no keywords for ${fronts.map(f => f.locale).join(', ')}; pass --keywords`);
|
||||||
|
log(TAG, `${plan.length} quer${plan.length === 1 ? 'y' : 'ies'} (${fronts.map(f => f.storefront).join(', ')}), one every ${delay / 1000}s: about ${Math.ceil(plan.length * delay / 60_000)} min`);
|
||||||
|
if (a.has('dry')) {
|
||||||
|
if (a.has('json')) printJson({ dryRun: true, queries: plan.map(p => ({ storefront: p.storefront, keyword: p.keyword, url: searchUrl(p.keyword, p.storefront, limit) })) });
|
||||||
|
else for (const p of plan) log(TAG, `would GET ${searchUrl(p.keyword, p.storefront, limit)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows: RankRow[] = [];
|
||||||
|
for (const [i, p] of plan.entries()) {
|
||||||
|
if (i) await Bun.sleep(delay);
|
||||||
|
const r = await fetch(searchUrl(p.keyword, p.storefront, limit), { headers: { Accept: 'application/json' } });
|
||||||
|
if (r.status === 403 || r.status === 429) { warn(TAG, `Search API answered ${r.status} (rate limit): stopped after ${rows.length} of ${plan.length}; try later or with a larger --delay`); break; }
|
||||||
|
if (!r.ok) { warn(TAG, `${p.storefront} "${p.keyword}": HTTP ${r.status}`); continue; }
|
||||||
|
const results = ((await r.json()) as { results?: SearchApp[] }).results ?? [];
|
||||||
|
const row: RankRow = { storefront: p.storefront, locale: p.locale, keyword: p.keyword, rank: rankOf(results, app), results: results.length, competitors: competitors(results, app, n) };
|
||||||
|
rows.push(row);
|
||||||
|
log(TAG, `${i + 1}/${plan.length} ${p.storefront} "${p.keyword}": ${row.rank ?? `not in the first ${results.length}`}`);
|
||||||
|
}
|
||||||
|
const dir = resolve('.storebound/aso');
|
||||||
|
const prevFile = existsSync(dir) ? readdirSync(dir).filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().at(-1) : undefined;
|
||||||
|
const prev = prevFile ? (JSON.parse(readFileSync(join(dir, prevFile), 'utf8')) as { rows?: RankRow[] }).rows : undefined;
|
||||||
|
const delta = rankDelta(rows, prev);
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
if (a.has('json')) printJson({ date, previous: prevFile ?? null, rows: rows.map(r => ({ ...r, change: delta.get(`${r.storefront}|${r.keyword.toLowerCase()}`) ?? null })) });
|
||||||
|
else console.log(textTable(['store', 'keyword', 'rank', 'change', 'top competitors'], rows.map(r => {
|
||||||
|
const d = delta.get(`${r.storefront}|${r.keyword.toLowerCase()}`);
|
||||||
|
return [r.storefront, r.keyword, r.rank ?? '-', d === null || d === undefined ? '' : d > 0 ? `+${d}` : String(d), r.competitors.map(c => `${c.rank}. ${c.title.slice(0, 28)} (${c.developer.slice(0, 18)})`).join('; ')];
|
||||||
|
})));
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, `${date}.json`), JSON.stringify({ date, app: { appId: app.appId ?? null, bundleId: app.bundleId }, rows }, null, 2) + '\n');
|
||||||
|
done(TAG, `${rows.length} rank(s) saved to ${join(dir, `${date}.json`)}${prevFile ? ` (change vs ${prevFile})` : ''}; Search API order is close to App Store search, read it as a trend`);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,14 @@
|
||||||
// localizations, offers, and a per-region price comparison between the stores (same currency, difference over
|
// localizations, offers, and a per-region price comparison between the stores (same currency, difference over
|
||||||
// --threshold percent). Exit 1 when an item is missing or mismatched; price differences between the stores are
|
// --threshold percent). Exit 1 when an item is missing or mismatched; price differences between the stores are
|
||||||
// information (stores round to their own price tiers). Pure rules: lib/catalog.ts.
|
// information (stores round to their own price tiers). Pure rules: lib/catalog.ts.
|
||||||
|
// storebound catalog pull [--store apple|play] [--write] [--json]
|
||||||
|
// The live titles and descriptions (Play: + benefits) of in-app purchases and subscriptions against the listings in
|
||||||
|
// storebound.config.ts: a proposal diff per product and language. The config (TypeScript) is never rewritten; --write
|
||||||
|
// saves the proposal as .storebound/pull/catalog.json for you or your agent to merge.
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
import { appleCurrent, pages, playProduct } from './prices';
|
import { appleCurrent, pages, playProduct } from './prices';
|
||||||
|
import { catalogProposal, type RemoteCatalog } from './lib/listing-pull';
|
||||||
import { ascClient, type J } from './apple/asc';
|
import { ascClient, type J } from './apple/asc';
|
||||||
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
|
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
|
||||||
import type { Ctx } from './lib/ctx';
|
import type { Ctx } from './lib/ctx';
|
||||||
|
|
@ -83,10 +90,67 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const out: RemoteCatalog = {};
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`))
|
||||||
|
out[p.attributes.productId] = Object.fromEntries((await c.all(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`))
|
||||||
|
.map((l: J) => [l.attributes.locale, { title: l.attributes.name ?? '', description: l.attributes.description ?? '' }]));
|
||||||
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
||||||
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`))
|
||||||
|
out[s.attributes.productId] = Object.fromEntries((await c.all(`/v1/subscriptions/${s.id}/subscriptionLocalizations?limit=200`))
|
||||||
|
.map((l: J) => [l.attributes.locale, { title: l.attributes.name ?? '', description: l.attributes.description ?? '' }]));
|
||||||
|
log(TAG, `apple: texts of ${Object.keys(out).length} product(s)`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const play = await playClient(root, config.play!.packageName);
|
||||||
|
const out: RemoteCatalog = {};
|
||||||
|
for (const { productId } of await listOneTimeProducts(play)) {
|
||||||
|
const p = await playProduct(play, productId);
|
||||||
|
out[productId] = Object.fromEntries(((p?.listings as { languageCode: string; title?: string; description?: string }[] | undefined) ?? []).map(l => [l.languageCode, { title: l.title ?? '', description: l.description ?? '' }]));
|
||||||
|
}
|
||||||
|
let page = '';
|
||||||
|
do {
|
||||||
|
const r = await play.call<{ subscriptions?: J[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
||||||
|
for (const s of r.subscriptions ?? []) out[s.productId] = Object.fromEntries((s.listings ?? []).map((l: J) => [l.languageCode, { title: l.title ?? '', description: l.description ?? '', ...(l.benefits?.length ? { benefits: l.benefits } : {}) }]));
|
||||||
|
page = r.nextPageToken ?? '';
|
||||||
|
} while (page);
|
||||||
|
log(TAG, `play: texts of ${Object.keys(out).length} product(s)`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pull(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const store = a.get('store');
|
||||||
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
||||||
|
const apple = (!store || store === 'apple') && config.apple ? await appleTexts(ctx) : undefined;
|
||||||
|
const play = (!store || store === 'play') && config.play ? await playTexts(ctx) : undefined;
|
||||||
|
const p = catalogProposal(config, { apple, play });
|
||||||
|
const file = resolve('.storebound/pull/catalog.json');
|
||||||
|
if (a.has('json')) printJson({ file, write: a.has('write') && !a.has('dry'), ...p });
|
||||||
|
else {
|
||||||
|
if (p.changes.length) console.log(textTable(['product', 'lang', 'store', 'field', 'config', 'store text'], p.changes.map(c => [c.id, c.lang, c.store, c.field, (c.from ?? '(missing)').slice(0, 40), c.to.slice(0, 40)])));
|
||||||
|
if (p.unknown.length) log(TAG, `on the store but not in the config: ${p.unknown.join(', ')}`);
|
||||||
|
if (p.unmapped.length) log(TAG, `store languages with no entry in languages: ${p.unmapped.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!p.changes.length) { done(TAG, 'the config listings match the stores'); return; }
|
||||||
|
if (!a.has('write') || a.has('dry')) { done(TAG, `${p.changes.length} difference(s); proposal not written (add --write for ${file})`); return; }
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, JSON.stringify({ note: 'storebound catalog pull: store texts that differ from storebound.config.ts. Merge by hand (or with your agent) into products[].listings / subscriptions[].subscriptions[].listings.', listings: p.listings, changes: p.changes }, null, 2) + '\n');
|
||||||
|
done(TAG, `proposal written: ${file} (${p.changes.length} difference(s)); storebound.config.ts is unchanged`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config } = ctx.loaded;
|
const { config } = ctx.loaded;
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
if (a._[0] !== 'diff') throw new Error('usage: storebound catalog diff [--store apple|play] [--threshold 10] [--json]');
|
if (a._[0] === 'pull') return pull(ctx);
|
||||||
|
if (a._[0] !== 'diff') throw new Error('usage: storebound catalog diff [--store apple|play] [--threshold 10] [--json] | pull [--store apple|play] [--write] [--json]');
|
||||||
const store = a.get('store');
|
const store = a.get('store');
|
||||||
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
||||||
const want = wantedCatalog(config);
|
const want = wantedCatalog(config);
|
||||||
|
|
|
||||||
21
cli/index.ts
21
cli/index.ts
|
|
@ -19,6 +19,7 @@ const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
||||||
'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'],
|
||||||
'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>)'],
|
||||||
|
'ads': [() => import('./ads'), 'scan --ios <path>: AdAttributionKit / SKAdNetwork checks of Info.plist (network ids, postback copy endpoints, attribution windows, cooldown) and sources; --json'],
|
||||||
};
|
};
|
||||||
const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'],
|
'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'],
|
||||||
|
|
@ -26,13 +27,16 @@ const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||||
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches'],
|
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
|
||||||
|
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],
|
||||||
|
'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'],
|
||||||
|
'aso': [() => import('./aso'), 'rank [--storefronts us,de] [--keywords a,b]: App Store rank per keyword and storefront + competitors from the public iTunes Search API (rate limited) -> .storebound/aso/'],
|
||||||
'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes)'],
|
'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes)'],
|
||||||
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
||||||
'whats-new': [() => import('./whats-new'), 'draft user-facing release notes from git log into the listing files (--since <tag>)'],
|
'whats-new': [() => import('./whats-new'), 'draft user-facing release notes from git log into the listing files (--since <tag>); --languages a,b: one file per language to translate | check | apply'],
|
||||||
'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'],
|
'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'],
|
||||||
'play subscriptions': [() => import('./play/subscriptions'), 'auto-renewable subscriptions: listings, base plan, regional prices, intro offer, activate (--csv prices sheet; asks first)'],
|
'play subscriptions': [() => import('./play/subscriptions'), 'auto-renewable subscriptions: listings, base plan, regional prices, intro offer, activate (--csv prices sheet; asks first)'],
|
||||||
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language'],
|
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'],
|
||||||
'play images': [() => import('./play/images'), 'icon, feature graphic, phone / 7" / 10" screenshots per language; --variant <id>'],
|
'play images': [() => import('./play/images'), 'icon, feature graphic, phone / 7" / 10" screenshots per language; --variant <id>'],
|
||||||
'play data-safety': [() => import('./play/data-safety'), 'Data safety form (CSV import through the API)'],
|
'play data-safety': [() => import('./play/data-safety'), 'Data safety form (CSV import through the API)'],
|
||||||
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
|
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
|
||||||
|
|
@ -43,7 +47,14 @@ const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],
|
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],
|
||||||
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
|
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
|
||||||
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
|
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
|
||||||
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale'],
|
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft)'],
|
||||||
|
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
|
||||||
|
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],
|
||||||
|
'apple promoted': [() => import('./apple/promoted'), 'promoted in-app purchases on the product page: create, order, --enable (asks first)'],
|
||||||
|
'apple preorder': [() => import('./apple/preorder'), 'status | enable --release-date | end: App Store pre-orders per territory (asks first)'],
|
||||||
|
'apple cpp': [() => import('./apple/cpp'), 'custom product pages: list | create | upload --name | submit --name | visible --name (submit / visible ask first)'],
|
||||||
|
'apple events': [() => import('./apple/events'), 'in-app events: list | create | upload --name | submit --name (submit asks first)'],
|
||||||
|
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first)'],
|
||||||
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>'],
|
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>'],
|
||||||
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
||||||
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
|
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
|
||||||
|
|
@ -53,7 +64,7 @@ const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'],
|
'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'],
|
||||||
'apple encryption': [() => import('./apple/encryption'), 'export compliance answer for builds, from the config'],
|
'apple encryption': [() => import('./apple/encryption'), 'export compliance answer for builds, from the config'],
|
||||||
'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'],
|
'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'],
|
||||||
'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review]'],
|
'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review] | wait --build N | expire --keep N|--older-than D (asks)'],
|
||||||
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
|
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
|
||||||
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
|
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
|
||||||
'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'],
|
'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'],
|
||||||
|
|
|
||||||
172
cli/lib/adattribution.ts
Normal file
172
cli/lib/adattribution.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// AdAttributionKit / SKAdNetwork checks of an iOS app's Info.plist and sources, as pure rules. The command
|
||||||
|
// (cli/ads.ts scan) finds the files, parses them (cli/lib/plist.ts) and prints the findings. Text checks only.
|
||||||
|
// Sources (Apple developer documentation, checked October 2026):
|
||||||
|
// https://developer.apple.com/documentation/adattributionkit
|
||||||
|
// Publisher app: "add the ad network IDs" under `AdNetworkIdentifiers` (array of strings); "Lowercase the ad network ID
|
||||||
|
// string; otherwise, the system doesn't recognize it as a valid network."
|
||||||
|
// https://developer.apple.com/documentation/adattributionkit/configuring-a-publisher-app
|
||||||
|
// https://developer.apple.com/documentation/bundleresources/information-property-list/adnetworkidentifiers
|
||||||
|
// SKAdNetwork: `SKAdNetworkItems`, one dictionary with `SKAdNetworkIdentifier` per network; "Ad network IDs are
|
||||||
|
// case-sensitive and are in lowercase." `.adattributionkit` and `.skadnetwork` ids work with both frameworks.
|
||||||
|
// https://developer.apple.com/documentation/bundleresources/information-property-list/skadnetworkitems
|
||||||
|
// https://developer.apple.com/documentation/adattributionkit/adattributionkit-skadnetwork-interoperability
|
||||||
|
// Advertised app: copies of winning postbacks with `AttributionCopyEndpoint` (Xcode shows it as "AdAttributionKit -
|
||||||
|
// Postback Copy URL"; Apple's property list reference lists the key at the top level, Xcode's editor files it under
|
||||||
|
// an `AdAttributionKit` dictionary: both are read here), "a valid URL in the format of https://example.com"; the
|
||||||
|
// system uses the registrable domain and posts to https://<domain>/.well-known/appattribution/report-attribution/.
|
||||||
|
// Re-engagement copies: `EligibleForAdAttributionKitReengagementPostbackCopies` (Boolean YES). The app has to call
|
||||||
|
// updateConversionValue at least once to start a postback's conversion window.
|
||||||
|
// https://developer.apple.com/documentation/adattributionkit/configuring-an-advertised-app
|
||||||
|
// https://developer.apple.com/documentation/bundleresources/information-property-list/attributioncopyendpoint
|
||||||
|
// SKAdNetwork postback copies and Private Click Measurement: `NSAdvertisingAttributionReportEndpoint`.
|
||||||
|
// https://developer.apple.com/documentation/bundleresources/information-property-list/nsadvertisingattributionreportendpoint
|
||||||
|
// Attribution rules: `AdAttributionKitConfigurations` > `AttributionWindows` > `global` | <ad network id> > `install` >
|
||||||
|
// `click` (1-30 days) / `view` (1-7 days) / `ignoreInteractionType` (view | click; ad network entries only);
|
||||||
|
// `AttributionCooldown` > `install-cooldown-hours` / `re-engagement-cooldown-hours` (0-720).
|
||||||
|
// https://developer.apple.com/documentation/adattributionkit/configuring-attribution-rules-for-your-app
|
||||||
|
// AdAttributionKit needs no App Tracking Transparency prompt.
|
||||||
|
import type { PlistValue } from './plist';
|
||||||
|
|
||||||
|
export type AakLevel = 'blocker' | 'warn' | 'ok' | 'info';
|
||||||
|
export interface AakFinding { level: AakLevel; code: string; msg: string }
|
||||||
|
|
||||||
|
type Dict = { [k: string]: PlistValue };
|
||||||
|
const isDict = (v: PlistValue | undefined): v is Dict => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
const ID_RE = /^[a-z0-9]+\.(skadnetwork|adattributionkit)$/;
|
||||||
|
|
||||||
|
export interface SourceSignals {
|
||||||
|
/** `import AdAttributionKit` (or the framework linked). */
|
||||||
|
aakImport: boolean;
|
||||||
|
/** updateConversionValue / updatePostbackConversionValue calls (app code or an attribution SDK). */
|
||||||
|
conversionUpdates: boolean;
|
||||||
|
/** Ad SDKs found (Podfile.lock, Package.resolved, frameworks): these show ads, so the app is a publisher. */
|
||||||
|
adSdks: string[];
|
||||||
|
/** Mobile measurement SDKs that update conversion values for you (AppsFlyer, Adjust, Branch, Singular, Kochava). */
|
||||||
|
mmpSdks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AD_SDKS: [RegExp, string][] = [
|
||||||
|
[/Google-Mobile-Ads-SDK|GoogleMobileAds|swift-package-manager-google-mobile-ads/i, 'Google Mobile Ads (AdMob)'], [/AppLovinSDK|applovin-ios-sdk/i, 'AppLovin MAX'],
|
||||||
|
[/IronSourceSDK|IronSource|LevelPlay/i, 'Unity LevelPlay / ironSource'], [/UnityAds/i, 'Unity Ads'], [/FBAudienceNetwork/i, 'Meta Audience Network'],
|
||||||
|
[/Vungle|LiftoffMonetize/i, 'Liftoff Monetize'], [/InMobiSDK/i, 'InMobi'], [/MintegralAdSDK/i, 'Mintegral'], [/Pangle|Ads-Global|PAGAdSDK/i, 'Pangle'], [/ChartboostSDK/i, 'Chartboost'],
|
||||||
|
];
|
||||||
|
export const MMP_SDKS: [RegExp, string][] = [[/AppsFlyer/i, 'AppsFlyer'], [/\bAdjust(Sdk|Signature)?\b/i, 'Adjust'], [/\bBranch\b|BranchSDK/i, 'Branch'], [/Singular/i, 'Singular'], [/KochavaNetworking|KochavaTracker/i, 'Kochava']];
|
||||||
|
|
||||||
|
/** Ad / MMP SDK names and AdAttributionKit signals in dependency lock files and sources. Pure. */
|
||||||
|
export function sourceSignals(files: { path: string; text: string }[]): SourceSignals {
|
||||||
|
const deps = files.filter(f => /(Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(f.path)).map(f => f.text).join('\n');
|
||||||
|
const code = files.filter(f => /\.(swift|m|mm)$/.test(f.path)).map(f => f.text).join('\n');
|
||||||
|
return {
|
||||||
|
aakImport: /^\s*import\s+AdAttributionKit\b|@import\s+AdAttributionKit|AdAttributionKit\.framework/m.test(code + deps),
|
||||||
|
conversionUpdates: /\bupdateConversionValue\s*\(|\bupdatePostbackConversionValue\s*\(/.test(code),
|
||||||
|
adSdks: AD_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n),
|
||||||
|
mmpSdks: MMP_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUrl(v: PlistValue | undefined, key: string, f: AakFinding[]): string | null {
|
||||||
|
if (v === undefined) return null;
|
||||||
|
if (typeof v !== 'string' || !/^https:\/\/[^\s/]+(\/.*)?$/i.test(v.trim())) { f.push({ level: 'blocker', code: 'endpoint-url', msg: `${key}: "${String(v)}" is not an https URL like https://example.com` }); return null; }
|
||||||
|
const host = new URL(v.trim()).hostname;
|
||||||
|
if (new URL(v.trim()).pathname.replace(/\/$/, '')) f.push({ level: 'warn', code: 'endpoint-path', msg: `${key}: the path is ignored; the system only uses the domain (${host})` });
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registrable-ish domain (last two labels; good enough to compare two endpoints of one app). */
|
||||||
|
const domainOf = (host: string): string => host.split('.').slice(-2).join('.');
|
||||||
|
|
||||||
|
/** Every finding for one Info.plist (+ the source signals of the project). Pure. */
|
||||||
|
export function evaluateAdAttribution(plist: Dict, s: SourceSignals): AakFinding[] {
|
||||||
|
const f: AakFinding[] = [];
|
||||||
|
// ---- publisher: ad network ids ----
|
||||||
|
const skItems = plist.SKAdNetworkItems;
|
||||||
|
const skIds: string[] = [];
|
||||||
|
if (skItems !== undefined) {
|
||||||
|
if (!Array.isArray(skItems)) f.push({ level: 'blocker', code: 'skadnetwork-type', msg: 'SKAdNetworkItems must be an array of dictionaries with SKAdNetworkIdentifier' });
|
||||||
|
else for (const [i, it] of skItems.entries()) {
|
||||||
|
const id = isDict(it) ? it.SKAdNetworkIdentifier : undefined;
|
||||||
|
if (typeof id !== 'string') { f.push({ level: 'blocker', code: 'skadnetwork-entry', msg: `SKAdNetworkItems[${i}]: no SKAdNetworkIdentifier string` }); continue; }
|
||||||
|
skIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const aak = plist.AdNetworkIdentifiers;
|
||||||
|
const aakIds: string[] = [];
|
||||||
|
if (aak !== undefined) {
|
||||||
|
if (!Array.isArray(aak) || !aak.every(x => typeof x === 'string')) f.push({ level: 'blocker', code: 'aak-ids-type', msg: 'AdNetworkIdentifiers must be an array of strings' });
|
||||||
|
else aakIds.push(...(aak as string[]));
|
||||||
|
}
|
||||||
|
for (const [list, name] of [[skIds, 'SKAdNetworkItems'], [aakIds, 'AdNetworkIdentifiers']] as const) {
|
||||||
|
const upper = list.filter(id => id !== id.toLowerCase());
|
||||||
|
if (upper.length) f.push({ level: 'blocker', code: 'id-case', msg: `${name}: ${upper.length} id(s) with capital letters (${upper.slice(0, 3).join(', ')}): the system ignores them; ad network ids are lowercase` });
|
||||||
|
const odd = list.filter(id => id === id.toLowerCase() && !ID_RE.test(id));
|
||||||
|
if (odd.length) f.push({ level: 'warn', code: 'id-format', msg: `${name}: ${odd.slice(0, 3).join(', ')} do not look like ad network ids (<id>.skadnetwork / <id>.adattributionkit)` });
|
||||||
|
const dup = list.filter((id, i) => list.indexOf(id) !== i);
|
||||||
|
if (dup.length) f.push({ level: 'info', code: 'id-duplicate', msg: `${name}: listed twice: ${[...new Set(dup)].slice(0, 3).join(', ')}` });
|
||||||
|
}
|
||||||
|
if (s.adSdks.length && !skIds.length && !aakIds.length)
|
||||||
|
f.push({ level: 'blocker', code: 'no-network-ids', msg: `ad SDKs (${s.adSdks.join(', ')}) but no SKAdNetworkItems / AdNetworkIdentifiers: installs from those ads cannot be attributed; add each network's ids from its list` });
|
||||||
|
if (skIds.length && !aakIds.length)
|
||||||
|
f.push({ level: 'warn', code: 'aak-ids-missing', msg: `SKAdNetworkItems has ${skIds.length} id(s) but there is no AdNetworkIdentifiers: impressions that networks sign with AdAttributionKit are only eligible for networks in AdNetworkIdentifiers (Configuring a publisher app); the same ids work for both` });
|
||||||
|
else if (aakIds.length && skIds.length) {
|
||||||
|
const onlySk = skIds.filter(x => !aakIds.includes(x));
|
||||||
|
if (onlySk.length) f.push({ level: 'info', code: 'aak-ids-partial', msg: `${onlySk.length} id(s) only in SKAdNetworkItems (${onlySk.slice(0, 3).join(', ')}${onlySk.length > 3 ? ', ...' : ''}): add them to AdNetworkIdentifiers too if those networks use AdAttributionKit` });
|
||||||
|
}
|
||||||
|
if (skIds.length || aakIds.length) f.push({ level: 'ok', code: 'network-ids', msg: `${new Set([...skIds, ...aakIds]).size} ad network id(s) (SKAdNetworkItems ${skIds.length}, AdNetworkIdentifiers ${aakIds.length})` });
|
||||||
|
|
||||||
|
// ---- advertised app: postback copies ----
|
||||||
|
const group = isDict(plist.AdAttributionKit) ? plist.AdAttributionKit : undefined;
|
||||||
|
const copyRaw = group?.AttributionCopyEndpoint ?? plist.AttributionCopyEndpoint;
|
||||||
|
const reRaw = group?.EligibleForAdAttributionKitReengagementPostbackCopies ?? plist.EligibleForAdAttributionKitReengagementPostbackCopies;
|
||||||
|
const copyHost = checkUrl(copyRaw, 'AttributionCopyEndpoint', f);
|
||||||
|
if (copyHost) f.push({ level: 'ok', code: 'copy-endpoint', msg: `AdAttributionKit postback copies go to https://${domainOf(copyHost)}/.well-known/appattribution/report-attribution/ (needs a valid TLS certificate and to accept HTTPS POST)` });
|
||||||
|
if (reRaw !== undefined && typeof reRaw !== 'boolean') f.push({ level: 'blocker', code: 'reengagement-type', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies must be a Boolean (YES)' });
|
||||||
|
if (reRaw === true && !copyHost) f.push({ level: 'warn', code: 'reengagement-without-endpoint', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies is on but there is no AttributionCopyEndpoint: no copy can be sent' });
|
||||||
|
const skHost = checkUrl(plist.NSAdvertisingAttributionReportEndpoint, 'NSAdvertisingAttributionReportEndpoint', f);
|
||||||
|
if (skHost) f.push({ level: 'ok', code: 'skan-endpoint', msg: `SKAdNetwork postback copies (and Private Click Measurement) go to https://${domainOf(skHost)}/.well-known/...` });
|
||||||
|
if (copyHost && skHost && domainOf(copyHost) !== domainOf(skHost)) f.push({ level: 'info', code: 'endpoint-domains', msg: `AdAttributionKit copies go to ${domainOf(copyHost)}, SKAdNetwork copies to ${domainOf(skHost)}` });
|
||||||
|
if (skHost && !copyHost && (s.aakImport || aakIds.length)) f.push({ level: 'info', code: 'copy-endpoint-missing', msg: 'SKAdNetwork postback copies are set up but AdAttributionKit ones are not: add AttributionCopyEndpoint for AdAttributionKit copies' });
|
||||||
|
if ((copyHost || s.aakImport) && !s.conversionUpdates && !s.mmpSdks.length)
|
||||||
|
f.push({ level: 'warn', code: 'no-conversion-update', msg: 'no updateConversionValue / updatePostbackConversionValue call and no attribution SDK found: the advertised app must update the conversion value at least once (best at first launch) to start the conversion window' });
|
||||||
|
|
||||||
|
// ---- attribution rules ----
|
||||||
|
const conf = plist.AdAttributionKitConfigurations;
|
||||||
|
if (conf !== undefined) {
|
||||||
|
if (!isDict(conf)) f.push({ level: 'blocker', code: 'config-type', msg: 'AdAttributionKitConfigurations must be a dictionary' });
|
||||||
|
else {
|
||||||
|
const known = new Set([...skIds, ...aakIds]);
|
||||||
|
const windows = conf.AttributionWindows;
|
||||||
|
if (windows !== undefined && !isDict(windows)) f.push({ level: 'blocker', code: 'windows-type', msg: 'AttributionWindows must be a dictionary' });
|
||||||
|
if (isDict(windows)) for (const [who, cfg] of Object.entries(windows)) {
|
||||||
|
const at = `AttributionWindows.${who}`;
|
||||||
|
if (who !== 'global') {
|
||||||
|
if (who !== who.toLowerCase()) f.push({ level: 'blocker', code: 'windows-id-case', msg: `${at}: ad network ids are lowercase` });
|
||||||
|
else if (known.size && !known.has(who)) f.push({ level: 'warn', code: 'windows-unknown-network', msg: `${at}: not in AdNetworkIdentifiers / SKAdNetworkItems` });
|
||||||
|
}
|
||||||
|
const install = isDict(cfg) ? cfg.install : undefined;
|
||||||
|
if (!isDict(install)) { f.push({ level: 'warn', code: 'windows-install', msg: `${at}: expected an "install" dictionary` }); continue; }
|
||||||
|
for (const [k, v] of Object.entries(install)) {
|
||||||
|
if (k === 'click' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 30)) f.push({ level: 'blocker', code: 'window-click', msg: `${at}.install.click: whole days 1-30` });
|
||||||
|
else if (k === 'view' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 7)) f.push({ level: 'blocker', code: 'window-view', msg: `${at}.install.view: whole days 1-7` });
|
||||||
|
else if (k === 'ignoreInteractionType') {
|
||||||
|
if (v !== 'view' && v !== 'click') f.push({ level: 'blocker', code: 'ignore-type', msg: `${at}.install.ignoreInteractionType: view | click` });
|
||||||
|
if (who === 'global') f.push({ level: 'warn', code: 'ignore-global', msg: `${at}.install.ignoreInteractionType: only read in ad network entries, not in global` });
|
||||||
|
} else if (!['click', 'view', 'ignoreInteractionType'].includes(k)) f.push({ level: 'info', code: 'window-key', msg: `${at}.install.${k}: not a documented key` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cool = conf.AttributionCooldown;
|
||||||
|
if (cool !== undefined) {
|
||||||
|
if (!isDict(cool)) f.push({ level: 'blocker', code: 'cooldown-type', msg: 'AttributionCooldown must be a dictionary' });
|
||||||
|
else for (const [k, v] of Object.entries(cool)) {
|
||||||
|
if (k !== 'install-cooldown-hours' && k !== 're-engagement-cooldown-hours') f.push({ level: 'info', code: 'cooldown-key', msg: `AttributionCooldown.${k}: not a documented key` });
|
||||||
|
else if (!(typeof v === 'number' && v >= 0 && v <= 720)) f.push({ level: 'blocker', code: 'cooldown-range', msg: `AttributionCooldown.${k}: hours 0-720` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(conf)) if (!['AttributionWindows', 'AttributionCooldown'].includes(k)) f.push({ level: 'info', code: 'config-key', msg: `AdAttributionKitConfigurations.${k}: not a documented key` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skIds.length && !aakIds.length && !copyHost && !skHost && conf === undefined && !s.aakImport && !s.adSdks.length)
|
||||||
|
f.push({ level: 'info', code: 'none', msg: 'no ad attribution setup found (fine for an app without ads and without ad campaigns)' });
|
||||||
|
if (typeof plist.NSUserTrackingUsageDescription === 'string' && !s.adSdks.length && !s.mmpSdks.length)
|
||||||
|
f.push({ level: 'info', code: 'att-only-for-aak', msg: 'NSUserTrackingUsageDescription is set; AdAttributionKit and SKAdNetwork need no ATT prompt (keep it only if you track across apps)' });
|
||||||
|
return f;
|
||||||
|
}
|
||||||
63
cli/lib/aso-rank.ts
Normal file
63
cli/lib/aso-rank.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// App Store keyword rank check against Apple's public iTunes Search API, as pure parts. The command (cli/aso.ts rank)
|
||||||
|
// sends the queries one by one, slower than Apple's limit, and stores a snapshot in .storebound/aso/.
|
||||||
|
// Source: Apple Search API (Performance Partners), checked October 2026:
|
||||||
|
// https://itunes.apple.com/search?term=<text>&country=<2-letter>&media=software&entity=software&limit=1..200 (default 50)
|
||||||
|
// "approximately 20 calls per minute (subject to change)". https://performance-partners.apple.com/search-api
|
||||||
|
// What this is and is not: the order of the Search API results is close to, but not the same as, App Store search
|
||||||
|
// (no personalisation, no search ads, no "in-app events" rows), so read ranks as a trend, not a promise. The API returns
|
||||||
|
// title (trackName), developer (sellerName), bundleId, rating and description, but no subtitle: competitor subtitles
|
||||||
|
// are not available through a public API, so they are not shown (storebound scrapes nothing).
|
||||||
|
// Result fields used (as the API returns them; Apple's table lists only the general ones): trackId, trackName,
|
||||||
|
// bundleId, sellerName, averageUserRating, userRatingCount, primaryGenreName.
|
||||||
|
|
||||||
|
export const SEARCH_API = 'https://itunes.apple.com/search';
|
||||||
|
/** Apple: about 20 calls per minute. One call every 3.5 s stays below it with room for retries. */
|
||||||
|
export const MIN_DELAY_MS = 3_500;
|
||||||
|
export const MAX_RESULTS = 200;
|
||||||
|
|
||||||
|
export interface SearchApp { trackId: number; trackName: string; bundleId?: string; sellerName?: string; averageUserRating?: number; userRatingCount?: number; primaryGenreName?: string }
|
||||||
|
|
||||||
|
export function searchUrl(term: string, country: string, limit = MAX_RESULTS): string {
|
||||||
|
if (!/^[a-z]{2}$/i.test(country)) throw new Error(`storefront "${country}": a two-letter country code (us, de, tr)`);
|
||||||
|
return `${SEARCH_API}?${new URLSearchParams({ term, country: country.toLowerCase(), media: 'software', entity: 'software', limit: String(Math.min(MAX_RESULTS, Math.max(1, limit))) })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keywords of one App Store locale's keyword field ("a,b,c"), trimmed and de-duplicated, plus extra terms. Pure. */
|
||||||
|
export function keywordList(field: string | undefined, extra: string[] = []): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const k of [...(field ?? '').split(','), ...extra].map(s => s.trim()).filter(Boolean)) if (!out.some(x => x.toLowerCase() === k.toLowerCase())) out.push(k);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "us=en-US,de=de-DE" or "us,de" -> storefront + listing locale (default locale for the bare ones). Pure. */
|
||||||
|
export function parseStorefronts(spec: string[], defaultLocale: string): { storefront: string; locale: string }[] {
|
||||||
|
const list = spec.length ? spec : ['us'];
|
||||||
|
return list.map(s => { const [sf, loc] = s.split('='); return { storefront: sf!.trim().toLowerCase(), locale: (loc ?? defaultLocale).trim() }; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based rank of the app in the results (by Apple ID, else bundle id), or null when not in the first N. Pure. */
|
||||||
|
export function rankOf(results: SearchApp[], app: { appId?: string; bundleId?: string }): number | null {
|
||||||
|
const i = results.findIndex(r => (app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId));
|
||||||
|
return i >= 0 ? i + 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Competitor { rank: number; title: string; developer: string; rating: number | null; ratings: number; genre: string }
|
||||||
|
|
||||||
|
/** The first n results that are not the app itself. Pure. */
|
||||||
|
export function competitors(results: SearchApp[], app: { appId?: string; bundleId?: string }, n = 3): Competitor[] {
|
||||||
|
return results.map((r, i) => ({ r, i })).filter(({ r }) => !((app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId))).slice(0, n)
|
||||||
|
.map(({ r, i }) => ({ rank: i + 1, title: r.trackName, developer: r.sellerName ?? '', rating: r.averageUserRating ?? null, ratings: r.userRatingCount ?? 0, genre: r.primaryGenreName ?? '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankRow { storefront: string; locale: string; keyword: string; rank: number | null; results: number; competitors: Competitor[] }
|
||||||
|
|
||||||
|
/** Rank change between two snapshots: positive = moved up. Pure. */
|
||||||
|
export function rankDelta(now: RankRow[], before: RankRow[] | undefined): Map<string, number | null> {
|
||||||
|
const prev = new Map((before ?? []).map(r => [`${r.storefront}|${r.keyword.toLowerCase()}`, r.rank] as const));
|
||||||
|
const out = new Map<string, number | null>();
|
||||||
|
for (const r of now) {
|
||||||
|
const p = prev.get(`${r.storefront}|${r.keyword.toLowerCase()}`);
|
||||||
|
out.set(`${r.storefront}|${r.keyword.toLowerCase()}`, p === undefined || p === null || r.rank === null ? null : p - r.rank);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
89
cli/lib/build-ops.ts
Normal file
89
cli/lib/build-ops.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// TestFlight build housekeeping as pure parts: waiting for an uploaded build to finish processing, and expiring old
|
||||||
|
// builds. The command (cli/apple/testflight.ts wait | expire) reads, calls these and prints / sends.
|
||||||
|
// API (App Store Connect API 4.5.1 OpenAPI spec and docs, checked October 2026):
|
||||||
|
// GET /v1/builds filter[app], filter[version], filter[expired], filter[preReleaseVersion.platform], sort=-uploadedDate,
|
||||||
|
// include=preReleaseVersion,buildBetaDetail,appStoreVersion
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds
|
||||||
|
// Build.processingState: PROCESSING | FAILED | INVALID | VALID
|
||||||
|
// PATCH /v1/builds/{id} { expired: true } "Expire a build or change its encryption exemption setting."
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_
|
||||||
|
// Expiring is permanent: testers lose the build, it cannot be un-expired. So `expire` is a dry run first, then a
|
||||||
|
// terminal confirmation; it is not an MCP tool.
|
||||||
|
import type { Req } from '../apple/jsonapi';
|
||||||
|
|
||||||
|
export type WaitOutcome = 'missing' | 'processing' | 'valid' | 'failed';
|
||||||
|
|
||||||
|
export interface BuildSeen { found: boolean; processingState?: string; internal?: string | null; external?: string | null }
|
||||||
|
|
||||||
|
/** One poll result -> what it means for the waiter. A build that is not listed yet is normal for some minutes after
|
||||||
|
* the upload (App Store Connect lists it once processing starts). */
|
||||||
|
export function waitOutcome(b: BuildSeen): WaitOutcome {
|
||||||
|
if (!b.found) return 'missing';
|
||||||
|
if (b.processingState === 'VALID') return 'valid';
|
||||||
|
if (b.processingState === 'FAILED' || b.processingState === 'INVALID') return 'failed';
|
||||||
|
return 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll delay: 20 s at first, x1.5 per poll, at most 2 minutes (processing usually takes 5 to 30 minutes). */
|
||||||
|
export function waitDelay(prevMs: number | null): number {
|
||||||
|
return prevMs === null ? 20_000 : Math.min(Math.round(prevMs * 1.5), 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exit codes of `testflight wait`: 0 VALID, 1 FAILED / INVALID, 7 timeout (the same as `apple status --wait-review`). */
|
||||||
|
export const BUILD_WAIT_EXIT = { valid: 0, failed: 1, timeout: 7 } as const;
|
||||||
|
|
||||||
|
/** What the build state means for the next step, one line. */
|
||||||
|
export function validHint(b: BuildSeen): string {
|
||||||
|
if (b.internal === 'MISSING_EXPORT_COMPLIANCE' || b.external === 'MISSING_EXPORT_COMPLIANCE')
|
||||||
|
return 'export compliance missing: storebound apple encryption --build N (from apple.usesNonExemptEncryption)';
|
||||||
|
return 'ready: storebound apple testflight distribute --build N --group <name>, or apple listing --build N';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpireCandidate {
|
||||||
|
id: string; build: string; version?: string; uploaded: string; processingState?: string;
|
||||||
|
/** Attached to an App Store version (in review, live or being prepared): never expired by storebound. */
|
||||||
|
attached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpirePlan { expire: ExpireCandidate[]; keep: ExpireCandidate[]; protected: { b: ExpireCandidate; why: string }[]; reqs: Req[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which builds to expire. `builds` = the app's builds that are not expired yet (any order). keep = keep the newest N
|
||||||
|
* builds (by upload date; protected builds count as kept), olderThanDays = only builds uploaded before now - D days.
|
||||||
|
* With both, a build must be beyond the newest N AND older than D days. Builds attached to an App Store version and
|
||||||
|
* builds still processing are never expired. Pure.
|
||||||
|
*/
|
||||||
|
export function planExpire(builds: ExpireCandidate[], o: { keep?: number; olderThanDays?: number; now?: number }): ExpirePlan {
|
||||||
|
if (o.keep === undefined && o.olderThanDays === undefined) throw new Error('--keep <n> or --older-than <days> (or both)');
|
||||||
|
if (o.keep !== undefined && !(Number.isInteger(o.keep) && o.keep >= 1)) throw new Error('--keep: a whole number >= 1 (the newest build always stays)');
|
||||||
|
if (o.olderThanDays !== undefined && !(o.olderThanDays >= 1)) throw new Error('--older-than: days >= 1');
|
||||||
|
const now = o.now ?? Date.now();
|
||||||
|
const sorted = [...builds].sort((a, b) => b.uploaded.localeCompare(a.uploaded));
|
||||||
|
const plan: ExpirePlan = { expire: [], keep: [], protected: [], reqs: [] };
|
||||||
|
sorted.forEach((b, i) => {
|
||||||
|
if (b.attached) { plan.protected.push({ b, why: 'attached to an App Store version' }); return; }
|
||||||
|
if (b.processingState && b.processingState !== 'VALID' && b.processingState !== 'FAILED' && b.processingState !== 'INVALID') { plan.protected.push({ b, why: `still ${b.processingState}` }); return; }
|
||||||
|
if (i === 0) { plan.keep.push(b); return; }
|
||||||
|
const beyondKeep = o.keep === undefined || i >= o.keep;
|
||||||
|
const old = o.olderThanDays === undefined || now - Date.parse(b.uploaded) > o.olderThanDays * 86_400_000;
|
||||||
|
(beyondKeep && old ? plan.expire : plan.keep).push(b);
|
||||||
|
});
|
||||||
|
plan.reqs = plan.expire.map(b => ({ method: 'PATCH', path: `/v1/builds/${b.id}`, note: `expire build ${b.build}${b.version ? ` (${b.version})` : ''}, uploaded ${b.uploaded.slice(0, 10)}`,
|
||||||
|
body: { data: { type: 'builds', id: b.id, attributes: { expired: true } } } }));
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds response (include=preReleaseVersion,appStoreVersion) -> expire candidates. Pure. */
|
||||||
|
export function expireCandidates(pages: { data?: { id: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data?: { id: string } | null }> }[]; included?: { type: string; id: string; attributes?: { version?: string } }[] }[]): ExpireCandidate[] {
|
||||||
|
const out: ExpireCandidate[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
const pre = new Map((page.included ?? []).filter(x => x.type === 'preReleaseVersions').map(x => [x.id, x.attributes?.version] as const));
|
||||||
|
for (const b of page.data ?? []) {
|
||||||
|
const a = b.attributes ?? {};
|
||||||
|
if (a.expired === true) continue;
|
||||||
|
out.push({ id: b.id, build: String(a.version ?? '?'), version: pre.get(b.relationships?.preReleaseVersion?.data?.id ?? ''), uploaded: String(a.uploadedDate ?? ''),
|
||||||
|
processingState: a.processingState as string | undefined, attached: !!b.relationships?.appStoreVersion?.data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||||
import { validateCapture, validateRecord } from '../shots/devices';
|
import { validateCapture, validateRecord } from '../shots/devices';
|
||||||
|
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
||||||
|
import { type IapOfferCodeDef, type PromotedConfig, validateIapOfferCodes, validatePromoted, validateWinBack, type WinBackDef } from './offers';
|
||||||
import { validateSubscriptions } from './subscriptions';
|
import { validateSubscriptions } from './subscriptions';
|
||||||
import { type TfConfig, validateTestflight } from './testflight';
|
import { type TfConfig, validateTestflight } from './testflight';
|
||||||
|
|
||||||
|
|
@ -46,6 +48,8 @@ export interface Product {
|
||||||
familySharable?: boolean;
|
familySharable?: boolean;
|
||||||
/** Sell this product only on these platforms (default: all of app.platforms). */
|
/** Sell this product only on these platforms (default: all of app.platforms). */
|
||||||
platforms?: Platform[];
|
platforms?: Platform[];
|
||||||
|
/** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */
|
||||||
|
offerCodes?: IapOfferCodeDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Language {
|
export interface Language {
|
||||||
|
|
@ -394,6 +398,8 @@ export interface Subscription {
|
||||||
basePlanId?: string;
|
basePlanId?: string;
|
||||||
/** Play grace period (ISO 8601: P3D, P7D, P14D, P30D). Default: Play's default. */
|
/** Play grace period (ISO 8601: P3D, P7D, P14D, P30D). Default: Play's default. */
|
||||||
gracePeriod?: string;
|
gracePeriod?: string;
|
||||||
|
/** Apple win-back offers for lapsed subscribers (`storebound apple win-back`; the subscription must be approved). */
|
||||||
|
winBack?: WinBackDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One subscription group. Apple: a subscription group (people can hold one subscription per group and move between
|
/** One subscription group. Apple: a subscription group (people can hold one subscription per group and move between
|
||||||
|
|
@ -458,6 +464,12 @@ export interface ShipConfig {
|
||||||
iphoneMedium?: boolean;
|
iphoneMedium?: boolean;
|
||||||
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
||||||
testflight?: TfConfig;
|
testflight?: TfConfig;
|
||||||
|
/** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */
|
||||||
|
promoted?: PromotedConfig;
|
||||||
|
/** Custom product pages (`storebound apple cpp`; at most 70). */
|
||||||
|
customProductPages?: CustomProductPageDef[];
|
||||||
|
/** In-app events (`storebound apple events`). */
|
||||||
|
events?: AppEventDef[];
|
||||||
};
|
};
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
/** Auto-renewable subscriptions (`storebound apple subscriptions`, `storebound play subscriptions`). */
|
/** Auto-renewable subscriptions (`storebound apple subscriptions`, `storebound play subscriptions`). */
|
||||||
|
|
@ -548,6 +560,10 @@ export function validateConfig(c: unknown): string[] {
|
||||||
if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false');
|
if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false');
|
||||||
if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67');
|
if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67');
|
||||||
e.push(...validateTestflight(cfg.apple.testflight));
|
e.push(...validateTestflight(cfg.apple.testflight));
|
||||||
|
const catalogIds = new Set([...(cfg.products ?? []).map(p => p.id), ...(cfg.subscriptions ?? []).flatMap(g => (g.subscriptions ?? []).map(x => x.id))]);
|
||||||
|
e.push(...validatePromoted(cfg.apple.promoted, catalogIds));
|
||||||
|
e.push(...validateCpp(cfg.apple.customProductPages, ids, Object.keys(cfg.shots?.variants ?? {})));
|
||||||
|
e.push(...validateEvents(cfg.apple.events, ids));
|
||||||
}
|
}
|
||||||
if (cfg.ads?.appAdsTxt && !cfg.ads.appAdsTxt.every(l => typeof l === 'string' && l.split(',').length >= 3))
|
if (cfg.ads?.appAdsTxt && !cfg.ads.appAdsTxt.every(l => typeof l === 'string' && l.split(',').length >= 3))
|
||||||
e.push('ads.appAdsTxt: lines like "example-network.com, pub-123, DIRECT, abc123"');
|
e.push('ads.appAdsTxt: lines like "example-network.com, pub-123, DIRECT, abc123"');
|
||||||
|
|
@ -567,6 +583,7 @@ export function validateConfig(c: unknown): string[] {
|
||||||
if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`);
|
if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`);
|
||||||
if (!l.description) e.push(`${at}.listings.${lang}.description: missing`);
|
if (!l.description) e.push(`${at}.listings.${lang}.description: missing`);
|
||||||
}
|
}
|
||||||
|
e.push(...validateIapOfferCodes(at, p.offerCodes, p.type));
|
||||||
for (const o of p.overrides ?? []) {
|
for (const o of p.overrides ?? []) {
|
||||||
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);
|
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);
|
||||||
if (!/^[A-Z]{3}$/.test(o.currency)) e.push(`${at}.overrides: currency "${o.currency}" must be ISO 4217 (TRY)`);
|
if (!/^[A-Z]{3}$/.test(o.currency)) e.push(`${at}.overrides: currency "${o.currency}" must be ISO 4217 (TRY)`);
|
||||||
|
|
@ -574,6 +591,7 @@ export function validateConfig(c: unknown): string[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.push(...validateSubscriptions(cfg.subscriptions, ids, seen));
|
e.push(...validateSubscriptions(cfg.subscriptions, ids, seen));
|
||||||
|
for (const [gi, g] of (cfg.subscriptions ?? []).entries()) for (const [si, sub] of (g.subscriptions ?? []).entries()) e.push(...validateWinBack(`subscriptions[${gi}].subscriptions[${si}]`, sub.winBack));
|
||||||
if (cfg.prices?.baseTerritory && !/^[A-Z]{3}$/.test(cfg.prices.baseTerritory)) e.push('prices.baseTerritory: App Store territory, alpha-3 (USA)');
|
if (cfg.prices?.baseTerritory && !/^[A-Z]{3}$/.test(cfg.prices.baseTerritory)) e.push('prices.baseTerritory: App Store territory, alpha-3 (USA)');
|
||||||
if (cfg.dataSafety) {
|
if (cfg.dataSafety) {
|
||||||
for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) {
|
for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) {
|
||||||
|
|
|
||||||
202
cli/lib/cpp-events.ts
Normal file
202
cli/lib/cpp-events.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// App Store custom product pages and in-app events, as pure parts: config checks, the create requests and what to
|
||||||
|
// change. The commands (cli/apple/cpp.ts, cli/apple/events.ts) read, plan, show the dry run and send. Creating and
|
||||||
|
// uploading change nothing customers see; sending a page or an event to App Review, and making a page visible, are
|
||||||
|
// the owner's (terminal, asks first, not MCP tools).
|
||||||
|
// Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026):
|
||||||
|
// Custom product pages: POST /v1/appCustomProductPages (name, app, optional appStoreVersionTemplate /
|
||||||
|
// customProductPageTemplate, inline appCustomProductPageVersions + appCustomProductPageLocalizations with locale and
|
||||||
|
// promotionalText; Apple's example request), PATCH /v1/appCustomProductPages/{id} (name, visible),
|
||||||
|
// POST /v1/appCustomProductPageVersions (deepLink), POST /v1/appCustomProductPageLocalizations (locale,
|
||||||
|
// promotionalText), screenshots: appScreenshotSets with the appCustomProductPageLocalization relationship.
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-appcustomproductpages
|
||||||
|
// Help: up to 70 custom product pages per app; each needs App Review (independent of an app update); the deep link
|
||||||
|
// works from iOS / iPadOS 18 once approved. https://developer.apple.com/app-store/custom-product-pages/
|
||||||
|
// In-app events: POST /v1/appEvents (referenceName, badge, deepLink, purchaseRequirement, primaryLocale, priority,
|
||||||
|
// purpose, territorySchedules [{ territories, publishStart, eventStart, eventEnd }]), POST /v1/appEventLocalizations
|
||||||
|
// (locale, name, shortDescription, longDescription), POST /v1/appEventScreenshots (fileName, fileSize,
|
||||||
|
// appEventAssetType EVENT_CARD | EVENT_DETAILS_PAGE) + the upload operations.
|
||||||
|
// Help "Offer In-App Events": reference name up to 64 characters, event name 30, short description 50, long
|
||||||
|
// description 120; an event lasts 15 minutes to 31 days; it can be published at most 14 days before it starts;
|
||||||
|
// up to 10 published and 15 approved events per app; events need App Review.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/offer-in-app-events/offer-in-app-events
|
||||||
|
// Media: event card 16:9, 1920x1080 to 3840x2160; details page 9:16, 1080x1920 to 2160x3840; .jpg / .png.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/reference/in-app-events/in-app-event-media-and-audio-specifications
|
||||||
|
// Sending either to review: a review submission item with appCustomProductPageVersion | appEvent
|
||||||
|
// (https://developer.apple.com/documentation/appstoreconnectapi/review-submissions).
|
||||||
|
// Deprecation note (API 4.5.1): screenshot sets and app event screenshots are deprecated in favour of the App Asset
|
||||||
|
// Library; they still work and storebound uses them until the asset library is the only way.
|
||||||
|
import type { Req } from '../apple/jsonapi';
|
||||||
|
import { one } from '../apple/jsonapi';
|
||||||
|
import type { Language } from './config';
|
||||||
|
|
||||||
|
export const CPP_LIMITS = { pages: 70, promotionalText: 170 } as const;
|
||||||
|
export const EVENT_LIMITS = { referenceName: 64, name: 30, shortDescription: 50, longDescription: 120, published: 10, approved: 15, maxDays: 31, minMinutes: 15, publishLeadDays: 14 } as const;
|
||||||
|
export const EVENT_BADGES = ['LIVE_EVENT', 'PREMIERE', 'CHALLENGE', 'COMPETITION', 'NEW_SEASON', 'MAJOR_UPDATE', 'SPECIAL_EVENT'] as const;
|
||||||
|
export const EVENT_PURPOSES = ['APPROPRIATE_FOR_ALL_USERS', 'ATTRACT_NEW_USERS', 'KEEP_ACTIVE_USERS_INFORMED', 'BRING_BACK_LAPSED_USERS'] as const;
|
||||||
|
/** Event media: [min w, min h, max w, max h]. */
|
||||||
|
export const EVENT_MEDIA = { EVENT_CARD: [1920, 1080, 3840, 2160], EVENT_DETAILS_PAGE: [1080, 1920, 2160, 3840] } as const;
|
||||||
|
|
||||||
|
export interface CustomProductPageDef {
|
||||||
|
/** Page name (unique per app; shown only in App Store Connect). */
|
||||||
|
name: string;
|
||||||
|
/** Screenshots from this shots variant (`storebound shots render --variant <id>`); default: the base renders. */
|
||||||
|
variant?: string;
|
||||||
|
/** Universal link or custom URL that "Open" goes to (iOS / iPadOS 18+, after approval). */
|
||||||
|
deepLink?: string;
|
||||||
|
/** Language id -> promotional text (170). Every language with an App Store locale gets a localization. */
|
||||||
|
promotionalText?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppEventDef {
|
||||||
|
/** Reference name (unique per app, up to 64 characters). */
|
||||||
|
referenceName: string;
|
||||||
|
badge: typeof EVENT_BADGES[number];
|
||||||
|
purpose?: typeof EVENT_PURPOSES[number];
|
||||||
|
priority?: 'high' | 'normal';
|
||||||
|
deepLink?: string;
|
||||||
|
/** Text shown when the event needs a purchase or subscription, e.g. "Pro subscription". */
|
||||||
|
purchaseRequirement?: string;
|
||||||
|
/** Language id of the primary locale (default: the first language with an App Store locale). */
|
||||||
|
primaryLanguage?: string;
|
||||||
|
/** When and where: ISO date-times; territories (alpha-3) default to every territory of the app. */
|
||||||
|
schedule: { publishStart: string; eventStart: string; eventEnd: string; territories?: string[] };
|
||||||
|
/** Language id -> name (30), shortDescription (50), longDescription (120). */
|
||||||
|
listings: Record<string, { name: string; shortDescription: string; longDescription: string }>;
|
||||||
|
/** Images (paths from the project root; "{lang}" is replaced by the language id): card 16:9, details 9:16. */
|
||||||
|
media?: { card?: string; details?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = (s: string): number => [...s].length;
|
||||||
|
|
||||||
|
export function validateCpp(pages: CustomProductPageDef[] | undefined, langIds: Set<string>, variants: string[]): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
if ((pages ?? []).length > CPP_LIMITS.pages) e.push(`apple.customProductPages: at most ${CPP_LIMITS.pages} per app`);
|
||||||
|
for (const [i, p] of (pages ?? []).entries()) {
|
||||||
|
const at = `apple.customProductPages[${i}]`;
|
||||||
|
if (!p.name) e.push(`${at}.name: missing`);
|
||||||
|
if (names.has(p.name)) e.push(`${at}.name: duplicate "${p.name}"`);
|
||||||
|
names.add(p.name);
|
||||||
|
if (p.variant && !variants.includes(p.variant)) e.push(`${at}.variant: "${p.variant}" is not in shots.variants`);
|
||||||
|
if (p.deepLink && !/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(p.deepLink)) e.push(`${at}.deepLink: a universal link (https://...) or custom URL (myapp://...)`);
|
||||||
|
for (const [l, t] of Object.entries(p.promotionalText ?? {})) {
|
||||||
|
if (!langIds.has(l)) e.push(`${at}.promotionalText.${l}: not in languages`);
|
||||||
|
if (len(t) > CPP_LIMITS.promotionalText) e.push(`${at}.promotionalText.${l}: ${len(t)} > ${CPP_LIMITS.promotionalText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEvents(events: AppEventDef[] | undefined, langIds: Set<string>): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const [i, ev] of (events ?? []).entries()) {
|
||||||
|
const at = `apple.events[${i}]`;
|
||||||
|
if (!ev.referenceName || len(ev.referenceName) > EVENT_LIMITS.referenceName) e.push(`${at}.referenceName: 1-${EVENT_LIMITS.referenceName} characters`);
|
||||||
|
if (names.has(ev.referenceName)) e.push(`${at}.referenceName: duplicate`);
|
||||||
|
names.add(ev.referenceName);
|
||||||
|
if (!EVENT_BADGES.includes(ev.badge)) e.push(`${at}.badge: ${EVENT_BADGES.join(' | ')}`);
|
||||||
|
if (ev.purpose && !EVENT_PURPOSES.includes(ev.purpose)) e.push(`${at}.purpose: ${EVENT_PURPOSES.join(' | ')}`);
|
||||||
|
if (ev.priority && ev.priority !== 'high' && ev.priority !== 'normal') e.push(`${at}.priority: high | normal`);
|
||||||
|
if (ev.primaryLanguage && !langIds.has(ev.primaryLanguage)) e.push(`${at}.primaryLanguage: not in languages`);
|
||||||
|
const s = ev.schedule;
|
||||||
|
const t = (x: string | undefined) => (x ? Date.parse(x) : NaN);
|
||||||
|
if (!s || [s.publishStart, s.eventStart, s.eventEnd].some(x => Number.isNaN(t(x)))) e.push(`${at}.schedule: publishStart, eventStart, eventEnd as ISO date-times`);
|
||||||
|
else {
|
||||||
|
const dur = t(s.eventEnd) - t(s.eventStart);
|
||||||
|
if (dur < EVENT_LIMITS.minMinutes * 60_000) e.push(`${at}.schedule: an event lasts at least ${EVENT_LIMITS.minMinutes} minutes`);
|
||||||
|
if (dur > EVENT_LIMITS.maxDays * 86_400_000) e.push(`${at}.schedule: an event lasts at most ${EVENT_LIMITS.maxDays} days`);
|
||||||
|
if (t(s.publishStart) > t(s.eventStart)) e.push(`${at}.schedule: publishStart after eventStart`);
|
||||||
|
if (t(s.eventStart) - t(s.publishStart) > EVENT_LIMITS.publishLeadDays * 86_400_000) e.push(`${at}.schedule: published at most ${EVENT_LIMITS.publishLeadDays} days before it starts`);
|
||||||
|
if (s.territories && !s.territories.every(x => /^[A-Z]{3}$/.test(x))) e.push(`${at}.schedule.territories: alpha-3 (USA)`);
|
||||||
|
}
|
||||||
|
if (!ev.listings || !Object.keys(ev.listings).length) e.push(`${at}.listings: at least one language`);
|
||||||
|
for (const [l, x] of Object.entries(ev.listings ?? {})) {
|
||||||
|
if (!langIds.has(l)) e.push(`${at}.listings.${l}: not in languages`);
|
||||||
|
for (const k of ['name', 'shortDescription', 'longDescription'] as const) {
|
||||||
|
if (!x[k]) e.push(`${at}.listings.${l}.${k}: missing`);
|
||||||
|
else if (len(x[k]) > EVENT_LIMITS[k]) e.push(`${at}.listings.${l}.${k}: ${len(x[k])} > ${EVENT_LIMITS[k]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/appCustomProductPages with its first version and one localization per locale (Apple's example shape).
|
||||||
|
* templateVersionId copies the screenshots / previews of an App Store version (optional). */
|
||||||
|
export function cppCreateBody(appId: string, p: CustomProductPageDef, locales: { locale: string; promotionalText?: string }[], templateVersionId?: string) {
|
||||||
|
const locIds = locales.map((_, i) => `\${cpp-loc-${i}}`);
|
||||||
|
return {
|
||||||
|
data: { type: 'appCustomProductPages', attributes: { name: p.name }, relationships: {
|
||||||
|
app: one('apps', appId), ...(templateVersionId ? { appStoreVersionTemplate: one('appStoreVersions', templateVersionId) } : {}),
|
||||||
|
appCustomProductPageVersions: { data: [{ type: 'appCustomProductPageVersions', id: '${cpp-version}' }] } } },
|
||||||
|
included: [
|
||||||
|
{ type: 'appCustomProductPageVersions', id: '${cpp-version}', ...(p.deepLink ? { attributes: { deepLink: p.deepLink } } : {}),
|
||||||
|
relationships: { appCustomProductPageLocalizations: { data: locIds.map(id => ({ type: 'appCustomProductPageLocalizations', id })) } } },
|
||||||
|
...locales.map((l, i) => ({ type: 'appCustomProductPageLocalizations', id: locIds[i], attributes: { locale: l.locale, ...(l.promotionalText ? { promotionalText: l.promotionalText } : {}) } })),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localizations of an editable page version to add (POST) or change (PATCH promotional text). Pure. */
|
||||||
|
export function planCppLocales(versionId: string, have: { id: string; locale: string; promotionalText?: string | null }[], want: { locale: string; promotionalText?: string }[]): Req[] {
|
||||||
|
const out: Req[] = [];
|
||||||
|
for (const w of want) {
|
||||||
|
const cur = have.find(h => h.locale === w.locale);
|
||||||
|
if (!cur) out.push({ method: 'POST', path: '/v1/appCustomProductPageLocalizations', note: w.locale,
|
||||||
|
body: { data: { type: 'appCustomProductPageLocalizations', attributes: { locale: w.locale, ...(w.promotionalText ? { promotionalText: w.promotionalText } : {}) }, relationships: { appCustomProductPageVersion: one('appCustomProductPageVersions', versionId) } } } });
|
||||||
|
else if (w.promotionalText !== undefined && (cur.promotionalText ?? '') !== w.promotionalText) out.push({ method: 'PATCH', path: `/v1/appCustomProductPageLocalizations/${cur.id}`, note: `${w.locale} promotional text`,
|
||||||
|
body: { data: { type: 'appCustomProductPageLocalizations', id: cur.id, attributes: { promotionalText: w.promotionalText } } } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locales of a page from the config: every language with an App Store locale. */
|
||||||
|
export function cppLocales(p: CustomProductPageDef, langs: Language[]): { locale: string; langId: string; promotionalText?: string }[] {
|
||||||
|
return langs.filter(l => l.apple).map(l => ({ locale: l.apple!, langId: l.id, ...(p.promotionalText?.[l.id] ? { promotionalText: p.promotionalText[l.id] } : {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable states of a custom product page version / in-app event (can still be changed and uploaded to). */
|
||||||
|
export const CPP_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'REJECTED']);
|
||||||
|
export const EVENT_EDITABLE = new Set(['DRAFT', 'REJECTED']);
|
||||||
|
|
||||||
|
/** POST /v1/appEvents. */
|
||||||
|
export function eventCreateBody(appId: string, ev: AppEventDef, primaryLocale: string, territories: string[]) {
|
||||||
|
return { data: { type: 'appEvents', attributes: {
|
||||||
|
referenceName: ev.referenceName, badge: ev.badge, primaryLocale, priority: (ev.priority ?? 'normal').toUpperCase(),
|
||||||
|
purpose: ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS', ...(ev.deepLink ? { deepLink: ev.deepLink } : {}), ...(ev.purchaseRequirement ? { purchaseRequirement: ev.purchaseRequirement } : {}),
|
||||||
|
territorySchedules: [{ territories, publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }],
|
||||||
|
}, relationships: { app: one('apps', appId) } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event localizations to add or change. Pure. */
|
||||||
|
export function planEventLocales(eventId: string, have: { id: string; locale: string; name?: string | null; shortDescription?: string | null; longDescription?: string | null }[],
|
||||||
|
want: { locale: string; name: string; shortDescription: string; longDescription: string }[]): Req[] {
|
||||||
|
const out: Req[] = [];
|
||||||
|
for (const w of want) {
|
||||||
|
const cur = have.find(h => h.locale === w.locale);
|
||||||
|
const attrs = { name: w.name, shortDescription: w.shortDescription, longDescription: w.longDescription };
|
||||||
|
if (!cur) out.push({ method: 'POST', path: '/v1/appEventLocalizations', note: w.locale, body: { data: { type: 'appEventLocalizations', attributes: { locale: w.locale, ...attrs }, relationships: { appEvent: one('appEvents', eventId) } } } });
|
||||||
|
else {
|
||||||
|
const changed = Object.fromEntries(Object.entries(attrs).filter(([k, v]) => (cur as Record<string, unknown>)[k] !== v));
|
||||||
|
if (Object.keys(changed).length) out.push({ method: 'PATCH', path: `/v1/appEventLocalizations/${cur.id}`, note: `${w.locale} ${Object.keys(changed).join(', ')}`, body: { data: { type: 'appEventLocalizations', id: cur.id, attributes: changed } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event media size check (card 16:9 landscape, details 9:16 portrait). Pure. */
|
||||||
|
export function checkEventMedia(kind: keyof typeof EVENT_MEDIA, w: number, h: number, format: string): string[] {
|
||||||
|
const [minW, minH, maxW, maxH] = EVENT_MEDIA[kind];
|
||||||
|
const e: string[] = [];
|
||||||
|
if (format !== 'png' && format !== 'jpeg') e.push('PNG or JPEG only');
|
||||||
|
if (w < minW || h < minH || w > maxW || h > maxH) e.push(`${w}x${h}: ${minW}x${minH} to ${maxW}x${maxH}`);
|
||||||
|
if (Math.abs(w / h - minW / minH) > 0.01) e.push(`${w}x${h}: aspect ${kind === 'EVENT_CARD' ? '16:9' : '9:16'}`);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/reviewSubmissionItems for a custom product page version or an in-app event (into a draft submission). */
|
||||||
|
export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageVersion' | 'appEvent', id: string, label: string): Req => ({
|
||||||
|
method: 'POST', path: '/v1/reviewSubmissionItems', note: label,
|
||||||
|
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } },
|
||||||
|
});
|
||||||
|
|
@ -80,3 +80,10 @@ export const maskUser = (u: string): string => {
|
||||||
return at > 0 ? `${u[0]}***${u.slice(at)}` : `${u[0] ?? ''}***`;
|
return at > 0 ? `${u[0]}***${u.slice(at)}` : `${u[0] ?? ''}***`;
|
||||||
};
|
};
|
||||||
export const maskSecret = (s: string): string => (s ? '********' : '(empty)');
|
export const maskSecret = (s: string): string => (s ? '********' : '(empty)');
|
||||||
|
|
||||||
|
/** App Store Connect vendor number for sales and finance reports (Payments and Financial Reports, top left): env
|
||||||
|
* ASC_VENDOR_NUMBER, else "ASC_VENDOR_NUMBER" in .storebound/credentials.json. An account id, so never in the config. */
|
||||||
|
export function ascVendorNumber(root: string): string | null {
|
||||||
|
const v = value(root, 'ASC_VENDOR_NUMBER');
|
||||||
|
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
|
||||||
|
}
|
||||||
|
|
|
||||||
32
cli/lib/diff.ts
Normal file
32
cli/lib/diff.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Line diff for "pull" commands (live store text vs the local files). Pure, no dependency: longest common subsequence
|
||||||
|
// over lines, printed like a unified diff without hunks (" " same, "-" only local, "+" only on the store).
|
||||||
|
|
||||||
|
export function lineDiff(before: string, after: string): string[] {
|
||||||
|
const a = before.split('\n'), b = after.split('\n');
|
||||||
|
if (a.length * b.length > 4_000_000) return [...a.map(l => `-${l}`), ...b.map(l => `+${l}`)];
|
||||||
|
const n = a.length, m = b.length;
|
||||||
|
const lcs: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||||
|
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) { out.push(` ${a[i]}`); i++; j++; }
|
||||||
|
else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) out.push(`-${a[i++]}`);
|
||||||
|
else out.push(`+${b[j++]}`);
|
||||||
|
}
|
||||||
|
while (i < n) out.push(`-${a[i++]}`);
|
||||||
|
while (j < m) out.push(`+${b[j++]}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the changed lines with `context` unchanged lines around them ("..." between gaps). Empty when equal. */
|
||||||
|
export function compactDiff(before: string, after: string, context = 2): string[] {
|
||||||
|
const d = lineDiff(before, after);
|
||||||
|
if (!d.some(l => l[0] !== ' ')) return [];
|
||||||
|
const keep = new Set<number>();
|
||||||
|
d.forEach((l, i) => { if (l[0] !== ' ') for (let k = i - context; k <= i + context; k++) keep.add(k); });
|
||||||
|
const out: string[] = [];
|
||||||
|
let last = -1;
|
||||||
|
d.forEach((l, i) => { if (!keep.has(i)) return; if (last >= 0 && i > last + 1) out.push('...'); out.push(l); last = i; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
164
cli/lib/listing-pull.ts
Normal file
164
cli/lib/listing-pull.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// "Pull": the live store texts back into the local sources, as pure merges. The commands (apple listing pull, play
|
||||||
|
// listing pull, catalog pull) read the stores, call these, print the diff and write only with --write.
|
||||||
|
// - App Store: appInfoLocalizations (name, subtitle) + appStoreVersionLocalizations (description, keywords,
|
||||||
|
// promotionalText, whatsNew) -> store/appstore.md, one "## <locale>" section per locale, "### <field>" blocks.
|
||||||
|
// - Google Play: edits.listings (title, shortDescription, fullDescription) -> store/listing.md sections with
|
||||||
|
// "<!-- play: <codes> -->". A section serving several Play codes is only changed when they agree on the store.
|
||||||
|
// - In-app purchases and subscriptions: the store texts as a proposal for storebound.config.ts (a TypeScript file is
|
||||||
|
// never rewritten by a tool): the diff per product and language, and a JSON proposal file with --write.
|
||||||
|
// Only fields the store has a value for are written; a local field the store leaves empty is kept.
|
||||||
|
import type { AppleCopy, AppleField } from './copy';
|
||||||
|
import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy';
|
||||||
|
import type { Language, ShipConfig } from './config';
|
||||||
|
import { playCodes } from './config';
|
||||||
|
|
||||||
|
export interface FieldChange { section: string; field: string; from: string | null; to: string }
|
||||||
|
export interface MergeResult { md: string; changes: FieldChange[]; added: string[]; conflicts: string[] }
|
||||||
|
|
||||||
|
const fence = (t: string): string => '```\n' + t + '\n```';
|
||||||
|
const BLOCK = (heading: RegExp) => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\``, 'm' + (heading.flags.includes('i') ? 'i' : ''));
|
||||||
|
|
||||||
|
/** Replace (or append) the fenced block under the first "### " heading matching `heading` in one section. */
|
||||||
|
function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } {
|
||||||
|
const re = BLOCK(heading);
|
||||||
|
const m = re.exec(sec);
|
||||||
|
if (m) return { sec: sec.replace(re, (_a, h: string) => h + fence(text)), from: m[2]!.trim() };
|
||||||
|
const body = sec.replace(/\s*$/, '');
|
||||||
|
return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPLE_ORDER: AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'];
|
||||||
|
|
||||||
|
/** Live App Store texts per locale merged into the App Store listing file. Pure. */
|
||||||
|
export function mergeAppleListing(md: string, remote: Record<string, AppleCopy>): MergeResult {
|
||||||
|
const parts = md.split(/^(?=## )/m);
|
||||||
|
const res: MergeResult = { md: '', changes: [], added: [], conflicts: [] };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out = parts.map(sec => {
|
||||||
|
if (!sec.startsWith('## ')) return sec;
|
||||||
|
const loc = sec.slice(3).split(/[\s·]/)[0]!.trim();
|
||||||
|
const r = remote[loc];
|
||||||
|
if (!r) return sec;
|
||||||
|
seen.add(loc);
|
||||||
|
for (const f of APPLE_ORDER) {
|
||||||
|
const v = r[f];
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
const { sec: next, from } = setBlock(sec, new RegExp(`${f}\\b`), f, v);
|
||||||
|
if (from !== v.trim()) res.changes.push({ section: loc, field: f, from, to: v });
|
||||||
|
sec = next;
|
||||||
|
}
|
||||||
|
return sec;
|
||||||
|
});
|
||||||
|
let text = out.join('');
|
||||||
|
for (const [loc, r] of Object.entries(remote).sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
if (seen.has(loc)) continue;
|
||||||
|
const fields = APPLE_ORDER.filter(f => r[f]);
|
||||||
|
if (!fields.length) continue;
|
||||||
|
res.added.push(loc);
|
||||||
|
text = text.replace(/\s*$/, '') + `\n\n## ${loc}\n\n` + fields.map(f => `### ${f}\n${fence(r[f]!)}`).join('\n\n') + '\n';
|
||||||
|
for (const f of fields) res.changes.push({ section: loc, field: f, from: null, to: r[f]! });
|
||||||
|
}
|
||||||
|
res.md = text.endsWith('\n') ? text : text + '\n';
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayRemote { language: string; title?: string; shortDescription?: string; fullDescription?: string }
|
||||||
|
|
||||||
|
const PLAY_FIELDS: { key: keyof PlayRemote; re: RegExp; heading: string; limit: number }[] = [
|
||||||
|
{ key: 'title', re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})`, limit: PLAY_LIMITS.title },
|
||||||
|
{ key: 'shortDescription', re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})`, limit: PLAY_LIMITS.short },
|
||||||
|
{ key: 'fullDescription', re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})`, limit: PLAY_LIMITS.full },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Live Play listings merged into the Play listing file. A title equal to `defaultTitle` (app.name) is only written
|
||||||
|
* where the section already has a title block. Pure. */
|
||||||
|
export function mergePlayListing(md: string, remote: PlayRemote[], defaultTitle: string): MergeResult {
|
||||||
|
const byLang = new Map(remote.map(l => [l.language, l] as const));
|
||||||
|
const res: MergeResult = { md: '', changes: [], added: [], conflicts: [] };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const parts = md.split(/^(?=## )/m).map(sec => {
|
||||||
|
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
|
||||||
|
if (!m) return sec;
|
||||||
|
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const hits = codes.map(c => byLang.get(c)).filter((x): x is PlayRemote => !!x);
|
||||||
|
for (const c of codes) if (byLang.has(c)) seen.add(c);
|
||||||
|
if (!hits.length) return sec;
|
||||||
|
for (const f of PLAY_FIELDS) {
|
||||||
|
const values = [...new Set(hits.map(h => (h[f.key] ?? '').trim()).filter(Boolean))];
|
||||||
|
if (!values.length) continue;
|
||||||
|
if (values.length > 1) { res.conflicts.push(`${codes.join(', ')}: ${f.key} differs between these Play languages on the store; left as it is (give each its own section)`); continue; }
|
||||||
|
const v = values[0]!;
|
||||||
|
const has = BLOCK(f.re).test(sec);
|
||||||
|
if (f.key === 'title' && !has && v === defaultTitle) continue;
|
||||||
|
const { sec: next, from } = setBlock(sec, f.re, f.heading, v);
|
||||||
|
if (from !== v) res.changes.push({ section: codes.join(', '), field: f.key, from, to: v });
|
||||||
|
sec = next;
|
||||||
|
}
|
||||||
|
return sec;
|
||||||
|
});
|
||||||
|
let text = parts.join('');
|
||||||
|
for (const l of [...remote].sort((a, b) => a.language.localeCompare(b.language))) {
|
||||||
|
if (seen.has(l.language)) continue;
|
||||||
|
res.added.push(l.language);
|
||||||
|
const blocks = PLAY_FIELDS.filter(f => l[f.key] && !(f.key === 'title' && l.title === defaultTitle)).map(f => `### ${f.heading}\n${fence(String(l[f.key]))}`);
|
||||||
|
text = text.replace(/\s*$/, '') + `\n\n## ${l.language}\n<!-- play: ${l.language} -->\n\n${blocks.join('\n\n')}\n`;
|
||||||
|
for (const f of PLAY_FIELDS) if (l[f.key]) res.changes.push({ section: l.language, field: f.key, from: null, to: String(l[f.key]) });
|
||||||
|
}
|
||||||
|
res.md = text.endsWith('\n') ? text : text + '\n';
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store values over a store limit (the pull still writes them; `check` will flag them). */
|
||||||
|
export function overLimits(changes: FieldChange[], store: 'apple' | 'play'): string[] {
|
||||||
|
const lim: Record<string, number> = store === 'apple' ? APPLE_LIMITS : { title: PLAY_LIMITS.title, shortDescription: PLAY_LIMITS.short, fullDescription: PLAY_LIMITS.full };
|
||||||
|
return changes.filter(c => lim[c.field] !== undefined && len(c.to) > lim[c.field]!).map(c => `${c.section} ${c.field}: ${len(c.to)} > ${lim[c.field]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- in-app purchases and subscriptions -> config proposal ----
|
||||||
|
|
||||||
|
export interface RemoteText { title: string; description: string; benefits?: string[] }
|
||||||
|
/** Store texts per product id and store language code (App Store locale or Play language code). */
|
||||||
|
export type RemoteCatalog = Record<string, Record<string, RemoteText>>;
|
||||||
|
|
||||||
|
export interface CatalogProposal {
|
||||||
|
/** product id -> language id -> the store's texts (only where they differ from the config or are missing there). */
|
||||||
|
listings: Record<string, Record<string, { title: string; description: string; benefits?: string[] }>>;
|
||||||
|
changes: { id: string; lang: string; field: string; from: string | null; to: string; store: 'apple' | 'play' }[];
|
||||||
|
/** Store languages the config has no language entry for (not mapped). */
|
||||||
|
unmapped: string[];
|
||||||
|
/** Products on the store that are not in the config. */
|
||||||
|
unknown: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store language code -> config language id (App Store locale or any Play code of the entry). */
|
||||||
|
export function langIdOf(langs: Language[], store: 'apple' | 'play', code: string): string | undefined {
|
||||||
|
return langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The store texts of in-app purchases and subscriptions against the config's listings. Apple wins over Play when
|
||||||
|
* both differ for the same language (shown as two changes). Pure. */
|
||||||
|
export function catalogProposal(config: Pick<ShipConfig, 'languages' | 'products' | 'subscriptions'>, remote: { apple?: RemoteCatalog; play?: RemoteCatalog }): CatalogProposal {
|
||||||
|
const local = new Map<string, Record<string, { title: string; description: string; benefits?: string[] }>>();
|
||||||
|
for (const p of config.products ?? []) local.set(p.id, p.listings);
|
||||||
|
for (const g of config.subscriptions ?? []) for (const s of g.subscriptions) local.set(s.id, s.listings);
|
||||||
|
const out: CatalogProposal = { listings: {}, changes: [], unmapped: [], unknown: [] };
|
||||||
|
for (const store of ['play', 'apple'] as const) {
|
||||||
|
for (const [id, byCode] of Object.entries(remote[store] ?? {})) {
|
||||||
|
const mine = local.get(id);
|
||||||
|
if (!mine) { if (!out.unknown.includes(id)) out.unknown.push(id); continue; }
|
||||||
|
for (const [code, t] of Object.entries(byCode)) {
|
||||||
|
const lang = langIdOf(config.languages, store, code);
|
||||||
|
if (!lang) { const u = `${store}:${code}`; if (!out.unmapped.includes(u)) out.unmapped.push(u); continue; }
|
||||||
|
const cur = mine[lang];
|
||||||
|
const fields: [string, string | null, string][] = [['title', cur?.title ?? null, t.title], ['description', cur?.description ?? null, t.description]];
|
||||||
|
if (store === 'play' && t.benefits?.length) fields.push(['benefits', cur?.benefits?.join(' | ') ?? null, t.benefits.join(' | ')]);
|
||||||
|
const diff = fields.filter(([, from, to]) => to && from !== to);
|
||||||
|
if (!diff.length) continue;
|
||||||
|
for (const [field, from, to] of diff) out.changes.push({ id, lang, field, from, to, store });
|
||||||
|
const prev = out.listings[id]?.[lang] ?? { title: cur?.title ?? t.title, description: cur?.description ?? t.description, ...(cur?.benefits ? { benefits: cur.benefits } : {}) };
|
||||||
|
out.listings[id] = { ...out.listings[id], [lang]: { ...prev, title: t.title || prev.title, description: t.description || prev.description, ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
301
cli/lib/offers.ts
Normal file
301
cli/lib/offers.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
// App Store offers beyond introductory / promotional offers, as pure parts: offer codes for one-time purchases,
|
||||||
|
// win-back offers for subscriptions, promoted in-app purchases and app pre-orders. The commands (cli/apple/offers.ts)
|
||||||
|
// read, plan, print the dry run and send; everything that becomes visible to customers asks first and none of it is
|
||||||
|
// an MCP tool.
|
||||||
|
// Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026):
|
||||||
|
// In-app purchase offer codes: POST /v1/inAppPurchaseOfferCodes (name, customerEligibilities NON_SPENDER |
|
||||||
|
// ACTIVE_SPENDER | CHURNED_SPENDER, inAppPurchase, prices = inAppPurchaseOfferPrices: territory + pricePoint),
|
||||||
|
// POST /v1/inAppPurchaseOfferCodeCustomCodes (customCode, numberOfCodes, expirationDate?),
|
||||||
|
// POST /v1/inAppPurchaseOfferCodeOneTimeUseCodes (numberOfCodes, expirationDate, environment PRODUCTION | SANDBOX),
|
||||||
|
// GET /v1/inAppPurchaseOfferCodeOneTimeUseCodes/{id}/values (the codes), GET /v2/inAppPurchases/{id}/offerCodes.
|
||||||
|
// Help "Create offer codes for In-App Purchases": up to 10 active offers per app, 1 million codes per app per
|
||||||
|
// quarter, one-time-use batches of 500-25,000 valid at most 6 months, custom codes up to 64 characters without
|
||||||
|
// special characters and up to 25,000 redemptions, paid or free offers. Consumables, non-consumables and
|
||||||
|
// non-renewing subscriptions (iOS 16.3+).
|
||||||
|
// https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/create-offer-codes-for-in-app-purchases
|
||||||
|
// Win-back offers: POST /v1/winBackOffers (referenceName, offerId, duration, offerMode, periodCount,
|
||||||
|
// customerEligibilityPaidSubscriptionDurationInMonths, customerEligibilityTimeSinceLastSubscribedInMonths {minimum,
|
||||||
|
// maximum}, customerEligibilityWaitBetweenOffersInMonths?, startDate, endDate?, priority HIGH | NORMAL,
|
||||||
|
// promotionIntent NOT_PROMOTED | USE_AUTO_GENERATED_ASSETS; subscription; prices = winBackOfferPrices with a
|
||||||
|
// subscriptionPricePoint), GET /v1/subscriptions/{id}/winBackOffers.
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-winbackoffers
|
||||||
|
// Help "Set up win-back offers": the subscription must be approved first; paid duration 1-24 months or 3, 4, 5
|
||||||
|
// years; time since last subscribed 1-24 months (min and max required); wait between offers 2-24 months; start at
|
||||||
|
// the earliest tomorrow, run at least 3 days; at most 350 per subscription and 5 at a time per storefront; reference
|
||||||
|
// name, offer id and price cannot be changed later; an offer id must not be reused in the group.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/manage-subscriptions/set-up-win-back-offers
|
||||||
|
// Promoted purchases: POST /v1/promotedPurchases (visibleForAllUsers, enabled?, app, inAppPurchaseV2 | subscription),
|
||||||
|
// PATCH /v1/promotedPurchases/{id} (visibleForAllUsers, enabled), GET /v1/apps/{id}/promotedPurchases,
|
||||||
|
// PATCH /v1/apps/{id}/relationships/promotedPurchases (the order). Help "Promote In-App Purchases": up to 20
|
||||||
|
// promoted at a time, each needs its promotional image, the app must handle purchases started on the App Store
|
||||||
|
// (PurchaseIntent). https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/promote-in-app-purchases
|
||||||
|
// Pre-orders: GET /v1/apps/{id}/appAvailabilityV2, GET /v2/appAvailabilities/{id}/territoryAvailabilities
|
||||||
|
// (available, releaseDate, preOrderEnabled, preOrderPublishDate, contentStatuses), PATCH
|
||||||
|
// /v1/territoryAvailabilities/{id} (available, releaseDate, preOrderEnabled), POST /v1/endAppAvailabilityPreOrders
|
||||||
|
// ("End the pre-order for your app and release to store immediately"). Help "Publish for pre-order": a version
|
||||||
|
// must be approved first; release date 2-180 days ahead (Apple's pre-orders page: up to 365 days for an existing
|
||||||
|
// app in a new region); not possible where the app is already released.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/publish-for-pre-order
|
||||||
|
// Google Play pre-registration has no API (androidpublisher v3 lists no such resource): Play Console only.
|
||||||
|
import type { Req } from '../apple/jsonapi';
|
||||||
|
import { many, one } from '../apple/jsonapi';
|
||||||
|
import { APPLE_OFFER_DURATION, APPLE_OFFER_MODE } from './subscriptions';
|
||||||
|
|
||||||
|
// ---- config ----
|
||||||
|
|
||||||
|
/** Offer code campaign for a one-time purchase (consumable, non-consumable, non-renewing). */
|
||||||
|
export interface IapOfferCodeDef {
|
||||||
|
/** Reference name of the campaign (unique per product). */
|
||||||
|
name: string;
|
||||||
|
/** NON_SPENDER = never purchased in the app, ACTIVE_SPENDER = purchased in the last 30 days, CHURNED_SPENDER =
|
||||||
|
* purchased more than 30 days ago. All three = everyone. */
|
||||||
|
eligibility: ('NON_SPENDER' | 'ACTIVE_SPENDER' | 'CHURNED_SPENDER')[];
|
||||||
|
/** Free offer (no price) or a paid one in USD (the USA price point and its equalizations). */
|
||||||
|
free?: boolean;
|
||||||
|
usd?: number;
|
||||||
|
/** Limit the offer to these App Store territories (alpha-3). Default: every territory. */
|
||||||
|
territories?: string[];
|
||||||
|
/** Custom codes (anyone with the code; created only from a terminal run with --codes that confirms). */
|
||||||
|
customCodes?: { code: string; count: number; expires?: string }[];
|
||||||
|
/** One-time-use code batches (500-25,000, expiry at most 6 months; --codes, confirms; the codes are saved to
|
||||||
|
* .storebound/offer-codes/). */
|
||||||
|
oneTimeCodes?: { count: number; expires: string; sandbox?: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Win-back offer for lapsed subscribers of one subscription. */
|
||||||
|
export interface WinBackDef {
|
||||||
|
/** Offer identifier (unique in the subscription group, never reused; cannot change later). */
|
||||||
|
id: string;
|
||||||
|
/** Reference name (default: the id; cannot change later). */
|
||||||
|
name?: string;
|
||||||
|
type: 'free' | 'pay-as-you-go' | 'pay-up-front';
|
||||||
|
/** free / pay-up-front: P3D ... P1Y; pay-as-you-go: the subscription period (default). */
|
||||||
|
duration?: string;
|
||||||
|
/** pay-as-you-go: number of periods (default 1). */
|
||||||
|
periods?: number;
|
||||||
|
/** USD price (per period for pay-as-you-go, total for pay-up-front); fixed after creation. */
|
||||||
|
usd?: number;
|
||||||
|
/** Customers who paid at least this many months: 1-24, 36, 48 or 60. */
|
||||||
|
paidMonths: number;
|
||||||
|
/** Lapsed between min and max months ago (1-24). */
|
||||||
|
lapsedMonths: { min: number; max: number };
|
||||||
|
/** Months between two win-back offers for one customer (2-24, optional). */
|
||||||
|
waitMonths?: number;
|
||||||
|
/** YYYY-MM-DD, at the earliest tomorrow. */
|
||||||
|
start: string;
|
||||||
|
/** YYYY-MM-DD, at least 3 days after start (optional). */
|
||||||
|
end?: string;
|
||||||
|
priority?: 'high' | 'normal';
|
||||||
|
/** Promote on the App Store with auto-generated assets (needs an approved subscription image). */
|
||||||
|
promote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromotedConfig {
|
||||||
|
/** Product / subscription ids in display order (at most 20). */
|
||||||
|
products: string[];
|
||||||
|
/** Shown to every user (true) or only to users your app makes it visible for (false). Default true. */
|
||||||
|
visibleForAllUsers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const isDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
|
||||||
|
const ELIG = ['NON_SPENDER', 'ACTIVE_SPENDER', 'CHURNED_SPENDER'];
|
||||||
|
const PAID_MONTHS = new Set([...Array.from({ length: 24 }, (_, i) => i + 1), 36, 48, 60]);
|
||||||
|
|
||||||
|
/** Problems with products[].offerCodes. With `today` (when codes are about to be created) also the 6-month expiry
|
||||||
|
* rule; the config check leaves dates relative to today out, so an old campaign never breaks the config. Pure. */
|
||||||
|
export function validateIapOfferCodes(at: string, codes: IapOfferCodeDef[] | undefined, type: string, today?: string): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
const max = today ? new Date(Date.parse(today) + 183 * DAY).toISOString().slice(0, 10) : '9999-12-31';
|
||||||
|
for (const [i, c] of (codes ?? []).entries()) {
|
||||||
|
const p = `${at}.offerCodes[${i}]`;
|
||||||
|
if (!c.name || [...c.name].length > 64) e.push(`${p}.name: 1-64 characters`);
|
||||||
|
if (names.has(c.name)) e.push(`${p}.name: duplicate "${c.name}"`);
|
||||||
|
names.add(c.name);
|
||||||
|
if (!Array.isArray(c.eligibility) || !c.eligibility.length || !c.eligibility.every(x => ELIG.includes(x))) e.push(`${p}.eligibility: some of ${ELIG.join(', ')}`);
|
||||||
|
if (!!c.free === (c.usd !== undefined)) e.push(`${p}: free: true or usd (one of them)`);
|
||||||
|
if (c.usd !== undefined && !(c.usd > 0)) e.push(`${p}.usd: positive`);
|
||||||
|
if (c.territories && !c.territories.every(t => /^[A-Z]{3}$/.test(t))) e.push(`${p}.territories: App Store territories, alpha-3 (USA)`);
|
||||||
|
for (const [k, cc] of (c.customCodes ?? []).entries()) {
|
||||||
|
if (!/^[A-Za-z0-9]{1,64}$/.test(cc.code ?? '')) e.push(`${p}.customCodes[${k}].code: letters and digits, up to 64`);
|
||||||
|
if (!(Number.isInteger(cc.count) && cc.count >= 1 && cc.count <= 25000)) e.push(`${p}.customCodes[${k}].count: 1-25000`);
|
||||||
|
if (cc.expires !== undefined && (!isDate(cc.expires) || cc.expires > max)) e.push(`${p}.customCodes[${k}].expires: YYYY-MM-DD within 6 months`);
|
||||||
|
}
|
||||||
|
for (const [k, b] of (c.oneTimeCodes ?? []).entries()) {
|
||||||
|
const lo = b.sandbox ? 10 : 500, hi = b.sandbox ? 10000 : 25000;
|
||||||
|
if (!(Number.isInteger(b.count) && b.count >= lo && b.count <= hi)) e.push(`${p}.oneTimeCodes[${k}].count: ${lo}-${hi}`);
|
||||||
|
if (!isDate(b.expires) || b.expires > max) e.push(`${p}.oneTimeCodes[${k}].expires: YYYY-MM-DD within 6 months (required)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((codes ?? []).length && !['consumable', 'non_consumable', 'non_renewing'].includes(type)) e.push(`${at}.offerCodes: one-time purchases only`);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems with subscriptions[].winBack. With `today` (right before creating) also "start at the earliest
|
||||||
|
* tomorrow". Pure. */
|
||||||
|
export function validateWinBack(at: string, offers: WinBackDef[] | undefined, today?: string): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const [i, o] of (offers ?? []).entries()) {
|
||||||
|
const p = `${at}.winBack[${i}]`;
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(o.id ?? '')) e.push(`${p}.id: letters, digits, ".", "_", "-"`);
|
||||||
|
if (ids.has(o.id)) e.push(`${p}.id: duplicate "${o.id}"`);
|
||||||
|
ids.add(o.id);
|
||||||
|
if (!(o.type in APPLE_OFFER_MODE)) e.push(`${p}.type: free | pay-as-you-go | pay-up-front`);
|
||||||
|
if (o.type !== 'pay-as-you-go' && !(o.duration && o.duration in APPLE_OFFER_DURATION)) e.push(`${p}.duration: ${Object.keys(APPLE_OFFER_DURATION).join(', ')}`);
|
||||||
|
if (o.type !== 'free' && !(typeof o.usd === 'number' && o.usd > 0)) e.push(`${p}.usd: positive (a paid offer)`);
|
||||||
|
if (o.type === 'free' && o.usd !== undefined) e.push(`${p}.usd: not for a free offer`);
|
||||||
|
if (o.periods !== undefined && !(Number.isInteger(o.periods) && o.periods >= 1 && o.periods <= 12)) e.push(`${p}.periods: 1-12`);
|
||||||
|
if (!PAID_MONTHS.has(o.paidMonths)) e.push(`${p}.paidMonths: 1-24, 36, 48 or 60`);
|
||||||
|
const l = o.lapsedMonths;
|
||||||
|
if (!l || !(Number.isInteger(l.min) && Number.isInteger(l.max) && l.min >= 1 && l.max <= 24 && l.min <= l.max)) e.push(`${p}.lapsedMonths: { min, max } within 1-24`);
|
||||||
|
if (o.waitMonths !== undefined && !(Number.isInteger(o.waitMonths) && o.waitMonths >= 2 && o.waitMonths <= 24)) e.push(`${p}.waitMonths: 2-24`);
|
||||||
|
if (!isDate(o.start)) e.push(`${p}.start: YYYY-MM-DD`);
|
||||||
|
else if (today && o.start <= today) e.push(`${p}.start: at the earliest tomorrow`);
|
||||||
|
if (o.end !== undefined && (!isDate(o.end) || (isDate(o.start) && Date.parse(o.end) - Date.parse(o.start) < 3 * DAY))) e.push(`${p}.end: YYYY-MM-DD, at least 3 days after start`);
|
||||||
|
if (o.priority !== undefined && o.priority !== 'high' && o.priority !== 'normal') e.push(`${p}.priority: high | normal`);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePromoted(p: PromotedConfig | undefined, ids: Set<string>): string[] {
|
||||||
|
if (!p) return [];
|
||||||
|
const e: string[] = [];
|
||||||
|
if (!Array.isArray(p.products) || !p.products.length) e.push('apple.promoted.products: at least one product id');
|
||||||
|
if ((p.products ?? []).length > 20) e.push('apple.promoted.products: at most 20 (App Store limit)');
|
||||||
|
for (const id of p.products ?? []) if (!ids.has(id)) e.push(`apple.promoted.products: "${id}" is not a product or subscription of the config`);
|
||||||
|
if (new Set(p.products).size !== (p.products ?? []).length) e.push('apple.promoted.products: duplicates');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- request bodies ----
|
||||||
|
|
||||||
|
export interface TerritoryPoint { territory: string; pointId?: string }
|
||||||
|
|
||||||
|
/** POST /v1/inAppPurchaseOfferCodes. A free offer sends the territories without a price point (the schema marks the
|
||||||
|
* price point optional; not verified against a live account). */
|
||||||
|
export function iapOfferCodeBody(iapId: string, c: IapOfferCodeDef, prices: TerritoryPoint[]) {
|
||||||
|
const ids = prices.map((_, i) => `\${iapcode-${i}}`);
|
||||||
|
return {
|
||||||
|
data: { type: 'inAppPurchaseOfferCodes', attributes: { name: c.name, customerEligibilities: c.eligibility },
|
||||||
|
relationships: { inAppPurchase: one('inAppPurchases', iapId), prices: { data: ids.map(id => ({ type: 'inAppPurchaseOfferPrices', id })) } } },
|
||||||
|
included: prices.map((p, i) => {
|
||||||
|
if (!c.free && !p.pointId) throw new Error(`${c.name}: paid offer in ${p.territory} needs a price point`);
|
||||||
|
return { type: 'inAppPurchaseOfferPrices', id: ids[i], relationships: { territory: one('territories', p.territory), ...(p.pointId ? { pricePoint: one('inAppPurchasePricePoints', p.pointId) } : {}) } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const iapCustomCodeReq = (offerCodeId: string, cc: { code: string; count: number; expires?: string }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeCustomCodes',
|
||||||
|
note: `custom code ${cc.code} (${cc.count} redemptions${cc.expires ? `, until ${cc.expires}` : ''})`,
|
||||||
|
body: { data: { type: 'inAppPurchaseOfferCodeCustomCodes', attributes: { customCode: cc.code, numberOfCodes: cc.count, ...(cc.expires ? { expirationDate: cc.expires } : {}) },
|
||||||
|
relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } });
|
||||||
|
|
||||||
|
export const iapOneTimeCodesReq = (offerCodeId: string, b: { count: number; expires: string; sandbox?: boolean }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeOneTimeUseCodes',
|
||||||
|
note: `${b.count} one-time-use ${b.sandbox ? 'sandbox ' : ''}codes until ${b.expires}`,
|
||||||
|
body: { data: { type: 'inAppPurchaseOfferCodeOneTimeUseCodes', attributes: { numberOfCodes: b.count, expirationDate: b.expires, environment: b.sandbox ? 'SANDBOX' : 'PRODUCTION' },
|
||||||
|
relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } });
|
||||||
|
|
||||||
|
/** POST /v1/winBackOffers. `subPeriod` = the subscription's period (pay-as-you-go duration). Paid prices: one
|
||||||
|
* subscriptionPricePoint per territory (the point carries its territory, as in Apple's example request). A free offer
|
||||||
|
* sends the territories without a price point (not verified against a live account). */
|
||||||
|
export function winBackBody(subId: string, subPeriod: string, o: WinBackDef, prices: TerritoryPoint[]) {
|
||||||
|
const ids = prices.map((_, i) => `\${winback-${i}}`);
|
||||||
|
const duration = o.type === 'pay-as-you-go' ? (o.duration ?? subPeriod) : o.duration!;
|
||||||
|
return {
|
||||||
|
data: { type: 'winBackOffers', attributes: {
|
||||||
|
referenceName: o.name ?? o.id, offerId: o.id, duration: APPLE_OFFER_DURATION[duration], offerMode: APPLE_OFFER_MODE[o.type],
|
||||||
|
periodCount: o.type === 'pay-as-you-go' ? (o.periods ?? 1) : 1,
|
||||||
|
customerEligibilityPaidSubscriptionDurationInMonths: o.paidMonths,
|
||||||
|
customerEligibilityTimeSinceLastSubscribedInMonths: { minimum: o.lapsedMonths.min, maximum: o.lapsedMonths.max },
|
||||||
|
...(o.waitMonths !== undefined ? { customerEligibilityWaitBetweenOffersInMonths: o.waitMonths } : {}),
|
||||||
|
startDate: o.start, ...(o.end ? { endDate: o.end } : {}), priority: (o.priority ?? 'normal').toUpperCase(),
|
||||||
|
promotionIntent: o.promote ? 'USE_AUTO_GENERATED_ASSETS' : 'NOT_PROMOTED',
|
||||||
|
}, relationships: { subscription: one('subscriptions', subId), prices: { data: ids.map(id => ({ type: 'winBackOfferPrices', id })) } } },
|
||||||
|
included: prices.map((p, i) => {
|
||||||
|
if (o.type !== 'free' && !p.pointId) throw new Error(`${o.id}: paid win-back offer in ${p.territory} needs a price point`);
|
||||||
|
return { type: 'winBackOfferPrices', id: ids[i], relationships: p.pointId ? { subscriptionPricePoint: one('subscriptionPricePoints', p.pointId) } : { territory: one('territories', p.territory) } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- promoted purchases ----
|
||||||
|
|
||||||
|
export interface PromotedNow { id: string; productId: string; enabled: boolean; visibleForAllUsers: boolean; state?: string }
|
||||||
|
|
||||||
|
export interface PromotedPlan { create: Req[]; update: Req[]; order: Req | null; unknown: string[]; extra: string[] }
|
||||||
|
|
||||||
|
/** Wanted promoted purchases vs the current ones. New ones are created disabled unless enable; `enable` also turns on
|
||||||
|
* existing ones. The order request lists the configured ones first, then the others as they are. Pure. */
|
||||||
|
export function planPromoted(appId: string, cfg: PromotedConfig, now: PromotedNow[], products: Map<string, { apiId: string; kind: 'iap' | 'sub' }>, o: { enable?: boolean } = {}): PromotedPlan {
|
||||||
|
const plan: PromotedPlan = { create: [], update: [], order: null, unknown: [], extra: [] };
|
||||||
|
const visible = cfg.visibleForAllUsers ?? true;
|
||||||
|
const have = new Map(now.map(p => [p.productId, p] as const));
|
||||||
|
for (const id of cfg.products) {
|
||||||
|
const cur = have.get(id);
|
||||||
|
if (cur) {
|
||||||
|
const attrs: Record<string, boolean> = {};
|
||||||
|
if (cur.visibleForAllUsers !== visible) attrs.visibleForAllUsers = visible;
|
||||||
|
if (o.enable && !cur.enabled) attrs.enabled = true;
|
||||||
|
if (Object.keys(attrs).length) plan.update.push({ method: 'PATCH', path: `/v1/promotedPurchases/${cur.id}`, note: `${id}: ${Object.entries(attrs).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
||||||
|
body: { data: { type: 'promotedPurchases', id: cur.id, attributes: attrs } } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const p = products.get(id);
|
||||||
|
if (!p) { plan.unknown.push(id); continue; }
|
||||||
|
plan.create.push({ method: 'POST', path: '/v1/promotedPurchases', note: `promote ${id}${o.enable ? ' (enabled)' : ' (not enabled yet)'}`,
|
||||||
|
body: { data: { type: 'promotedPurchases', attributes: { visibleForAllUsers: visible, enabled: !!o.enable },
|
||||||
|
relationships: { app: one('apps', appId), ...(p.kind === 'iap' ? { inAppPurchaseV2: one('inAppPurchases', p.apiId) } : { subscription: one('subscriptions', p.apiId) }) } } } });
|
||||||
|
}
|
||||||
|
plan.extra = now.filter(p => !cfg.products.includes(p.productId)).map(p => p.productId);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /v1/apps/{id}/relationships/promotedPurchases: the display order (configured first, then the rest). */
|
||||||
|
export function promotedOrderReq(appId: string, cfg: PromotedConfig, now: PromotedNow[]): Req | null {
|
||||||
|
const byProduct = new Map(now.map(p => [p.productId, p.id] as const));
|
||||||
|
const ordered = [...cfg.products.map(id => byProduct.get(id)).filter((x): x is string => !!x), ...now.filter(p => !cfg.products.includes(p.productId)).map(p => p.id)];
|
||||||
|
if (!ordered.length || ordered.join() === now.map(p => p.id).join()) return null;
|
||||||
|
return { method: 'PATCH', path: `/v1/apps/${appId}/relationships/promotedPurchases`, note: 'promoted purchase order', body: many('promotedPurchases', ordered) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pre-orders ----
|
||||||
|
|
||||||
|
export interface TerritoryAvail { id: string; territory: string; available?: boolean; releaseDate?: string | null; preOrderEnabled?: boolean; preOrderPublishDate?: string | null; contentStatuses?: string[] }
|
||||||
|
|
||||||
|
/** Territory availabilities response(s) (include=territory) -> rows. Pure. */
|
||||||
|
export function territoryRows(pages: { data?: { id: string; attributes?: Record<string, unknown>; relationships?: { territory?: { data?: { id: string } } } }[] }[]): TerritoryAvail[] {
|
||||||
|
return pages.flatMap(p => (p.data ?? []).map(t => ({ id: t.id, territory: t.relationships?.territory?.data?.id ?? '?', available: t.attributes?.available as boolean | undefined,
|
||||||
|
releaseDate: (t.attributes?.releaseDate as string | null | undefined) ?? null, preOrderEnabled: t.attributes?.preOrderEnabled as boolean | undefined,
|
||||||
|
preOrderPublishDate: (t.attributes?.preOrderPublishDate as string | null | undefined) ?? null, contentStatuses: t.attributes?.contentStatuses as string[] | undefined })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH requests that put the chosen territories on pre-order with this release date. Territories where the app is
|
||||||
|
* already out (AVAILABLE) cannot go on pre-order. Pure. */
|
||||||
|
export function planPreOrder(rows: TerritoryAvail[], releaseDate: string, territories: string[] | null, today = new Date().toISOString().slice(0, 10)): { reqs: Req[]; problems: string[]; warnings: string[] } {
|
||||||
|
const problems: string[] = [], warnings: string[] = [];
|
||||||
|
if (!isDate(releaseDate)) problems.push('--release-date: YYYY-MM-DD');
|
||||||
|
const days = (Date.parse(releaseDate) - Date.parse(today)) / DAY;
|
||||||
|
if (isDate(releaseDate) && days < 2) problems.push('--release-date: at least 2 days ahead');
|
||||||
|
if (isDate(releaseDate) && days > 365) problems.push('--release-date: at most 365 days ahead (180 for a new app)');
|
||||||
|
else if (isDate(releaseDate) && days > 180) warnings.push('more than 180 days ahead: only allowed for an existing app in a new region');
|
||||||
|
const pick = territories ? rows.filter(r => territories.includes(r.territory)) : rows;
|
||||||
|
if (territories) for (const t of territories) if (!rows.some(r => r.territory === t)) problems.push(`territory ${t}: not in the app's availability`);
|
||||||
|
const reqs: Req[] = [];
|
||||||
|
for (const r of pick) {
|
||||||
|
if (r.contentStatuses?.includes('AVAILABLE')) { warnings.push(`${r.territory}: already released there, no pre-order possible`); continue; }
|
||||||
|
if (r.preOrderEnabled && r.releaseDate === releaseDate && r.available) continue;
|
||||||
|
reqs.push({ method: 'PATCH', path: `/v1/territoryAvailabilities/${r.id}`, note: `${r.territory}: pre-order, release ${releaseDate}`,
|
||||||
|
body: { data: { type: 'territoryAvailabilities', id: r.id, attributes: { available: true, preOrderEnabled: true, releaseDate } } } });
|
||||||
|
}
|
||||||
|
return { reqs, problems, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/endAppAvailabilityPreOrders: end the pre-order in these territories and release there now. */
|
||||||
|
export function endPreOrderReq(rows: TerritoryAvail[], territories: string[] | null): Req | null {
|
||||||
|
const pick = rows.filter(r => r.preOrderEnabled && (!territories || territories.includes(r.territory)));
|
||||||
|
if (!pick.length) return null;
|
||||||
|
return { method: 'POST', path: '/v1/endAppAvailabilityPreOrders', note: `end the pre-order and release now in ${pick.map(r => r.territory).join(', ')}`,
|
||||||
|
body: { data: { type: 'endAppAvailabilityPreOrders', relationships: { territoryAvailabilities: many('territoryAvailabilities', pick.map(r => r.id)) } } } };
|
||||||
|
}
|
||||||
56
cli/lib/plist.ts
Normal file
56
cli/lib/plist.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Minimal XML property list reader (Info.plist as Xcode writes it): dict, array, string, integer, real, true, false,
|
||||||
|
// date, data. No dependency; binary plists are reported, not read (a source Info.plist is XML). Pure.
|
||||||
|
|
||||||
|
export type PlistValue = string | number | boolean | PlistValue[] | { [k: string]: PlistValue } | null;
|
||||||
|
|
||||||
|
const unescape = (s: string): string => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_m, d: string) => String.fromCodePoint(Number(d))).replace(/&/g, '&');
|
||||||
|
|
||||||
|
export function parsePlist(xml: string): PlistValue {
|
||||||
|
if (xml.startsWith('bplist')) throw new Error('binary plist: convert it with `plutil -convert xml1` first');
|
||||||
|
const tokens = [...xml.replace(/<\?xml[^>]*\?>|<!DOCTYPE[^>]*>|<!--[\s\S]*?-->/g, '').matchAll(/<(\/?)([A-Za-z]+)(?:\s[^>]*?)?(\/)?>|([^<]+)/g)];
|
||||||
|
let i = 0;
|
||||||
|
const skipText = () => { while (i < tokens.length && tokens[i]![4] !== undefined && !tokens[i]![4]!.trim()) i++; };
|
||||||
|
const textUntil = (tag: string): string => {
|
||||||
|
let t = '';
|
||||||
|
while (i < tokens.length && !(tokens[i]![1] === '/' && tokens[i]![2] === tag)) { t += tokens[i]![4] ?? ''; i++; }
|
||||||
|
i++;
|
||||||
|
return unescape(t);
|
||||||
|
};
|
||||||
|
const value = (): PlistValue => {
|
||||||
|
skipText();
|
||||||
|
const tok = tokens[i++];
|
||||||
|
if (!tok || tok[4] !== undefined) throw new Error('plist: value expected');
|
||||||
|
const [, close, tag, selfClose] = tok;
|
||||||
|
if (close) throw new Error(`plist: unexpected </${tag}>`);
|
||||||
|
switch (tag) {
|
||||||
|
case 'plist': { const v = value(); skipText(); if (tokens[i]?.[1] === '/' && tokens[i]?.[2] === 'plist') i++; return v; }
|
||||||
|
case 'true': return true;
|
||||||
|
case 'false': return false;
|
||||||
|
case 'string': return selfClose ? '' : textUntil('string');
|
||||||
|
case 'date': return textUntil('date');
|
||||||
|
case 'data': return textUntil('data').replace(/\s+/g, '');
|
||||||
|
case 'integer': return Number(textUntil('integer').trim());
|
||||||
|
case 'real': return Number(textUntil('real').trim());
|
||||||
|
case 'array': {
|
||||||
|
const out: PlistValue[] = [];
|
||||||
|
if (selfClose) return out;
|
||||||
|
for (;;) { skipText(); if (tokens[i]?.[1] === '/' && tokens[i]?.[2] === 'array') { i++; return out; } out.push(value()); }
|
||||||
|
}
|
||||||
|
case 'dict': {
|
||||||
|
const out: { [k: string]: PlistValue } = {};
|
||||||
|
if (selfClose) return out;
|
||||||
|
for (;;) {
|
||||||
|
skipText();
|
||||||
|
const t = tokens[i];
|
||||||
|
if (t?.[1] === '/' && t[2] === 'dict') { i++; return out; }
|
||||||
|
if (t?.[2] !== 'key') throw new Error('plist: <key> expected in <dict>');
|
||||||
|
i++;
|
||||||
|
const k = textUntil('key');
|
||||||
|
out[k] = value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: throw new Error(`plist: unsupported <${tag}>`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return value();
|
||||||
|
}
|
||||||
150
cli/lib/reports.ts
Normal file
150
cli/lib/reports.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// Sales and finance reports, as pure parts: request paths, file names and the summaries. The command
|
||||||
|
// (cli/reports.ts) downloads into .storebound/reports/ (gitignored: these files hold per-order and per-buyer-location
|
||||||
|
// rows) and prints / exports only aggregates.
|
||||||
|
// Sources (checked October 2026):
|
||||||
|
// Apple: GET /v1/salesReports (filter[vendorNumber], filter[reportType], filter[reportSubType], filter[frequency],
|
||||||
|
// filter[reportDate], filter[version]; answer application/a-gzip, tab-separated text) and GET /v1/financeReports
|
||||||
|
// (filter[vendorNumber], filter[reportType] FINANCIAL | FINANCE_DETAIL, filter[regionCode], filter[reportDate] =
|
||||||
|
// fiscal month YYYY-MM). Team key only, not an individual key.
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-salesreports
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-financereports
|
||||||
|
// Allowed combinations (docs table): SALES SUMMARY DAILY|WEEKLY|MONTHLY|YEARLY 1_0; SUBSCRIPTION SUMMARY DAILY 1_3;
|
||||||
|
// SUBSCRIPTION_EVENT SUMMARY DAILY 1_3; SUBSCRIBER DETAILED DAILY 1_3; PRE_ORDER SUMMARY ... 1_0;
|
||||||
|
// SUBSCRIPTION_OFFER_CODE_REDEMPTION SUMMARY DAILY 1_0; WIN_BACK_ELIGIBILITY SUMMARY DAILY 1_0; INSTALLS ...
|
||||||
|
// Region codes (Help, "Financial report regions and currencies"): ZZ = all regions consolidated (FINANCIAL),
|
||||||
|
// Z1 = the Financial Detail report (FINANCE_DETAIL).
|
||||||
|
// Summary Sales Report columns (Help): Provider, Provider Country, SKU, Developer, Title, Version, Product Type
|
||||||
|
// Identifier, Units, Developer Proceeds, Begin Date, End Date, Customer Currency, Country Code, Currency of Proceeds,
|
||||||
|
// Apple Identifier, Customer Price, ...
|
||||||
|
// Google Play: Cloud Storage bucket of the developer account (play.reportsBucket), read-only scope
|
||||||
|
// devstorage.read_only: earnings/earnings_YYYYMM*.zip and sales/salesreport_YYYYMM*.zip, one CSV inside each.
|
||||||
|
// Earnings columns: Description, Transaction Date, Transaction Time, Tax Type, Transaction Type, Refund Type,
|
||||||
|
// Product Title, Package ID, Product Type, SKU ID, Hardware, Buyer Country, ..., Merchant Currency, Amount
|
||||||
|
// (Merchant Currency), ... Estimated sales columns: Order Number, Order Charged Date, ..., Financial Status, ...,
|
||||||
|
// Product Title, Package ID, Product Type, SKU ID, Currency of Sale, Item Price, Taxes Collected, Charged Amount,
|
||||||
|
// ..., Country of Buyer, ... The service account needs "View financial data" in Play Console.
|
||||||
|
// https://support.google.com/googleplay/android-developer/answer/6135870
|
||||||
|
import { parseCsv } from './csv';
|
||||||
|
|
||||||
|
export const APPLE_SALES_COMBOS: Record<string, { subType: string; frequencies: string[]; version: string }> = {
|
||||||
|
SALES: { subType: 'SUMMARY', frequencies: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], version: '1_0' },
|
||||||
|
PRE_ORDER: { subType: 'SUMMARY', frequencies: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], version: '1_0' },
|
||||||
|
SUBSCRIPTION: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_3' },
|
||||||
|
SUBSCRIPTION_EVENT: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_3' },
|
||||||
|
SUBSCRIBER: { subType: 'DETAILED', frequencies: ['DAILY'], version: '1_3' },
|
||||||
|
SUBSCRIPTION_OFFER_CODE_REDEMPTION: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_0' },
|
||||||
|
WIN_BACK_ELIGIBILITY: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The report date for a frequency: DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY. Default: the newest that
|
||||||
|
* is usually there (yesterday minus one day, last month, last year). */
|
||||||
|
export function appleReportDate(frequency: string, given: string | undefined, now = new Date()): string {
|
||||||
|
if (given) {
|
||||||
|
const ok = frequency === 'YEARLY' ? /^\d{4}$/ : frequency === 'MONTHLY' ? /^\d{4}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!ok.test(given)) throw new Error(`--date for ${frequency}: ${frequency === 'YEARLY' ? 'YYYY' : frequency === 'MONTHLY' ? 'YYYY-MM' : 'YYYY-MM-DD'}`);
|
||||||
|
return given;
|
||||||
|
}
|
||||||
|
const d = new Date(now);
|
||||||
|
if (frequency === 'YEARLY') return String(d.getUTCFullYear() - 1);
|
||||||
|
if (frequency === 'MONTHLY') { d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); }
|
||||||
|
d.setUTCDate(d.getUTCDate() - 2);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appleSalesPath(vendor: string, o: { type: string; frequency: string; date: string; subType?: string; version?: string }): string {
|
||||||
|
const combo = APPLE_SALES_COMBOS[o.type];
|
||||||
|
if (!combo) throw new Error(`--type: one of ${Object.keys(APPLE_SALES_COMBOS).join(', ')}`);
|
||||||
|
if (!combo.frequencies.includes(o.frequency)) throw new Error(`${o.type} reports come ${combo.frequencies.join(' / ')} only`);
|
||||||
|
const q = new URLSearchParams({ 'filter[vendorNumber]': vendor, 'filter[reportType]': o.type, 'filter[reportSubType]': o.subType ?? combo.subType,
|
||||||
|
'filter[frequency]': o.frequency, 'filter[reportDate]': o.date, 'filter[version]': o.version ?? combo.version });
|
||||||
|
return `/v1/salesReports?${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appleFinancePath(vendor: string, month: string, o: { type?: 'FINANCIAL' | 'FINANCE_DETAIL'; region?: string } = {}): string {
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('--month YYYY-MM (Apple fiscal month)');
|
||||||
|
const type = o.type ?? 'FINANCIAL';
|
||||||
|
const region = o.region ?? (type === 'FINANCE_DETAIL' ? 'Z1' : 'ZZ');
|
||||||
|
return `/v1/financeReports?${new URLSearchParams({ 'filter[vendorNumber]': vendor, 'filter[reportType]': type, 'filter[regionCode]': region, 'filter[reportDate]': month })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab-separated report text -> records (header row first). Apple's finance reports end with total lines after a
|
||||||
|
* blank line; rows without the first header column are dropped. Pure. */
|
||||||
|
export function parseTsv(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.replace(/^/, '').split(/\r?\n/);
|
||||||
|
const head = (lines[0] ?? '').split('\t').map(h => h.trim());
|
||||||
|
const out: Record<string, string>[] = [];
|
||||||
|
for (const l of lines.slice(1)) {
|
||||||
|
if (!l.trim()) break;
|
||||||
|
const cells = l.split('\t');
|
||||||
|
out.push(Object.fromEntries(head.map((h, i) => [h, (cells[i] ?? '').trim()])));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryRow { key: string; title: string; currency: string; units: number; amount: number; rows: number }
|
||||||
|
|
||||||
|
const num = (s: string | undefined): number => { const n = Number(String(s ?? '').replace(/,/g, '')); return Number.isFinite(n) ? n : 0; };
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
function group(rows: Record<string, string>[], keyOf: (r: Record<string, string>) => [string, string, string], unitsOf: (r: Record<string, string>) => number, amountOf: (r: Record<string, string>) => number): SummaryRow[] {
|
||||||
|
const m = new Map<string, SummaryRow>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const [key, title, currency] = keyOf(r);
|
||||||
|
const id = `${key}\u0000${currency}`;
|
||||||
|
const cur = m.get(id) ?? { key, title, currency, units: 0, amount: 0, rows: 0 };
|
||||||
|
cur.units += unitsOf(r); cur.amount += amountOf(r); cur.rows++;
|
||||||
|
m.set(id, cur);
|
||||||
|
}
|
||||||
|
return [...m.values()].map(x => ({ ...x, amount: round2(x.amount) })).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary Sales Report: units and proceeds (units x Developer Proceeds) per SKU and proceeds currency. Pure. */
|
||||||
|
export function summarizeAppleSales(rows: Record<string, string>[]): SummaryRow[] {
|
||||||
|
return group(rows, r => [r.SKU || r['Apple Identifier'] || '?', r.Title ?? '', r['Currency of Proceeds'] ?? ''], r => num(r.Units), r => num(r.Units) * num(r['Developer Proceeds']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Financial report: quantity and Extended Partner Share per Vendor Identifier and currency. Pure. */
|
||||||
|
export function summarizeAppleFinance(rows: Record<string, string>[]): SummaryRow[] {
|
||||||
|
return group(rows.filter(r => r['Vendor Identifier'] !== undefined && r['Vendor Identifier'] !== ''),
|
||||||
|
r => [r['Vendor Identifier']!, r.Title ?? '', r['Partner Share Currency'] ?? ''], r => num(r.Quantity), r => num(r['Extended Partner Share']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play earnings: amount in the merchant currency per SKU, transaction type and currency. Pure. */
|
||||||
|
export function summarizePlayEarnings(rows: Record<string, string>[]): SummaryRow[] {
|
||||||
|
return group(rows, r => [`${r['SKU ID'] || r['Product Type'] || '?'} · ${r['Transaction Type'] ?? ''}`, r['Product Title'] ?? '', r['Merchant Currency'] ?? ''],
|
||||||
|
r => (/^charge$/i.test(r['Transaction Type'] ?? '') ? 1 : 0), r => num(r['Amount (Merchant Currency)']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play estimated sales: charged amount per SKU, financial status and currency of sale. Pure. */
|
||||||
|
export function summarizePlaySales(rows: Record<string, string>[]): SummaryRow[] {
|
||||||
|
return group(rows, r => [`${r['SKU ID'] || '?'} · ${r['Financial Status'] ?? ''}`, r['Product Title'] ?? '', r['Currency of Sale'] ?? ''], () => 1, r => num(r['Charged Amount']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total per currency (summaries are never added across currencies). */
|
||||||
|
export function totalsByCurrency(rows: SummaryRow[]): { currency: string; units: number; amount: number }[] {
|
||||||
|
const m = new Map<string, { currency: string; units: number; amount: number }>();
|
||||||
|
for (const r of rows) { const t = m.get(r.currency) ?? { currency: r.currency, units: 0, amount: 0 }; t.units += r.units; t.amount = round2(t.amount + r.amount); m.set(r.currency, t); }
|
||||||
|
return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const summaryCsvRows = (rows: SummaryRow[]): (string | number)[][] => [['key', 'title', 'currency', 'units', 'amount', 'rows'], ...rows.map(r => [r.key, r.title, r.currency, r.units, r.amount, r.rows])];
|
||||||
|
|
||||||
|
/** Play month -> object name prefix in the bucket. */
|
||||||
|
export const playReportPrefix = (kind: 'earnings' | 'sales', month: string): string => {
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('--month YYYY-MM');
|
||||||
|
return kind === 'earnings' ? `earnings/earnings_${month.replace('-', '')}` : `sales/salesreport_${month.replace('-', '')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CSV text of a Play report (they are UTF-8, some exports UTF-16 with a BOM). Pure. */
|
||||||
|
export function decodeReport(b: Uint8Array): string {
|
||||||
|
if (b[0] === 0xff && b[1] === 0xfe) return new TextDecoder('utf-16le').decode(b.subarray(2));
|
||||||
|
return new TextDecoder().decode(b).replace(/^/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play report CSV -> records keyed by the original header names. Pure. */
|
||||||
|
export const parsePlayCsv = (text: string): Record<string, string>[] => {
|
||||||
|
const [head, ...rest] = parseCsv(text);
|
||||||
|
if (!head) return [];
|
||||||
|
const keys = head.map(h => h.trim());
|
||||||
|
return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
|
||||||
|
};
|
||||||
166
cli/lib/review-submit.ts
Normal file
166
cli/lib/review-submit.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// Sending in-app purchases and subscriptions to App Review through review submissions, as a pure planner. The command
|
||||||
|
// (cli/apple/iap-submit.ts) reads the state, calls `planIapSubmission`, prints the plan and only sends it after the
|
||||||
|
// owner says yes in a terminal. Never an MCP tool: submitting for review is the owner's action.
|
||||||
|
//
|
||||||
|
// How App Store Connect does it (API 4.4.1+, OpenAPI spec 4.5.1, checked October 2026):
|
||||||
|
// - Every in-app purchase, subscription and subscription group has versions (inAppPurchaseVersions,
|
||||||
|
// subscriptionVersions, subscriptionGroupVersions; GET /v2/inAppPurchases/{id}/versions, /v1/subscriptions/{id}/versions,
|
||||||
|
// /v1/subscriptionGroups/{id}/versions). "Use the In-App Purchase version for submitting to review."
|
||||||
|
// - A version goes to App Review as an item of a review submission: POST /v1/reviewSubmissions (app; platform
|
||||||
|
// optional), POST /v1/reviewSubmissionItems with inAppPurchaseVersion | subscriptionVersion | subscriptionGroupVersion
|
||||||
|
// (or appStoreVersion), then PATCH /v1/reviewSubmissions/{id} { submitted: true }.
|
||||||
|
// The older inAppPurchaseSubmissions / subscriptionSubmissions / subscriptionGroupSubmissions are deprecated.
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/app-store-connect-api-4-4-1-release-notes
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/review-submissions
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchase-versions
|
||||||
|
// The rules (App Store Connect Help, "Submit an In-App Purchase", checked October 2026):
|
||||||
|
// - "The first consumable, non-consumable, auto-renewable subscription, and non-renewing subscription In-App
|
||||||
|
// Purchase of each type must be submitted with a new app version."
|
||||||
|
// - Later items of an approved type go without an app version "as long as your app has at least one approved app
|
||||||
|
// version at the time of submission".
|
||||||
|
// - A new subscription goes together with its subscription group while the group is not approved; "each new
|
||||||
|
// subscription group must be submitted with at least one of its subscriptions".
|
||||||
|
// - Everything reviewed together goes into the same draft submission; up to 200 items per submission.
|
||||||
|
// - In-App Purchases with content hosting can no longer be submitted. In-app purchases are not supported on Apple Watch.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-an-in-app-purchase
|
||||||
|
import type { Req } from '../apple/jsonapi';
|
||||||
|
import { one } from '../apple/jsonapi';
|
||||||
|
|
||||||
|
export const MAX_SUBMISSION_ITEMS = 200;
|
||||||
|
|
||||||
|
/** App Store version states after which the app counts as having an approved version. */
|
||||||
|
const APPROVED_APP = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'PROCESSING_FOR_DISTRIBUTION',
|
||||||
|
'PROCESSING_FOR_APP_STORE', 'REPLACED_WITH_NEW_VERSION', 'ACCEPTED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']);
|
||||||
|
/** Product states (InAppPurchaseState / Subscription.state) that mean the product was approved once. */
|
||||||
|
const APPROVED_PRODUCT = new Set(['APPROVED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']);
|
||||||
|
/** Version states that can still go into a draft submission. */
|
||||||
|
const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW']);
|
||||||
|
const IN_REVIEW = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']);
|
||||||
|
|
||||||
|
export type ItemKind = 'inAppPurchaseVersion' | 'subscriptionVersion' | 'subscriptionGroupVersion' | 'appStoreVersion';
|
||||||
|
const ITEM_TYPE: Record<ItemKind, string> = {
|
||||||
|
inAppPurchaseVersion: 'inAppPurchaseVersions', subscriptionVersion: 'subscriptionVersions', subscriptionGroupVersion: 'subscriptionGroupVersions', appStoreVersion: 'appStoreVersions',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PVersion { id: string; version?: number; state: string }
|
||||||
|
export interface PIap { id: string; productId: string; type: string; state: string; versions: PVersion[] }
|
||||||
|
export interface PSub { id: string; productId: string; groupId: string; state: string; versions: PVersion[] }
|
||||||
|
export interface PGroup { id: string; name: string; versions: PVersion[] }
|
||||||
|
export interface PAppVersion { id: string; versionString: string; state: string }
|
||||||
|
|
||||||
|
export interface SubmitState {
|
||||||
|
appVersions: PAppVersion[];
|
||||||
|
iaps: PIap[];
|
||||||
|
subs: PSub[];
|
||||||
|
groups: PGroup[];
|
||||||
|
/** The app's draft review submission (state READY_FOR_REVIEW), if one exists, and the version ids already in it. */
|
||||||
|
draft?: { id: string; itemVersionIds: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanItem { kind: ItemKind; versionId: string; label: string }
|
||||||
|
export interface SubmitPlan {
|
||||||
|
items: PlanItem[];
|
||||||
|
/** Product ids that are left out, with the reason. */
|
||||||
|
skipped: { id: string; why: string }[];
|
||||||
|
/** Types submitted for the first time (need an app version in the same submission). */
|
||||||
|
firstOfType: string[];
|
||||||
|
hasApprovedVersion: boolean;
|
||||||
|
/** An app version must be in this submission (first of a type, or no approved app version yet). */
|
||||||
|
needsAppVersion: boolean;
|
||||||
|
/** The editable app version that would go in (with withVersion). */
|
||||||
|
appVersion?: PAppVersion;
|
||||||
|
/** Problems that stop the submission (nothing is sent while any is left). */
|
||||||
|
blockers: string[];
|
||||||
|
/** Requests up to and including adding the items (a draft the owner can also submit in App Store Connect). */
|
||||||
|
prepare: Req[];
|
||||||
|
/** The submit request itself (the owner's action). Uses the placeholder id "${submission}" when the draft is new. */
|
||||||
|
submit: Req;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (t: string): string => ({ CONSUMABLE: 'consumable', NON_CONSUMABLE: 'non-consumable', NON_RENEWING_SUBSCRIPTION: 'non-renewing subscription', AUTO_RENEWABLE: 'auto-renewable subscription' }[t] ?? t);
|
||||||
|
|
||||||
|
/** The newest version of a product (highest `version`, else the last listed). */
|
||||||
|
export function latestVersion(vs: PVersion[]): PVersion | undefined {
|
||||||
|
return [...vs].sort((a, b) => (a.version ?? 0) - (b.version ?? 0)).at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan for submitting `want` (product ids of in-app purchases and subscriptions). withVersion: put the editable app
|
||||||
|
* version into the same submission (needed for the first item of a type, or while the app has no approved version).
|
||||||
|
*/
|
||||||
|
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean } = {}): SubmitPlan {
|
||||||
|
const plan: SubmitPlan = { items: [], skipped: [], firstOfType: [], hasApprovedVersion: s.appVersions.some(v => APPROVED_APP.has(v.state)), needsAppVersion: false, blockers: [], prepare: [],
|
||||||
|
submit: { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}', note: 'submit for App Review', body: {} } };
|
||||||
|
const approvedTypes = new Set([...s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type), ...(s.subs.some(x => APPROVED_PRODUCT.has(x.state)) ? ['AUTO_RENEWABLE'] : [])]);
|
||||||
|
const firstTypes = new Set<string>();
|
||||||
|
const already = new Set(s.draft?.itemVersionIds ?? []);
|
||||||
|
const groupsNeeded = new Set<string>();
|
||||||
|
|
||||||
|
const pick = (id: string, productState: string, versions: PVersion[], kind: ItemKind, type: string): void => {
|
||||||
|
if (productState === 'MISSING_METADATA') { plan.skipped.push({ id, why: 'missing metadata (price, localization or review screenshot): storebound apple iap / apple subscriptions / apple iap-review' }); return; }
|
||||||
|
if (productState === 'WAITING_FOR_UPLOAD' || productState === 'PROCESSING_CONTENT') { plan.skipped.push({ id, why: `hosted content ${productState}: content-hosted purchases can no longer be submitted` }); return; }
|
||||||
|
const v = latestVersion(versions);
|
||||||
|
if (!v) { plan.skipped.push({ id, why: 'no version on App Store Connect yet (create the product first: storebound apple iap / apple subscriptions)' }); return; }
|
||||||
|
if (IN_REVIEW.has(v.state)) { plan.skipped.push({ id, why: `already ${v.state}` }); return; }
|
||||||
|
if (!SUBMITTABLE.has(v.state)) { plan.skipped.push({ id, why: `latest version is ${v.state}: change it in App Store Connect (or --new-version) to send a new version` }); return; }
|
||||||
|
if (already.has(v.id)) { plan.skipped.push({ id, why: 'already in the draft submission' }); return; }
|
||||||
|
plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})` });
|
||||||
|
if (!approvedTypes.has(type)) firstTypes.add(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const id of want) {
|
||||||
|
const iap = s.iaps.find(p => p.productId === id);
|
||||||
|
const sub = s.subs.find(p => p.productId === id);
|
||||||
|
if (iap) pick(id, iap.state, iap.versions, 'inAppPurchaseVersion', iap.type);
|
||||||
|
else if (sub) {
|
||||||
|
const before = plan.items.length;
|
||||||
|
pick(id, sub.state, sub.versions, 'subscriptionVersion', 'AUTO_RENEWABLE');
|
||||||
|
if (plan.items.length > before) groupsNeeded.add(sub.groupId);
|
||||||
|
} else plan.skipped.push({ id, why: 'not on App Store Connect (storebound apple iap / apple subscriptions creates it)' });
|
||||||
|
}
|
||||||
|
for (const gid of groupsNeeded) {
|
||||||
|
const g = s.groups.find(x => x.id === gid);
|
||||||
|
if (!g) continue;
|
||||||
|
if (g.versions.some(v => v.state === 'APPROVED') || s.subs.some(x => x.groupId === gid && APPROVED_PRODUCT.has(x.state))) continue;
|
||||||
|
const v = latestVersion(g.versions);
|
||||||
|
if (!v) { plan.blockers.push(`subscription group "${g.name}" has no version to submit`); continue; }
|
||||||
|
if (IN_REVIEW.has(v.state) || already.has(v.id)) continue;
|
||||||
|
if (!SUBMITTABLE.has(v.state)) { plan.blockers.push(`subscription group "${g.name}": version is ${v.state}`); continue; }
|
||||||
|
plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)` });
|
||||||
|
}
|
||||||
|
plan.firstOfType = [...firstTypes].map(typeLabel);
|
||||||
|
plan.needsAppVersion = plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion);
|
||||||
|
if (plan.needsAppVersion) {
|
||||||
|
const editable = s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state));
|
||||||
|
const why = firstTypes.size ? `first ${plan.firstOfType.join(' / ')} of this app: Apple reviews it only together with a new app version`
|
||||||
|
: 'the app has no approved version yet: in-app purchases go together with an app version';
|
||||||
|
if (!o.withVersion) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
|
||||||
|
else if (!editable) plan.blockers.push(`${why}, and there is no editable app version: storebound apple listing --version x.y.z --build N first`);
|
||||||
|
else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}` }); }
|
||||||
|
}
|
||||||
|
if (plan.items.length + (s.draft?.itemVersionIds.length ?? 0) > MAX_SUBMISSION_ITEMS) plan.blockers.push(`more than ${MAX_SUBMISSION_ITEMS} items in one submission: use --only for a part`);
|
||||||
|
const sid = s.draft?.id ?? '${submission}';
|
||||||
|
if (!s.draft && plan.items.length) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', '${app}') } } } });
|
||||||
|
for (const it of plan.items) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissionItems', note: it.label,
|
||||||
|
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', sid), [it.kind]: one(ITEM_TYPE[it.kind], it.versionId) } } } });
|
||||||
|
plan.submit = { method: 'PATCH', path: `/v1/reviewSubmissions/${sid}`, note: 'submit for App Review', body: { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } } };
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills the placeholders of a planned request (app id, the id of the draft created a moment ago). Pure. */
|
||||||
|
export function bindIds(r: Req, ids: { app?: string; submission?: string }): Req {
|
||||||
|
const text = JSON.stringify(r).replaceAll('${app}', ids.app ?? '${app}').replaceAll('${submission}', ids.submission ?? '${submission}');
|
||||||
|
return JSON.parse(text) as Req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/inAppPurchaseVersions | /v1/subscriptionVersions: a new draft version of an approved (or rejected)
|
||||||
|
* product, "capturing its current localized metadata and review images for App Review submission". */
|
||||||
|
export function newVersionReq(kind: 'iap' | 'sub', productApiId: string, label: string): Req {
|
||||||
|
return kind === 'iap'
|
||||||
|
? { method: 'POST', path: '/v1/inAppPurchaseVersions', note: `new version of ${label}`, body: { data: { type: 'inAppPurchaseVersions', relationships: { inAppPurchase: one('inAppPurchases', productApiId) } } } }
|
||||||
|
: { method: 'POST', path: '/v1/subscriptionVersions', note: `new version of ${label}`, body: { data: { type: 'subscriptionVersions', relationships: { subscription: one('subscriptions', productApiId) } } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versions response -> PVersion list. Pure. */
|
||||||
|
export const versionsOf = (data: { id: string; attributes?: { version?: number; state?: string } }[]): PVersion[] =>
|
||||||
|
data.map(v => ({ id: v.id, version: v.attributes?.version, state: v.attributes?.state ?? '?' }));
|
||||||
122
cli/lib/triage.ts
Normal file
122
cli/lib/triage.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// Crash and feedback triage, as pure parts: crash signatures from TestFlight crash logs, and clusters by signature,
|
||||||
|
// device and OS version for TestFlight feedback and Google Play vitals error reports. The command (cli/triage.ts)
|
||||||
|
// reads (GET only), groups and prints a table or --json. Tester emails are never read into the output (the feedback
|
||||||
|
// requests ask only for the fields listed below).
|
||||||
|
// Sources (checked October 2026):
|
||||||
|
// TestFlight feedback (App Store Connect API 4.0+, OpenAPI 4.5.1): GET /v1/apps/{id}/betaFeedbackCrashSubmissions,
|
||||||
|
// GET /v1/apps/{id}/betaFeedbackScreenshotSubmissions (createdDate, comment, deviceModel, osVersion, locale,
|
||||||
|
// appPlatform, deviceFamily, build relationship; filter[build], sort=-createdDate, fields[...]),
|
||||||
|
// GET /v1/betaFeedbackCrashSubmissions/{id}/crashLog -> BetaCrashLog.logText.
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/prerelease-versions-and-beta-testers
|
||||||
|
// Google Play Developer Reporting API v1beta1: errorIssues:search (issues = Play's own clusters: type, cause,
|
||||||
|
// location, errorReportCount, distinctUsers) and errorReports:search (filter "errorIssueId = <id>", interval
|
||||||
|
// hour-aligned UTC, pageSize <= 100; ErrorReport: name, type, reportText, issue, eventTime, deviceModel
|
||||||
|
// {deviceId {buildBrand, buildDevice}, marketingName}, osVersion {apiLevel}, appVersion {versionCode}).
|
||||||
|
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports/search
|
||||||
|
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports
|
||||||
|
|
||||||
|
/** Fields read for feedback (no email, no tester relationship). */
|
||||||
|
export const FEEDBACK_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build';
|
||||||
|
export const CRASH_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build,crashLog';
|
||||||
|
|
||||||
|
const cleanFrame = (s: string): string => s.replace(/\s+\+\s+\d+$/, '').replace(/0x[0-9a-f]+/gi, '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One crash signature from a crash log: "<exception type> @ <first frame of the crashed thread in the app>" (else the
|
||||||
|
* first frame). Handles the text crash report format and the JSON .ips format (header line + body). Pure.
|
||||||
|
*/
|
||||||
|
export function crashSignature(log: string): string {
|
||||||
|
const text = log.trim();
|
||||||
|
// .ips: first line is a JSON header, the rest a JSON body
|
||||||
|
const nl = text.indexOf('\n');
|
||||||
|
if (text.startsWith('{') && nl > 0) {
|
||||||
|
try {
|
||||||
|
const head = JSON.parse(text.slice(0, nl)) as { app_name?: string; name?: string };
|
||||||
|
const body = JSON.parse(text.slice(nl + 1)) as { exception?: { type?: string; signal?: string }; faultingThread?: number; threads?: { triggered?: boolean; frames?: { imageIndex?: number; symbol?: string; imageOffset?: number }[] }[]; usedImages?: { name?: string }[]; procName?: string };
|
||||||
|
const ex = [body.exception?.type, body.exception?.signal].filter(Boolean).join(' ') || 'crash';
|
||||||
|
const t = body.threads?.[body.faultingThread ?? -1] ?? body.threads?.find(x => x.triggered);
|
||||||
|
const app = body.procName ?? head.app_name ?? head.name;
|
||||||
|
const frames = (t?.frames ?? []).map(f => ({ image: body.usedImages?.[f.imageIndex ?? -1]?.name ?? '?', symbol: f.symbol ?? `+${f.imageOffset ?? '?'}` }));
|
||||||
|
const f = frames.find(x => app && x.image === app) ?? frames[0];
|
||||||
|
return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex;
|
||||||
|
} catch { /* fall through to the text format */ }
|
||||||
|
}
|
||||||
|
const ex = /^Exception Type:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? /^Termination Reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? 'crash';
|
||||||
|
const proc = /^Process:\s*([^\s\[]+)/m.exec(text)?.[1];
|
||||||
|
const crashed = /^Crashed Thread:\s*(\d+)/m.exec(text)?.[1];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const at = lines.findIndex(l => (crashed !== undefined ? new RegExp(`^Thread ${crashed} Crashed`) : /^Thread \d+ Crashed/).test(l));
|
||||||
|
const block: string[] = [];
|
||||||
|
if (at >= 0) for (const l of lines.slice(at + 1)) { if (!l.trim()) break; block.push(l); }
|
||||||
|
const frames = block.map(l => /^\d+\s+(\S+)\s+0x[0-9a-f]+\s+(.+)$/i.exec(l.trim())).filter((m): m is RegExpExecArray => !!m).map(m => ({ image: m[1]!, symbol: m[2]! }));
|
||||||
|
const f = frames.find(x => proc && x.image === proc) ?? frames[0];
|
||||||
|
return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriageItem { signature: string; device: string; os: string; build?: string; date?: string; comment?: string }
|
||||||
|
export interface Cluster { signature: string; count: number; devices: [string, number][]; os: [string, number][]; builds: [string, number][]; first?: string; last?: string; comments?: string[] }
|
||||||
|
|
||||||
|
const top = (xs: string[], n = 3): [string, number][] => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
|
||||||
|
return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Items -> clusters by signature, most frequent first, each with its top devices, OS versions and builds. Pure. */
|
||||||
|
export function cluster(items: TriageItem[], o: { comments?: number } = {}): Cluster[] {
|
||||||
|
const by = new Map<string, TriageItem[]>();
|
||||||
|
for (const i of items) by.set(i.signature, [...(by.get(i.signature) ?? []), i]);
|
||||||
|
return [...by.entries()].map(([signature, xs]) => {
|
||||||
|
const dates = xs.map(x => x.date ?? '').filter(Boolean).sort();
|
||||||
|
const comments = o.comments ? xs.map(x => (x.comment ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, o.comments).map(c => c.slice(0, 120)) : undefined;
|
||||||
|
return { signature, count: xs.length, devices: top(xs.map(x => x.device)), os: top(xs.map(x => x.os)), builds: top(xs.map(x => x.build ?? '?')),
|
||||||
|
...(dates.length ? { first: dates[0], last: dates.at(-1) } : {}), ...(comments?.length ? { comments } : {}) };
|
||||||
|
}).sort((a, b) => b.count - a.count || a.signature.localeCompare(b.signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TestFlight feedback (crash or screenshot submissions) -> items. Screenshot feedback has no crash: its signature is
|
||||||
|
* "feedback". Pure. */
|
||||||
|
export function feedbackItems(subs: { id: string; attributes?: Record<string, unknown>; relationships?: { build?: { data?: { id: string } | null } } }[], builds: Map<string, string>, signatures: Map<string, string> = new Map()): TriageItem[] {
|
||||||
|
return subs.map(s => {
|
||||||
|
const a = s.attributes ?? {};
|
||||||
|
return { signature: signatures.get(s.id) ?? 'feedback', device: String(a.deviceModel ?? '?'), os: String(a.osVersion ?? '?'),
|
||||||
|
build: builds.get(s.relationships?.build?.data?.id ?? '') ?? undefined, date: typeof a.createdDate === 'string' ? a.createdDate : undefined,
|
||||||
|
comment: typeof a.comment === 'string' ? a.comment : undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayIssueRef { id: string; type: string; cause: string; location: string; reports: number; users: number }
|
||||||
|
|
||||||
|
/** errorIssues:search -> issue refs with their id (the last part of `name`). Pure. */
|
||||||
|
export function playIssues(resp: { errorIssues?: { name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string }[] }): PlayIssueRef[] {
|
||||||
|
return (resp.errorIssues ?? []).map(i => ({ id: (i.name ?? '').split('/').pop() ?? '', type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '',
|
||||||
|
location: i.location ?? '', reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0) })).filter(i => i.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errorReports:search -> items with the signature of their issue. Pure. */
|
||||||
|
export function playReportItems(resp: { errorReports?: { issue?: string; type?: string; eventTime?: string; deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } }; osVersion?: { apiLevel?: string }; appVersion?: { versionCode?: string } }[] }, issues: PlayIssueRef[]): TriageItem[] {
|
||||||
|
const sig = new Map(issues.map(i => [i.id, `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`] as const));
|
||||||
|
return (resp.errorReports ?? []).map(r => {
|
||||||
|
const id = (r.issue ?? '').split('/').pop() ?? '';
|
||||||
|
const dev = r.deviceModel?.marketingName || [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/') || '?';
|
||||||
|
return { signature: sig.get(id) ?? `${r.type ?? '?'} issue ${id}`, device: dev, os: r.osVersion?.apiLevel ? `API ${r.osVersion.apiLevel}` : '?', build: r.appVersion?.versionCode, date: r.eventTime };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errorReports:search query string for one issue (UTC, hour-aligned interval, newest window). Pure. */
|
||||||
|
export function reportsQuery(issueId: string, start: Date, end: Date, pageSize = 100): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, d] of [['start', start], ['end', end]] as const) {
|
||||||
|
p.set(`interval.${k}Time.year`, String(d.getUTCFullYear())); p.set(`interval.${k}Time.month`, String(d.getUTCMonth() + 1)); p.set(`interval.${k}Time.day`, String(d.getUTCDate()));
|
||||||
|
p.set(`interval.${k}Time.hours`, '0'); p.set(`interval.${k}Time.timeZone.id`, 'UTC');
|
||||||
|
}
|
||||||
|
p.set('filter', `errorIssueId = ${issueId}`);
|
||||||
|
p.set('pageSize', String(Math.min(100, pageSize)));
|
||||||
|
return p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table rows for a cluster list. Pure. */
|
||||||
|
export function clusterRows(cs: Cluster[]): (string | number)[][] {
|
||||||
|
const fmt = (xs: [string, number][]) => xs.map(([k, n]) => `${k} (${n})`).join(', ');
|
||||||
|
return cs.map(c => [c.count, c.signature.slice(0, 70), fmt(c.devices), fmt(c.os), fmt(c.builds)]);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// User-facing release notes from commit subjects, and writing them into the two listing files. Pure.
|
// User-facing release notes from commit subjects, and writing them into the two listing files. Pure.
|
||||||
// The draft is a starting point for a human: commit messages are written for developers, notes are for users.
|
// The draft is a starting point for a human: commit messages are written for developers, notes are for users.
|
||||||
|
import type { Language } from './config';
|
||||||
|
import { playCodes } from './config';
|
||||||
import { len, PLAY_LIMITS } from './copy';
|
import { len, PLAY_LIMITS } from './copy';
|
||||||
|
|
||||||
export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const;
|
export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const;
|
||||||
|
|
@ -98,3 +100,69 @@ export function setAppleWhatsNew(md: string, locale: string, text: string): { md
|
||||||
});
|
});
|
||||||
return { md: out.join(''), changed };
|
return { md: out.join(''), changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- many languages: one file per language, translated by your agent (whats-new skill) or a provider hook ----
|
||||||
|
// Limits per store (what each store enforces for the release-notes field):
|
||||||
|
// Google Play "What's new" (edits.tracks releaseNotes / Play Console): 500 characters per language.
|
||||||
|
// App Store "What's New in This Version" (appStoreVersionLocalizations.whatsNew): 4000 characters.
|
||||||
|
|
||||||
|
export type NotesStatus = 'source' | 'translate' | 'translated';
|
||||||
|
export interface NotesFile { lang: string; source: string; status: NotesStatus; text: string }
|
||||||
|
|
||||||
|
const HEAD_RE = /^<!--\s*whats-new\s+lang=(\S+)\s+source=(\S+)\s+status=(source|translate|translated)\s*-->\n?/;
|
||||||
|
|
||||||
|
/** One language file: a header line the tools read, then the text. Pure. */
|
||||||
|
export function notesFileText(f: NotesFile): string {
|
||||||
|
return `<!-- whats-new lang=${f.lang} source=${f.source} status=${f.status} -->\n${f.text.trim()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a language file; a file without the header is taken as translated text. Pure. */
|
||||||
|
export function parseNotesFile(text: string, lang: string): NotesFile {
|
||||||
|
const m = HEAD_RE.exec(text);
|
||||||
|
if (!m) return { lang, source: '', status: 'translated', text: text.trim() };
|
||||||
|
return { lang: m[1]!, source: m[2]!, status: m[3] as NotesStatus, text: text.slice(m[0].length).trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --languages values (config language ids, App Store locales or Play codes) -> language entries, in that order. */
|
||||||
|
export function resolveLanguages(langs: Language[], wanted: string[]): Language[] {
|
||||||
|
const out: Language[] = [];
|
||||||
|
for (const w of wanted) {
|
||||||
|
const l = langs.find(x => x.id === w) ?? langs.find(x => x.apple === w) ?? langs.find(x => playCodes(x).includes(w));
|
||||||
|
if (!l) throw new Error(`--languages: "${w}" is not a language of the config (ids: ${langs.map(x => x.id).join(', ')})`);
|
||||||
|
if (!out.includes(l)) out.push(l);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LangCheck { lang: string; chars: number; play?: { limit: number; ok: boolean }; apple?: { limit: number; ok: boolean }; problems: string[] }
|
||||||
|
|
||||||
|
/** Limits and wording of one language's notes, for the stores that language is listed on. Pure. */
|
||||||
|
export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string): LangCheck {
|
||||||
|
const chars = len(f.text);
|
||||||
|
const c: LangCheck = { lang: l.id, chars, problems: [] };
|
||||||
|
if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: chars <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${chars} > ${NOTES_LIMITS.play} characters`); }
|
||||||
|
if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: chars <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${chars} > ${NOTES_LIMITS.apple} characters`); }
|
||||||
|
if (!f.text) c.problems.push('empty');
|
||||||
|
if (f.status === 'translate') c.problems.push('not translated yet (status=translate)');
|
||||||
|
else if (sourceText !== undefined && f.status !== 'source' && f.text === sourceText.trim()) c.problems.push('identical to the source language: translate it');
|
||||||
|
const hook = f.text.split('\n')[0] ?? '';
|
||||||
|
if (len(hook) > HOOK_CHARS) c.problems.push(`first line is ${len(hook)} characters; about ${HOOK_CHARS} show before "more"`);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale). Pure. */
|
||||||
|
export function applyLanguageNotes(md: { play?: string; apple?: string }, l: Language, text: string): { play?: string; apple?: string; changed: string[] } {
|
||||||
|
const out: { play?: string; apple?: string; changed: string[] } = { ...md, changed: [] };
|
||||||
|
if (md.play !== undefined) for (const code of playCodes(l)) {
|
||||||
|
const r = setPlayWhatsNew(out.play!, code, text);
|
||||||
|
out.play = r.md;
|
||||||
|
out.changed.push(...r.changed.map(x => `play:${x}`));
|
||||||
|
}
|
||||||
|
if (md.apple !== undefined && l.apple) {
|
||||||
|
const r = setAppleWhatsNew(out.apple!, l.apple, text);
|
||||||
|
out.apple = r.md;
|
||||||
|
out.changed.push(...r.changed.map(x => `apple:${x}`));
|
||||||
|
}
|
||||||
|
out.changed = [...new Set(out.changed)];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
|
||||||
32
cli/lib/zip.ts
Normal file
32
cli/lib/zip.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Minimal ZIP reader for the Play financial report exports (earnings_YYYYMM.zip, salesreport_YYYYMM.zip: one CSV
|
||||||
|
// inside). Reads the central directory, supports stored (0) and deflate (8) entries; no ZIP64, no encryption. Uses
|
||||||
|
// node:zlib (built into Bun), so storebound keeps zero runtime dependencies. Pure.
|
||||||
|
import { inflateRawSync } from 'node:zlib';
|
||||||
|
|
||||||
|
export interface ZipEntry { name: string; data: Uint8Array }
|
||||||
|
|
||||||
|
export function unzip(buf: Uint8Array): ZipEntry[] {
|
||||||
|
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||||
|
// end of central directory: signature 0x06054b50 within the last 64 KiB + 22 bytes
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65_557); i--) if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
||||||
|
if (eocd < 0) throw new Error('not a zip file (no end of central directory)');
|
||||||
|
const count = dv.getUint16(eocd + 10, true);
|
||||||
|
let p = dv.getUint32(eocd + 16, true);
|
||||||
|
const out: ZipEntry[] = [];
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory entry');
|
||||||
|
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true), nameLen = dv.getUint16(p + 28, true);
|
||||||
|
const extraLen = dv.getUint16(p + 30, true), commentLen = dv.getUint16(p + 32, true), local = dv.getUint32(p + 42, true);
|
||||||
|
const name = dec.decode(buf.subarray(p + 46, p + 46 + nameLen));
|
||||||
|
if (dv.getUint32(local, true) !== 0x04034b50) throw new Error(`zip: bad local header for ${name}`);
|
||||||
|
const start = local + 30 + dv.getUint16(local + 26, true) + dv.getUint16(local + 28, true);
|
||||||
|
const raw = buf.subarray(start, start + size);
|
||||||
|
if (method === 0) out.push({ name, data: raw });
|
||||||
|
else if (method === 8) out.push({ name, data: new Uint8Array(inflateRawSync(raw)) });
|
||||||
|
else throw new Error(`zip: ${name} uses compression method ${method}`);
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -211,6 +211,74 @@ export const TOOLS: ToolDef[] = [
|
||||||
props: { dir: { type: 'string', pattern: '^(?!/)(?!.*\\.\\.)(?!.*,/)[A-Za-z0-9._ /,-]{1,400}$', description: 'fastlane folder(s) relative to the project, comma-separated (default: fastlane, ios/fastlane, android/fastlane)' },
|
props: { dir: { type: 'string', pattern: '^(?!/)(?!.*\\.\\.)(?!.*,/)[A-Za-z0-9._ /,-]{1,400}$', description: 'fastlane folder(s) relative to the project, comma-separated (default: fastlane, ios/fastlane, android/fastlane)' },
|
||||||
profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], description: 'Default: game for the GAMES category, else app' } },
|
profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], description: 'Default: game for the GAMES category, else app' } },
|
||||||
args: i => [...opt('dir', i.dir), ...opt('profile', i.profile)] },
|
args: i => [...opt('dir', i.dir), ...opt('profile', i.profile)] },
|
||||||
|
|
||||||
|
// ---- store operations (read-only plans, local pulls and reports; submits, codes, offers and pre-orders stay terminal-only) ----
|
||||||
|
{ name: 'apple_testflight_wait', title: 'Wait for a TestFlight build', command: ['apple', 'testflight', 'wait'], write: false, network: true,
|
||||||
|
description: 'Poll one uploaded build (exact number) until processing ends: exit 0 VALID, 1 FAILED / INVALID, 7 timeout. Read-only. check_once reads the state once without waiting.',
|
||||||
|
props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number (CFBundleVersion) you uploaded' }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' },
|
||||||
|
timeout_minutes: { type: 'integer', minimum: 1, maximum: 60, default: 30 }, check_once: { type: 'boolean', default: false } },
|
||||||
|
required: ['build'], args: i => [...opt('build', i.build), ...opt('version', i.version), '--timeout', `${Number(i.timeout_minutes ?? 30)}m`, '--json', ...flag('dry', i.check_once)] },
|
||||||
|
{ name: 'apple_iap_review_plan', title: 'IAP review submission plan', command: ['apple', 'iap-submit'], write: false, network: true,
|
||||||
|
description: 'Read-only plan for sending in-app purchases and subscriptions to App Review: which version of each goes in, which are the first of their type (Apple reviews those only with a new app version), what is left out and why. Submitting is an owner-only terminal command.',
|
||||||
|
props: { only, with_version: { type: 'boolean', default: false, description: 'Plan with the editable app version in the same submission' } },
|
||||||
|
args: i => [...opt('only', i.only), ...flag('with-version', i.with_version), '--json', '--dry'] },
|
||||||
|
{ name: 'apple_listing_pull', title: 'Pull App Store texts', command: ['apple', 'listing', 'pull'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Read the live App Store texts (name, subtitle, description, keywords, promotional text, what\'s new per locale) and diff them against the App Store listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).',
|
||||||
|
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'Version to read (default: the editable one, else the newest)' } },
|
||||||
|
args: i => [...opt('version', i.version), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||||
|
{ name: 'play_listing_pull', title: 'Pull Google Play texts', command: ['play', 'listing', 'pull'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Read the live Play listings (title, short and full description per language; one throwaway edit, deleted) and diff them against the Play listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).',
|
||||||
|
args: i => ['--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||||
|
{ name: 'catalog_pull', title: 'Pull IAP and subscription texts', command: ['catalog', 'pull'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.',
|
||||||
|
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||||
|
{ name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true,
|
||||||
|
description: 'Draft release notes from git into one file per language (store/whats-new/<language id>.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.',
|
||||||
|
props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } },
|
||||||
|
required: ['languages'], args: i => [...opt('languages', i.languages), ...opt('since', i.since), '--json'] },
|
||||||
|
{ name: 'whats_new_check', title: 'Check release notes per language', command: ['whats-new', 'check'], write: false, network: false,
|
||||||
|
description: 'Offline: the per-language release notes files against the store limits (Google Play 500, App Store 4000 characters), untranslated or identical-to-source files, long first lines.',
|
||||||
|
args: () => ['--json'] },
|
||||||
|
{ name: 'whats_new_apply', title: 'Apply release notes per language', command: ['whats-new', 'apply'], write: true, network: false, idempotent: true,
|
||||||
|
description: 'Write the checked per-language release notes into both listing files (Play "What\'s new" per Play code, App Store whatsNew per locale). Refuses while a language is over a limit or untranslated. Local files only; nothing is sent to a store.',
|
||||||
|
args: () => ['--json'] },
|
||||||
|
{ name: 'reports', title: 'Sales and finance reports', command: ['reports'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Download a sales or finance report into .storebound/reports/ (gitignored) and print a summary per product and currency (amounts never added across currencies) plus a summary CSV. Apple needs ASC_VENDOR_NUMBER and a team key; Play needs play.reportsBucket and "View financial data". dry_run shows the request only.',
|
||||||
|
props: { kind: { type: 'string', enum: ['apple-sales', 'apple-finance', 'play-earnings', 'play-sales'] }, month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM (apple-finance: Apple fiscal month; Play reports)' },
|
||||||
|
date: { type: 'string', pattern: '^\\d{4}(-\\d{2}(-\\d{2})?)?$', description: 'apple-sales report date (DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY)' },
|
||||||
|
frequency: { type: 'string', enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }, type: { type: 'string', enum: ['SALES', 'PRE_ORDER', 'SUBSCRIPTION', 'SUBSCRIPTION_EVENT', 'SUBSCRIBER', 'SUBSCRIPTION_OFFER_CODE_REDEMPTION', 'WIN_BACK_ELIGIBILITY'] } },
|
||||||
|
required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] },
|
||||||
|
{ name: 'triage', title: 'Crash and feedback triage', command: ['triage'], write: false, network: true,
|
||||||
|
description: 'Read-only: TestFlight crash reports (signature from each crash log), TestFlight screenshot feedback and Google Play error reports of the top issues, grouped by signature with their top devices, OS versions and builds. Tester emails are never read.',
|
||||||
|
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 14 }, build: { type: 'string', pattern: '^[0-9][0-9.]*$' },
|
||||||
|
issues: { type: 'integer', minimum: 1, maximum: 50, default: 10 } },
|
||||||
|
args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days), ...opt('build', i.build), ...opt('issues', i.issues), '--json'] },
|
||||||
|
{ name: 'aso_rank', title: 'App Store keyword ranks', command: ['aso', 'rank'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Rank of the app per keyword (the keyword field of store/appstore.md, plus extra keywords) and storefront in Apple\'s public iTunes Search API results, with the top competitors. One request every 3.5 s (Apple allows about 20 a minute). dry_run lists the queries; with dry_run false it queries and saves .storebound/aso/<date>.json. Search API order is close to, not equal to, App Store search.',
|
||||||
|
props: { storefronts: { type: 'string', pattern: '^[a-z]{2}(=[A-Za-z-]+)?(,[a-z]{2}(=[A-Za-z-]+)?)*$', description: 'e.g. us,de=de-DE (default us)' }, keywords: { type: 'string', description: 'Extra keywords, comma-separated' },
|
||||||
|
competitors: { type: 'integer', minimum: 0, maximum: 10, default: 3 } },
|
||||||
|
args: i => [...opt('storefronts', i.storefronts), ...opt('keywords', i.keywords), ...opt('competitors', i.competitors), '--json'] },
|
||||||
|
{ name: 'ads_scan', title: 'AdAttributionKit scan', command: ['ads', 'scan'], write: false, network: false,
|
||||||
|
description: 'Local scan of an iOS project for AdAttributionKit and SKAdNetwork: ad network ids (SKAdNetworkItems, AdNetworkIdentifiers, lowercase), postback copy endpoints (AttributionCopyEndpoint, re-engagement copies, NSAdvertisingAttributionReportEndpoint), attribution windows and cooldown, conversion value updates in code, ad SDKs without ids.',
|
||||||
|
props: { ios: { type: 'string', description: 'Path of the iOS project folder, e.g. ios/App' } }, required: ['ios'], args: i => [...opt('ios', i.ios), '--json'] },
|
||||||
|
{ name: 'apple_cpp_list', title: 'Custom product pages', command: ['apple', 'cpp', 'list'], write: false, network: true,
|
||||||
|
description: 'Read-only list of the App Store custom product pages (visible or not, version state, URL) and the configured ones not created yet (at most 70 per app).', args: () => ['--json'] },
|
||||||
|
{ name: 'apple_cpp_create', title: 'Create custom product pages', command: ['apple', 'cpp', 'create'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Create the custom product pages of apple.customProductPages that are missing (with a localization per App Store locale, promotional text, deep link) and update editable ones. Nothing is shown to customers: submitting a page and making it visible are owner-only terminal commands.',
|
||||||
|
props: { name: { type: 'string', description: 'Only this page' } }, args: i => opt('name', i.name) },
|
||||||
|
{ name: 'apple_cpp_upload', title: 'Upload custom product page screenshots', command: ['apple', 'cpp', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
||||||
|
description: 'Sync the screenshots of a custom product page from the renders of its shots variant, per locale (MD5-idempotent; changed sets are replaced). Only on an editable page version.',
|
||||||
|
props: { name: { type: 'string', description: 'Page name from apple.customProductPages' }, only }, required: ['name'], args: i => [...opt('name', i.name), ...opt('only', i.only)] },
|
||||||
|
{ name: 'apple_events_list', title: 'In-app events', command: ['apple', 'events', 'list'], write: false, network: true,
|
||||||
|
description: 'Read-only list of the App Store in-app events (badge, state, schedule) and the configured ones not created yet (10 published / 15 approved per app).', args: () => ['--json'] },
|
||||||
|
{ name: 'apple_events_create', title: 'Create in-app events', command: ['apple', 'events', 'create'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Create the in-app events of apple.events that are missing (reference name, badge, purpose, priority, deep link, schedule, localizations with name 30 / short 50 / long 120 characters) and update draft ones. Sending an event to App Review is an owner-only terminal command.',
|
||||||
|
props: { name: { type: 'string', description: 'Only this event (reference name)' } }, args: i => opt('name', i.name) },
|
||||||
|
{ name: 'apple_events_upload', title: 'Upload in-app event images', command: ['apple', 'events', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
||||||
|
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
|
||||||
|
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
|
||||||
|
{ name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true,
|
||||||
|
description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@
|
||||||
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo
|
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo
|
||||||
// video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with
|
// video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with
|
||||||
// `storebound shots video --target youtube`, upload it to YouTube (public or unlisted, ads off, embeddable), paste the URL.
|
// `storebound shots video --target youtube`, upload it to YouTube (public or unlisted, ads off, embeddable), paste the URL.
|
||||||
import { readFileSync } from 'node:fs';
|
// And the other way round (read-only on Play, one throwaway edit that is deleted):
|
||||||
|
// storebound play listing pull [--write] [--json] live listings -> diff -> (with --write) the Play listing file
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { parsePlayListing } from '../lib/copy';
|
import { parsePlayListing } from '../lib/copy';
|
||||||
|
import { compactDiff } from '../lib/diff';
|
||||||
|
import { mergePlayListing, overLimits } from '../lib/listing-pull';
|
||||||
import type { Ctx } from '../lib/ctx';
|
import type { Ctx } from '../lib/ctx';
|
||||||
import { need } from '../lib/ctx';
|
import { need } from '../lib/ctx';
|
||||||
import { done, log, step } from '../lib/log';
|
import { done, log, printJson, step, warn } from '../lib/log';
|
||||||
import { playClient } from './client';
|
import { playClient } from './client';
|
||||||
|
|
||||||
export function readPlayCopy(ctx: Ctx) {
|
export function readPlayCopy(ctx: Ctx) {
|
||||||
|
|
@ -47,8 +51,33 @@ export function detailsChanges(current: Record<string, string | undefined>, want
|
||||||
return Object.keys(wanted).filter(k => (current[k] ?? '') !== (wanted[k] ?? ''));
|
return Object.keys(wanted).filter(k => (current[k] ?? '') !== (wanted[k] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pull(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||||
|
const now = await play.read(async id => (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []);
|
||||||
|
const file = resolve(config.play?.listing ?? 'store/listing.md');
|
||||||
|
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# Google Play listing copy\n\nPulled from Google Play by `storebound play listing pull`.\n';
|
||||||
|
const r = mergePlayListing(before, now, config.app.name);
|
||||||
|
const over = overLimits(r.changes, 'play');
|
||||||
|
const diff = compactDiff(before, r.md);
|
||||||
|
if (a.has('json')) printJson({ file, write: a.has('write') && !a.has('dry'), languages: now.map(l => l.language), changes: r.changes, added: r.added, conflicts: r.conflicts, overLimits: over, diff });
|
||||||
|
else {
|
||||||
|
log('play', `pull: ${now.length} listing(s) on Play`);
|
||||||
|
for (const ch of r.changes) log('play', `${ch.section} ${ch.field}: ${ch.from === null ? 'new' : 'changed'}`);
|
||||||
|
if (diff.length) console.log(diff.join('\n'));
|
||||||
|
}
|
||||||
|
for (const c of r.conflicts) warn('play', c);
|
||||||
|
for (const o of over) warn('play', `over the limit on the store: ${o}`);
|
||||||
|
if (!r.changes.length) { done('play', `${file} already matches the store`); return; }
|
||||||
|
if (!a.has('write') || a.has('dry')) { done('play', `${r.changes.length} change(s); nothing written (add --write to update ${file})`); return; }
|
||||||
|
writeFileSync(file, r.md);
|
||||||
|
done('play', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new languages ${r.added.join(', ')}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root } = ctx.loaded;
|
const { config, root } = ctx.loaded;
|
||||||
|
if (ctx.args._[0] === 'pull') return pull(ctx);
|
||||||
const pkg = need(config.play?.packageName, 'play.packageName');
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||||
const copy = readPlayCopy(ctx);
|
const copy = readPlayCopy(ctx);
|
||||||
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
|
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
|
||||||
|
|
|
||||||
121
cli/reports.ts
Normal file
121
cli/reports.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Sales and finance reports of both stores into .storebound/reports/ (gitignored), with a summary table and a CSV export.
|
||||||
|
// storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]
|
||||||
|
// storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]
|
||||||
|
// storebound reports play-earnings --month YYYY-MM
|
||||||
|
// storebound reports play-sales --month YYYY-MM
|
||||||
|
// common: [--out .storebound/reports] [--csv <summary.csv>] [--dry] [--json]
|
||||||
|
// The raw report is saved as it came (the TSV / the CSV out of the zip) next to a <name>.summary.csv with only
|
||||||
|
// aggregates (per product and currency; amounts are never added across currencies). --dry prints what would be
|
||||||
|
// fetched and fetches nothing. Apple needs a team API key (not an individual key) with access to reports and the vendor
|
||||||
|
// number (ASC_VENDOR_NUMBER in env or .storebound/credentials.json; --vendor). Play needs play.reportsBucket and the
|
||||||
|
// service account with "View financial data". Read-only on both stores. Pure parts and sources: cli/lib/reports.ts.
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { ascClient } from './apple/asc';
|
||||||
|
import { bucketName } from './lib/analytics';
|
||||||
|
import { ascVendorNumber } from './lib/credentials';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
import { need } from './lib/ctx';
|
||||||
|
import { toCsv } from './lib/csv';
|
||||||
|
import { STORAGE_READ_SCOPE } from './lib/google-auth';
|
||||||
|
import { done, log, printJson, warn } from './lib/log';
|
||||||
|
import {
|
||||||
|
APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, decodeReport, parsePlayCsv, parseTsv, playReportPrefix, type SummaryRow, summarizeAppleFinance,
|
||||||
|
summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summaryCsvRows, totalsByCurrency,
|
||||||
|
} from './lib/reports';
|
||||||
|
import { textTable } from './lib/table';
|
||||||
|
import { unzip } from './lib/zip';
|
||||||
|
import { googleClient } from './play/google';
|
||||||
|
|
||||||
|
const TAG = 'reports';
|
||||||
|
const USAGE = 'usage: storebound reports apple-sales | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM';
|
||||||
|
|
||||||
|
function show(name: string, rows: SummaryRow[], json: boolean, extra: Record<string, unknown>): void {
|
||||||
|
const totals = totalsByCurrency(rows);
|
||||||
|
if (json) { printJson({ report: name, ...extra, summary: rows, totals }); return; }
|
||||||
|
console.log(rows.length ? textTable(['product', 'title', 'currency', 'units', 'amount'], rows.map(r => [r.key, r.title.slice(0, 30), r.currency, r.units, r.amount.toFixed(2)])) : 'no rows');
|
||||||
|
if (totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], totals.map(t => [t.currency, t.units, t.amount.toFixed(2)])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(dir: string, name: string, raw: string | Uint8Array, rows: SummaryRow[], csvOut: string | undefined): { raw: string; summary: string } {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const rawFile = join(dir, name);
|
||||||
|
writeFileSync(rawFile, raw);
|
||||||
|
const summary = csvOut ?? join(dir, name.replace(/\.(tsv|csv|txt)$/, '') + '.summary.csv');
|
||||||
|
mkdirSync(dirname(summary), { recursive: true });
|
||||||
|
writeFileSync(summary, toCsv(summaryCsvRows(rows)));
|
||||||
|
return { raw: rawFile, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const sub = a._[0];
|
||||||
|
if (!sub) throw new Error(USAGE);
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const out = resolve(a.get('out') ?? '.storebound/reports');
|
||||||
|
const csvOut = a.get('csv') ? resolve(a.get('csv')!) : undefined;
|
||||||
|
|
||||||
|
if (sub === 'apple-sales' || sub === 'apple-finance') {
|
||||||
|
const vendor = a.get('vendor') ?? ascVendorNumber(root);
|
||||||
|
if (!vendor || !/^\d{5,12}$/.test(vendor)) throw new Error('vendor number missing: ASC_VENDOR_NUMBER in env or .storebound/credentials.json, or --vendor (App Store Connect > Payments and Financial Reports)');
|
||||||
|
let path: string, name: string;
|
||||||
|
if (sub === 'apple-sales') {
|
||||||
|
const type = a.str('type', 'SALES').toUpperCase();
|
||||||
|
const frequency = a.str('frequency', APPLE_SALES_COMBOS[type]?.frequencies[0] ?? 'DAILY').toUpperCase();
|
||||||
|
const date = appleReportDate(frequency, a.get('date'));
|
||||||
|
path = appleSalesPath(vendor, { type, frequency, date });
|
||||||
|
name = `apple-${type.toLowerCase()}-${frequency.toLowerCase()}-${date}.tsv`;
|
||||||
|
} else {
|
||||||
|
const month = need(a.get('month'), '--month YYYY-MM');
|
||||||
|
const type = a.has('detail') ? 'FINANCE_DETAIL' as const : 'FINANCIAL' as const;
|
||||||
|
path = appleFinancePath(vendor, month, { type, region: a.get('region') });
|
||||||
|
name = `apple-${type === 'FINANCIAL' ? 'finance' : 'finance-detail'}-${month}.tsv`;
|
||||||
|
}
|
||||||
|
const shown = path.replace(vendor, '<vendor>');
|
||||||
|
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${shown}`, file: join(out, name) }); else log(TAG, `would GET ${shown} -> ${join(out, name)}`); return; }
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
log(TAG, `GET ${shown}`);
|
||||||
|
const gz = await c.bytes(path);
|
||||||
|
if (!gz) { done(TAG, 'no report for that date (no sales then, or not published yet: daily reports come out the next day, monthly ones about a week later)'); if (a.has('json')) printJson({ report: name, empty: true }); return; }
|
||||||
|
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? Bun.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
|
||||||
|
const rows = parseTsv(text);
|
||||||
|
const summary = sub === 'apple-sales' ? (a.str('type', 'SALES').toUpperCase() === 'SALES' ? summarizeAppleSales(rows) : []) : summarizeAppleFinance(rows);
|
||||||
|
const files = save(out, name, text, summary, csvOut);
|
||||||
|
show(name, summary, a.has('json'), { rows: rows.length, files });
|
||||||
|
if (sub === 'apple-sales' && a.str('type', 'SALES').toUpperCase() !== 'SALES') log(TAG, `${rows.length} rows saved; the summary table covers SALES reports, open the TSV for this type`);
|
||||||
|
done(TAG, `${rows.length} rows -> ${files.raw}; summary ${files.summary}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'play-earnings' || sub === 'play-sales') {
|
||||||
|
const bucket = config.play?.reportsBucket;
|
||||||
|
if (!bucket) throw new Error('play.reportsBucket is not set (Play Console > Download reports > Financial > Copy Cloud Storage URI)');
|
||||||
|
const month = need(a.get('month'), '--month YYYY-MM');
|
||||||
|
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
|
||||||
|
const prefix = playReportPrefix(kind, month);
|
||||||
|
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
|
||||||
|
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET gs://${bucketName(bucket)}/${prefix}*.zip`, dir: out }); else log(TAG, `would read gs://${bucketName(bucket)}/${prefix}*.zip -> ${out}`); return; }
|
||||||
|
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||||
|
const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
|
||||||
|
if (!items.length) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; }
|
||||||
|
const all: Record<string, string>[] = [];
|
||||||
|
let text = '';
|
||||||
|
for (const o of items) {
|
||||||
|
log(TAG, `gs://${bucketName(bucket)}/${o.name}`);
|
||||||
|
const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`);
|
||||||
|
for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) {
|
||||||
|
const t = decodeReport(e.data);
|
||||||
|
text += (text ? '\n' : '') + t;
|
||||||
|
all.push(...parsePlayCsv(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!all.length) warn(TAG, 'the zip(s) held no CSV rows');
|
||||||
|
const summary = kind === 'earnings' ? summarizePlayEarnings(all) : summarizePlaySales(all);
|
||||||
|
const files = save(out, `play-${kind}-${month}.csv`, text, summary, csvOut);
|
||||||
|
show(`play-${kind}-${month}`, summary, a.has('json'), { rows: all.length, files });
|
||||||
|
done(TAG, `${all.length} rows -> ${files.raw}; summary ${files.summary}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`unknown reports command "${sub}"\n${USAGE}`);
|
||||||
|
}
|
||||||
96
cli/triage.ts
Normal file
96
cli/triage.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Crash and feedback triage: TestFlight crash and screenshot feedback, and Google Play vitals error reports, grouped
|
||||||
|
// by signature with their top devices, OS versions and builds. Read-only on both stores.
|
||||||
|
// storebound triage [--apple] [--play] [--days 14] [--build N] [--issues 10] [--comments 3] [--dry] [--json]
|
||||||
|
// Apple: crash feedback -> the crash log of each (signature = exception + first app frame of the crashed thread);
|
||||||
|
// screenshot feedback grouped by device / OS / build (--comments N shows up to N tester comments per group, cut to
|
||||||
|
// 120 characters; emails are never read). Play: the top --issues issues of the window (Play's own clusters), then
|
||||||
|
// their error reports for the device / API level / versionCode spread. --dry lists the requests without sending.
|
||||||
|
// Pure parts and sources: cli/lib/triage.ts.
|
||||||
|
import { ascClient, type J } from './apple/asc';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
import { REPORTING_SCOPE } from './lib/google-auth';
|
||||||
|
import { issuesQuery, ymd } from './lib/health';
|
||||||
|
import { done, log, printJson, warn } from './lib/log';
|
||||||
|
import { textTable } from './lib/table';
|
||||||
|
import { CRASH_FIELDS, cluster, type Cluster, clusterRows, crashSignature, FEEDBACK_FIELDS, feedbackItems, playIssues, playReportItems, reportsQuery } from './lib/triage';
|
||||||
|
import { googleClient, REPORTING_API } from './play/google';
|
||||||
|
|
||||||
|
const TAG = 'triage';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const both = !a.has('apple') && !a.has('play');
|
||||||
|
const days = a.num('days', 14);
|
||||||
|
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||||
|
const result: { apple?: { crashes: Cluster[]; feedback: Cluster[] } | { error: string }; play?: { issues: Cluster[] } | { error: string } } = {};
|
||||||
|
|
||||||
|
if ((both || a.has('apple')) && config.apple) {
|
||||||
|
try {
|
||||||
|
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
||||||
|
const appId = dry ? '<app>' : await c.app();
|
||||||
|
const buildFilter = a.get('build');
|
||||||
|
let buildIds: string[] = [];
|
||||||
|
const builds = new Map<string, string>();
|
||||||
|
if (!dry) for (const b of (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=200${buildFilter ? `&filter[version]=${encodeURIComponent(buildFilter)}` : ''}`)).data as J[]) builds.set(b.id, b.attributes.version);
|
||||||
|
buildIds = [...builds.keys()];
|
||||||
|
if (buildFilter && !dry && !buildIds.length) throw new Error(`no build ${buildFilter}`);
|
||||||
|
const q = (fields: string, kind: string) => `/v1/apps/${appId}/${kind}?sort=-createdDate&limit=200&include=build&fields[builds]=version&fields[${kind}]=${fields}${buildFilter && buildIds.length ? `&filter[build]=${buildIds.join(',')}` : ''}`;
|
||||||
|
if (dry) {
|
||||||
|
log(TAG, `would GET ${q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions')} + each crashLog`);
|
||||||
|
log(TAG, `would GET ${q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions')}`);
|
||||||
|
} else {
|
||||||
|
const recent = (xs: J[]) => xs.filter(x => (x.attributes?.createdDate ?? '') >= since);
|
||||||
|
const crashes = recent(await c.all(q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions')));
|
||||||
|
log(TAG, `apple: ${crashes.length} TestFlight crash report(s) in ${days} days`);
|
||||||
|
const sigs = new Map<string, string>();
|
||||||
|
for (const s of crashes) {
|
||||||
|
const logText = (await c.maybe(`/v1/betaFeedbackCrashSubmissions/${s.id}/crashLog`))?.data?.attributes?.logText as string | undefined;
|
||||||
|
sigs.set(s.id, logText ? crashSignature(logText) : 'crash (no log)');
|
||||||
|
}
|
||||||
|
const shots = recent(await c.all(q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions')));
|
||||||
|
log(TAG, `apple: ${shots.length} TestFlight screenshot feedback(s)`);
|
||||||
|
const comments = a.num('comments', 0);
|
||||||
|
const fb = feedbackItems(shots, builds).map(i => ({ ...i, signature: `feedback on ${i.device}` }));
|
||||||
|
result.apple = { crashes: cluster(feedbackItems(crashes, builds, sigs)), feedback: cluster(fb, { comments }) };
|
||||||
|
}
|
||||||
|
} catch (e) { result.apple = { error: (e as Error).message }; warn(TAG, `apple: ${(e as Error).message.slice(0, 300)}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((both || a.has('play')) && config.play) {
|
||||||
|
try {
|
||||||
|
const app = `${REPORTING_API}/apps/${encodeURIComponent(config.play.packageName)}`;
|
||||||
|
const end = ymd(new Date(), 0);
|
||||||
|
const iq = issuesQuery(end, days, { pageSize: a.num('issues', 10) });
|
||||||
|
if (dry) log(TAG, `would GET ${app}/errorIssues:search?${iq} + errorReports:search per issue`);
|
||||||
|
else {
|
||||||
|
const g = googleClient(root, REPORTING_SCOPE);
|
||||||
|
const issues = playIssues(await g.json('GET', `${app}/errorIssues:search?${iq}`));
|
||||||
|
log(TAG, `play: ${issues.length} top issue(s) in ${days} days`);
|
||||||
|
const items = [];
|
||||||
|
const startD = new Date(Date.UTC(end.year, end.month - 1, end.day) - days * 86_400_000), endD = new Date(Date.UTC(end.year, end.month - 1, end.day));
|
||||||
|
for (const i of issues) items.push(...playReportItems(await g.json('GET', `${app}/errorReports:search?${reportsQuery(i.id, startD, endD)}`), issues));
|
||||||
|
const cs = cluster(items);
|
||||||
|
// issues with no sampled reports still show with Play's own counts
|
||||||
|
for (const i of issues) {
|
||||||
|
const s = `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`;
|
||||||
|
if (!cs.some(c => c.signature === s)) cs.push({ signature: s, count: 0, devices: [], os: [], builds: [] });
|
||||||
|
}
|
||||||
|
result.play = { issues: cs.map(c => ({ ...c, ...(() => { const i = issues.find(x => c.signature.startsWith(`${x.type} ${x.cause}`)); return i ? { reports: i.reports, users: i.users } : {}; })() })) };
|
||||||
|
}
|
||||||
|
} catch (e) { result.play = { error: (e as Error).message }; warn(TAG, `play: ${(e as Error).message.slice(0, 300)}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dry) { if (a.has('json')) printJson({ dryRun: true }); done(TAG, 'dry run: nothing read'); return; }
|
||||||
|
if (a.has('json')) { printJson(result); return; }
|
||||||
|
const head = ['n', 'signature', 'devices', 'os', 'builds'];
|
||||||
|
if (result.apple && !('error' in result.apple)) {
|
||||||
|
console.log(`\nTestFlight crashes (${days} days)\n` + (result.apple.crashes.length ? textTable(head, clusterRows(result.apple.crashes)) : 'none'));
|
||||||
|
console.log(`\nTestFlight feedback (${days} days)\n` + (result.apple.feedback.length ? textTable(head, clusterRows(result.apple.feedback)) : 'none'));
|
||||||
|
for (const c of result.apple.feedback) for (const t of c.comments ?? []) console.log(` ${c.signature}: "${t}"`);
|
||||||
|
}
|
||||||
|
if (result.play && !('error' in result.play)) console.log(`\nGoogle Play crashes and ANRs (${days} days; n = sampled reports)\n` + (result.play.issues.length ? textTable(head, clusterRows(result.play.issues)) : 'none'));
|
||||||
|
done(TAG, 'triage done (fix the top signature first; storebound health for the rates)');
|
||||||
|
}
|
||||||
103
cli/whats-new.ts
103
cli/whats-new.ts
|
|
@ -5,10 +5,78 @@
|
||||||
// --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write
|
// --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write
|
||||||
// (default: the Play default language and the first App Store locale); --all: write the same draft into every
|
// (default: the Play default language and the first App Store locale); --all: write the same draft into every
|
||||||
// language (to translate afterwards; `storebound check` warns while they are identical).
|
// language (to translate afterwards; `storebound check` warns while they are identical).
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
//
|
||||||
|
// Many languages, one file each (the CLI never calls a translation service and needs no key for one):
|
||||||
|
// storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate "<command>"] [--force] [--dry] [--json]
|
||||||
|
// the draft goes into the first language's file (status=source); every other language gets a file with the
|
||||||
|
// source text and status=translate for your agent to translate (whats-new skill), or the output of --translate:
|
||||||
|
// a command of yours run once per language with the source text on stdin and STOREBOUND_SOURCE_LANG /
|
||||||
|
// STOREBOUND_TARGET_LANG (App Store locale, else Play code, else language id) in the environment; its stdout is
|
||||||
|
// the translation. Existing translated files are kept unless --force.
|
||||||
|
// storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files
|
||||||
|
// storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Language } from './lib/config';
|
||||||
|
import { playCodes } from './lib/config';
|
||||||
import type { Ctx } from './lib/ctx';
|
import type { Ctx } from './lib/ctx';
|
||||||
import { log, warn } from './lib/log';
|
import { done, log, printJson, warn } from './lib/log';
|
||||||
import { checkNotes, formatNotes, NOTES_LIMITS, noteItems, setAppleWhatsNew, setPlayWhatsNew } from './lib/whats-new';
|
import { textTable } from './lib/table';
|
||||||
|
import {
|
||||||
|
applyLanguageNotes, checkLanguageNotes, checkNotes, formatNotes, type LangCheck, NOTES_LIMITS, type NotesFile, noteItems, notesFileText, parseNotesFile,
|
||||||
|
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
|
||||||
|
} from './lib/whats-new';
|
||||||
|
|
||||||
|
const TAG = 'whats-new';
|
||||||
|
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
|
||||||
|
|
||||||
|
/** The language files of a notes folder, in config order. */
|
||||||
|
function readDir(dir: string, langs: Language[]): { l: Language; f: NotesFile }[] {
|
||||||
|
if (!existsSync(dir)) throw new Error(`no ${dir} (storebound whats-new --languages ... writes it)`);
|
||||||
|
const names = new Set(readdirSync(dir).filter(n => n.endsWith('.md')).map(n => n.slice(0, -3)));
|
||||||
|
return langs.filter(l => names.has(l.id)).map(l => ({ l, f: parseNotesFile(readFileSync(join(dir, `${l.id}.md`), 'utf8'), l.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAll(files: { l: Language; f: NotesFile }[]): LangCheck[] {
|
||||||
|
const src = files.find(x => x.f.status === 'source');
|
||||||
|
return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function printChecks(checks: LangCheck[]): void {
|
||||||
|
console.log(textTable(['language', 'chars', 'play (500)', 'app store (4000)', 'problems'], checks.map(c => [c.lang, c.chars,
|
||||||
|
c.play ? (c.play.ok ? 'ok' : 'OVER') : '-', c.apple ? (c.apple.ok ? 'ok' : 'OVER') : '-', c.problems.join('; ')])));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): Promise<void> {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const dir = resolve(a.get('dir') ?? 'store/whats-new');
|
||||||
|
const langs = resolveLanguages(config.languages, a.list('languages'));
|
||||||
|
const [src, ...rest] = langs;
|
||||||
|
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500)
|
||||||
|
const text = playCodes(src!).length ? draft.play : draft.apple;
|
||||||
|
const files: { l: Language; f: NotesFile; write: boolean; note: string }[] = [{ l: src!, f: { lang: src!.id, source: src!.id, status: 'source', text }, write: true, note: 'draft (source)' }];
|
||||||
|
const hook = a.get('translate');
|
||||||
|
for (const l of rest) {
|
||||||
|
const path = join(dir, `${l.id}.md`);
|
||||||
|
const cur = existsSync(path) ? parseNotesFile(readFileSync(path, 'utf8'), l.id) : null;
|
||||||
|
if (cur && cur.status === 'translated' && !a.has('force')) { files.push({ l, f: cur, write: false, note: 'kept (translated; --force replaces it)' }); continue; }
|
||||||
|
if (hook && !a.has('dry')) {
|
||||||
|
const r = Bun.spawnSync(['sh', '-c', hook], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
|
||||||
|
env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src!), STOREBOUND_TARGET_LANG: storeCode(l), STOREBOUND_TARGET_LANG_ID: l.id } });
|
||||||
|
const out = r.stdout.toString().trim();
|
||||||
|
if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: 'to translate (hook failed)' }); continue; }
|
||||||
|
files.push({ l, f: { lang: l.id, source: src!.id, status: 'translated', text: out }, write: true, note: 'translated by the hook' });
|
||||||
|
} else files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: hook ? 'would run the --translate hook' : 'to translate (your agent: whats-new skill)' });
|
||||||
|
}
|
||||||
|
const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })));
|
||||||
|
if (a.has('json')) printJson({ dir, dryRun: a.has('dry'), files: files.map((x, i) => ({ lang: x.l.id, file: join(dir, `${x.l.id}.md`), status: x.f.status, write: x.write, note: x.note, check: checks[i] })) });
|
||||||
|
else { for (const x of files) log(TAG, `${join(dir, `${x.l.id}.md`)}: ${x.note}`); printChecks(checks); }
|
||||||
|
if (a.has('dry')) { done(TAG, 'dry run: nothing written'); return; }
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const x of files) if (x.write) writeFileSync(join(dir, `${x.l.id}.md`), notesFileText(x.f));
|
||||||
|
done(TAG, `${files.filter(x => x.write).length} file(s) in ${dir}; translate the status=translate ones, then storebound whats-new apply`);
|
||||||
|
}
|
||||||
|
|
||||||
function git(args: string[], cwd: string): string {
|
function git(args: string[], cwd: string): string {
|
||||||
const r = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
const r = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
|
@ -19,6 +87,31 @@ function git(args: string[], cwd: string): string {
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root, resolve } = ctx.loaded;
|
const { config, root, resolve } = ctx.loaded;
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
|
if (a._[0] === 'check' || a._[0] === 'apply') {
|
||||||
|
const dir = resolve(a.get('dir') ?? 'store/whats-new');
|
||||||
|
const files = readDir(dir, config.languages);
|
||||||
|
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
|
||||||
|
const checks = checkAll(files);
|
||||||
|
const bad = checks.filter(c => c.problems.some(p => !p.startsWith('first line')));
|
||||||
|
if (a._[0] === 'check' || bad.length) {
|
||||||
|
if (a.has('json')) printJson({ dir, checks, ok: !bad.length });
|
||||||
|
else printChecks(checks);
|
||||||
|
if (bad.length) { process.exitCode = 1; done(TAG, `${bad.length} language(s) not ready: ${bad.map(c => c.lang).join(', ')}${a._[0] === 'apply' ? '; nothing written' : ''}`); return; }
|
||||||
|
if (a._[0] === 'check') { done(TAG, `${checks.length} language(s) within the store limits`); return; }
|
||||||
|
}
|
||||||
|
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : undefined;
|
||||||
|
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : undefined;
|
||||||
|
let md: { play?: string; apple?: string } = { play: playFile ? readFileSync(playFile, 'utf8') : undefined, apple: appleFile ? readFileSync(appleFile, 'utf8') : undefined };
|
||||||
|
const changed: string[] = [];
|
||||||
|
for (const { l, f } of files) { const r = applyLanguageNotes(md, l, f.text); md = { play: r.play, apple: r.apple }; changed.push(...r.changed); }
|
||||||
|
if (a.has('json')) printJson({ dir, checks, dryRun: a.has('dry'), changed });
|
||||||
|
else { printChecks(checks); log(TAG, `sections: ${changed.join(', ') || 'none (no matching listing sections)'}`); }
|
||||||
|
if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; }
|
||||||
|
if (playFile && md.play !== undefined) writeFileSync(playFile, md.play);
|
||||||
|
if (appleFile && md.apple !== undefined) writeFileSync(appleFile, md.apple);
|
||||||
|
done(TAG, `release notes of ${files.length} language(s) written into the listing files; push them with play release / apple listing`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
|
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
|
||||||
const range = since ? `${since}..HEAD` : 'HEAD';
|
const range = since ? `${since}..HEAD` : 'HEAD';
|
||||||
const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
|
const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
|
||||||
|
|
@ -28,7 +121,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
|
const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
|
||||||
const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
|
const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
|
||||||
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`);
|
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`);
|
||||||
console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`);
|
if (a.has('languages')) return languagesFlow(ctx, { play, apple });
|
||||||
|
if (a.has('json')) printJson({ since: since || null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS });
|
||||||
|
else console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`);
|
||||||
if (a.has('dry')) return;
|
if (a.has('dry')) return;
|
||||||
|
|
||||||
const all = a.has('all');
|
const all = a.has('all');
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
---
|
---
|
||||||
name: ads-mediation-checks
|
name: ads-mediation-checks
|
||||||
description: Diagnose and verify ad mediation in a mobile app or game before and after store release - Google AdMob (Ad Inspector), AppLovin MAX (Mediation Debugger) and Unity LevelPlay / ironSource (validateIntegration) with their networks - app ids and ad unit ids, adapter versions, test devices, no-fill before launch, app-ads.txt on the developer website, SKAdNetwork ids, ATT, privacy manifests, consent, COPPA and Data safety / App Privacy answers. Use when ads do not load, a network reports invalid ids, the user adds an ad SDK, or prepares ads for launch.
|
description: Diagnose and verify ad mediation in a mobile app or game before and after store release - Google AdMob (Ad Inspector), AppLovin MAX (Mediation Debugger) and Unity LevelPlay / ironSource (validateIntegration) with their networks - app ids and ad unit ids, adapter versions, test devices, no-fill before launch, app-ads.txt on the developer website, SKAdNetwork ids, AdAttributionKit (ad network ids, postback copies, attribution windows; storebound ads scan), ATT, privacy manifests, consent, COPPA and Data safety / App Privacy answers. Use when ads do not load, a network reports invalid ids, the user adds an ad SDK, or prepares ads for launch.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ad mediation checks (AdMob, AppLovin MAX, LevelPlay)
|
# Ad mediation checks (AdMob, AppLovin MAX, LevelPlay)
|
||||||
|
|
||||||
No CLI needed apart from `storebound validate --play` (app-ads.txt) and `storebound privacy-manifest`; these are checks
|
CLI: `storebound ads scan --ios <path>` (AdAttributionKit / SKAdNetwork configuration, below), `storebound validate
|
||||||
to run and read. Ask the owner before changing any dashboard setting. Most apps without ads can skip this skill.
|
--play` (app-ads.txt) and `storebound privacy-manifest`; the rest are checks to run and read. Ask the owner before changing any dashboard setting. Most apps without ads can skip this skill.
|
||||||
|
|
||||||
## Every mediation platform
|
## Every mediation platform
|
||||||
1. **Ids per platform**: iOS and Android have different app ids / keys and ad unit ids. Production ids only in release
|
1. **Ids per platform**: iOS and Android have different app ids / keys and ad unit ids. Production ids only in release
|
||||||
|
|
@ -32,6 +32,30 @@ to run and read. Ask the owner before changing any dashboard setting. Most apps
|
||||||
8. **Placement**: no ads at launch, on exit or mid-task / mid-round; close buttons visible; rewarded ads always
|
8. **Placement**: no ads at launch, on exit or mid-task / mid-round; close buttons visible; rewarded ads always
|
||||||
optional. Reviewers test the first minutes.
|
optional. Reviewers test the first minutes.
|
||||||
|
|
||||||
|
## AdAttributionKit and SKAdNetwork (iOS)
|
||||||
|
`storebound ads scan --ios ios/App [--json]` (MCP: `ads_scan`; local, read-only, exit 1 on a blocker) reads the app's
|
||||||
|
Info.plist (Pods, build output, tests and extensions left out), `Podfile.lock` / `Package.resolved` and the Swift /
|
||||||
|
Objective-C sources. What it checks, with Apple's documentation (links in `cli/lib/adattribution.ts`):
|
||||||
|
- **Publisher app** (shows ads): every network's ids in `SKAdNetworkItems` (dictionaries with `SKAdNetworkIdentifier`)
|
||||||
|
and, for AdAttributionKit, in `AdNetworkIdentifiers` (array of strings). Apple: lowercase, "otherwise, the system
|
||||||
|
doesn't recognize it as a valid network" (capital letters = blocker). `.skadnetwork` and `.adattributionkit` ids work
|
||||||
|
for both frameworks, so the same list can go into both keys; ids only in `SKAdNetworkItems` are flagged for
|
||||||
|
AdAttributionKit. Ad SDKs without any ids = blocker (installs from those ads cannot be attributed).
|
||||||
|
- **Advertised app** (runs ad campaigns): copies of winning postbacks with `AttributionCopyEndpoint` ("AdAttributionKit -
|
||||||
|
Postback Copy URL" in Xcode; storebound reads it inside an `AdAttributionKit` dictionary or at the top level), an
|
||||||
|
https URL whose registrable domain gets POSTs at `/.well-known/appattribution/report-attribution/` (a path is
|
||||||
|
ignored); re-engagement copies with `EligibleForAdAttributionKitReengagementPostbackCopies` = YES (needs the copy
|
||||||
|
endpoint); SKAdNetwork copies (and Private Click Measurement) with `NSAdvertisingAttributionReportEndpoint`. The app
|
||||||
|
must call `updateConversionValue` (AdAttributionKit) or `updatePostbackConversionValue` (SKAdNetwork) at least once,
|
||||||
|
best at first launch, or an attribution SDK (AppsFlyer, Adjust, Branch, Singular, Kochava) must do it.
|
||||||
|
- **Attribution rules**: `AdAttributionKitConfigurations` > `AttributionWindows` > `global` or an ad network id >
|
||||||
|
`install` > `click` 1-30 days, `view` 1-7 days, `ignoreInteractionType` view | click (ad network entries only);
|
||||||
|
`AttributionCooldown` > `install-cooldown-hours` / `re-engagement-cooldown-hours` 0-720.
|
||||||
|
- AdAttributionKit and SKAdNetwork need **no ATT prompt**; keep `NSUserTrackingUsageDescription` only when the app
|
||||||
|
really tracks across apps (store-declarations).
|
||||||
|
Test attribution with Developer Mode and the postback testing in Settings > Developer (Apple: "Testing ad
|
||||||
|
attributions with Developer Mode"); the scan only checks the configuration, not delivery.
|
||||||
|
|
||||||
## Google AdMob
|
## Google AdMob
|
||||||
- Ad Inspector (SDK 20.0.0+, test devices only) shows each ad unit's mediation waterfall, adapter status and errors:
|
- Ad Inspector (SDK 20.0.0+, test devices only) shows each ad unit's mediation waterfall, adapter status and errors:
|
||||||
https://developers.google.com/admob/android/ad-inspector (iOS: the same page under /admob/ios/).
|
https://developers.google.com/admob/android/ad-inspector (iOS: the same page under /admob/ios/).
|
||||||
|
|
|
||||||
100
skills/app-store-promotion/SKILL.md
Normal file
100
skills/app-store-promotion/SKILL.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
name: app-store-promotion
|
||||||
|
description: App Store promotion tools for a mobile app or game through the App Store Connect API - offer codes for one-time purchases, win-back offers for lapsed subscribers, promoted in-app purchases on the product page, pre-orders per territory, custom product pages (screenshots per audience, deep links) and in-app events, with Apple's limits; Google Play pre-registration as a console guide (no API). Use when the user wants promo codes or discounts for IAPs, to win back subscribers, to feature purchases on the product page, a pre-order or pre-registration launch, ad-specific product pages, or an in-app event.
|
||||||
|
---
|
||||||
|
|
||||||
|
# App Store promotion (offers, promoted IAPs, pre-orders, custom product pages, in-app events)
|
||||||
|
|
||||||
|
Everything here is App Store Connect API (`cli/lib/offers.ts`, `cli/lib/cpp-events.ts` cite the endpoints and Apple's
|
||||||
|
Help pages). Each command has `--dry` and `--json`. What customers can see or redeem is the **owner's** step: those
|
||||||
|
commands show the dry run, then ask in the terminal, and none of them is an MCP tool. MCP has the read-only and
|
||||||
|
nothing-public parts (`apple_cpp_list | create | upload`, `apple_events_list | create | upload`,
|
||||||
|
`apple_preorder_status`).
|
||||||
|
|
||||||
|
## Offer codes for one-time purchases
|
||||||
|
Config, on the product (consumable, non-consumable, non-renewing):
|
||||||
|
```ts
|
||||||
|
products: [{ id: 'com.example.app.pro', type: 'non_consumable', usd: 9.99, listings: { ... },
|
||||||
|
offerCodes: [{ name: 'Launch week', eligibility: ['NON_SPENDER'], usd: 4.99, // or free: true
|
||||||
|
customCodes: [{ code: 'LAUNCH26', count: 1000, expires: '2026-12-31' }],
|
||||||
|
oneTimeCodes: [{ count: 500, expires: '2027-01-31' }] }] }],
|
||||||
|
```
|
||||||
|
- `storebound apple offer-codes --dry`, then without `--dry`: creates missing campaigns (by name) with prices for every
|
||||||
|
territory (the USA price point of `usd` and its equalizations, or `territories: ['USA', 'DEU']`). A campaign alone
|
||||||
|
lets nobody redeem anything.
|
||||||
|
- Codes only with `--codes` (asks): custom codes (anyone who has the code, up to 25,000 redemptions, letters and
|
||||||
|
digits up to 64) and one-time-use batches (500-25,000, expiry at most 6 months). One-time codes are saved to
|
||||||
|
`.storebound/offer-codes/` (gitignored): treat them like vouchers, never paste them into chat, issues or commits.
|
||||||
|
- Apple's limits (Help, "Create offer codes for In-App Purchases"): 10 active offers per app, 1 million codes per app
|
||||||
|
per quarter; eligibility NON_SPENDER (never bought in the app), ACTIVE_SPENDER (bought in the last 30 days),
|
||||||
|
CHURNED_SPENDER (bought more than 30 days ago). Needs iOS 16.3+. Subscription offer codes are `offerCodes` on a
|
||||||
|
subscription (`storebound apple subscriptions --codes`, store-subscriptions skill).
|
||||||
|
- Play promo codes have no API: Play Console > Monetize > Promo codes.
|
||||||
|
|
||||||
|
## Win-back offers (subscriptions)
|
||||||
|
```ts
|
||||||
|
winBack: [{ id: 'come-back-3m', type: 'pay-up-front', duration: 'P3M', usd: 9.99,
|
||||||
|
paidMonths: 6, lapsedMonths: { min: 2, max: 12 }, waitMonths: 6, start: '2026-11-01', end: '2026-11-30',
|
||||||
|
priority: 'high', promote: false }],
|
||||||
|
```
|
||||||
|
`storebound apple win-back --dry`, then the owner runs it (asks). Rules (Help, "Set up win-back offers"): the
|
||||||
|
subscription must be approved; paid at least 1-24 months or 3, 4, 5 years; lapsed between min and max months (1-24,
|
||||||
|
both required); wait between offers 2-24 months; start at the earliest tomorrow and run at least 3 days; at most 5
|
||||||
|
running per storefront and 350 per subscription. Reference name, offer id and price cannot change later; the offer id
|
||||||
|
must not be reused in the group. An offer goes live by itself on its start date (Manage Subscriptions, the in-app
|
||||||
|
offer sheet on iOS 18+, the App Store with `promote: true` and an approved subscription image).
|
||||||
|
|
||||||
|
## Promoted in-app purchases
|
||||||
|
`apple: { promoted: { products: ['com.example.app.pro', 'com.example.app.coins_100'] } }` (order = display order, at
|
||||||
|
most 20). `storebound apple promoted --dry` shows the status and the plan: missing ones are created **disabled**, the
|
||||||
|
order is set. `--enable` switches them on (asks; they show on the product page once approved). Each needs its
|
||||||
|
promotional image in App Store Connect, and the app must handle purchases that start on the App Store (StoreKit
|
||||||
|
`PurchaseIntent`; StoreKit 1 `paymentQueue(_:shouldAddStorePayment:for:)`). Without `apple.promoted` the command only
|
||||||
|
prints the status.
|
||||||
|
|
||||||
|
## Pre-orders
|
||||||
|
- `storebound apple preorder status` (MCP: `apple_preorder_status`): per territory available, release date, pre-order
|
||||||
|
on, publish date, content status.
|
||||||
|
- `storebound apple preorder enable --release-date 2026-12-01 [--territories USA,DEU] --dry`, then for real (asks).
|
||||||
|
Apple: a version must be approved first; the release date is 2-180 days ahead for a new app (up to 365 days for an
|
||||||
|
existing app in a new region); a territory where the app is already released cannot go on pre-order.
|
||||||
|
- `storebound apple preorder end [--territories USA]` ends the pre-order and **releases the app there now** (asks).
|
||||||
|
- **Google Play pre-registration has no API** (the androidpublisher v3 reference lists no such resource): Play Console
|
||||||
|
> Grow users > Pre-registration (set it up on a testing / production track that is not yet available, optional
|
||||||
|
pre-registration reward as a one-time product). Pre-registration ads: Google Ads.
|
||||||
|
|
||||||
|
## Custom product pages
|
||||||
|
```ts
|
||||||
|
apple: { customProductPages: [{ name: 'Students', variant: 'students', deepLink: 'https://example.com/open/students',
|
||||||
|
promotionalText: { en: 'Class notes that sync.', de: '...' } }] },
|
||||||
|
```
|
||||||
|
1. Screenshots: `storebound shots render --variant students` (a `shots.variants` entry), store-screenshots skill.
|
||||||
|
2. `storebound apple cpp create --dry`, then for real (MCP `apple_cpp_create`): the page, its first version and a
|
||||||
|
localization per App Store locale.
|
||||||
|
3. `storebound apple cpp upload --name Students --dry` (MCP `apple_cpp_upload`): the variant's renders per locale,
|
||||||
|
MD5-idempotent.
|
||||||
|
4. Owner: `storebound apple cpp submit --name Students` (asks; App Review, independent of an app update), after
|
||||||
|
approval `storebound apple cpp visible --name Students` (asks). `apple cpp list` shows each page's URL for ads.
|
||||||
|
Limits: 70 pages per app; promotional text 170; the deep link works from iOS / iPadOS 18 once approved. Screenshot
|
||||||
|
sets are deprecated in API 4.5.1 in favour of the App Asset Library; they still work and storebound uses them.
|
||||||
|
|
||||||
|
## In-app events
|
||||||
|
```ts
|
||||||
|
apple: { events: [{ referenceName: 'Winter Challenge', badge: 'CHALLENGE', purpose: 'KEEP_ACTIVE_USERS_INFORMED',
|
||||||
|
schedule: { publishStart: '2026-12-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2026-12-20T00:00:00Z' },
|
||||||
|
listings: { en: { name: 'Winter Challenge', shortDescription: 'Finish ten lists before New Year', longDescription: '...' } },
|
||||||
|
media: { card: 'store/events/winter/{lang}-card.png', details: 'store/events/winter/{lang}-details.png' } }] },
|
||||||
|
```
|
||||||
|
`storebound apple events create --dry` / `upload --name ...` (MCP `apple_events_create` / `apple_events_upload`), then
|
||||||
|
the owner runs `storebound apple events submit --name ...` (asks; once approved the event publishes by its schedule).
|
||||||
|
Limits (Help, "Offer In-App Events"): reference name 64, event name 30, short description 50, long description 120;
|
||||||
|
15 minutes to 31 days; published at most 14 days before it starts; 10 published and 15 approved events per app. Media:
|
||||||
|
event card 16:9 (1920x1080 to 3840x2160), details page 9:16 (1080x1920 to 2160x3840), PNG or JPEG; storebound checks
|
||||||
|
the size before uploading. Event text is held to the same honesty rules as the listing: a real event in the app.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Free offer prices (offer codes, win-back) are sent as territories without a price point, as the API schema allows;
|
||||||
|
not yet verified against a live account. A refusal names the field: create that one offer in App Store Connect.
|
||||||
|
- Everything with a start date goes live without another click: read the dates with the owner.
|
||||||
|
- No manipulative offers: no fake scarcity in event text, no misleading "free" when a purchase is needed
|
||||||
|
(`purchaseRequirement` says it).
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: aso-basics
|
name: aso-basics
|
||||||
description: App store optimization basics for a mobile app's or game's text - App Store keyword field rules (100 characters, commas without spaces, no repeats of name / subtitle words, fill 90%+), subtitle and name, cross-localization keyword room per storefront, screenshot captions with real keywords, Google Play title / short / full description keyword placement without stuffing. Use when the user writes or improves store listing text, keywords, or asks why the app or game is not found in store search.
|
description: App store optimization basics for a mobile app's or game's text - App Store keyword field rules (100 characters, commas without spaces, no repeats of name / subtitle words, fill 90%+), subtitle and name, cross-localization keyword room per storefront, screenshot captions with real keywords, Google Play title / short / full description keyword placement without stuffing. Keyword rank checks per storefront through the public iTunes Search API. Use when the user writes or improves store listing text, keywords, asks why the app or game is not found in store search, or wants keyword ranks and competitors.
|
||||||
---
|
---
|
||||||
|
|
||||||
# ASO basics
|
# ASO basics
|
||||||
|
|
@ -42,3 +42,15 @@ true; never use competitor or other apps' or games' names, "best / #1", or unrel
|
||||||
4. Push with `apple listing` / `play listing`; measure after a few weeks (App Store Connect / Play Console acquisition
|
4. Push with `apple listing` / `play listing`; measure after a few weeks (App Store Connect / Play Console acquisition
|
||||||
by search term; `storebound analytics snapshot` + `analytics compare` line impressions and conversion up with
|
by search term; `storebound analytics snapshot` + `analytics compare` line impressions and conversion up with
|
||||||
each committed listing change, store-analytics skill) and change a few terms per release, not all at once.
|
each committed listing change, store-analytics skill) and change a few terms per release, not all at once.
|
||||||
|
5. Track ranks: `storebound aso rank --storefronts us,de=de-DE --dry` lists the queries, then without `--dry` (MCP:
|
||||||
|
`aso_rank`, dry_run false). One query per keyword of the locale's keyword field (plus `--keywords`) and storefront
|
||||||
|
against Apple's public iTunes Search API, at most one every 3.5 s (Apple allows about 20 calls a minute; a 403 or
|
||||||
|
429 stops the run with what was read). The table shows the app's position in the first 200 results, the change
|
||||||
|
since the last snapshot (`.storebound/aso/<date>.json`) and the first competitors (title, developer, rating).
|
||||||
|
|
||||||
|
## Rank check limits (say them to the owner)
|
||||||
|
- The Search API order is close to, not the same as, App Store search (no personalisation, no Search Ads, no in-app
|
||||||
|
event rows). Read ranks as a trend over weeks, not as a promise.
|
||||||
|
- Competitor subtitles are not in any public API, so storebound does not show them; it scrapes nothing.
|
||||||
|
- Never copy a competitor's name or terms into your keywords (both stores reject it); use the list to see which jobs
|
||||||
|
and words the top apps lead with.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: release-management
|
name: release-management
|
||||||
description: Ship updates safely after the first release - App Store phased release (on, pause, resume, complete) with manual release by default, Google Play staged rollout (fraction, halt, resume, complete), waiting for review, picking build numbers, and what to do when a release goes wrong (pausing is not a rollback). Use when the user releases an update, wants a gradual rollout, needs to stop a bad release, or asks how long review takes.
|
description: Ship updates safely after the first release - App Store phased release (on, pause, resume, complete) with manual release by default, Google Play staged rollout (fraction, halt, resume, complete), waiting for review, picking build numbers, pulling live store texts back into the listing files, and what to do when a release goes wrong (pausing is not a rollback). Use when the user releases an update, wants a gradual rollout, needs to stop a bad release, or asks how long review takes.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Release management (updates on both stores)
|
# Release management (updates on both stores)
|
||||||
|
|
@ -48,4 +48,16 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
|
||||||
- Do not resubmit or cancel to "jump the queue": it puts you at the back.
|
- Do not resubmit or cancel to "jump the queue": it puts you at the back.
|
||||||
|
|
||||||
## Release notes
|
## Release notes
|
||||||
`whats-new` skill (`storebound whats-new`), then `apple listing` / `play release` push them.
|
`whats-new` skill (`storebound whats-new`, `--languages` for one file per language), then `apple listing` /
|
||||||
|
`play release` push them.
|
||||||
|
|
||||||
|
## Someone edited the listing in the console
|
||||||
|
Before pushing local texts over a console edit, pull the live ones and read the diff:
|
||||||
|
- `storebound apple listing pull [--version x.y.z]` (name, subtitle, description, keywords, promotional text, what's
|
||||||
|
new per locale) and `storebound play listing pull` (title, short and full description; one throwaway edit, deleted).
|
||||||
|
Both print the diff against `store/appstore.md` / `store/listing.md` and write only with `--write` (MCP:
|
||||||
|
`apple_listing_pull` / `play_listing_pull` with dry_run false). New locales are appended as sections; a Play section
|
||||||
|
shared by several codes is only changed when the store has the same text for all of them.
|
||||||
|
- `storebound catalog pull [--write]`: IAP and subscription titles / descriptions (Play: benefits) against the config.
|
||||||
|
`storebound.config.ts` is never rewritten: `--write` saves `.storebound/pull/catalog.json` as a proposal; merge the
|
||||||
|
listings by hand or with your agent, then `storebound check`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: store-analytics
|
name: store-analytics
|
||||||
description: 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. Use when the user asks whether a listing change, new screenshots or a store experiment worked, wants install / conversion numbers, or sets up store reporting.
|
description: 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; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency. Use when the user asks whether a listing change, new screenshots or a store experiment worked, wants install / conversion numbers, sets up store reporting, or wants sales, proceeds or earnings numbers.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Store analytics
|
# Store analytics
|
||||||
|
|
@ -38,6 +38,24 @@ CLI: `storebound analytics setup | snapshot | compare`. MCP: `analytics_snapshot
|
||||||
impressions (App Store) or visitors (Play) and conversion in the 7 days before vs the 7 days from the change
|
impressions (App Store) or visitors (Play) and conversion in the 7 days before vs the 7 days from the change
|
||||||
(`--days 14` for small apps).
|
(`--days 14` for small apps).
|
||||||
|
|
||||||
|
## Sales and finance reports
|
||||||
|
`storebound reports <apple-sales | apple-finance | play-earnings | play-sales>` downloads one report into
|
||||||
|
`.storebound/reports/` (gitignored: these files hold per-order rows and buyer locations) and prints a summary per
|
||||||
|
product and currency plus `<name>.summary.csv` (or `--csv <file>`); totals are never added across currencies. `--dry`
|
||||||
|
shows the request only. MCP: `reports`.
|
||||||
|
- **App Store**: `apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...]` (Sales and Trends;
|
||||||
|
other types: PRE_ORDER, SUBSCRIPTION, SUBSCRIPTION_EVENT, SUBSCRIBER, SUBSCRIPTION_OFFER_CODE_REDEMPTION,
|
||||||
|
WIN_BACK_ELIGIBILITY, each with Apple's fixed frequency and version) and `apple-finance --month YYYY-MM` (Apple
|
||||||
|
fiscal month; consolidated region ZZ, `--detail` for the Financial Detail report, region Z1). Needs a **team** API
|
||||||
|
key (not an individual key) with access to reports, and the vendor number (App Store Connect > Payments and
|
||||||
|
Financial Reports) as `ASC_VENDOR_NUMBER` in the environment or `.storebound/credentials.json`, never in the config.
|
||||||
|
A 404 means "no report for that date" (no sales, or not out yet: daily reports come the next day).
|
||||||
|
- **Google Play**: `play-earnings --month YYYY-MM` / `play-sales --month YYYY-MM` read
|
||||||
|
`earnings/earnings_YYYYMM*.zip` and `sales/salesreport_YYYYMM*.zip` from `play.reportsBucket` and unzip them. The
|
||||||
|
service account needs "View financial data" in Play Console (Users and permissions). Earnings arrive early in the
|
||||||
|
next month; estimated sales update daily.
|
||||||
|
- Money numbers are the owner's: never paste them into issues, commits or public text.
|
||||||
|
|
||||||
## Read it honestly
|
## Read it honestly
|
||||||
- Small numbers swing: under a few hundred page views a week, a 20 % change is noise. Say so.
|
- Small numbers swing: under a few hundred page views a week, a 20 % change is noise. Say so.
|
||||||
- Seasonality, featuring, ads campaigns, a rating drop and price changes move the same numbers. Look at the same
|
- Seasonality, featuring, ads campaigns, a rating drop and price changes move the same numbers. Look at the same
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: store-health
|
name: store-health
|
||||||
description: Watch a released mobile app's or game's crash and ANR health on both stores - Google Play Android vitals (user-perceived crash rate 1.09 % and ANR rate 0.47 % bad behavior thresholds, 8 % per device model) through the Play Developer Reporting API, App Store power and performance metrics, diagnostic signatures and TestFlight crash reports, a weekly Markdown report, and validate blockers when a threshold is crossed. Use when the user asks about crashes, ANRs, vitals, stability, a weekly health check, or why Play shows a quality warning.
|
description: Watch a released mobile app's or game's crash and ANR health on both stores - Google Play Android vitals (user-perceived crash rate 1.09 % and ANR rate 0.47 % bad behavior thresholds, 8 % per device model) through the Play Developer Reporting API, App Store power and performance metrics, diagnostic signatures and TestFlight crash reports, crash and feedback triage grouped by signature, device and OS version, a weekly Markdown report, and validate blockers when a threshold is crossed. Use when the user asks about crashes, ANRs, vitals, stability, a weekly health check, or why Play shows a quality warning.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Store health (crashes, ANRs, diagnostics)
|
# Store health (crashes, ANRs, diagnostics)
|
||||||
|
|
@ -29,8 +29,22 @@ instead). `storebound validate` runs the same checks for live apps. MCP: `health
|
||||||
launch time, memory, disk writes, battery) and on diagnostic signatures with a large share of reports (Xcode >
|
launch time, memory, disk writes, battery) and on diagnostic signatures with a large share of reports (Xcode >
|
||||||
Organizer shows the stack traces). TestFlight crash reports count crashes testers sent from the beta.
|
Organizer shows the stack traces). TestFlight crash reports count crashes testers sent from the beta.
|
||||||
|
|
||||||
|
## Triage: which crash first
|
||||||
|
`storebound triage [--days 14] [--build N] [--apple | --play] [--json]` (MCP: `triage`, read-only) groups what testers
|
||||||
|
and players hit:
|
||||||
|
- **TestFlight crashes**: every crash report's log is read and turned into a signature (exception type + the first
|
||||||
|
frame of the crashed thread inside the app; text and `.ips` crash formats), then grouped with its top device models,
|
||||||
|
OS versions and builds.
|
||||||
|
- **TestFlight screenshot feedback**: grouped by device; `--comments 3` shows up to three comments per group (cut to 120
|
||||||
|
characters). Tester emails are never requested (the reads list their fields without `email` / `tester`).
|
||||||
|
- **Google Play**: the top `--issues` (default 10) crash / ANR issues of the window (Play's own clusters), then their
|
||||||
|
error reports for the spread by device (marketing name), API level and versionCode. Counts are of the sampled
|
||||||
|
reports; the issue's total reports and users come from Play.
|
||||||
|
Fix the top signature first, then check its device / OS column: one model or one OS version points at a device bug.
|
||||||
|
|
||||||
## Act on it
|
## Act on it
|
||||||
1. Start with the top issues table (Play: cause + location + versionCode; Apple: signature + build).
|
1. Start with the top issues table (Play: cause + location + versionCode; Apple: signature + build), or with
|
||||||
|
`storebound triage` for the grouped view.
|
||||||
2. Reproduce on the named device model / OS; for ANRs look for main-thread work (I/O, ads SDK init, big asset
|
2. Reproduce on the named device model / OS; for ANRs look for main-thread work (I/O, ads SDK init, big asset
|
||||||
loads), for crashes the null / out-of-memory paths.
|
loads), for crashes the null / out-of-memory paths.
|
||||||
3. Ship the fix with a higher versionCode / build number (release-management skill). Halting a rollout stops new
|
3. Ship the fix with a higher versionCode / build number (release-management skill). Halting a rollout stops new
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: store-iap-setup
|
name: store-iap-setup
|
||||||
description: 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 - regional prices, local overrides, a price sheet CSV for many products and regions (diff first, future-dated App Store changes), activation, review screenshots, submitting first IAPs with a version, license/sandbox testers. Use when the user sets up, prices, re-prices or tests in-app products/IAPs, checks that both stores match (catalog diff), or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors.
|
description: 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 - regional prices, local overrides, a price sheet CSV for many products and regions (diff first, future-dated App Store changes), activation, review screenshots, submitting IAPs and subscriptions to review through the API (first ones together with an app version), offer codes for one-time purchases, license/sandbox testers. Use when the user sets up, prices, re-prices or tests in-app products/IAPs, checks that both stores match (catalog diff), or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors.
|
||||||
---
|
---
|
||||||
|
|
||||||
# In-app purchases on both stores
|
# In-app purchases on both stores
|
||||||
|
|
@ -44,9 +44,24 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr
|
||||||
changed by it; price changes go through `storebound prices import` (diff first, then the owner confirms).
|
changed by it; price changes go through `storebound prices import` (diff first, then the owner confirms).
|
||||||
2. Review screenshot per product (a real device size, e.g. 1290 x 2796, showing the item in the shop):
|
2. Review screenshot per product (a real device size, e.g. 1290 x 2796, showing the item in the shop):
|
||||||
`storebound apple iap-review`. States move to READY_TO_SUBMIT when complete.
|
`storebound apple iap-review`. States move to READY_TO_SUBMIT when complete.
|
||||||
3. **UI**: each IAP page > "Add for Review"; this puts them into the version's draft submission. Then submit the
|
3. **Send them to App Review** (the owner's step): `storebound apple iap-submit --dry` shows the plan: which version
|
||||||
version (owner, see appstore-first-release).
|
of each product goes in, what is left out and why, and which products are the **first of their type** for this app.
|
||||||
4. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
|
- Apple's rule (App Store Connect Help, "Submit an In-App Purchase"): the first consumable, non-consumable,
|
||||||
|
non-renewing subscription and auto-renewable subscription of an app must be submitted **with a new app version**;
|
||||||
|
later ones of an approved type go alone as long as the app has at least one approved version. A new subscription
|
||||||
|
goes with its subscription group while the group is not approved. Everything reviewed together sits in one draft
|
||||||
|
submission, up to 200 items. Content-hosted IAPs can no longer be submitted.
|
||||||
|
- Through the API (App Store Connect API 4.4.1+): each product has versions (`inAppPurchaseVersions`,
|
||||||
|
`subscriptionVersions`, `subscriptionGroupVersions`) that go into a review submission as items, next to the app
|
||||||
|
version when needed. So the first ones no longer need the web UI: `--with-version` puts the editable app version
|
||||||
|
(`storebound apple listing --version x.y.z --build N` first) into the same submission, and then submitting it
|
||||||
|
**submits the app too**.
|
||||||
|
- The real run asks before adding the items and again before submitting; `--no-submit` stops at the filled draft so
|
||||||
|
the owner presses "Submit for Review" in App Store Connect. `--new-version` makes a new draft version of an
|
||||||
|
approved or rejected product first (changed name, description or screenshot). Never an MCP tool; MCP has the
|
||||||
|
read-only plan `apple_iap_review_plan`. `--json` prints the plan for scripts.
|
||||||
|
4. Offer codes (free or discounted) for one-time purchases: see the app-store-promotion skill (`storebound apple offer-codes`).
|
||||||
|
5. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
|
||||||
runs (those transactions are signed locally and fail server-side receipt checks).
|
runs (those transactions are signed locally and fail server-side receipt checks).
|
||||||
|
|
||||||
## Many prices at once: the price sheet (both stores)
|
## Many prices at once: the price sheet (both stores)
|
||||||
|
|
@ -77,13 +92,14 @@ App Store only, a future YYYY-MM-DD).
|
||||||
the wrong casing answers with an HTML 404 page.
|
the wrong casing answers with an HTML 404 page.
|
||||||
- Play has no consumable flag: the app consumes the purchase after granting it. Use `legacyCompatible: true` so older
|
- Play has no consumable flag: the app consumes the purchase after granting it. Use `legacyCompatible: true` so older
|
||||||
billing libraries see the product by id.
|
billing libraries see the product by id.
|
||||||
- Apple: first IAPs must ship with an app version ("FIRST_*_MUST_BE_SUBMITTED_ON_VERSION" from
|
- Apple: first IAPs must ship with an app version ("FIRST_*_MUST_BE_SUBMITTED_ON_VERSION" from the old
|
||||||
inAppPurchaseSubmissions). If the version is already in review without them: cancel the review submission
|
inAppPurchaseSubmissions). If the version is already in review without them: cancel the review submission
|
||||||
(`storebound apple status --cancel-submission <id>`, ask the owner), re-add via "Add for Review", resubmit.
|
(`storebound apple status --cancel-submission <id>`, ask the owner), then `storebound apple iap-submit --with-version`
|
||||||
- Reported, **verify** before relying on it: App Store Connect API 4.4.1 manages IAPs as versions
|
(or "Add for Review" in the UI) and resubmit.
|
||||||
(`inAppPurchaseVersions`) and its release notes say a review submission item can take an `inAppPurchaseVersion`.
|
- Not verified against a live account yet: adding the **first** IAP of a type next to the app version through
|
||||||
That should let **later** IAP versions be added to `reviewSubmissions` through the API; the **first** attachment of
|
`reviewSubmissionItems` follows the API 4.4.1 release notes and the 4.5.1 spec (the item takes `appStoreVersion` and
|
||||||
a new app's first IAPs still needed the UI "Add for Review" in our runs. storebound does not automate either yet.
|
`inAppPurchaseVersion` in one submission). If App Store Connect refuses it, `--no-submit` leaves the draft and the
|
||||||
|
UI "Add for Review" finishes the job. The deprecated `inAppPurchaseSubmissions` / `subscriptionSubmissions` are not used.
|
||||||
- Apple needs an exact existing price point for each manual price; the error lists the nearest ones.
|
- Apple needs an exact existing price point for each manual price; the error lists the nearest ones.
|
||||||
- Apple IAP display name 2-30 characters, description max **45** (App Store Connect Help > Reference > "In-app
|
- Apple IAP display name 2-30 characters, description max **45** (App Store Connect Help > Reference > "In-app
|
||||||
purchase information"; an older App Store marketing page says 55, but App Store Connect enforces 45).
|
purchase information"; an older App Store marketing page says 55, but App Store Connect enforces 45).
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,13 @@ offers, offerCodes, reviewScreenshot, basePlanId, gracePeriod, platforms }] }]`,
|
||||||
run them in a terminal (they ask). Through MCP (no terminal) they only create what is new; price changes of
|
run them in a terminal (they ask). Through MCP (no terminal) they only create what is new; price changes of
|
||||||
existing subscriptions wait for the owner.
|
existing subscriptions wait for the owner.
|
||||||
4. Review screenshot of the paywall per subscription (`reviewScreenshot`), uploaded by `apple subscriptions`.
|
4. Review screenshot of the paywall per subscription (`reviewScreenshot`), uploaded by `apple subscriptions`.
|
||||||
5. **First subscriptions go with an app version**: on the version page in App Store Connect, add them under
|
5. **First subscriptions go with an app version** (and a new group with at least one of its subscriptions):
|
||||||
"In-App Purchases and Subscriptions" before Submit for Review (no API; the owner or an agent with browser tools).
|
`storebound apple iap-submit --with-version --dry` plans one review submission with the app version, the
|
||||||
|
subscription versions and the group version; the owner runs it without `--dry` (it asks, and submitting it submits
|
||||||
|
the app), or adds `--no-submit` and presses "Submit for Review" in App Store Connect. Details and the exact limits:
|
||||||
|
store-iap-setup skill, step 3.
|
||||||
|
5b. **Win-back offers** for lapsed subscribers (`winBack` on a subscription, `storebound apple win-back`) once the
|
||||||
|
subscription is approved: app-store-promotion skill.
|
||||||
6. `storebound validate` (states, localizations, group names, review screenshots, active Play base plans, terms URL)
|
6. `storebound validate` (states, localizations, group names, review screenshots, active Play base plans, terms URL)
|
||||||
and `storebound catalog diff` (both stores against the config, prices per region).
|
and `storebound catalog diff` (both stores against the config, prices per region).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,22 @@ beta app localization before a Beta App Review submission, and fields cannot be
|
||||||
- The Beta App Review submission is public-facing and the owner's call: never through MCP; an agent explains and the
|
- The Beta App Review submission is public-facing and the owner's call: never through MCP; an agent explains and the
|
||||||
owner confirms in the terminal.
|
owner confirms in the terminal.
|
||||||
|
|
||||||
|
## 6. Wait for processing
|
||||||
|
`storebound apple testflight wait --build N [--timeout 60m] [--json]` polls the build you just uploaded (20 s, then
|
||||||
|
x1.5, at most every 2 minutes) and prints a line whenever its state changes: "not listed yet" for the first minutes,
|
||||||
|
`PROCESSING`, then `VALID`. Exit codes: 0 VALID, 1 FAILED / INVALID (read Apple's email, fix, upload a new build
|
||||||
|
number), 7 timeout (run it again). `--dry` looks once and does not wait. On VALID it names the next step, e.g. the
|
||||||
|
export compliance answer when the build shows `MISSING_EXPORT_COMPLIANCE`. MCP: `apple_testflight_wait` (read-only).
|
||||||
|
In CI, run it in the background with a log and report its lines.
|
||||||
|
|
||||||
|
## 7. Expire old builds (asks first)
|
||||||
|
`storebound apple testflight expire --keep 5 --dry` (or `--older-than 60`, or both: beyond the newest 5 AND older than
|
||||||
|
60 days; `--version 1.2.0` limits it to one version). The newest build always stays; builds attached to an App Store
|
||||||
|
version (in review, live or being prepared) and builds still processing are never expired. The dry run lists every
|
||||||
|
build with "expire" / "keep" and why; the real run asks first (`--yes` in scripts the owner runs) because an expired
|
||||||
|
build cannot be brought back and testers lose it at once. Not an MCP tool. API: `PATCH /v1/builds/{id}` with
|
||||||
|
`expired: true` (cited in `cli/lib/build-ops.ts`).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
- Builds expire after 90 days in TestFlight; upload a new one (`storebound apple next-build`).
|
- Builds expire after 90 days in TestFlight; upload a new one (`storebound apple next-build`).
|
||||||
- Up to 100 internal testers and 10,000 external testers per app (TestFlight overview in App Store Connect Help:
|
- Up to 100 internal testers and 10,000 external testers per app (TestFlight overview in App Store Connect Help:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: whats-new
|
name: whats-new
|
||||||
description: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag - hook in the first line, plain user language, localized into every listing language, within store limits (Google Play 500, App Store 4000 characters). Use when preparing an update, when the user asks for release notes / changelog for the stores, or before play release / apple listing for a new version.
|
description: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag - hook in the first line, plain user language, localized into every listing language, one file per language translated by the agent (or a provider hook the owner sets), checked against store limits (Google Play 500, App Store 4000 characters) and applied to both listings. Use when preparing an update, when the user asks for release notes / changelog for the stores, or before play release / apple listing for a new version.
|
||||||
---
|
---
|
||||||
|
|
||||||
# What's new (release notes)
|
# What's new (release notes)
|
||||||
|
|
@ -28,6 +28,26 @@ translation; `storebound check` warns while a translation is still identical to
|
||||||
5. Push: `storebound play release ...` uses the Play notes; `storebound apple listing --version x --build N` the
|
5. Push: `storebound play release ...` uses the Play notes; `storebound apple listing --version x --build N` the
|
||||||
App Store ones.
|
App Store ones.
|
||||||
|
|
||||||
|
## Many languages, one file each
|
||||||
|
1. `storebound whats-new --languages en,de,tr,ja --dry`, then without `--dry` (MCP: `whats_new_languages`). Values
|
||||||
|
are language ids of the config or store codes; the **first is the source**. It writes `store/whats-new/<id>.md`:
|
||||||
|
the source gets the draft (the Play-sized one when the source language is on Play, so it fits 500), every other
|
||||||
|
language a copy of it with the header `<!-- whats-new lang=de source=en status=translate -->`. Translated files
|
||||||
|
(`status=translated`) are kept on later runs unless `--force`.
|
||||||
|
2. **You translate** each `status=translate` file: replace the text below the header, keep the header line and set
|
||||||
|
`status=translated`. Follow step 3 above (natural tone, the app's terms, same meaning). The CLI never calls a
|
||||||
|
translation service and holds no key for one.
|
||||||
|
Optional provider hook (the owner's choice): `--translate "<command>"` runs that command once per language with the
|
||||||
|
source text on stdin and `STOREBOUND_SOURCE_LANG` / `STOREBOUND_TARGET_LANG` (App Store locale, else Play code) /
|
||||||
|
`STOREBOUND_TARGET_LANG_ID` in the environment; its stdout becomes the file (`status=translated`). Any key it needs
|
||||||
|
lives in the owner's environment, never in the config. Read hook output like any draft before applying it.
|
||||||
|
3. `storebound whats-new check` (MCP: `whats_new_check`): per language the characters against the stores that
|
||||||
|
language is listed on (Google Play 500, App Store 4000), untranslated files, a translation identical to the source,
|
||||||
|
a first line over ~170 characters. Exit 1 while a language is not ready.
|
||||||
|
4. `storebound whats-new apply --dry`, then `apply` (MCP: `whats_new_apply`): writes every file into its Play
|
||||||
|
sections ("What's new" for each Play code of the language) and its App Store locale (`whatsNew`). It refuses while
|
||||||
|
any language fails the check.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
- Squash-merged repos: the PR titles are the subjects; keep them in conventional form (`feat: ...`) to get clean drafts.
|
- Squash-merged repos: the PR titles are the subjects; keep them in conventional form (`feat: ...`) to get clean drafts.
|
||||||
- Commits marked `[skip notes]` or `[internal]` are left out.
|
- Commits marked `[skip notes]` or `[internal]` are left out.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"ASC_KEY_ID": "YOUR_KEY_ID",
|
"ASC_KEY_ID": "YOUR_KEY_ID",
|
||||||
"ASC_ISSUER_ID": "00000000-0000-0000-0000-000000000000",
|
"ASC_ISSUER_ID": "00000000-0000-0000-0000-000000000000",
|
||||||
"ASC_P8": "/absolute/path/to/AuthKey_<KEY_ID>.p8",
|
"ASC_P8": "/absolute/path/to/AuthKey_<KEY_ID>.p8",
|
||||||
|
"ASC_VENDOR_NUMBER": "",
|
||||||
"reviewContact": { "firstName": "", "lastName": "", "phone": "+1 555 0100", "email": "review-contact@example.com" },
|
"reviewContact": { "firstName": "", "lastName": "", "phone": "+1 555 0100", "email": "review-contact@example.com" },
|
||||||
"reviewDemoAccount": { "username": "", "password": "" }
|
"reviewDemoAccount": { "username": "", "password": "" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
tests/helpers/store-mock.ts
Normal file
107
tests/helpers/store-mock.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Recorded-response harness for command tests: throwaway API keys, a fetch router that answers like App Store Connect,
|
||||||
|
// Google and the iTunes Search API did (sanitized: every id, name and email is a placeholder), a temp project with a
|
||||||
|
// config, and capture of stdout / stderr / process.exit. Nothing here reaches the network.
|
||||||
|
import { generateKeyPairSync } from 'node:crypto';
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join } from 'node:path';
|
||||||
|
import { parseArgs } from '../../cli/lib/args';
|
||||||
|
import type { Loaded, ShipConfig } from '../../cli/lib/config';
|
||||||
|
import type { Ctx } from '../../cli/lib/ctx';
|
||||||
|
|
||||||
|
export type J = Record<string, unknown>;
|
||||||
|
export interface Call { method: string; url: string; path: string; body: unknown }
|
||||||
|
type Answer = Response | J | ((c: Call) => Response | J | Promise<Response | J>);
|
||||||
|
export interface Route { method?: string; match: RegExp; answer: Answer; times?: number }
|
||||||
|
|
||||||
|
export const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
export const notFound = () => json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '404', code: 'NOT_FOUND', title: 'The specified resource does not exist', detail: 'There is no resource' }] }, 404);
|
||||||
|
|
||||||
|
const saved = { ...process.env };
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
export function ascEnv(): void {
|
||||||
|
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||||
|
process.env.ASC_KEY_ID = 'ABCDE12345';
|
||||||
|
process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playEnv(): void {
|
||||||
|
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs the router; unknown requests fail the test loudly (answer 599 with the path). Returns the call log. */
|
||||||
|
export function mockFetch(routes: Route[]): Call[] {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const left = routes.map(r => ({ ...r, left: r.times ?? Infinity }));
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = String(input instanceof Request ? input.url : input);
|
||||||
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
|
const raw = init?.body;
|
||||||
|
let body: unknown = raw;
|
||||||
|
if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } }
|
||||||
|
const path = url.replace(/^https:\/\/[^/]+/, '');
|
||||||
|
const c: Call = { method, url, path, body };
|
||||||
|
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 });
|
||||||
|
calls.push(c);
|
||||||
|
const r = left.find(x => x.left > 0 && (x.method ?? 'GET') === method && x.match.test(decodeURIComponent(path)));
|
||||||
|
if (!r) return new Response(`not recorded: ${method} ${path}`, { status: 599 });
|
||||||
|
r.left--;
|
||||||
|
const a = typeof r.answer === 'function' ? await r.answer(c) : r.answer;
|
||||||
|
return a instanceof Response ? a : json(a);
|
||||||
|
}) as typeof fetch;
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restore(): void {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'ASC_VENDOR_NUMBER']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE: ShipConfig = {
|
||||||
|
app: { name: 'Example Notes', profile: 'app', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
|
||||||
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }],
|
||||||
|
apple: { bundleId: 'com.example.notes', appId: '6400000101' },
|
||||||
|
play: { packageName: 'com.example.notes', reportsBucket: 'pubsite_prod_rev_01234567890123456789' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A temp project folder with optional files; returns a Ctx factory for that config. */
|
||||||
|
export function project(config: ShipConfig = BASE, files: Record<string, string | Uint8Array> = {}): { root: string; ctx: (argv: string[], booleans?: string[]) => Ctx; loaded: Loaded } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'sb-ops-'));
|
||||||
|
for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); }
|
||||||
|
const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
||||||
|
return { root, loaded, ctx: (argv, booleans = []) => ({ loaded, args: parseArgs(argv, booleans) }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs fn with console output captured and process.exit turned into a thrown { exit } (the code is returned). */
|
||||||
|
export async function capture(fn: () => Promise<void>): Promise<{ out: string; err: string; exit: number | null }> {
|
||||||
|
const out: string[] = [], err: string[] = [];
|
||||||
|
const log = console.log, warn = console.warn, error = console.error, exit = process.exit, write = process.stdout.write;
|
||||||
|
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ')); };
|
||||||
|
console.warn = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
|
||||||
|
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
|
||||||
|
process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write;
|
||||||
|
let code: number | null = null;
|
||||||
|
process.exit = ((c?: number) => { code = c ?? 0; throw new ExitSignal(code); }) as typeof process.exit;
|
||||||
|
const before = process.exitCode;
|
||||||
|
process.exitCode = 0;
|
||||||
|
try { await fn(); } catch (e) { if (!(e instanceof ExitSignal)) { restoreIo(); throw e; } }
|
||||||
|
restoreIo();
|
||||||
|
if (code === null && process.exitCode) code = Number(process.exitCode);
|
||||||
|
process.exitCode = before ?? 0;
|
||||||
|
return { out: out.join('\n'), err: err.join('\n'), exit: code };
|
||||||
|
function restoreIo(): void { console.log = log; console.warn = warn; console.error = error; process.exit = exit; process.stdout.write = write; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } }
|
||||||
|
|
||||||
|
/** The --json object printed by a command (printJson pretty-prints one object; log lines may follow). */
|
||||||
|
export function jsonOut(out: string): J {
|
||||||
|
const i = out.indexOf('\n{\n') >= 0 ? out.indexOf('\n{\n') + 1 : out.indexOf('{\n');
|
||||||
|
if (i < 0) throw new Error(`no JSON in output:\n${out.slice(0, 500)}`);
|
||||||
|
// find the matching closing brace at column 0
|
||||||
|
const end = out.indexOf('\n}', i);
|
||||||
|
return JSON.parse(out.slice(i, end + 2)) as J;
|
||||||
|
}
|
||||||
363
tests/store-ops-apple.test.ts
Normal file
363
tests/store-ops-apple.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
// TestFlight wait / expire, IAP review submissions, offers (offer codes, win-back, promoted, pre-orders), custom
|
||||||
|
// product pages and in-app events: pure planners plus the commands against recorded App Store Connect answers
|
||||||
|
// (shapes as documented in the App Store Connect API 4.5.1 OpenAPI spec; every id and name is a placeholder).
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { planExpire, expireCandidates, waitOutcome, waitDelay } from '../cli/lib/build-ops';
|
||||||
|
import { bindIds, planIapSubmission, type SubmitState } from '../cli/lib/review-submit';
|
||||||
|
import {
|
||||||
|
endPreOrderReq, iapOfferCodeBody, planPreOrder, planPromoted, promotedOrderReq, territoryRows, validateIapOfferCodes, validatePromoted, validateWinBack, winBackBody,
|
||||||
|
} from '../cli/lib/offers';
|
||||||
|
import { checkEventMedia, cppCreateBody, eventCreateBody, planCppLocales, planEventLocales, validateCpp, validateEvents } from '../cli/lib/cpp-events';
|
||||||
|
import { validateConfig } from '../cli/lib/config';
|
||||||
|
import { run as testflight } from '../cli/apple/testflight';
|
||||||
|
import { run as iapSubmit } from '../cli/apple/iap-submit';
|
||||||
|
import { run as preorder } from '../cli/apple/preorder';
|
||||||
|
import { run as promoted } from '../cli/apple/promoted';
|
||||||
|
import { run as offerCodes } from '../cli/apple/offer-codes';
|
||||||
|
import { run as winBack } from '../cli/apple/win-back';
|
||||||
|
import { run as cpp } from '../cli/apple/cpp';
|
||||||
|
import { run as events } from '../cli/apple/events';
|
||||||
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, project, restore } from './helpers/store-mock';
|
||||||
|
|
||||||
|
afterEach(restore);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const page = (data: unknown[], included: unknown[] = []): any => ({ data, included, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
|
||||||
|
|
||||||
|
// builds of one app as GET /v1/builds?include=preReleaseVersion,appStoreVersion answers them
|
||||||
|
const BUILDS = page([
|
||||||
|
{ type: 'builds', id: 'b-50', attributes: { version: '50', uploadedDate: '2026-10-10T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, appStoreVersion: { data: null } } },
|
||||||
|
{ type: 'builds', id: 'b-49', attributes: { version: '49', uploadedDate: '2026-09-30T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, appStoreVersion: { data: { type: 'appStoreVersions', id: 'v-12' } } } },
|
||||||
|
{ type: 'builds', id: 'b-48', attributes: { version: '48', uploadedDate: '2026-08-01T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
||||||
|
{ type: 'builds', id: 'b-47', attributes: { version: '47', uploadedDate: '2026-07-01T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
||||||
|
{ type: 'builds', id: 'b-46', attributes: { version: '46', uploadedDate: '2026-06-01T10:00:00-07:00', processingState: 'PROCESSING', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
||||||
|
], [{ type: 'preReleaseVersions', id: 'p-12', attributes: { version: '1.2.0', platform: 'IOS' } }, { type: 'preReleaseVersions', id: 'p-11', attributes: { version: '1.1.0', platform: 'IOS' } }]);
|
||||||
|
|
||||||
|
describe('testflight wait', () => {
|
||||||
|
test('outcomes and poll delays', () => {
|
||||||
|
expect(waitOutcome({ found: false })).toBe('missing');
|
||||||
|
expect(waitOutcome({ found: true, processingState: 'PROCESSING' })).toBe('processing');
|
||||||
|
expect(waitOutcome({ found: true, processingState: 'VALID' })).toBe('valid');
|
||||||
|
expect(waitOutcome({ found: true, processingState: 'INVALID' })).toBe('failed');
|
||||||
|
expect([waitDelay(null), waitDelay(20_000), waitDelay(100_000)]).toEqual([20_000, 30_000, 120_000]);
|
||||||
|
});
|
||||||
|
test('VALID build: exit 0 with the beta state in --json; FAILED: exit 1; not listed + --dry: exit 7 after one look', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project();
|
||||||
|
const one = (state: string) => page([{ type: 'builds', id: 'b-50', attributes: { version: '50', processingState: state }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, buildBetaDetail: { data: { type: 'buildBetaDetails', id: 'd-50' } } } }],
|
||||||
|
[{ type: 'preReleaseVersions', id: 'p-12', attributes: { version: '1.2.0' } }, { type: 'buildBetaDetails', id: 'd-50', attributes: { internalBuildState: 'MISSING_EXPORT_COMPLIANCE', externalBuildState: 'MISSING_EXPORT_COMPLIANCE' } }]);
|
||||||
|
let calls = mockFetch([{ match: /^\/v1\/builds\?.*filter\[version\]=50/, answer: one('VALID') }]);
|
||||||
|
let r = await capture(() => testflight(p.ctx(['wait', '--build', '50', '--json'])));
|
||||||
|
expect(r.exit).toBe(0);
|
||||||
|
expect(jsonOut(r.out)).toMatchObject({ build: '50', version: '1.2.0', outcome: 'valid', internal: 'MISSING_EXPORT_COMPLIANCE' });
|
||||||
|
expect(r.out).toContain('storebound apple encryption --build 50');
|
||||||
|
expect(calls[0]!.path).toContain('include=preReleaseVersion%2CbuildBetaDetail');
|
||||||
|
mockFetch([{ match: /^\/v1\/builds\?/, answer: one('FAILED') }]);
|
||||||
|
r = await capture(() => testflight(p.ctx(['wait', '--build', '50'])));
|
||||||
|
expect(r.exit).toBe(1);
|
||||||
|
calls = mockFetch([{ match: /^\/v1\/builds\?/, answer: page([]) }]);
|
||||||
|
r = await capture(() => testflight(p.ctx(['wait', '--build', '51', '--dry'])));
|
||||||
|
expect(r.exit).toBe(7);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(r.out).toContain('not listed yet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('testflight expire', () => {
|
||||||
|
test('plan: newest kept, attached and processing builds protected, --keep / --older-than combine', () => {
|
||||||
|
const c = expireCandidates([BUILDS]);
|
||||||
|
expect(c.map(b => [b.build, b.version, b.attached])).toEqual([['50', '1.2.0', false], ['49', '1.2.0', true], ['48', '1.1.0', false], ['47', '1.1.0', false], ['46', '1.1.0', false]]);
|
||||||
|
const now = Date.parse('2026-10-11T00:00:00Z');
|
||||||
|
const k = planExpire(c, { keep: 2, now });
|
||||||
|
expect(k.expire.map(b => b.build)).toEqual(['48', '47']);
|
||||||
|
expect(k.protected.map(p => [p.b.build, p.why])).toEqual([['49', 'attached to an App Store version'], ['46', 'still PROCESSING']]);
|
||||||
|
expect(k.reqs[0]).toEqual({ method: 'PATCH', path: '/v1/builds/b-48', note: 'expire build 48 (1.1.0), uploaded 2026-08-01', body: { data: { type: 'builds', id: 'b-48', attributes: { expired: true } } } });
|
||||||
|
expect(planExpire(c, { olderThanDays: 80, now }).expire.map(b => b.build)).toEqual(['47']);
|
||||||
|
expect(planExpire(c, { keep: 3, olderThanDays: 30, now }).expire.map(b => b.build)).toEqual(['47']);
|
||||||
|
expect(() => planExpire(c, {})).toThrow('--keep');
|
||||||
|
expect(() => planExpire(c, { keep: 0 })).toThrow('>= 1');
|
||||||
|
});
|
||||||
|
test('dry run sends nothing; without --yes in a non-terminal nothing is expired; --yes sends the PATCHes', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project();
|
||||||
|
let calls = mockFetch([{ match: /^\/v1\/builds\?.*filter\[expired\]=false/, answer: BUILDS }, { method: 'PATCH', match: /^\/v1\/builds\/b-4[78]$/, answer: { data: {} } }]);
|
||||||
|
let r = await capture(() => testflight(p.ctx(['expire', '--keep', '2', '--dry', '--json'])));
|
||||||
|
expect(jsonOut(r.out)).toMatchObject({ dryRun: true, expire: [{ build: '48' }, { build: '47' }], keep: 1 });
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
r = await capture(() => testflight(p.ctx(['expire', '--keep', '2'])));
|
||||||
|
expect(r.out).toContain('re-run with --yes');
|
||||||
|
calls = mockFetch([{ match: /^\/v1\/builds\?/, answer: BUILDS }, { method: 'PATCH', match: /^\/v1\/builds\/b-4[78]$/, answer: { data: {} } }]);
|
||||||
|
r = await capture(() => testflight(p.ctx(['expire', '--keep', '2', '--yes'])));
|
||||||
|
expect(calls.filter(c => c.method === 'PATCH').map(c => [c.path, c.body])).toEqual([
|
||||||
|
['/v1/builds/b-48', { data: { type: 'builds', id: 'b-48', attributes: { expired: true } } }], ['/v1/builds/b-47', { data: { type: 'builds', id: 'b-47', attributes: { expired: true } } }]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- IAP review submissions ----
|
||||||
|
|
||||||
|
const STATE: SubmitState = {
|
||||||
|
appVersions: [{ id: 'v-2', versionString: '1.2.0', state: 'PREPARE_FOR_SUBMISSION' }, { id: 'v-1', versionString: '1.1.0', state: 'READY_FOR_DISTRIBUTION' }],
|
||||||
|
iaps: [
|
||||||
|
{ id: 'i-coins', productId: 'com.example.notes.coins_100', type: 'CONSUMABLE', state: 'APPROVED', versions: [{ id: 'iv-1', version: 1, state: 'APPROVED' }] },
|
||||||
|
{ id: 'i-coins2', productId: 'com.example.notes.coins_500', type: 'CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-2', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] },
|
||||||
|
{ id: 'i-pro', productId: 'com.example.notes.pro', type: 'NON_CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-3', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] },
|
||||||
|
{ id: 'i-meta', productId: 'com.example.notes.theme', type: 'NON_CONSUMABLE', state: 'MISSING_METADATA', versions: [] },
|
||||||
|
],
|
||||||
|
subs: [{ id: 's-m', productId: 'com.example.notes.plus_monthly', groupId: 'g-1', state: 'READY_TO_SUBMIT', versions: [{ id: 'sv-1', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }],
|
||||||
|
groups: [{ id: 'g-1', name: 'Plus', versions: [{ id: 'gv-1', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }],
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('IAP review submission plan', () => {
|
||||||
|
test('a later consumable goes alone; first non-consumable / first subscription need the app version', () => {
|
||||||
|
const later = planIapSubmission(STATE, ['com.example.notes.coins_500']);
|
||||||
|
expect(later.blockers).toEqual([]);
|
||||||
|
expect(later.needsAppVersion).toBe(false);
|
||||||
|
expect(later.items).toEqual([{ kind: 'inAppPurchaseVersion', versionId: 'iv-2', label: 'com.example.notes.coins_500 (consumable, version 1)' }]);
|
||||||
|
expect(later.prepare.map(r => `${r.method} ${r.path}`)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems']);
|
||||||
|
expect(bindIds(later.prepare[1]!, { submission: 'rs-9' }).body).toEqual({ data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-9' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: 'iv-2' } } } } });
|
||||||
|
expect(bindIds(later.submit, { submission: 'rs-9' }).body).toEqual({ data: { type: 'reviewSubmissions', id: 'rs-9', attributes: { submitted: true } } });
|
||||||
|
|
||||||
|
const first = planIapSubmission(STATE, ['com.example.notes.pro', 'com.example.notes.plus_monthly', 'com.example.notes.theme', 'com.example.notes.coins_100', 'com.example.nope']);
|
||||||
|
expect(first.firstOfType.sort()).toEqual(['auto-renewable subscription', 'non-consumable']);
|
||||||
|
expect(first.blockers[0]).toContain('--with-version');
|
||||||
|
expect(first.blockers[0]).toContain('1.2.0');
|
||||||
|
expect(first.items.map(i => i.kind)).toEqual(['inAppPurchaseVersion', 'subscriptionVersion', 'subscriptionGroupVersion']);
|
||||||
|
expect(Object.fromEntries(first.skipped.map(s => [s.id, s.why.split(' (')[0]!.split(':')[0]]))).toEqual({
|
||||||
|
'com.example.notes.theme': 'missing metadata', 'com.example.notes.coins_100': 'latest version is APPROVED', 'com.example.nope': 'not on App Store Connect' });
|
||||||
|
|
||||||
|
const withV = planIapSubmission(STATE, ['com.example.notes.pro'], { withVersion: true });
|
||||||
|
expect(withV.blockers).toEqual([]);
|
||||||
|
expect(withV.items.map(i => i.kind)).toEqual(['appStoreVersion', 'inAppPurchaseVersion']);
|
||||||
|
expect(withV.appVersion?.versionString).toBe('1.2.0');
|
||||||
|
});
|
||||||
|
test('no approved app version yet: even a later type needs the version; a draft is reused and items in it are skipped', () => {
|
||||||
|
const fresh = planIapSubmission({ ...STATE, appVersions: [{ id: 'v-1', versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }] }, ['com.example.notes.coins_500']);
|
||||||
|
expect(fresh.needsAppVersion).toBe(true);
|
||||||
|
expect(fresh.blockers[0]).toContain('no approved version');
|
||||||
|
const draft = planIapSubmission({ ...STATE, draft: { id: 'rs-1', itemVersionIds: ['iv-2'] } }, ['com.example.notes.coins_500', 'com.example.notes.pro'], { withVersion: true });
|
||||||
|
expect(draft.prepare.map(r => r.path)).not.toContain('/v1/reviewSubmissions');
|
||||||
|
expect(draft.skipped.find(s => s.id === 'com.example.notes.coins_500')?.why).toBe('already in the draft submission');
|
||||||
|
expect(draft.submit.path).toBe('/v1/reviewSubmissions/rs-1');
|
||||||
|
expect(planIapSubmission({ ...STATE, draft: { id: 'rs-1', itemVersionIds: Array.from({ length: 200 }, (_, i) => `x${i}`) } }, ['com.example.notes.coins_500']).blockers[0]).toContain('200');
|
||||||
|
});
|
||||||
|
test('command: --dry --json plans; a real run asks, creates the draft, adds the items, submits', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project({ ...BASE, products: [{ id: 'com.example.notes.coins_500', type: 'consumable', usd: 4.99, listings: { en: { title: '500 coins', description: 'A bag of coins' } } }] });
|
||||||
|
const routes = () => mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?/, answer: page([{ type: 'appStoreVersions', id: 'v-1', attributes: { versionString: '1.1.0', appVersionState: 'READY_FOR_DISTRIBUTION', platform: 'IOS' } }]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?/, answer: page([
|
||||||
|
{ type: 'inAppPurchases', id: 'i-coins', attributes: { productId: 'com.example.notes.coins_100', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' } },
|
||||||
|
{ type: 'inAppPurchases', id: 'i-coins2', attributes: { productId: 'com.example.notes.coins_500', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' } }]) },
|
||||||
|
{ match: /^\/v2\/inAppPurchases\/i-coins2\/versions/, answer: page([{ type: 'inAppPurchaseVersions', id: 'iv-2', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' } }]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
||||||
|
{ match: /^\/v1\/reviewSubmissions\?filter\[app\]=6400000101&filter\[platform\]=IOS&filter\[state\]=READY_FOR_REVIEW/, answer: page([]) },
|
||||||
|
{ method: 'POST', match: /^\/v1\/reviewSubmissions$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'READY_FOR_REVIEW' } } } },
|
||||||
|
{ method: 'POST', match: /^\/v1\/reviewSubmissionItems$/, answer: { data: { type: 'reviewSubmissionItems', id: 'it-1' } } },
|
||||||
|
{ method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } },
|
||||||
|
]);
|
||||||
|
let calls = routes();
|
||||||
|
let r = await capture(() => iapSubmit(p.ctx(['--dry', '--json'])));
|
||||||
|
const plan = jsonOut(r.out);
|
||||||
|
expect(plan).toMatchObject({ dryRun: true, blockers: [], needsAppVersion: false, hasApprovedVersion: true });
|
||||||
|
expect((plan.requests as string[]).at(-1)).toContain('PATCH /v1/reviewSubmissions/${submission} {submitted=true}');
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
calls = routes();
|
||||||
|
r = await capture(() => iapSubmit(p.ctx(['--yes'])));
|
||||||
|
expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-new']);
|
||||||
|
expect(calls.find(c => c.path === '/v1/reviewSubmissionItems')!.body).toEqual({ data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-new' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: 'iv-2' } } } } });
|
||||||
|
calls = routes();
|
||||||
|
r = await capture(() => iapSubmit(p.ctx(['--yes', '--no-submit'])));
|
||||||
|
expect(calls.filter(c => c.method === 'PATCH')).toHaveLength(0);
|
||||||
|
expect(r.out).toContain('Submit for Review');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- offers ----
|
||||||
|
|
||||||
|
describe('offer codes, win-back, promoted, pre-orders: config and bodies', () => {
|
||||||
|
test('config checks (date rules only right before creating)', () => {
|
||||||
|
expect(validateIapOfferCodes('p', [{ name: 'Spring', eligibility: ['NON_SPENDER'], usd: 0.99, oneTimeCodes: [{ count: 500, expires: '2027-01-01' }] }], 'non_consumable')).toEqual([]);
|
||||||
|
const e = validateIapOfferCodes('p', [{ name: 'X', eligibility: ['NEW' as never], customCodes: [{ code: 'NO WAY', count: 30000 }], oneTimeCodes: [{ count: 10, expires: '2030-01-01' }] }], 'non_consumable', '2026-10-11').join('\n');
|
||||||
|
for (const m of ['eligibility', 'free: true or usd', 'letters and digits', '1-25000', '500-25000', 'within 6 months']) expect(e).toContain(m);
|
||||||
|
const wb = { id: 'come-back', type: 'pay-up-front' as const, duration: 'P3M', usd: 9.99, paidMonths: 6, lapsedMonths: { min: 2, max: 24 }, start: '2026-11-01', end: '2026-11-30' };
|
||||||
|
expect(validateWinBack('s', [wb])).toEqual([]);
|
||||||
|
expect(validateWinBack('s', [{ ...wb, start: '2026-10-11' }], '2026-10-11').join()).toContain('at the earliest tomorrow');
|
||||||
|
expect(validateWinBack('s', [{ ...wb, paidMonths: 30, lapsedMonths: { min: 0, max: 30 }, waitMonths: 1, end: '2026-11-02' }]).join('\n')).toMatch(/paidMonths[\s\S]*lapsedMonths[\s\S]*waitMonths[\s\S]*at least 3 days/);
|
||||||
|
expect(validatePromoted({ products: Array.from({ length: 21 }, (_, i) => `p${i}`) }, new Set(['p0'])).join()).toContain('at most 20');
|
||||||
|
// a config with them validates, an old campaign never breaks the config
|
||||||
|
const cfg = { ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable' as const, usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } }, offerCodes: [{ name: 'Old', eligibility: ['CHURNED_SPENDER' as const], free: true, oneTimeCodes: [{ count: 500, expires: '2020-01-01' }] }] }],
|
||||||
|
apple: { ...BASE.apple!, promoted: { products: ['com.example.notes.pro'] } } };
|
||||||
|
expect(validateConfig(cfg)).toEqual([]);
|
||||||
|
});
|
||||||
|
test('IAP offer code body: paid per territory, free without a price point', () => {
|
||||||
|
const b = iapOfferCodeBody('i-pro', { name: 'Spring', eligibility: ['NON_SPENDER', 'CHURNED_SPENDER'], usd: 1.99 }, [{ territory: 'USA', pointId: 'pp-usa' }, { territory: 'DEU', pointId: 'pp-deu' }]);
|
||||||
|
expect(b.data).toEqual({ type: 'inAppPurchaseOfferCodes', attributes: { name: 'Spring', customerEligibilities: ['NON_SPENDER', 'CHURNED_SPENDER'] },
|
||||||
|
relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id: 'i-pro' } }, prices: { data: [{ type: 'inAppPurchaseOfferPrices', id: '${iapcode-0}' }, { type: 'inAppPurchaseOfferPrices', id: '${iapcode-1}' }] } } });
|
||||||
|
expect(b.included[1]).toEqual({ type: 'inAppPurchaseOfferPrices', id: '${iapcode-1}', relationships: { territory: { data: { type: 'territories', id: 'DEU' } }, pricePoint: { data: { type: 'inAppPurchasePricePoints', id: 'pp-deu' } } } });
|
||||||
|
expect(iapOfferCodeBody('i', { name: 'Free', eligibility: ['NON_SPENDER'], free: true }, [{ territory: 'USA' }]).included[0]!.relationships).toEqual({ territory: { data: { type: 'territories', id: 'USA' } } });
|
||||||
|
expect(() => iapOfferCodeBody('i', { name: 'P', eligibility: ['NON_SPENDER'], usd: 1 }, [{ territory: 'USA' }])).toThrow('needs a price point');
|
||||||
|
});
|
||||||
|
test('win-back body like Apple\'s example request', () => {
|
||||||
|
const b = winBackBody('s-1', 'P1M', { id: '6Monthfor3_a', name: '6 Months for 3 A', type: 'pay-up-front', duration: 'P6M', usd: 3, paidMonths: 6, lapsedMonths: { min: 2, max: 24 }, waitMonths: 2,
|
||||||
|
start: '2026-11-01', end: '2026-11-30', priority: 'high', promote: true }, [{ territory: 'USA', pointId: 'sp-usa' }]);
|
||||||
|
expect(b.data.attributes).toEqual({ referenceName: '6 Months for 3 A', offerId: '6Monthfor3_a', duration: 'SIX_MONTHS', offerMode: 'PAY_UP_FRONT', periodCount: 1,
|
||||||
|
customerEligibilityPaidSubscriptionDurationInMonths: 6, customerEligibilityTimeSinceLastSubscribedInMonths: { minimum: 2, maximum: 24 }, customerEligibilityWaitBetweenOffersInMonths: 2,
|
||||||
|
startDate: '2026-11-01', endDate: '2026-11-30', priority: 'HIGH', promotionIntent: 'USE_AUTO_GENERATED_ASSETS' });
|
||||||
|
expect(b.included[0]).toEqual({ type: 'winBackOfferPrices', id: '${winback-0}', relationships: { subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: 'sp-usa' } } } });
|
||||||
|
expect(winBackBody('s', 'P1M', { id: 'x', type: 'pay-as-you-go', usd: 1, periods: 3, paidMonths: 1, lapsedMonths: { min: 1, max: 3 }, start: '2026-11-01' }, [{ territory: 'USA', pointId: 'p' }]).data.attributes).toMatchObject({ duration: 'ONE_MONTH', offerMode: 'PAY_AS_YOU_GO', periodCount: 3, priority: 'NORMAL', promotionIntent: 'NOT_PROMOTED' });
|
||||||
|
});
|
||||||
|
test('promoted: create disabled, enable only with --enable, order configured first', () => {
|
||||||
|
const prods = new Map([['a', { apiId: 'i-a', kind: 'iap' as const }], ['b', { apiId: 's-b', kind: 'sub' as const }]]);
|
||||||
|
const now = [{ id: 'pp-x', productId: 'x', enabled: true, visibleForAllUsers: true }, { id: 'pp-b', productId: 'b', enabled: false, visibleForAllUsers: false }];
|
||||||
|
const p = planPromoted('app', { products: ['a', 'b', 'zzz'] }, now, prods);
|
||||||
|
expect(p.create[0]!.body).toEqual({ data: { type: 'promotedPurchases', attributes: { visibleForAllUsers: true, enabled: false }, relationships: { app: { data: { type: 'apps', id: 'app' } }, inAppPurchaseV2: { data: { type: 'inAppPurchases', id: 'i-a' } } } } });
|
||||||
|
expect(p.update.map(r => r.note)).toEqual(['b: visibleForAllUsers=true']);
|
||||||
|
expect(p.unknown).toEqual(['zzz']);
|
||||||
|
expect(p.extra).toEqual(['x']);
|
||||||
|
expect(planPromoted('app', { products: ['b'] }, now, prods, { enable: true }).update[0]!.body).toEqual({ data: { type: 'promotedPurchases', id: 'pp-b', attributes: { visibleForAllUsers: true, enabled: true } } });
|
||||||
|
expect(promotedOrderReq('app', { products: ['b'] }, now)).toEqual({ method: 'PATCH', path: '/v1/apps/app/relationships/promotedPurchases', note: 'promoted purchase order', body: { data: [{ type: 'promotedPurchases', id: 'pp-b' }, { type: 'promotedPurchases', id: 'pp-x' }] } });
|
||||||
|
expect(promotedOrderReq('app', { products: ['x'] }, now)).toBeNull();
|
||||||
|
});
|
||||||
|
test('pre-order: 2-365 days, released territories skipped, end releases now', () => {
|
||||||
|
const rows = territoryRows([page([
|
||||||
|
{ type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, releaseDate: null, preOrderEnabled: false, contentStatuses: ['AVAILABLE_FOR_SALE_UNRELEASED_APP'] }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } },
|
||||||
|
{ type: 'territoryAvailabilities', id: 'ta-deu', attributes: { available: true, releaseDate: '2026-12-01', preOrderEnabled: true, contentStatuses: ['AVAILABLE_FOR_PREORDER_ON_DATE'] }, relationships: { territory: { data: { type: 'territories', id: 'DEU' } } } },
|
||||||
|
{ type: 'territoryAvailabilities', id: 'ta-tur', attributes: { available: true, contentStatuses: ['AVAILABLE'] }, relationships: { territory: { data: { type: 'territories', id: 'TUR' } } } },
|
||||||
|
])]);
|
||||||
|
const p = planPreOrder(rows, '2026-12-01', null, '2026-10-11');
|
||||||
|
expect(p.problems).toEqual([]);
|
||||||
|
expect(p.reqs.map(r => r.path)).toEqual(['/v1/territoryAvailabilities/ta-usa']);
|
||||||
|
expect(p.reqs[0]!.body).toEqual({ data: { type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, preOrderEnabled: true, releaseDate: '2026-12-01' } } });
|
||||||
|
expect(p.warnings).toEqual(['TUR: already released there, no pre-order possible']);
|
||||||
|
expect(planPreOrder(rows, '2026-10-12', null, '2026-10-11').problems[0]).toContain('at least 2 days');
|
||||||
|
expect(planPreOrder(rows, '2027-06-01', null, '2026-10-11').warnings[0]).toContain('180 days');
|
||||||
|
expect(planPreOrder(rows, '2026-12-01', ['FRA'], '2026-10-11').problems[0]).toContain('FRA');
|
||||||
|
expect(endPreOrderReq(rows, null)!.body).toEqual({ data: { type: 'endAppAvailabilityPreOrders', relationships: { territoryAvailabilities: { data: [{ type: 'territoryAvailabilities', id: 'ta-deu' }] } } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offer commands against recorded answers', () => {
|
||||||
|
test('preorder status --json and enable --dry', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project();
|
||||||
|
mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appAvailabilityV2$/, answer: { data: { type: 'appAvailabilities', id: 'av-1', attributes: { availableInNewTerritories: true } } } },
|
||||||
|
{ match: /^\/v2\/appAvailabilities\/av-1\/territoryAvailabilities\?include=territory/, answer: page([
|
||||||
|
{ type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, releaseDate: null, preOrderEnabled: false, preOrderPublishDate: null, contentStatuses: ['AVAILABLE_FOR_SALE_UNRELEASED_APP'] }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
||||||
|
]);
|
||||||
|
let r = await capture(() => preorder(p.ctx(['status', '--json'])));
|
||||||
|
expect(jsonOut(r.out)).toMatchObject({ availableInNewTerritories: true, territories: [{ territory: 'USA', preOrderEnabled: false }] });
|
||||||
|
const future = new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
r = await capture(() => preorder(p.ctx(['enable', '--release-date', future, '--dry', '--json'])));
|
||||||
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('PATCH /v1/territoryAvailabilities/ta-usa {available=true, preOrderEnabled=true');
|
||||||
|
});
|
||||||
|
test('promoted: status without config; plan with config is a dry run', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const routes = [
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'APPROVED' } }]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/promotedPurchases\?include=/, answer: page([]) },
|
||||||
|
];
|
||||||
|
mockFetch(routes);
|
||||||
|
let r = await capture(() => promoted(project().ctx(['--json'])));
|
||||||
|
expect(jsonOut(r.out)).toEqual({ promoted: [] });
|
||||||
|
const calls = mockFetch(routes);
|
||||||
|
r = await capture(() => promoted(project({ ...BASE, apple: { ...BASE.apple!, promoted: { products: ['com.example.notes.pro'] } } }).ctx(['--dry', '--json'])));
|
||||||
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('POST /v1/promotedPurchases {visibleForAllUsers=true, enabled=false}');
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test('offer-codes --dry: campaign with equalized prices; codes need --codes', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project({ ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } },
|
||||||
|
offerCodes: [{ name: 'Spring', eligibility: ['NON_SPENDER'], usd: 0.99, customCodes: [{ code: 'SPRING26', count: 100 }] }] }] });
|
||||||
|
const calls = mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?filter\[productId\]=com.example.notes.pro/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' } }]) },
|
||||||
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/offerCodes/, answer: page([]) },
|
||||||
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/pricePoints\?filter\[territory\]=USA/, answer: page([
|
||||||
|
{ type: 'inAppPurchasePricePoints', id: 'pp-usa-099', attributes: { customerPrice: '0.99', proceeds: '0.84' }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } },
|
||||||
|
{ type: 'inAppPurchasePricePoints', id: 'pp-usa-499', attributes: { customerPrice: '4.99', proceeds: '4.24' }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
||||||
|
{ match: /^\/v1\/inAppPurchasePricePoints\/pp-usa-099\/equalizations/, answer: page([
|
||||||
|
{ type: 'inAppPurchasePricePoints', id: 'pp-deu-099', attributes: { customerPrice: '0.99' }, relationships: { territory: { data: { type: 'territories', id: 'DEU' } } } }]) },
|
||||||
|
]);
|
||||||
|
const r = await capture(() => offerCodes(p.ctx(['--dry', '--json'])));
|
||||||
|
expect(r.out).toContain('would POST /v1/inAppPurchaseOfferCodes {name="Spring"');
|
||||||
|
expect(r.out).toContain('2 territories');
|
||||||
|
expect(jsonOut(r.out).codes).toEqual(['POST /v1/inAppPurchaseOfferCodeCustomCodes {customCode="SPRING26", numberOfCodes=100} # custom code SPRING26 (100 redemptions)']);
|
||||||
|
expect(r.out).toContain('run with --codes');
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test('win-back --dry: an approved subscription gets the plan, others are skipped with the reason', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const start = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
const sub = (id: string) => ({ id, period: 'P1M' as const, level: 1, usd: 4.99, listings: { en: { title: 'Plus', description: 'Everything' } },
|
||||||
|
winBack: [{ id: `wb-${id.slice(-1)}`, type: 'free' as const, duration: 'P1M', paidMonths: 3, lapsedMonths: { min: 1, max: 12 }, start }] });
|
||||||
|
const p = project({ ...BASE, subscriptions: [{ name: 'Plus', listings: { en: { name: 'Plus' } }, subscriptions: [sub('com.example.notes.plus_a'), sub('com.example.notes.plus_b')] }] });
|
||||||
|
const calls = mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([{ type: 'subscriptionGroups', id: 'g-1', attributes: { referenceName: 'Plus' } }]) },
|
||||||
|
{ match: /^\/v1\/subscriptionGroups\/g-1\/subscriptions/, answer: page([{ type: 'subscriptions', id: 's-a', attributes: { productId: 'com.example.notes.plus_a', state: 'APPROVED' } }, { type: 'subscriptions', id: 's-b', attributes: { productId: 'com.example.notes.plus_b', state: 'READY_TO_SUBMIT' } }]) },
|
||||||
|
{ match: /^\/v1\/subscriptions\/s-[ab]\/winBackOffers/, answer: page([]) },
|
||||||
|
{ match: /^\/v1\/subscriptions\/s-a\/prices\?include=territory/, answer: page([{ type: 'subscriptionPrices', id: 'sp1', relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
||||||
|
]);
|
||||||
|
const r = await capture(() => winBack(p.ctx(['--dry', '--json'])));
|
||||||
|
const j = jsonOut(r.out);
|
||||||
|
expect((j.create as string[])[0]).toContain('POST /v1/winBackOffers {referenceName="wb-a", offerId="wb-a", duration="ONE_MONTH", offerMode="FREE_TRIAL"');
|
||||||
|
expect(j.skipped).toEqual([{ id: 'com.example.notes.plus_b', why: 'subscription is READY_TO_SUBMIT: win-back offers need an approved subscription' }]);
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- custom product pages and in-app events ----
|
||||||
|
|
||||||
|
describe('custom product pages and in-app events', () => {
|
||||||
|
const EV = { referenceName: 'Winter Challenge', badge: 'CHALLENGE' as const, purpose: 'KEEP_ACTIVE_USERS_INFORMED' as const,
|
||||||
|
schedule: { publishStart: '2026-12-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2026-12-20T00:00:00Z' },
|
||||||
|
listings: { en: { name: 'Winter Challenge', shortDescription: 'Finish ten lists before New Year', longDescription: 'Ten lists, one badge. Share your progress with friends.' } },
|
||||||
|
media: { card: 'store/events/winter/{lang}-card.png' } };
|
||||||
|
test('config checks', () => {
|
||||||
|
expect(validateCpp([{ name: 'Students', variant: 'b', promotionalText: { en: 'For class notes.' }, deepLink: 'https://example.com/students' }], new Set(['en']), ['b'])).toEqual([]);
|
||||||
|
expect(validateCpp([{ name: 'A', variant: 'z', deepLink: 'not a link', promotionalText: { xx: 'x'.repeat(171) } }], new Set(['en']), []).join('\n')).toMatch(/variant[\s\S]*deepLink[\s\S]*not in languages[\s\S]*171 > 170/);
|
||||||
|
expect(validateEvents([EV], new Set(['en']))).toEqual([]);
|
||||||
|
const bad = validateEvents([{ ...EV, schedule: { publishStart: '2026-11-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2027-02-10T00:00:00Z' }, listings: { en: { name: 'x'.repeat(31), shortDescription: '', longDescription: 'ok' } } }], new Set(['en'])).join('\n');
|
||||||
|
for (const m of ['at most 31 days', 'at most 14 days', 'name: 31 > 30', 'shortDescription: missing']) expect(bad).toContain(m);
|
||||||
|
expect(checkEventMedia('EVENT_CARD', 1920, 1080, 'png')).toEqual([]);
|
||||||
|
expect(checkEventMedia('EVENT_DETAILS_PAGE', 1920, 1080, 'png').join()).toContain('9:16');
|
||||||
|
});
|
||||||
|
test('request bodies', () => {
|
||||||
|
const b = cppCreateBody('6400000101', { name: 'Students', deepLink: 'https://example.com/s' }, [{ locale: 'en-US', promotionalText: 'For class notes.' }, { locale: 'de-DE' }]);
|
||||||
|
expect(b.data.relationships.appCustomProductPageVersions).toEqual({ data: [{ type: 'appCustomProductPageVersions', id: '${cpp-version}' }] });
|
||||||
|
expect(b.included[0]).toEqual({ type: 'appCustomProductPageVersions', id: '${cpp-version}', attributes: { deepLink: 'https://example.com/s' }, relationships: { appCustomProductPageLocalizations: { data: [{ type: 'appCustomProductPageLocalizations', id: '${cpp-loc-0}' }, { type: 'appCustomProductPageLocalizations', id: '${cpp-loc-1}' }] } } });
|
||||||
|
expect(b.included[2]).toEqual({ type: 'appCustomProductPageLocalizations', id: '${cpp-loc-1}', attributes: { locale: 'de-DE' } });
|
||||||
|
expect(planCppLocales('cv', [{ id: 'l1', locale: 'en-US', promotionalText: 'old' }], [{ locale: 'en-US', promotionalText: 'new' }, { locale: 'tr' }]).map(r => `${r.method} ${r.path}`)).toEqual(['PATCH /v1/appCustomProductPageLocalizations/l1', 'POST /v1/appCustomProductPageLocalizations']);
|
||||||
|
const e = eventCreateBody('6400000101', EV, 'en-US', ['USA', 'DEU']);
|
||||||
|
expect(e.data.attributes).toEqual({ referenceName: 'Winter Challenge', badge: 'CHALLENGE', primaryLocale: 'en-US', priority: 'NORMAL', purpose: 'KEEP_ACTIVE_USERS_INFORMED',
|
||||||
|
territorySchedules: [{ territories: ['USA', 'DEU'], publishStart: '2026-12-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2026-12-20T00:00:00Z' }] });
|
||||||
|
expect(planEventLocales('ev', [{ id: 'l', locale: 'en-US', name: 'Winter Challenge', shortDescription: 'old', longDescription: 'Ten lists, one badge. Share your progress with friends.' }], [{ locale: 'en-US', ...EV.listings.en }])[0]!.note).toBe('en-US shortDescription');
|
||||||
|
});
|
||||||
|
test('cpp create --dry --json and events create --dry against recorded answers', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project({ ...BASE, apple: { ...BASE.apple!, customProductPages: [{ name: 'Students', promotionalText: { en: 'For class notes.' } }], events: [EV] } });
|
||||||
|
const calls = mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appCustomProductPages/, answer: page([]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appEvents/, answer: page([]) },
|
||||||
|
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'DEU' }]) },
|
||||||
|
]);
|
||||||
|
let r = await capture(() => cpp(p.ctx(['create', '--dry', '--json'])));
|
||||||
|
expect(jsonOut(r.out).requests).toEqual(['POST /v1/appCustomProductPages {name="Students"} # page "Students" with 3 locale(s)']);
|
||||||
|
r = await capture(() => events(p.ctx(['create', '--dry', '--json'])));
|
||||||
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('POST /v1/appEvents {referenceName="Winter Challenge", badge="CHALLENGE", primaryLocale="en-US"');
|
||||||
|
expect(r.out).toContain('then: 1 localization(s)');
|
||||||
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
// submit of a created page: the dry run shows draft, item and submit, and sends nothing
|
||||||
|
const sent = mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appCustomProductPages/, answer: page([{ type: 'appCustomProductPages', id: 'cpp-1', attributes: { name: 'Students', visible: false, url: 'https://apps.apple.com/us/app/id6400000101?ppid=cpp-1' } }]) },
|
||||||
|
{ match: /^\/v1\/appCustomProductPages\/cpp-1\/appCustomProductPageVersions/, answer: page([{ type: 'appCustomProductPageVersions', id: 'cv-1', attributes: { version: '1', state: 'PREPARE_FOR_SUBMISSION', deepLink: null } }]) },
|
||||||
|
{ match: /^\/v1\/reviewSubmissions\?filter\[app\]=6400000101/, answer: page([]) },
|
||||||
|
]);
|
||||||
|
r = await capture(() => cpp(p.ctx(['submit', '--name', 'Students', '--dry'])));
|
||||||
|
expect(r.out).toContain('would POST /v1/reviewSubmissionItems');
|
||||||
|
expect(r.out).toContain('would PATCH /v1/reviewSubmissions/${submission}');
|
||||||
|
r = await capture(() => cpp(p.ctx(['submit', '--name', 'Students'])));
|
||||||
|
expect(r.out).toContain('re-run with --yes');
|
||||||
|
expect(sent.filter(c => c.method !== 'GET')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
202
tests/store-ops-reports.test.ts
Normal file
202
tests/store-ops-reports.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// Sales / finance reports and crash / feedback triage: parsers and summaries on sanitized report files, plus the
|
||||||
|
// commands against recorded App Store Connect, Cloud Storage and Play Developer Reporting answers.
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { deflateRawSync } from 'node:zlib';
|
||||||
|
import {
|
||||||
|
appleFinancePath, appleReportDate, appleSalesPath, parsePlayCsv, parseTsv, playReportPrefix, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, totalsByCurrency,
|
||||||
|
} from '../cli/lib/reports';
|
||||||
|
import { unzip } from '../cli/lib/zip';
|
||||||
|
import { cluster, crashSignature, feedbackItems, playIssues, playReportItems, reportsQuery } from '../cli/lib/triage';
|
||||||
|
import { run as reports } from '../cli/reports';
|
||||||
|
import { run as triage } from '../cli/triage';
|
||||||
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
|
||||||
|
|
||||||
|
afterEach(restore);
|
||||||
|
|
||||||
|
/** A ZIP with deflated entries (what Play puts in the reports bucket). */
|
||||||
|
function zip(files: Record<string, string>): Uint8Array {
|
||||||
|
const parts: Uint8Array[] = [], central: Uint8Array[] = [];
|
||||||
|
let off = 0;
|
||||||
|
for (const [name, text] of Object.entries(files)) {
|
||||||
|
const raw = new TextEncoder().encode(text), data = deflateRawSync(raw), n = new TextEncoder().encode(name);
|
||||||
|
const h = new DataView(new ArrayBuffer(30));
|
||||||
|
h.setUint32(0, 0x04034b50, true); h.setUint16(8, 8, true); h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true); h.setUint16(26, n.length, true);
|
||||||
|
const c = new DataView(new ArrayBuffer(46));
|
||||||
|
c.setUint32(0, 0x02014b50, true); c.setUint16(10, 8, true); c.setUint32(20, data.length, true); c.setUint32(24, raw.length, true); c.setUint16(28, n.length, true); c.setUint32(42, off, true);
|
||||||
|
parts.push(new Uint8Array(h.buffer), n, data);
|
||||||
|
central.push(new Uint8Array(c.buffer), n);
|
||||||
|
off += 30 + n.length + data.length;
|
||||||
|
}
|
||||||
|
const cdSize = central.reduce((s, x) => s + x.length, 0);
|
||||||
|
const e = new DataView(new ArrayBuffer(22));
|
||||||
|
e.setUint32(0, 0x06054b50, true); e.setUint16(8, Object.keys(files).length, true); e.setUint16(10, Object.keys(files).length, true); e.setUint32(12, cdSize, true); e.setUint32(16, off, true);
|
||||||
|
const all = [...parts, ...central, new Uint8Array(e.buffer)];
|
||||||
|
const out = new Uint8Array(all.reduce((s, x) => s + x.length, 0));
|
||||||
|
let p = 0;
|
||||||
|
for (const x of all) { out.set(x, p); p += x.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary Sales Report 1_0 (tab-separated, header as in App Store Connect Help); values are made up
|
||||||
|
const SALES_TSV = ['Provider\tProvider Country\tSKU\tDeveloper\tTitle\tVersion\tProduct Type Identifier\tUnits\tDeveloper Proceeds\tBegin Date\tEnd Date\tCustomer Currency\tCountry Code\tCurrency of Proceeds\tApple Identifier\tCustomer Price',
|
||||||
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t3\t3.49\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000301\t4.99',
|
||||||
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t1\t3.49\t10/09/2026\t10/09/2026\tUSD\tCA\tUSD\t6400000301\t4.99',
|
||||||
|
'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t2\t4.12\t10/09/2026\t10/09/2026\tEUR\tDE\tEUR\t6400000301\t5.99',
|
||||||
|
'APPLE\tUS\tNOTES1\tExample\tExample Notes\t1.2.0\t1F\t40\t0\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000101\t0', ''].join('\n');
|
||||||
|
const FINANCE_TSV = ['Start Date\tEnd Date\tUPC\tISRC/ISBN\tVendor Identifier\tQuantity\tPartner Share\tExtended Partner Share\tPartner Share Currency\tSales or Return\tApple Identifier\tArtist/Show/Developer/Author\tTitle',
|
||||||
|
'09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t10\t3.49\t34.90\tUSD\tS\t6400000301\tExample\tPro',
|
||||||
|
'09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t-1\t3.49\t-3.49\tUSD\tR\t6400000301\tExample\tPro', '', 'Total_Rows\t2', 'Total_Amount\t31.41'].join('\n');
|
||||||
|
const EARNINGS_CSV = 'Description,Transaction Date,Transaction Time,Tax Type,Transaction Type,Refund Type,Product Title,Package ID,Product Type,SKU ID,Hardware,Buyer Country,Buyer State,Buyer Postcode,Buyer Currency,Amount (Buyer Currency),Currency Conversion Rate,Merchant Currency,Amount (Merchant Currency)\n'
|
||||||
|
+ 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,4.99,1,USD,4.99\n'
|
||||||
|
+ 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Google fee,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,-0.75,1,USD,-0.75\n'
|
||||||
|
+ 'GPA.0000-0000-0000-00002,"Sep 4, 2026",11:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,DE,,,EUR,5.49,1.08,USD,5.93\n';
|
||||||
|
const SALES_CSV = 'Order Number,Order Charged Date,Order Charged Timestamp,Financial Status,Device Model,Product Title,Package ID,Product Type,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount,City of Buyer,State of Buyer,Postcode of Buyer,Country of Buyer\n'
|
||||||
|
+ 'GPA.0000-0000-0000-00001,2026-09-03,1788000000,Charged,phone1,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,CA,,US\n'
|
||||||
|
+ 'GPA.0000-0000-0000-00003,2026-09-05,1788100000,Refund,phone2,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,TX,,US\n';
|
||||||
|
|
||||||
|
describe('report parsers and summaries', () => {
|
||||||
|
test('request paths follow the allowed combinations', () => {
|
||||||
|
expect(appleSalesPath('12345678', { type: 'SALES', frequency: 'DAILY', date: '2026-10-09' })).toBe('/v1/salesReports?filter%5BvendorNumber%5D=12345678&filter%5BreportType%5D=SALES&filter%5BreportSubType%5D=SUMMARY&filter%5Bfrequency%5D=DAILY&filter%5BreportDate%5D=2026-10-09&filter%5Bversion%5D=1_0');
|
||||||
|
expect(appleSalesPath('12345678', { type: 'SUBSCRIPTION', frequency: 'DAILY', date: '2026-10-09' })).toContain('filter%5Bversion%5D=1_3');
|
||||||
|
expect(() => appleSalesPath('1', { type: 'SUBSCRIPTION', frequency: 'MONTHLY', date: '2026-09' })).toThrow('DAILY only');
|
||||||
|
expect(appleFinancePath('12345678', '2026-09')).toContain('filter%5BreportType%5D=FINANCIAL&filter%5BregionCode%5D=ZZ&filter%5BreportDate%5D=2026-09');
|
||||||
|
expect(appleFinancePath('12345678', '2026-09', { type: 'FINANCE_DETAIL' })).toContain('regionCode%5D=Z1');
|
||||||
|
expect(appleReportDate('MONTHLY', undefined, new Date('2026-01-15T00:00:00Z'))).toBe('2025-12');
|
||||||
|
expect(appleReportDate('DAILY', undefined, new Date('2026-10-11T12:00:00Z'))).toBe('2026-10-09');
|
||||||
|
expect(() => appleReportDate('YEARLY', '2026-01')).toThrow('YYYY');
|
||||||
|
expect(playReportPrefix('earnings', '2026-09')).toBe('earnings/earnings_202609');
|
||||||
|
});
|
||||||
|
test('Apple sales and finance: units x proceeds per SKU and currency, totals never mix currencies', () => {
|
||||||
|
const s = summarizeAppleSales(parseTsv(SALES_TSV));
|
||||||
|
expect(s).toEqual([
|
||||||
|
{ key: 'NOTES1', title: 'Example Notes', currency: 'USD', units: 40, amount: 0, rows: 1 },
|
||||||
|
{ key: 'com.example.notes.pro', title: 'Pro', currency: 'EUR', units: 2, amount: 8.24, rows: 1 },
|
||||||
|
{ key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 4, amount: 13.96, rows: 2 },
|
||||||
|
]);
|
||||||
|
expect(totalsByCurrency(s)).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]);
|
||||||
|
expect(summarizeAppleFinance(parseTsv(FINANCE_TSV))).toEqual([{ key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 9, amount: 31.41, rows: 2 }]);
|
||||||
|
});
|
||||||
|
test('Play earnings and estimated sales out of the zip', () => {
|
||||||
|
const files = unzip(zip({ 'PlayApps_202609.csv': EARNINGS_CSV }));
|
||||||
|
expect(files.map(f => f.name)).toEqual(['PlayApps_202609.csv']);
|
||||||
|
const e = summarizePlayEarnings(parsePlayCsv(new TextDecoder().decode(files[0]!.data)));
|
||||||
|
expect(e).toEqual([
|
||||||
|
{ key: 'com.example.notes.pro · Charge', title: 'Pro (Example Notes)', currency: 'USD', units: 2, amount: 10.92, rows: 2 },
|
||||||
|
{ key: 'com.example.notes.pro · Google fee', title: 'Pro (Example Notes)', currency: 'USD', units: 0, amount: -0.75, rows: 1 },
|
||||||
|
]);
|
||||||
|
expect(summarizePlaySales(parsePlayCsv(SALES_CSV)).map(r => [r.key, r.amount])).toEqual([['com.example.notes.pro · Charged', 4.99], ['com.example.notes.pro · Refund', 4.99]]);
|
||||||
|
expect(() => unzip(new Uint8Array(40))).toThrow('not a zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reports command', () => {
|
||||||
|
test('apple-sales: --dry hides the vendor number; a run saves the TSV and the summary CSV', async () => {
|
||||||
|
ascEnv();
|
||||||
|
process.env.ASC_VENDOR_NUMBER = '12345678';
|
||||||
|
const p = project();
|
||||||
|
const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: new Response(Bun.gzipSync(new TextEncoder().encode(SALES_TSV)), { headers: { 'content-type': 'application/a-gzip' } }) }]);
|
||||||
|
let r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--dry', '--json'])));
|
||||||
|
expect(jsonOut(r.out).request).toContain('filter[vendorNumber]=<vendor>'.replace(/[[\]]/g, m => encodeURIComponent(m)));
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--json'])));
|
||||||
|
const j = jsonOut(r.out);
|
||||||
|
expect(j.rows).toBe(4);
|
||||||
|
expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]);
|
||||||
|
expect(calls[0]!.path).toContain('filter%5BvendorNumber%5D=12345678');
|
||||||
|
expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.tsv'), 'utf8')).toBe(SALES_TSV);
|
||||||
|
expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.summary.csv'), 'utf8')).toStartWith('key,title,currency,units,amount,rows\nNOTES1,Example Notes,USD,40,0,1\n');
|
||||||
|
});
|
||||||
|
test('apple-finance: 404 means no report yet; missing vendor number is explained', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project();
|
||||||
|
await expect(capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09'])))).rejects.toThrow('ASC_VENDOR_NUMBER');
|
||||||
|
mockFetch([{ match: /^\/v1\/financeReports\?/, answer: new Response('{"errors":[{"status":"404","code":"NOT_FOUND","title":"The request expected results but none were found"}]}', { status: 404 }) }]);
|
||||||
|
const r = await capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09', '--vendor', '12345678'])));
|
||||||
|
expect(r.out).toContain('no report for that date');
|
||||||
|
});
|
||||||
|
test('play-earnings: lists the bucket, unzips, summarizes', async () => {
|
||||||
|
playEnv();
|
||||||
|
const p = project();
|
||||||
|
const calls = mockFetch([
|
||||||
|
{ match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\?prefix=earnings\/earnings_202609$/, answer: { kind: 'storage#objects', items: [{ kind: 'storage#object', name: 'earnings/earnings_202609_0123456789-1.zip', size: '512' }] } },
|
||||||
|
{ match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\/earnings\/earnings_202609_0123456789-1\.zip\?alt=media$/, answer: new Response(zip({ 'PlayApps_202609.csv': EARNINGS_CSV }) as Uint8Array<ArrayBuffer>) },
|
||||||
|
]);
|
||||||
|
const r = await capture(() => reports(p.ctx(['play-earnings', '--month', '2026-09', '--json'])));
|
||||||
|
expect(jsonOut(r.out)).toMatchObject({ rows: 3, totals: [{ currency: 'USD', units: 2, amount: 10.17 }] });
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(existsSync(join(p.root, '.storebound/reports/play-earnings-2026-09.csv'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- triage ----
|
||||||
|
|
||||||
|
const TEXT_CRASH = `Incident Identifier: 00000000-0000-0000-0000-000000000000
|
||||||
|
Hardware Model: iPhone16,2
|
||||||
|
Process: ExampleNotes [1234]
|
||||||
|
Identifier: com.example.notes
|
||||||
|
Version: 1.2.0 (50)
|
||||||
|
OS Version: iPhone OS 26.0 (23A341)
|
||||||
|
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
|
||||||
|
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010
|
||||||
|
Crashed Thread: 3
|
||||||
|
|
||||||
|
Thread 0 name: Dispatch queue: com.apple.main-thread
|
||||||
|
Thread 0:
|
||||||
|
0 libsystem_kernel.dylib 0x00000001e0f0a1b4 mach_msg2_trap + 8
|
||||||
|
|
||||||
|
Thread 3 Crashed:
|
||||||
|
0 libswiftCore.dylib 0x0000000190a1b2c4 swift_retain + 12
|
||||||
|
1 ExampleNotes 0x0000000100f1a2b4 ListStore.sync(_:) + 220 (ListStore.swift:88)
|
||||||
|
2 ExampleNotes 0x0000000100f1a000 closure #1 in ListStore.start() + 40
|
||||||
|
|
||||||
|
Thread 4:
|
||||||
|
0 libsystem_pthread.dylib 0x00000001f0a0b0c0 start_wqthread + 0
|
||||||
|
`;
|
||||||
|
const IPS_CRASH = `{"app_name":"ExampleNotes","bundleID":"com.example.notes","app_version":"1.2.0","build_version":"50","os_version":"iPhone OS 26.0 (23A341)","name":"ExampleNotes"}
|
||||||
|
{"procName":"ExampleNotes","exception":{"type":"EXC_CRASH","signal":"SIGABRT"},"faultingThread":0,"threads":[{"triggered":true,"frames":[{"imageIndex":1,"symbol":"__pthread_kill","imageOffset":8},{"imageIndex":0,"symbol":"ListStore.save()","imageOffset":4096}]}],"usedImages":[{"name":"ExampleNotes"},{"name":"libsystem_kernel.dylib"}]}`;
|
||||||
|
|
||||||
|
describe('triage parts', () => {
|
||||||
|
test('crash signatures: first app frame of the crashed thread, text and .ips', () => {
|
||||||
|
expect(crashSignature(TEXT_CRASH)).toBe('EXC_BAD_ACCESS (SIGSEGV) @ ExampleNotes ListStore.sync(_:) + 220 (ListStore.swift:88)');
|
||||||
|
expect(crashSignature(IPS_CRASH)).toBe('EXC_CRASH SIGABRT @ ExampleNotes ListStore.save()');
|
||||||
|
expect(crashSignature('garbage')).toBe('crash');
|
||||||
|
});
|
||||||
|
test('clusters with top devices, OS versions and builds; Play reports grouped by issue', () => {
|
||||||
|
const items = feedbackItems([
|
||||||
|
{ id: 'c1', attributes: { createdDate: '2026-10-09T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0', comment: 'It crashed when I synced' }, relationships: { build: { data: { id: 'b-50' } } } },
|
||||||
|
{ id: 'c2', attributes: { createdDate: '2026-10-10T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0.1' }, relationships: { build: { data: { id: 'b-50' } } } },
|
||||||
|
{ id: 'c3', attributes: { createdDate: '2026-10-10T11:00:00Z', deviceModel: 'iPhone15,3', osVersion: '26.0' }, relationships: { build: { data: { id: 'b-49' } } } },
|
||||||
|
], new Map([['b-50', '50'], ['b-49', '49']]), new Map([['c1', 'A'], ['c2', 'A'], ['c3', 'B']]));
|
||||||
|
const cs = cluster(items, { comments: 2 });
|
||||||
|
expect(cs[0]).toEqual({ signature: 'A', count: 2, devices: [['iPhone16,2', 2]], os: [['26.0', 1], ['26.0.1', 1]], builds: [['50', 2]], first: '2026-10-09T10:00:00Z', last: '2026-10-10T10:00:00Z', comments: ['It crashed when I synced'] });
|
||||||
|
const issues = playIssues({ errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.notes.ListStore.sync', errorReportCount: '12', distinctUsers: '9' }] });
|
||||||
|
const play = playReportItems({ errorReports: [
|
||||||
|
{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', eventTime: '2026-10-09T00:00:00Z', deviceModel: { marketingName: 'Pixel 9', deviceId: { buildBrand: 'google', buildDevice: 'tokay' } }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } },
|
||||||
|
{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', deviceModel: { deviceId: { buildBrand: 'samsung', buildDevice: 'dm3q' } }, osVersion: { apiLevel: '35' }, appVersion: { versionCode: '120' } },
|
||||||
|
] }, issues);
|
||||||
|
expect(cluster(play)[0]).toMatchObject({ signature: 'CRASH java.lang.IllegalStateException @ com.example.notes.ListStore.sync', count: 2, devices: [['Pixel 9', 1], ['samsung/dm3q', 1]], os: [['API 35', 1], ['API 36', 1]], builds: [['120', 2]] });
|
||||||
|
expect(reportsQuery('1111', new Date('2026-09-27T00:00:00Z'), new Date('2026-10-11T00:00:00Z'))).toContain('filter=errorIssueId+%3D+1111');
|
||||||
|
});
|
||||||
|
test('command: both stores, emails never requested, --json', async () => {
|
||||||
|
ascEnv(); playEnv();
|
||||||
|
const p = project();
|
||||||
|
const calls = mockFetch([
|
||||||
|
{ match: /^\/v1\/builds\?filter\[app\]=6400000101/, answer: { data: [{ type: 'builds', id: 'b-50', attributes: { version: '50' } }], links: {} } },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/betaFeedbackCrashSubmissions\?/, answer: { data: [{ type: 'betaFeedbackCrashSubmissions', id: 'c1', attributes: { createdDate: new Date().toISOString(), deviceModel: 'iPhone16,2', osVersion: '26.0' }, relationships: { build: { data: { type: 'builds', id: 'b-50' } } } }], links: {} } },
|
||||||
|
{ match: /^\/v1\/betaFeedbackCrashSubmissions\/c1\/crashLog$/, answer: { data: { type: 'betaCrashLogs', id: 'cl1', attributes: { logText: TEXT_CRASH } } } },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/betaFeedbackScreenshotSubmissions\?/, answer: { data: [], links: {} } },
|
||||||
|
{ match: /^\/v1beta1\/apps\/com\.example\.notes\/errorIssues:search\?/, answer: { errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.notes.MainActivity', errorReportCount: '4', distinctUsers: '3' }] } },
|
||||||
|
{ match: /^\/v1beta1\/apps\/com\.example\.notes\/errorReports:search\?/, answer: { errorReports: [{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', deviceModel: { marketingName: 'Pixel 9' }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } }] } },
|
||||||
|
]);
|
||||||
|
const r = await capture(() => triage(p.ctx(['--days', '14', '--json'])));
|
||||||
|
const j = jsonOut(r.out) as { apple: { crashes: { signature: string }[] }; play: { issues: { signature: string; reports: number }[] } };
|
||||||
|
expect(j.apple.crashes[0]!.signature).toContain('ListStore.sync');
|
||||||
|
expect(j.play.issues[0]).toMatchObject({ signature: 'ANR Input dispatching timed out @ com.example.notes.MainActivity', count: 1, reports: 4 });
|
||||||
|
const fb = calls.filter(c => c.path.includes('betaFeedback') && c.path.includes('fields'));
|
||||||
|
expect(fb).toHaveLength(2);
|
||||||
|
for (const c of fb) { expect(decodeURIComponent(c.path)).not.toContain('email'); expect(decodeURIComponent(c.path)).not.toContain('tester'); }
|
||||||
|
expect(BASE.app.contactEmail).toBe('support@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
283
tests/store-ops-text.test.ts
Normal file
283
tests/store-ops-text.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
// Listing pulls, release notes in many languages, keyword ranks and the AdAttributionKit scan: pure parts plus the
|
||||||
|
// commands against recorded store answers (placeholders only).
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { compactDiff, lineDiff } from '../cli/lib/diff';
|
||||||
|
import { catalogProposal, mergeAppleListing, mergePlayListing, overLimits } from '../cli/lib/listing-pull';
|
||||||
|
import { applyLanguageNotes, checkLanguageNotes, notesFileText, parseNotesFile, resolveLanguages } from '../cli/lib/whats-new';
|
||||||
|
import { competitors, keywordList, parseStorefronts, rankDelta, rankOf, searchUrl } from '../cli/lib/aso-rank';
|
||||||
|
import { parsePlist } from '../cli/lib/plist';
|
||||||
|
import { evaluateAdAttribution, sourceSignals } from '../cli/lib/adattribution';
|
||||||
|
import { parseArgs } from '../cli/lib/args';
|
||||||
|
import { run as appleListing } from '../cli/apple/listing';
|
||||||
|
import { run as playListing } from '../cli/play/listing';
|
||||||
|
import { run as catalog } from '../cli/catalog';
|
||||||
|
import { run as whatsNew } from '../cli/whats-new';
|
||||||
|
import { run as aso } from '../cli/aso';
|
||||||
|
import { run as ads } from '../cli/ads';
|
||||||
|
import { TOOLS, toArgv } from '../cli/mcp-tools';
|
||||||
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
|
||||||
|
|
||||||
|
afterEach(restore);
|
||||||
|
const page = (data: unknown[]) => ({ data, links: { self: 'x' } });
|
||||||
|
|
||||||
|
const APPLE_MD = '# App Store\n\n## en-US · English\n\n### subtitle\n```\nNotes that sync\n```\n\n### keywords\n```\nnotes,lists\n```\n\n### description\n```\nOld text.\n```\n\n## Notes\n\nprose\n';
|
||||||
|
const PLAY_MD = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description (max 80)\n```\nOld short\n```\n\n### Play full description (max 4000)\n```\nOld full.\n```\n\n## Spanish\n<!-- play: es-ES, es-419 -->\n\n### short\n```\nCorta\n```\n\n### full\n```\nLarga.\n```\n';
|
||||||
|
|
||||||
|
describe('diff', () => {
|
||||||
|
test('line diff and compact diff', () => {
|
||||||
|
expect(lineDiff('a\nb\nc', 'a\nx\nc')).toEqual([' a', '-b', '+x', ' c']);
|
||||||
|
expect(compactDiff('1\n2\n3\n4\n5\n6\n7\n8', '1\n2\n3\n4\n5\n6\n7\nX', 1)).toEqual([' 7', '-8', '+X']);
|
||||||
|
expect(compactDiff('same', 'same')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listing pull merges', () => {
|
||||||
|
test('App Store: changed blocks replaced, missing ones added, new locales appended, prose kept', () => {
|
||||||
|
const r = mergeAppleListing(APPLE_MD, { 'en-US': { subtitle: 'Notes that sync', description: 'New text.', promotionalText: 'Now with lists.' }, 'tr': { subtitle: 'Senkron notlar', keywords: 'not,liste' } });
|
||||||
|
expect(r.changes.map(c => `${c.section} ${c.field} ${c.from === null ? 'new' : 'changed'}`)).toEqual(['en-US promotionalText new', 'en-US description changed', 'tr subtitle new', 'tr keywords new']);
|
||||||
|
expect(r.md).toContain('### description\n```\nNew text.\n```');
|
||||||
|
expect(r.md).toContain('### promotionalText\n```\nNow with lists.\n```');
|
||||||
|
expect(r.md).toContain('## Notes\n\nprose');
|
||||||
|
expect(r.md).toContain('## tr\n\n### subtitle\n```\nSenkron notlar\n```');
|
||||||
|
expect(r.added).toEqual(['tr']);
|
||||||
|
expect(overLimits([{ section: 'tr', field: 'subtitle', from: null, to: 'x'.repeat(31) }], 'apple')).toEqual(['tr subtitle: 31 > 30']);
|
||||||
|
});
|
||||||
|
test('Play: per section, shared sections only when the codes agree, title equal to the app name left out', () => {
|
||||||
|
const r = mergePlayListing(PLAY_MD, [
|
||||||
|
{ language: 'en-US', title: 'Example Notes', shortDescription: 'New short', fullDescription: 'Old full.' },
|
||||||
|
{ language: 'es-ES', shortDescription: 'Corta nueva', fullDescription: 'Larga.' }, { language: 'es-419', shortDescription: 'Otra corta', fullDescription: 'Larga.' },
|
||||||
|
{ language: 'fr-FR', title: 'Notes Exemple', shortDescription: 'Court', fullDescription: 'Long.' },
|
||||||
|
], 'Example Notes');
|
||||||
|
expect(r.changes.map(c => `${c.section} ${c.field}`)).toEqual(['en-US shortDescription', 'fr-FR title', 'fr-FR shortDescription', 'fr-FR fullDescription']);
|
||||||
|
expect(r.conflicts[0]).toContain('es-ES, es-419: shortDescription differs');
|
||||||
|
expect(r.md).toContain('### Play short description (max 80)\n```\nNew short\n```');
|
||||||
|
expect(r.md).not.toContain('### Title (max 30)\n```\nExample Notes');
|
||||||
|
expect(r.md).toContain('## fr-FR\n<!-- play: fr-FR -->\n\n### Title (max 30)\n```\nNotes Exemple\n```');
|
||||||
|
});
|
||||||
|
test('catalog proposal maps store languages to language ids and never touches unknown products', () => {
|
||||||
|
const cfg = { ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable' as const, usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } } }] };
|
||||||
|
const p = catalogProposal(cfg, { apple: { 'com.example.notes.pro': { 'en-US': { title: 'Pro', description: 'Every feature' }, 'tr': { title: 'Pro', description: 'Tüm özellikler' }, 'ja': { title: 'プロ', description: 'x' } }, 'com.example.notes.other': {} },
|
||||||
|
play: { 'com.example.notes.pro': { 'en-US': { title: 'Pro unlock', description: 'All features' } } } });
|
||||||
|
expect(p.changes.map(c => `${c.store} ${c.lang} ${c.field}`)).toEqual(['play en title', 'apple en description', 'apple tr title', 'apple tr description']);
|
||||||
|
expect(p.listings['com.example.notes.pro']!.tr).toEqual({ title: 'Pro', description: 'Tüm özellikler' });
|
||||||
|
expect(p.unmapped).toEqual(['apple:ja']);
|
||||||
|
expect(p.unknown).toEqual(['com.example.notes.other']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listing pull commands', () => {
|
||||||
|
test('apple listing pull: diff without --write, file updated with --write', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project(BASE, { 'store/appstore.md': APPLE_MD });
|
||||||
|
mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([{ type: 'appStoreVersions', id: 'v-1', attributes: { versionString: '1.1.0', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-01T00:00:00-07:00' } }]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'READY_FOR_DISTRIBUTION' } }]) },
|
||||||
|
{ match: /^\/v1\/appInfos\/ai-1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'l', attributes: { locale: 'en-US', name: 'Example Notes', subtitle: 'Notes that sync', privacyPolicyUrl: 'https://example.com/privacy' } }]) },
|
||||||
|
{ match: /^\/v1\/appStoreVersions\/v-1\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl', attributes: { locale: 'en-US', description: 'Live text.', keywords: 'notes,lists', promotionalText: null, whatsNew: 'Faster sync.' } }]) },
|
||||||
|
]);
|
||||||
|
let r = await capture(() => appleListing(p.ctx(['pull', '--json'])));
|
||||||
|
const j = jsonOut(r.out);
|
||||||
|
expect(j).toMatchObject({ version: '1.1.0', write: false, added: [] });
|
||||||
|
expect((j.changes as { field: string }[]).map(c => c.field)).toEqual(['name', 'description', 'whatsNew']);
|
||||||
|
expect(j.diff).toContain('-Old text.');
|
||||||
|
expect(readFileSync(join(p.root, 'store/appstore.md'), 'utf8')).toBe(APPLE_MD);
|
||||||
|
r = await capture(() => appleListing(p.ctx(['pull', '--write'])));
|
||||||
|
expect(readFileSync(join(p.root, 'store/appstore.md'), 'utf8')).toContain('### whatsNew\n```\nFaster sync.\n```');
|
||||||
|
expect(r.out).toContain('3 change(s) written');
|
||||||
|
});
|
||||||
|
test('play listing pull: one read-only edit, deleted; --write updates the file', async () => {
|
||||||
|
playEnv();
|
||||||
|
const p = project(BASE, { 'store/listing.md': PLAY_MD });
|
||||||
|
const routes = () => mockFetch([
|
||||||
|
{ method: 'POST', match: /\/applications\/com\.example\.notes\/edits$/, answer: { id: 'edit-1', expiryTimeSeconds: '1760000000' } },
|
||||||
|
{ match: /\/edits\/edit-1\/listings$/, answer: { kind: 'androidpublisher#listingsListResponse', listings: [{ language: 'en-US', title: 'Example Notes', shortDescription: 'Live short', fullDescription: 'Old full.' }] } },
|
||||||
|
{ method: 'DELETE', match: /\/edits\/edit-1$/, answer: new Response(null, { status: 204 }) },
|
||||||
|
]);
|
||||||
|
let calls = routes();
|
||||||
|
let r = await capture(() => playListing(p.ctx(['pull', '--json'])));
|
||||||
|
expect(jsonOut(r.out)).toMatchObject({ write: false, languages: ['en-US'], changes: [{ section: 'en-US', field: 'shortDescription', from: 'Old short', to: 'Live short' }] });
|
||||||
|
expect(calls.map(c => c.method)).toEqual(['POST', 'GET', 'DELETE']);
|
||||||
|
calls = routes();
|
||||||
|
r = await capture(() => playListing(p.ctx(['pull', '--write'])));
|
||||||
|
expect(readFileSync(join(p.root, 'store/listing.md'), 'utf8')).toContain('```\nLive short\n```');
|
||||||
|
expect(calls.some(c => c.path.includes(':commit'))).toBe(false);
|
||||||
|
});
|
||||||
|
test('catalog pull --write saves a proposal next to an unchanged config', async () => {
|
||||||
|
ascEnv();
|
||||||
|
const p = project({ ...BASE, play: undefined, products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } } }] });
|
||||||
|
mockFetch([
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro' } }]) },
|
||||||
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([{ type: 'inAppPurchaseLocalizations', id: 'l1', attributes: { locale: 'en-US', name: 'Pro', description: 'Every feature' } }]) },
|
||||||
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
||||||
|
]);
|
||||||
|
const r = await capture(() => catalog(p.ctx(['pull', '--store', 'apple', '--write'])));
|
||||||
|
const saved = JSON.parse(readFileSync(join(p.root, '.storebound/pull/catalog.json'), 'utf8'));
|
||||||
|
expect(saved.listings['com.example.notes.pro'].en).toEqual({ title: 'Pro', description: 'Every feature' });
|
||||||
|
expect(r.out).toContain('storebound.config.ts is unchanged');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('release notes in many languages', () => {
|
||||||
|
const langs = BASE.languages;
|
||||||
|
test('language files, limits per store, apply into both listing files', () => {
|
||||||
|
expect(resolveLanguages(langs, ['en-US', 'de', 'tr-TR']).map(l => l.id)).toEqual(['en', 'de', 'tr']);
|
||||||
|
expect(() => resolveLanguages(langs, ['fr'])).toThrow('not a language of the config');
|
||||||
|
const f = { lang: 'de', source: 'en', status: 'translate' as const, text: 'New: lists.' };
|
||||||
|
expect(parseNotesFile(notesFileText(f), 'de')).toEqual(f);
|
||||||
|
expect(parseNotesFile('Plain text', 'tr')).toEqual({ lang: 'tr', source: '', status: 'translated', text: 'Plain text' });
|
||||||
|
expect(checkLanguageNotes(langs[1]!, f, 'New: lists.').problems).toEqual(['not translated yet (status=translate)']);
|
||||||
|
expect(checkLanguageNotes(langs[1]!, { ...f, status: 'translated' }, 'New: lists.').problems).toEqual(['identical to the source language: translate it']);
|
||||||
|
const long = checkLanguageNotes(langs[2]!, { ...f, status: 'translated', text: 'x'.repeat(501) });
|
||||||
|
expect(long.play).toEqual({ limit: 500, ok: false });
|
||||||
|
expect(long.apple).toEqual({ limit: 4000, ok: true });
|
||||||
|
expect(checkLanguageNotes({ id: 'ios-only', apple: 'ja' }, { ...f, status: 'translated', text: 'x'.repeat(600) }).problems.filter(p => !p.startsWith('first line'))).toEqual([]);
|
||||||
|
const out = applyLanguageNotes({ play: PLAY_MD.replace('<!-- play: en-US -->', '<!-- play: en-US -->').replace('## Spanish', '## German\n<!-- play: de-DE -->\n\n### short\n```\nk\n```\n\n### full\n```\nl\n```\n\n## Spanish'), apple: APPLE_MD.replace('## Notes', '## de-DE\n\n### subtitle\n```\nx\n```\n\n## Notes') }, langs[1]!, 'Neu: Listen.');
|
||||||
|
expect(out.changed).toEqual(['play:de-DE', 'apple:de-DE']);
|
||||||
|
expect(out.play).toContain("### What's new (max 500)\n```\nNeu: Listen.\n```");
|
||||||
|
expect(out.apple).toContain('### whatsNew\n```\nNeu: Listen.\n```');
|
||||||
|
});
|
||||||
|
test('command: --languages writes one file per language, a --translate hook fills them, check and apply', async () => {
|
||||||
|
const p = project(BASE, { 'store/listing.md': PLAY_MD.replace('## Spanish', '## German\n<!-- play: de-DE -->\n\n### short\n```\nk\n```\n\n### full\n```\nl\n```\n\n## Spanish'), 'store/appstore.md': APPLE_MD });
|
||||||
|
const git = (...a: string[]) => Bun.spawnSync(['git', ...a], { cwd: p.root, env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@example.com', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@example.com' } });
|
||||||
|
git('init', '-q'); git('add', '.'); git('commit', '-qm', 'chore: init'); git('tag', 'v1.0.0'); git('commit', '-q', '--allow-empty', '-m', 'feat: shared lists');
|
||||||
|
let r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de', '--dry', '--json'])));
|
||||||
|
expect((jsonOut(r.out).files as { status: string }[]).map(f => f.status)).toEqual(['source', 'translate']);
|
||||||
|
expect(existsSync(join(p.root, 'store/whats-new'))).toBe(false);
|
||||||
|
r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de'])));
|
||||||
|
expect(readFileSync(join(p.root, 'store/whats-new/de.md'), 'utf8')).toMatch(/^<!-- whats-new lang=de source=en status=translate -->\nNew in this update:\n• Shared lists\./);
|
||||||
|
r = await capture(() => whatsNew(p.ctx(['check', '--json'])));
|
||||||
|
expect(r.exit).toBe(1);
|
||||||
|
expect((jsonOut(r.out).checks as { lang: string; problems: string[] }[])[1]!.problems).toContain('not translated yet (status=translate)');
|
||||||
|
r = await capture(() => whatsNew(p.ctx(['apply'])));
|
||||||
|
expect(r.out).toContain('nothing written');
|
||||||
|
// the provider hook: any command; here one that upper-cases the text
|
||||||
|
r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de', '--translate', 'tr a-z A-Z'])));
|
||||||
|
expect(readFileSync(join(p.root, 'store/whats-new/de.md'), 'utf8')).toContain('status=translated -->\nNEW IN THIS UPDATE:');
|
||||||
|
r = await capture(() => whatsNew(p.ctx(['apply', '--json'])));
|
||||||
|
expect(jsonOut(r.out).changed).toEqual(['play:en-US', 'apple:en-US', 'play:de-DE']);
|
||||||
|
expect(readFileSync(join(p.root, 'store/listing.md'), 'utf8')).toContain('NEW IN THIS UPDATE:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyword ranks (iTunes Search API)', () => {
|
||||||
|
const RESULTS = { resultCount: 3, results: [
|
||||||
|
{ trackId: 6400000999, trackName: 'Other Notes', bundleId: 'com.example.other', sellerName: 'Other Studio', averageUserRating: 4.5, userRatingCount: 1200, primaryGenreName: 'Productivity' },
|
||||||
|
{ trackId: 6400000101, trackName: 'Example Notes', bundleId: 'com.example.notes', sellerName: 'Example', averageUserRating: 4.8, userRatingCount: 50 },
|
||||||
|
{ trackId: 6400000998, trackName: 'Lists Pro', bundleId: 'com.example.lists', sellerName: 'Lists Inc' },
|
||||||
|
] };
|
||||||
|
test('pure parts', () => {
|
||||||
|
expect(searchUrl('shared lists', 'US', 500)).toBe('https://itunes.apple.com/search?term=shared+lists&country=us&media=software&entity=software&limit=200');
|
||||||
|
expect(() => searchUrl('x', 'usa')).toThrow('two-letter');
|
||||||
|
expect(keywordList('notes, lists,Notes,,todo', ['todo', 'planner'])).toEqual(['notes', 'lists', 'todo', 'planner']);
|
||||||
|
expect(parseStorefronts(['us', 'de=de-DE'], 'en-US')).toEqual([{ storefront: 'us', locale: 'en-US' }, { storefront: 'de', locale: 'de-DE' }]);
|
||||||
|
expect(rankOf(RESULTS.results, { appId: '6400000101' })).toBe(2);
|
||||||
|
expect(rankOf(RESULTS.results, { bundleId: 'com.example.lists' })).toBe(3);
|
||||||
|
expect(rankOf(RESULTS.results, { appId: '1' })).toBeNull();
|
||||||
|
expect(competitors(RESULTS.results, { appId: '6400000101' }, 2)).toEqual([{ rank: 1, title: 'Other Notes', developer: 'Other Studio', rating: 4.5, ratings: 1200, genre: 'Productivity' }, { rank: 3, title: 'Lists Pro', developer: 'Lists Inc', rating: null, ratings: 0, genre: '' }]);
|
||||||
|
expect(rankDelta([{ storefront: 'us', locale: 'en-US', keyword: 'Notes', rank: 2, results: 3, competitors: [] }], [{ storefront: 'us', locale: 'en-US', keyword: 'notes', rank: 5, results: 3, competitors: [] }]).get('us|notes')).toBe(3);
|
||||||
|
});
|
||||||
|
test('command: --dry lists the queries; a run saves a snapshot', async () => {
|
||||||
|
const p = project(BASE, { 'store/appstore.md': '## en-US\n\n### keywords\n```\nnotes\n```\n' });
|
||||||
|
const calls = mockFetch([{ match: /^\/search\?term=notes&country=us/, answer: RESULTS }]);
|
||||||
|
let r = await capture(() => aso(p.ctx(['rank', '--dry', '--json'])));
|
||||||
|
expect(jsonOut(r.out).queries).toEqual([{ storefront: 'us', keyword: 'notes', url: 'https://itunes.apple.com/search?term=notes&country=us&media=software&entity=software&limit=200' }]);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
r = await capture(() => aso(p.ctx(['rank', '--json'])));
|
||||||
|
expect((jsonOut(r.out).rows as { rank: number; competitors: unknown[] }[])[0]).toMatchObject({ keyword: 'notes', rank: 2, results: 3 });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const snap = JSON.parse(readFileSync(join(p.root, '.storebound/aso', `${new Date().toISOString().slice(0, 10)}.json`), 'utf8'));
|
||||||
|
expect(snap.rows[0].competitors[0].title).toBe('Other Notes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdAttributionKit scan', () => {
|
||||||
|
const PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>SKAdNetworkItems</key>
|
||||||
|
<array>
|
||||||
|
<dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict>
|
||||||
|
<dict><key>SKAdNetworkIdentifier</key><string>4FZDC2EVR5.skadnetwork</string></dict>
|
||||||
|
</array>
|
||||||
|
<key>AdNetworkIdentifiers</key>
|
||||||
|
<array><string>cstr6suwn9.skadnetwork</string><string>f2d92a.adattributionkit</string></array>
|
||||||
|
<key>AdAttributionKit</key>
|
||||||
|
<dict>
|
||||||
|
<key>AttributionCopyEndpoint</key><string>https://postbacks.example.com/attribution</string>
|
||||||
|
<key>EligibleForAdAttributionKitReengagementPostbackCopies</key><true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSAdvertisingAttributionReportEndpoint</key><string>https://example.com</string>
|
||||||
|
<key>AdAttributionKitConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>AttributionWindows</key>
|
||||||
|
<dict>
|
||||||
|
<key>global</key><dict><key>install</key><dict><key>view</key><integer>9</integer><key>ignoreInteractionType</key><string>view</string></dict></dict>
|
||||||
|
<key>f2d92a.adattributionkit</key><dict><key>install</key><dict><key>click</key><integer>23</integer></dict></dict>
|
||||||
|
</dict>
|
||||||
|
<key>AttributionCooldown</key><dict><key>install-cooldown-hours</key><integer>24</integer><key>re-engagement-cooldown-hours</key><integer>900</integer></dict>
|
||||||
|
</dict>
|
||||||
|
<key>Empty</key><string/>
|
||||||
|
<key>Ratio</key><real>1.5</real>
|
||||||
|
</dict>
|
||||||
|
</plist>`;
|
||||||
|
test('plist reader', () => {
|
||||||
|
const p = parsePlist(PLIST) as Record<string, unknown>;
|
||||||
|
expect(p.CFBundleIdentifier).toBe('$(PRODUCT_BUNDLE_IDENTIFIER)');
|
||||||
|
expect(p.Empty).toBe('');
|
||||||
|
expect(p.Ratio).toBe(1.5);
|
||||||
|
expect((p.AdAttributionKit as Record<string, unknown>).EligibleForAdAttributionKitReengagementPostbackCopies).toBe(true);
|
||||||
|
expect(() => parsePlist('bplist00...')).toThrow('binary plist');
|
||||||
|
});
|
||||||
|
test('findings: id case, windows and cooldown ranges, global ignore, endpoints, missing conversion updates', () => {
|
||||||
|
const sig = sourceSignals([{ path: 'Podfile.lock', text: 'PODS:\n - Google-Mobile-Ads-SDK (11.0.0)\n' }, { path: 'App/AppDelegate.swift', text: 'import UIKit\nimport AdAttributionKit\n' }]);
|
||||||
|
expect(sig).toEqual({ aakImport: true, conversionUpdates: false, adSdks: ['Google Mobile Ads (AdMob)'], mmpSdks: [] });
|
||||||
|
const f = evaluateAdAttribution(parsePlist(PLIST) as Record<string, never>, sig);
|
||||||
|
const codes = f.map(x => `${x.level}:${x.code}`);
|
||||||
|
for (const c of ['blocker:id-case', 'info:aak-ids-partial', 'warn:endpoint-path', 'ok:copy-endpoint', 'ok:skan-endpoint', 'blocker:window-view', 'warn:ignore-global', 'blocker:cooldown-range', 'warn:no-conversion-update'])
|
||||||
|
expect(codes).toContain(c);
|
||||||
|
expect(codes).not.toContain('warn:windows-unknown-network');
|
||||||
|
expect(f.find(x => x.code === 'copy-endpoint')!.msg).toContain('https://example.com/.well-known/appattribution/report-attribution/');
|
||||||
|
// a publisher with an ad SDK and no ids at all, the top-level copy key, an MMP SDK doing the conversion updates
|
||||||
|
const g = evaluateAdAttribution({ AttributionCopyEndpoint: 'http://example.com' } as never, { aakImport: false, conversionUpdates: false, adSdks: ['AppLovin MAX'], mmpSdks: ['AppsFlyer'] });
|
||||||
|
expect(g.map(x => x.code)).toEqual(['no-network-ids', 'endpoint-url']);
|
||||||
|
expect(evaluateAdAttribution({}, { aakImport: false, conversionUpdates: false, adSdks: [], mmpSdks: [] }).map(x => x.code)).toEqual(['none']);
|
||||||
|
});
|
||||||
|
test('command: scans the app Info.plist, skips Pods and tests, exit 1 on a blocker', async () => {
|
||||||
|
const p = project(BASE, { 'ios/App/App/Info.plist': PLIST, 'ios/App/Pods/X/Info.plist': '<plist><dict/></plist>', 'ios/App/AppTests/Info.plist': '<plist><dict/></plist>', 'ios/App/Podfile.lock': 'PODS:\n - AppLovinSDK (13.0.0)\n',
|
||||||
|
'ios/App/App/Attribution.swift': 'import AdAttributionKit\nfunc first() async { try? await Postback.updateConversionValue(1, lockPostback: false) }\n' });
|
||||||
|
const r = await capture(() => ads(parseArgs(['scan', '--ios', join(p.root, 'ios/App'), '--json'])));
|
||||||
|
const j = jsonOut(r.out);
|
||||||
|
expect((j.results as { plist: string }[]).map(x => x.plist.split('/').slice(-3).join('/'))).toEqual(['App/App/Info.plist']);
|
||||||
|
expect(j.signals).toEqual({ aakImport: true, conversionUpdates: true, adSdks: ['AppLovin MAX'], mmpSdks: [] });
|
||||||
|
expect(j.blockers).toBe(3);
|
||||||
|
expect(r.exit).toBe(1);
|
||||||
|
mkdirSync(join(p.root, 'empty'), { recursive: true });
|
||||||
|
writeFileSync(join(p.root, 'empty/readme.txt'), 'no app');
|
||||||
|
const e = await capture(() => ads(parseArgs(['scan', '--ios', join(p.root, 'empty')])));
|
||||||
|
expect(e.out).toContain('no app Info.plist');
|
||||||
|
expect(e.exit).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP tools of the store operations', () => {
|
||||||
|
const t = (n: string) => TOOLS.find(x => x.name === n)!;
|
||||||
|
test('pulls write only with dry_run false; plans and reads stay read-only; owner steps are not tools', () => {
|
||||||
|
expect(toArgv(t('apple_listing_pull')).argv).toEqual(['apple', 'listing', 'pull', '--json', '--dry']);
|
||||||
|
expect(toArgv(t('apple_listing_pull'), { dry_run: false }).argv).toEqual(['apple', 'listing', 'pull', '--json', '--write']);
|
||||||
|
expect(toArgv(t('play_listing_pull'), { dry_run: false }).argv).toEqual(['play', 'listing', 'pull', '--json', '--write']);
|
||||||
|
expect(toArgv(t('apple_iap_review_plan'), { with_version: true }).argv).toEqual(['apple', 'iap-submit', '--with-version', '--json', '--dry']);
|
||||||
|
expect(toArgv(t('apple_testflight_wait'), { build: '50', check_once: true }).argv).toEqual(['apple', 'testflight', 'wait', '--build', '50', '--timeout', '30m', '--json', '--dry']);
|
||||||
|
expect(toArgv(t('reports'), { kind: 'play-earnings', month: '2026-09' }).argv).toEqual(['reports', 'play-earnings', '--month', '2026-09', '--json', '--dry']);
|
||||||
|
expect(toArgv(t('aso_rank'), { storefronts: 'us,de=de-DE' }).argv).toEqual(['aso', 'rank', '--storefronts', 'us,de=de-DE', '--json', '--dry']);
|
||||||
|
expect(t('triage')).toMatchObject({ write: false });
|
||||||
|
expect(t('ads_scan')).toMatchObject({ write: false, network: false });
|
||||||
|
const commands = TOOLS.map(x => x.command.join(' '));
|
||||||
|
for (const c of ['apple offer-codes', 'apple win-back', 'apple promoted', 'apple preorder enable', 'apple preorder end', 'apple cpp submit', 'apple cpp visible', 'apple events submit', 'apple testflight expire'])
|
||||||
|
expect(commands).not.toContain(c);
|
||||||
|
for (const x of TOOLS) expect(toArgv(x, { dry_run: false, kind: 'apple-sales', build: '1', languages: 'en', name: 'x', ios: 'ios' }).argv.join(' ')).not.toMatch(/--(codes|enable|yes|translate|no-submit)\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue