Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)

This commit is contained in:
CRTkafa 2026-10-13 07:25:01 +08:00
parent d2ce185441
commit e5f148e534
55 changed files with 1648 additions and 158 deletions

View file

@ -238,7 +238,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **appInfos** (20) | | | | |
| `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | apple/appinfo, console/verify, validate |
| `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | |
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, validate |
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, onboard, validate |
| `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | |
| `appInfos_getInstance` | GET | `/v1/appInfos/{id}` | read | apple/appinfo |
| `appInfos_primaryCategory_getToOneRelated` | GET | `/v1/appInfos/{id}/primaryCategory` | read | |
@ -365,13 +365,13 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appStoreVersions_appClipDefaultExperience_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appClipDefaultExperience` | read | |
| `appStoreVersions_appClipDefaultExperience_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appClipDefaultExperience` | read | |
| `appStoreVersions_appClipDefaultExperience_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/appClipDefaultExperience` | write (dry by default) | |
| `appStoreVersions_appStoreReviewDetail_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreReviewDetail` | read | apple/review-details, validate |
| `appStoreVersions_appStoreReviewDetail_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreReviewDetail` | read | apple/review-details, onboard, validate |
| `appStoreVersions_appStoreReviewDetail_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreReviewDetail` | read | |
| `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperimentsV2` | read | |
| `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperimentsV2` | read | |
| `appStoreVersions_appStoreVersionExperiments_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperiments` | read | |
| `appStoreVersions_appStoreVersionExperiments_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperiments` | read | |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, validate |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/listing, apple/previews, apple/screenshots, onboard, validate |
| `appStoreVersions_appStoreVersionLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionLocalizations` | read | |
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionPhasedRelease` | read | apple/release, apple/status |
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | |
@ -421,7 +421,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | |
| `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo |
| `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | |
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, validate |
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, onboard, validate |
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |
@ -459,7 +459,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | |
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, console/verify, lib/store-apps |
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status |
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status, apps, onboard |
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
| `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | |
@ -1565,9 +1565,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.appstorecatalog.recentupdateevents.list` | GET | `androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentUpdateEvents` | read | |
| **androidpublisher.edits** (5) | | | | |
| `androidpublisher.edits.commit` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:commit` | owner only (typed confirm) | play/client |
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, play/client, validate |
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client, validate |
| `androidpublisher.edits.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | read | |
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, play/client, validate |
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client, validate |
| `androidpublisher.edits.validate` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:validate` | write (dry by default) | play/client |
| **androidpublisher.edits.apks** (3) | | | | |
| `androidpublisher.edits.apks.addexternallyhosted` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/externallyHosted` | write (dry by default) | |
@ -1581,7 +1581,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **androidpublisher.edits.deobfuscationfiles** (1) | | | | |
| `androidpublisher.edits.deobfuscationfiles.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}` | write (dry by default) | |
| **androidpublisher.edits.details** (3) | | | | |
| `androidpublisher.edits.details.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | read | apps, play/listing, play/status, validate |
| `androidpublisher.edits.details.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | read | apps, onboard, play/listing, play/status, validate |
| `androidpublisher.edits.details.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | write (dry by default) | |
| `androidpublisher.edits.details.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/details` | write (dry by default) | play/listing |
| **androidpublisher.edits.expansionfiles** (4) | | | | |

View file

