# Console parity: App Store Connect and Play Console in the desktop app The desktop app (apps/desktop, [desktop.md](desktop.md)) replaces App Store Connect and Play Console for everything storebound can read or write. This page lists every owner-editable thing in the two consoles that the CLI covers, and what the desktop does with it. `tests/console-parity.test.ts` keeps it honest: every row is edited in the window or names the reason it is not. Columns: - **storebound**: the command that reads or writes it, and where the value lives (the source of truth in the app folder: `storebound.config.ts`, `store/listing.md`, `store/appstore.md`, the shots copy file, `.storebound/`). - **View**: the desktop shows the current value. - **Edit**: the desktop edits it in place (live counter against the store limit where there is one, the checks of `check` / `scan` while typing, unsaved-changes mark, undo / redo) and saves it into the source file through `project.edit` (`storebound edit`: dry run with the diff and the checks in the window, then the native dialog). - **Store write**: the desktop writes it to the store (the dry run in the window, the native confirm dialog, then the real run; owner-only steps through the owner-action dialog). - **Gap**: "none", or why a cell stays "no" on purpose. Status: **after the editors (2026-10-14)**. The audit before them (Phase 1) had 70 rows, 62 of them with a gap. Now: 73 rows, 55 with nothing missing, 18 with the reason a cell stays "no" (no store API, an owner-only public step, a secret, or personal data). ## App Store Connect | Console screen | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | | App Information > Name (per locale) | `apple listing` (`### name`, apple.name, app.name) | yes | yes | yes (`apple.listing`) | none | | App Information > Subtitle | `apple listing` (`### subtitle`) | yes | yes | yes | none | | Version > Promotional Text | `apple listing` (`### promotionalText`) | yes | yes | yes | none | | Version > Description | `apple listing` (`### description`) | yes | yes | yes | none | | Version > Keywords | `apple listing` (`### keywords`), `check --aso` | yes | yes (keyword rules while typing) | yes | none | | Version > What's New | `apple listing`, `whats-new apply` (`### whatsNew`) | yes | yes | yes | none | | Version > Support URL | `apple listing` (app.supportUrl) | yes | yes | yes | none | | Version > Marketing URL | `apple listing` (app.marketingUrl) | yes | yes | yes | none | | App Privacy > Privacy Policy URL | `apple listing` (app.privacyPolicyUrl) | yes | yes | yes | none | | Version > Copyright | `apple listing` (app.copyright) | yes | yes | yes | none | | Version > version string, build | `apple listing --version --build` | yes | yes | yes | none | | Version > Screenshots (per device, locale) | `shots render`, `apple screenshots` (shots.scenes, the shots copy file) | yes | yes (scene order, the frame of each scene, the captions per language) | yes (`apple.screenshots`) | none | | Version > App Previews | `shots record`, `shots video`, `apple previews` | yes | n/a | yes (`apple.previews`) | reason: a preview is cut from recorded clips, not a field; replacing one is a new recording (`shots.video` render job in the window) | | App Information > Category, subcategories | `apple appinfo` (apple.info.categories) | yes | yes | yes (`apple.appinfo`) | none | | App Information > Age Rating | `apple appinfo` (apple.info.ageRating), `console fill apple-age-rating` | yes | yes (every question) | yes | none | | App Information > Content Rights | `apple appinfo` (apple.info.contentRights) | yes | yes | yes | none | | Pricing and Availability > Free, territories | `apple appinfo` (apple.info.free) | yes | yes | yes | none | | Pricing and Availability > Pre-orders | `apple preorder status / enable / end` | no | no | no | reason: putting an app on pre-order or ending it is public at once; it stays the owner's terminal step (`apple preorder enable --release-date`, asks first), there is no config value to edit | | App Privacy (data types, purposes) | appPrivacy.types, `console fill apple-app-privacy` | yes | yes (checked JSON, validated with the config) | no | reason: App Privacy has no API; Fill in console types the saved answers, the owner presses Publish | | App Review Information > Contact | `apple review-details` (.storebound/credentials.json reviewContact) | masked | yes (write-only, masked) | yes (`apple.review_details`) | none | | App Review Information > Notes | `apple review-details` (apple.info.reviewNotes, reviewAccess.instructions) | yes | yes | yes | none | | App Review Information > Sign-in required | reviewAccess.signInRequired | yes | yes | yes | none | | App Review Information > Demo account | credentials.json reviewDemoAccount | masked | no | yes (sent by `apple.review_details`) | reason: a password; the window never reads or takes it (AGENTS.md rule 4), the owner puts it in `.storebound/credentials.json` | | Export compliance (encryption) | `apple encryption` (apple.usesNonExemptEncryption) | yes | yes | yes (`apple.encryption`) | none | | In-App Purchases > reference name, display name, description per locale | `apple iap` (products[]) | yes | yes | yes (`apple.iap`) | none | | In-App Purchases > price | products[].usd, overrides, `prices import` | yes | yes | yes, owner gate for live products | none | | In-App Purchases > review note, screenshot | products[].reviewNote / reviewScreenshot, `apple iap-review` | yes | yes | yes (`apple.iap_review`) | none | | Subscriptions > group name per locale | `apple subscriptions` (subscriptions[].listings) | yes | yes | yes (`apple.subscriptions`) | none | | Subscriptions > name, description per locale | `apple subscriptions` | yes | yes | yes | none | | Subscriptions > level, period, price | `apple subscriptions` | yes | yes | yes, owner gate for live prices | none | | Subscriptions > introductory and promotional offers | subscriptions[].subscriptions[].intro / offers | yes | yes (checked JSON) | yes (`apple.subscriptions`) | none | | Subscriptions > offer codes | offerCodes, `apple subscriptions`, `apple offer-codes --codes` | yes | yes (checked JSON) | yes for the offers; the codes: no | reason: generating codes makes vouchers anyone can redeem; it stays the owner's terminal step (`apple offer-codes --codes`, asks first, codes saved to .storebound/) | | Subscriptions > win-back offers | winBack, `apple win-back` | yes | yes (checked JSON) | no | reason: a win-back offer goes live by itself on its start date; creating one is the owner's terminal step (`apple win-back`, asks first) | | In-App Purchases > promoted purchases | apple.promoted, `apple promoted` | yes | yes (checked JSON) | no | reason: promoting shows the purchase on the product page; `apple promoted [--enable]` asks first and stays the owner's terminal step | | Custom product pages | `apple cpp create / upload` (apple.customProductPages) | yes | yes (checked JSON) | yes (`apple.cpp_create`, `apple.cpp_upload`) | none | | In-app events | `apple events create / upload` (apple.events) | yes | yes (checked JSON) | yes (`apple.events_create`, `apple.events_upload`) | none | | Product Page Optimization | `apple ppo` | yes | yes | yes (owner start / stop) | none | | TestFlight > What to Test (per build, per locale) | `apple testflight what-to-test` (`### whatToTest`, else `### whatsNew`) | yes | yes (per locale) | yes (`apple.testflight_what_to_test`) | none | | TestFlight > groups | `apple testflight groups sync` (apple.testflight.groups) | yes | yes (checked JSON) | yes (creates the missing groups) | none | | TestFlight > Test Information (beta description, notes) | `apple testflight info` (apple.testflight.description / reviewNotes) | yes | yes | yes (`apple.testflight_info`) | none | | TestFlight > testers | `owner.testflight_testers` (.storebound/testers.csv) | count | no | yes (owner) | reason: tester emails are personal data kept in the gitignored CSV; adding them is the owner action, removing them stays a terminal step | | Ratings and Reviews > reply | `reviews draft / reply / approve / send` | yes | yes | yes (owner send) | none | | Version > phased release | `owner.ios_phased` | yes | n/a | yes (owner) | none | | Version > Submit for review | `owner.apple_submit` / `owner.apple_resubmit` | yes | n/a | yes (owner) | none | ## Google Play Console | Console screen | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | | Main store listing > App name (per language) | `play listing` (`### title`, app.name) | yes | yes | yes (`play.listing`) | none | | Main store listing > Short description | `play listing` (`### short`) | yes | yes | yes | none | | Main store listing > Full description | `play listing` (`### full`) | yes | yes (stuffing and claims checks while typing) | yes | none | | Release > Release notes (per language) | `play release`, `whats-new apply` (`### What's new`) | yes | yes | yes (with the release: `owner.play_production`) | none | | Main store listing > Graphics (icon, feature graphic, screenshots) | `icons`, `shots render`, `play images` | yes | yes (scene order, frames, captions) | yes (`play.images`) | none | | Main store listing > Video (YouTube URL) | `play listing` (play.video) | yes | yes | yes | none | | Store settings > Contact email | `play listing` app details (app.contactEmail) | yes | yes | yes | none | | Store settings > Website | `play listing` app details (app.website) | yes | yes | yes | none | | Store settings > App category | play.category, console plan | yes | yes | no | reason: no API; the console plan step (App info > Console steps) uses the saved value | | App content > Privacy policy | app.privacyPolicyUrl, console plan | yes | yes | no | reason: no API; the console plan step uses the saved value | | App content > Data safety | `play data-safety` (dataSafety) | yes | yes | yes (`play.data_safety`) | none | | App content > Content rating (IARC) | console.contentRating, `console fill play-content-rating` | yes | yes (checked JSON) | no | reason: no API; Fill in console types the saved answers, the owner presses Submit | | App content > Target audience | play.targetAges, console.targetAudience, `console fill play-target-audience` | yes | yes | no | reason: no API; Fill in console types the saved answers, the owner saves | | App content > other declarations (ads ID, government, finance, health) | console.appContent, console plan | yes | yes (checked JSON) | no | reason: no API; the console plan step uses the saved answers | | Monetize > In-app products: title, description per language | `play products` (products[].listings) | yes | yes | yes (`play.products`) | none | | Monetize > In-app products: price | products[].usd / overrides, `prices import` | yes | yes | yes, owner gate for live products | none | | Monetize > Subscriptions: title, benefits, description per language | `play subscriptions` | yes | yes | yes (`play.subscriptions`) | none | | Monetize > Subscriptions: base plans, offers, price | `play subscriptions` | yes | yes | yes, owner gate for live prices | none | | Pricing > regional prices (price sheet) | `prices export / import`, overrides | yes | yes (per-product overrides in the Catalog editor) | yes (owner) | none | | Production > Countries / regions | play.countries, console plan | yes | yes | no | reason: no API for the production countries; the console plan step uses the saved list | | Publishing overview > Managed publishing | play.managedPublishing | yes | yes | no | reason: no API; only Play Console sets it (the console plan names the switch) | | Testing tracks (internal, closed, open) | `play release --track` | yes | n/a | no | reason: sends a build to testers; the owner's terminal step by design (desktop.md safety model) | | Production > staged rollout | `owner.play_rollout` | yes | n/a | yes (owner) | none | | Production > Send to production | `owner.play_production` | yes | n/a | yes (owner) | none | | Ratings and reviews > reply | `reviews draft / reply / approve / send` | yes | yes | yes (owner send) | none | | Internal app sharing | `play share` | no | n/a | no | reason: uploads a build file for testers, not a console field; a terminal or agent step (`play share`) | ## Both stores | What | storebound | View | Edit | Store write | Gap | | --- | --- | --- | --- | --- | --- | | Pull the live texts into the files | `apple listing pull`, `play listing pull` | yes | n/a | n/a (writes the listing files after the diff) | none | | Store-only app (no folder): listing text per language | `listing live`, `listing set` | yes | yes | yes (`listing.set`, one language per apply) | none | | Store-only app (no folder): URLs, contact, catalog, art | (the config of an app folder) | yes (reads) | no | no | reason: those writes send what an app folder's config holds; "Link a folder" or "Start an app folder" turns the app into a full one | | Uncommitted changes in the app folder | git status of the edited files | yes | n/a | n/a | none (storebound never commits) |