@ -25,12 +25,13 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `listing translate --to de,ja [--from en] [--translate "<cmd>"]` | local | per-language listing sections in both listing files (App Store name, subtitle, promotional text, keywords, description; Play title, short, full) for your agent (or a hook) to translate, marked as drafts until reviewed; keywords cleaned up |
| `listing check` / `listing reviewed --lang <ids>` | local | every language section against the store limits and keyword rules, untranslated and unreviewed drafts (exit 1 on a problem) / remove the draft marker after a human read it |
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply [--yes]] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](studios.md#repo-local-workflows)) |
| `onboard [--store] [--write] [--no-web] [--no-catalog]` | API, read-only + local | an app that is already live: products and subscriptions with their USD / base prices (catalog diff clean), contact e-mail, website, privacy / support URLs, App Review contact and demo account (`.storebound/credentials.json`, masked), sign-in for review (Apple review detail + code auth signals), the account deletion page on the app's site; then only the owner questions nothing proves. Never changes store prices |
| `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 |
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](api.md)); GET runs, writes are dry unless `--yes` in a terminal |
| `desktop [--yes] [--dry] [--download]` | local + download | the desktop app of this CLI's version, opened on this folder: installs it for this user when missing (size, SHA-256 and signature checked; asks once, `--yes`), updates an older one, notes a newer one (`--force` to downgrade); `--download` only saves the checked installer |
| `accounts [list] \| import [--dry] \| use <profile>` | local | the store accounts of this machine (`~/.storebound/credentials`, shared with the desktop app); `import` moves a project's store keys into a profile (same key = same profile), never prints values |
| `accounts [list] \| import [--dry] \| use <profile> \| default <profile>` | local | the store accounts of this machine (`~/.storebound/credentials`, shared with the desktop app); `import` moves a project's store keys into a profile (same key = same profile), never prints values; `default` sets the machine default profile (env > a profile the project names > the project's own keys > this default > the only profile) |
| `apps folders \| add [<folder>] \| remove <folder>` | local | the app folders of this machine (`~/.storebound/projects.json`), the same list as the desktop app |
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |

View file

@ -53,6 +53,26 @@ catalog", "the reviewer could not sign in", "make store screenshots in 5 languag
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
screenshots help". The matching skill takes over.
## An app that is already live
`storebound init`, then pull what the stores already have instead of typing it in:
```sh
bunx storebound apple listing pull --write && bunx storebound play listing pull --write # texts per language
bunx storebound onboard # the plan: products + prices, contact, review contact, sign-in, deletion page
bunx storebound onboard --write # into storebound.config.ts and .storebound/credentials.json
bunx storebound catalog diff # clean: the config now sells what the stores sell
```
`onboard` is read-only on the stores and never changes a store price. It imports the live one-time products and
subscriptions (usd = the US price; Apple's base territory as an override when it is not the USA; an item on one store
only keeps `platforms`), replaces template contact values (`support@example.com`, example.com URLs) with Play's and App
Store Connect's, puts the App Review contact and a demo account set on App Store Connect into
`.storebound/credentials.json` (never printed), decides "sign-in required for review" from Apple's review detail and the
code, and looks for the account deletion page on the app's own site. What nothing proves is listed as owner questions
at the end, in one batch. An iOS app whose source is not on this machine: `storebound apps add --store apple --app-id
<id> --account <profile>` shows it in the desktop, read-only.
## Coming from fastlane
`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run),

View file

@ -9,6 +9,16 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 52 | 2026-10 | An iOS app that is live on the App Store but whose source is not on this machine could not appear in the desktop: the machine's app list (`~/.storebound/projects.json`) took only folders. | `storebound apps add --store apple\|play --app-id <id> --account <profile>` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:<id>`); the desktop adds it to the app list of the account with that profile (`registry::with_store_only`, `store_apps_cached` / `store_apps_refresh`), shown read-only as "Store only"; writing the folder list keeps the entries. `tests/onboard.test.ts`, `src-tauri/tests/machine.rs`, the boot check's `shared-profiles` case (zero uncaught errors). |
| 51 | 2026-10 | With several credential profiles the only way to have a machine default was naming one `default`. | `storebound accounts default <profile>` (and `--clear`) writes the pointer file `<credentials>/default-profile`; `accounts list` shows the default; order: env > a profile the project names > the project's own keys > the pointer > the only profile (or `default`) (`defaultProfile`, `setDefaultPointer` in `cli/lib/credentials.ts`; `tests/machine.test.ts`). |
| 50 | 2026-10 | Onboarding an app with sign-in left "account deletion URL" as an owner question although the app's own website had the page. | `storebound onboard` looks on the app's site before asking: /delete-account, /account/delete, /delete, /account-deletion on the website and the privacy policy's host, then links in the privacy policy that mention delete / deletion, then a deletion section of the privacy page; a page counts only when it answers 200 and speaks of deleting. Not found stays a finding ("a real gap, Play requires it"); never invented (`findDeletionUrl` in `cli/lib/onboard.ts`; `tests/onboard.test.ts`). |
| 49 | 2026-10 | "Sign-in required for review" defaulted to yes during onboarding, so every app got a demo-account question. | `signInDecision`: Apple's live review detail (`demoAccountRequired`) wins, else no auth code in the project (scan's auth signals, import lines ignored) means no; only when neither proves it is it an owner question. A demo account set on App Store Connect goes into `.storebound/credentials.json` `reviewDemoAccount`, never printed (`cli/lib/onboard.ts`, `cli/onboard.ts`; `tests/onboard.test.ts`). |
| 48 | 2026-10 | Onboarding left `support@example.com` and the template URLs in the config and asked the owner for the contact e-mail, website and App Review contact, all of which the stores have. | `storebound onboard` fills app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and App Store Connect where the config holds a template value (a real value that differs is reported, kept); the App Review contact goes into `.storebound/credentials.json` `reviewContact`, masked in every line (phone: last 2 digits) (`contactProposal`, `maskContact`; `tests/onboard.test.ts`). |
| 47 | 2026-10 | Setting storebound up on live apps left their products out of the config "because there were no USD prices": the import read only texts (`catalog pull`), never prices. | `storebound onboard --write` imports the live one-time products and subscriptions of both stores: ids, type, listings per language, usd = the US price (else Play's own USD price for new regions), Apple's price schedule base territory as an override when it is not the USA, an item on one store only kept with `platforms`; `catalog diff` comes out clean; store prices are never changed, an item with no USD price anywhere is skipped with the reason (`importCatalog`; `tests/onboard.test.ts` runs `catalog diff` on the written config). |
| 46 | 2026-10 | `apps list --store play` and Play vitals failed outright when the Play Developer Reporting API was off in the service account's Cloud project, with a generic "enable it" hint. | The finding names the project and the exact link (`https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=<id>`, the id from Google's 403 or the service account e-mail: `reportingEnableUrl`); `apps list` no longer fails: it lists the Play apps of this machine's app folders and store-only entries instead, marked as not read from Play (`knownPlayPackages`, `localPlayApps`); `healthFindings` uses the same link. `tests/onboard.test.ts`, `tests/store-real-shapes.test.ts`. |
| 45 | 2026-10 | `validate` reported 34 blockers for a Play app whose 17 extra languages have no icon or feature graphic of their own; Play shows the default language's graphics there. | `playRules`: a non-default language with none of its own uses the default language's graphics (one note per image type); only the default language's missing graphics block (`cli/lib/validate-rules.ts`; `tests/wave1.test.ts`). |
| 44 | 2026-10 | "en-US: no APP_IPHONE_67 screenshots" blocked live apps that have the 6.5" set: App Store Connect requires the 6.9" set or, without it, the 6.5" set (and scales down from it). | `ACCEPTED_INSTEAD` in `cli/lib/validate-rules.ts`: the 6.5" set satisfies the 6.9" requirement (and either 13" iPad type the other); then a recommendation, not a blocker (`tests/wave1.test.ts`, a live-shape set list with only APP_IPHONE_65). |
| 43 | 2026-10 | The App Store keyword field is limited to 100 characters, not UTF-8 bytes (entry 4 was wrong): live versions READY_FOR_SALE read on 2026-10-13 hold Thai 95 characters / 265 bytes, Russian 91 / 169, Japanese 49 / 123, Turkish 100 / 105, Polish 99 / 105, and App Store Connect accepted them. | `check` (`cli/lib/copy.ts`), `check --aso` (`auditAppleKeywords`), `listing check` (`checkFields`), `listing translate` (`normalizeKeywords`) and `aso suggest` (`fitKeywordField`) count characters; `tests/listing-translate.test.ts`, `tests/wave1.test.ts`, `tests/aso-plus.test.ts` with Thai, Turkish and Polish fields over 100 bytes. |
| 42 | 2026-10 | `apple testflight distribute --build 1 --group Team --yes` failed with 400 on `GET /v1/betaGroups?filter[builds]=`: Apple's spec declares the filter, App Store Connect refuses it, and the spec has no GET of a build's groups. Before the groups existed it said `unknown group "Team" ()`. | Distribute reads each target group's builds (`GET /v1/betaGroups/{id}/relationships/builds`), reports internal all-builds groups as "already available" and never sends them the build; `unknownGroupMessage` points at `groups sync` for configured groups. `APPLE_LIVE_REJECTED` (`cli/api/client.ts`, also refused by `storebound api`); `tests/asc-query-guard.test.ts` checks every App Store Connect GET with a query in `cli/` against the spec's declared parameters and that list; the fake App Store Connect answers 400 to them (`tests/idempotency.test.ts`). |
| 41 | 2026-10 | Every Play App content card ended in an owner-only Save, so the owner came back once per card (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, content rating, data safety). Saving an App content declaration is not public: it goes to Google only with the next "Send changes for review" in Publishing overview (verified 2026-10-13). | Card Saves are the agent's (`declaration-save`): `declarationSaveAllowed` (exactly Save / Kaydet, a button on an /app-content/<card> page) and `flowAllowed` for content rating and target audience, while `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit, Publish, "Restart review" everywhere; the plans' `buttons.final` is "Send changes for review"; `console fill` presses the declaration's Save; `console plan play-app-content --all` walks every card and ends with one owner list (`cli/lib/console-forms.ts`, `cli/lib/console-plan.ts`, `cli/lib/console-play-plans.ts`, `cli/console/fill.ts`; `tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
| 40 | 2026-10 | Finding a new app's Play Console app id (`play.appId`) took several steps: the app list shows 10 apps per page and the new app was not on page 1. The id was in the URL Create app redirects to (/developers/<devId>/app/<appId>/app-dashboard) all along. | `console verify play-app-record --url <url>` reads the id from the redirect (or the capture JSON's url), shows the `play.appId` change as a dry diff and `--write` upserts it (`upsertNestedString`); `storebound play app-id` finds it by package name across captured app list pages (search box or pages) as an agent step (`cli/lib/play-app-id.ts`, `cli/play/app-id.ts`; `tests/play-app-id.test.ts`, a modeled 3-page list). |
@ -47,7 +57,7 @@ fails without the fix.
| 7 | 2026-10 | Console UIs: the App Store Connect sign-in runs in an `idmsa.apple.com` iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies without `--restore-last-session`. | `console` session and fill (`cli/console/`), `tests/console-*.test.ts`. |
| 6 | 2026-10 | Screenshot fonts: only part of the glyphs a language needs were available, so Turkish / Polish / Vietnamese characters fell back to another font silently. | `shots fonts` / `shots check` check every language's characters against the fonts (`tests/glyphs.test.ts`). |
| 5 | 2026-10 | Account features do not imply email collection; Data safety said "email collected" without evidence. | `scan` marks email only with code evidence, else "ask owner" (`cli/lib/scan/declare.ts`). |
| 4 | 2026-10 | The App Store keyword limit (100) counts UTF-8 bytes, not characters. | `check` counts bytes (`cli/lib/copy.ts`). |
| 4 | 2026-10 | (Superseded by 43: the limit counts characters.) The App Store keyword limit (100) was taken to count UTF-8 bytes. | See 43. |
| 3 | 2026-10 | App Store app names are unique per storefront: "already being used" although the developer owns no such app. | `apple.name` (App Store name with a suffix the owner picks); `app.name` stays the brand. |
| 2 | 2026-10 | Dry runs that print no plan ("listing: 20 locales") break "show the dry run to the owner". | `cli/lib/dry.ts` `dryPlan(title, rows)`; `tests/pitfalls.test.ts` scans `cli/**` for `--dry` branches that print only a count (allowlist with TODOs for commands being fixed). |
| 1 | 2026-10 | App Store Connect's `/inAppPurchasesV2` list lags right after `apple iap` creates a product; `apple iap-review` then skipped it silently. | `apple iap-review` names config products missing from the store's list and exits 1; `apple iap` and `play products` re-read the list after creating and warn (`cli/lib/store-lag.ts`, `tests/pitfalls.test.ts`). |