storebound/CHANGELOG.md
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

111 KiB

Changelog

Unreleased

One product, one mental model: storebound is installed once per machine (npx storebound and the desktop app), each project has its storebound.config.ts and .storebound/, and store accounts and app folders live once per machine in ~/.storebound, shared by the CLI and the app.

  • One version. The desktop app has the CLI's version from now on; the next release is 0.8.0 for both. package.json's version is the authority (desktopVersion is gone); tools/desktop-version.ts keeps the plugin manifests and the app's copies equal (CI, and tests/desktop-release.test.ts / src-tauri/tests/d5.rs as the drift tests), desktop-release.ts publish refuses a folder of another version, and the release order is the app first, then npm publish. The desktop 0.1.0 release candidate was never published, so nothing public changes.
  • npx storebound desktop just works. The app has this CLI's version: it opens on the current folder. Not installed: it downloads the app for this system (the CLI's own version, else the newest published), checks size, SHA-256 and the minisign signature, installs it for this user without admin rights (macOS: from the read-only DMG into /Applications or ~/Applications, no xattr changes; Windows: the per-user NSIS installer, silent; Linux: the AppImage in ~/.local/bin with a .desktop entry and the storebound:// handler) and opens it. An older app is updated the same way (never while it runs), a newer one is opened with a note (downgrade only with --force). It asks once ([Y/n]); --yes skips the question; without a terminal and --yes it prints the command and exits 3. --dry / --json change nothing, --download only saves the checked installer. The publish script also writes desktop/dl/<version>/latest.json (redeploy tools/vds/storebound-desktop-publish on the server).
  • Store accounts, one store. Credential profiles in ~/.storebound/credentials are shared by the CLI and the desktop app: the app writes its keys there (files 0600, folders 0700) and shows the profiles the CLI made; accounts of earlier desktop builds move from the OS keychain into profiles on first start (ids, studios and app mappings kept; the keychain entry is deleted after the profile reads back). A removed account's key file goes to credentials/.removed/. Precedence: env, then a profile the project names, then the project's .storebound/credentials.json, then the default profile (the only one, or default). A project names its profile with account: in the config, "account" in its .storebound/credentials.json, or --account.
  • storebound accounts lists the profiles (masked) and which one this folder uses; accounts import moves the store keys of a project's .storebound/credentials.json into a profile (key files copied to ~/.storebound/credentials/<name>/, the same Apple key id or service account e-mail = the same profile, the keystore and review contact stay in the project, a backup of the project file is kept, safe to run again; --dry first, never prints values); accounts use <profile> points a project at one.
  • App folders, one list. ~/.storebound/projects.json is the desktop's App folders and the CLI's storebound apps folders | add | remove; storebound desktop in an app folder and ws init add to it, and ws without a workspace file works over it. The app syncs it at start, when the list is shown and before a storebound://open link, so a folder added in the terminal is selected without a question.
  • Desktop: a folder whose package.json pins another storebound version shows "This project uses storebound X, the app runs Y". storebound-desktop --version. ws desktop import / export carry the accounts' profile names.
  • ws children get the app's own account choice when the workspace names no account (before: no profile at all).

Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):

  • Fix (scan, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (const rate = F.genRate(lv, bonus.gen(r))) is no longer a reward for rating. The signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for rating / reviewing", or a reward granted within a few lines of a system review prompt call.
  • Fix (scan, account findings and the declarations proposal): import, require and export-from lines are never sign-in / sign-up / deletion evidence (import './buildings/register' was), auth endpoints must be an absolute path, a URL or a ${base}/... template, credential fields and guest-account creation count; accounts only the config claims read "from config (accounts.enabled)".
  • Fix (scan, 5.1.1(v)): accounts.inApp alone no longer clears the finding: without deletion code it stays, medium, "claimed by config (accounts.inApp), not found in code".
  • Fix (privacy-manifest --propose): NSPrivacyCollectedDataTypes is built from appPrivacy.types (Apple's manifest keys for type and purposes, linked, tracking); NSPrivacyTracking stays true only with app-level domains. check warns once when the app manifest's list is empty but appPrivacy.types marks types collected.
  • Fix (console plan apple-app-privacy): appPrivacy.types is authoritative: a type it does not list is "not collected" (certain), never derived from dataSafety or asked. Without it, appPrivacy.tracking: false and no ad / attribution SDK answer Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected" unless a support SDK or AR code is found. console plan scans the project for these signals.
  • Fix (console plan play-advertising-id / play-app-content): "Does your app use advertising ID?" comes from ad / attribution SDKs, the AD_ID permission in the source or merged manifest (or its tools:node="remove"), ads.appAdsTxt or console.appContent.advertisingId; a Data safety Device ID alone is not the advertising ID. No signal: No (certain); signals that disagree: asked.
  • scan.ignore[].evidence: accept a finding by the text of an evidence line (match keeps matching the title). scan and check name every entry that matches no finding.
  • Fix (init): .storebound/credentials.json starts with empty strings only. Placeholder-looking values ("your-…", <…>, "xxx", "/absolute/path/to/…", "example") count as missing in every credential read; check warns about them and doctor lists each credential as set, missing or placeholder.

Console facts from a second real game (2026-10-13): the owner now comes back once for Play's App content instead of once per card.

  • Play App content card Saves are the agent's: saving a declaration (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, the content rating questionnaire and Summary, data safety) is not public; Google gets it only with the next "Send changes for review" in Publishing overview, which stays the owner's. New button class declaration-save; declarationSaveAllowed lets exactly Save / Kaydet through on an App content card page; publishingControl refuses "Send changes for review", "Submit N changes for review", Submit, Publish and "Restart review" everywhere. The plans' final button is "Send changes for review"; console fill presses the content rating Summary's and the target audience's Save itself.
  • console plan prints an owner batch first (ownerBatch): every owner step of the plan in ONE list, in the order the agent needs them (open answers, the IARC Terms of Use box with its exact place before the run, Send changes for review after). console plan play-app-content --all (MCP all: true) walks every card inline in order and ends with that one owner list.
  • IARC questionnaire: page 1's Next does nothing until the IARC Terms of Use box is ticked (an owner-only agreement). It is in the owner batch before the run; console fill waits for the tick instead of pressing a dead Next.
  • Sign in details: with reviewDemoAccount in .storebound/credentials.json the agent types the demo account (never printed); only without it is it the owner's.
  • New App (App Store Connect): a programmatic value set on Name and SKU left the dialog's state empty; the plan says "type, don't set value" and console fill types every text field key by key.
  • play.appId captured automatically: console verify play-app-record --url <url after Create app> reads the id from the redirect and shows the play.appId change as a dry diff; --write upserts it into the config. New read-only storebound play app-id: finds the app by package name in captured app list pages (10 per page, a new app is not on page 1), then the same dry diff and --write.
  • Pitfalls 38-41.

TestFlight distribute from real use (2026-10-13, pitfall 42):

  • Fix (apple testflight distribute): it failed with 400 right after printing the build state, because App Store Connect refuses GET /v1/betaGroups?filter[builds]= although Apple's spec declares the filter (and the spec has no GET of a build's groups). A build's groups are now read per target group with GET /v1/betaGroups/{id}/relationships/builds. An internal group with all builds is reported as "already available to (internal, all builds)" and never sent the build (App Store Connect refuses that); internal groups without all builds and external groups get it through the relationship; a re-run adds nothing.
  • Fix (apple testflight distribute, testers): a group in apple.testflight.groups that is not on App Store Connect yet says so and names storebound apple testflight groups sync; a group in neither lists the groups that exist (was unknown group "Team" ()).
  • Guard: tests/asc-query-guard.test.ts walks every App Store Connect GET with a query that cli/ builds (template literals and the URLSearchParams they interpolate) and fails offline on a path that is no declared GET, a query parameter the operation does not declare in cli/api/apple.gen.ts, or one in the new APPLE_LIVE_REJECTED list (declared, refused live). storebound api apple refuses those parameters too. The fake App Store Connect in the tests answers 400 PARAMETER_ERROR.ILLEGAL to such parameters, as the live API does.

0.7.1 · 2026-10-13

  • Fix: README links and images on npmjs.com resolved against npmjs.com and gave 404 (docs/README.md, the logo). Every README link and image is now absolute (git.crt.fyi), and a test keeps it that way.

0.7.0 · 2026-10-13

Agent-first: the AI does the whole job ("do it, don't ask") and the owner presses only the final public buttons, with every compliance question asked once, in one batch. Consoles without an API get console plan / console verify (start URL, steps, answer sheet, never-press list) for App Privacy, Play App content, IARC, Create app and the first production release. The desktop app sends for review itself (dry-run plan first). The CLI runs on Node 20+ (npx storebound). Plus many store fixes from real releases (IAP resubmits, Invalid Binary, Data safety, uploads).

  • Fix (check): "N Play App content answer(s) are not in the config" no longer counts the two steps that stay the owner's however complete the config is (ticking the IARC Terms of Use, typing the demo account's username and password), so a fully answered project checks clean. The site's check shot (tools/site-term-shots.ts) now shows that ready project: a fresh init plus icon files, apple.name, the App content answers and the demo account.

  • The CLI runs on Node 20+ (owner decision, 2026-10-13): npx storebound init works, bunx storebound keeps working. The npm package's bin is one Node ESM bundle, dist/storebound.mjs (built by prepack with bun tools/build-node.ts, zero runtime dependencies, playwright still an optional peer); engines names Node 20. Everything the CLI needs from its runtime goes through cli/lib/rt.ts (spawn / spawnSync with Bun's defaults and exit codes, which with PATHEXT, sleep, a small HTTP server for shots render / video, glob, hashing, gunzip, stdin, SQLite, the package folder); templates, the shots pages and Play's Data safety form are embedded by tools/gen-embedded.ts instead of Bun text imports, so the bundle and the compiled desktop sidecar carry them. storebound.config.ts loads on Node too (Node's own type stripping, or a fallback on Node 20). storebound install run with Node writes npx MCP commands. bun run ci builds the bundle and smoke-tests it with the real node (--version, help, check same as Bun, scan, release plan --help, init, an MCP handshake and tool call); tests/node-compat.test.ts fails on any direct Bun API use in cli/. bin/storebound falls back to Node when Bun is not installed. Development, tests and CI stay on Bun.

  • console verify apple-app-privacy checked against real page text (2026-10-12): the summary sections ("Data Used to Track You" and the others) list category names only and are skipped; a type's lines may come joined by " | "; Apple's comma style ("X, and Y") and the straight apostrophe parse. The tracking radio label of a type that already ends in "Data" no longer reads "crash data data". The Play Data safety page path (app-content/data-privacy-security, with Export / Import CSV) is confirmed.

  • Fix (play build / check versionCode source warning): a variable set from the property first (def code = (project.findProperty('versionCode') ?: ...).toInteger(), then versionCode code; also ext. and Kotlin val) is followed to its definition and no longer flagged.

  • Fix (apple iap-submit, apple resubmit): after the owner canceled a submission, every IAP version was DEVELOPER_REJECTED and the plan skipped them all ("change it in App Store Connect (or --new-version)", then "nothing to submit"). Apple accepts the same version again (201 READY_FOR_REVIEW, then the submit 200), so DEVELOPER_REJECTED in-app purchase, subscription and subscription group versions now go in as they are, listed as "add (developer rejected, re-submitted as is)"; an empty draft is reused. --new-version is only for APPROVED or REJECTED products (pitfall 31).

  • play build and check: warn before Gradle runs when the app module's defaultConfig does not read -PversionCode / -PversionName (a literal, an env var, ...), naming the file and line and the exact Groovy or Kotlin line to use (both built with AGP 8.13). android.injected.version.code does not override defaultConfig for bundleRelease (checked with AGP 8.13); the docs no longer suggest it. An AAB with another versionCode than planned is now a hard stop (it was a warning) and is not copied (pitfall 32).

  • Console plan play-production-release: the "Do you want to restart your review?" dialog that "Submit N changes for review" opens while a review is running is a known step; "Restart review" is the owner's button and in neverPress (pitfall 33).

  • Desktop: send for review from the window (owner decision, 2026-10-12). New owner actions (desktop only, never MCP tools): owner.apple_submit (new storebound apple submit --build <n> [--version x.y.z]: the steps of apple resubmit for a first submission: build n attached by number, the open review submission reused, the ready IAPs / subscriptions with it, submitted with the "not ready yet" retry; plan table without --yes), owner.apple_resubmit (apple resubmit --build <n> [--resolve-iaps]), owner.apple_iap_submit (apple iap-submit [--with-version]) and owner.play_production (play release --version-code <n> --production completed | inProgress | draft). Each is a dry run from the window; the real run needs the host's token minted after the native dialog, and only then serve adds --yes (apple submit / resubmit) and answers the command's question. OWNER_ALLOWED_ARGS ties every otherwise forbidden flag (--yes, submit, --production, --csv, --allow-price-changes) to its one owner action, a dry run never carries an owner flag, and the MCP server still has no submit, resubmit or production release (tests/serve.test.ts, tests/desktop-d3.test.ts, src-tauri/tests/d3_writes.rs). The submit dialog says the app goes to review and may go live by itself once approved.

    • Releases tab, "Send for review": the App Store submission state from apple status (waiting / in review, UNRESOLVED_ISSUES with its items and which ones get marked resolved, empty drafts labelled harmless), the uploaded builds to pick by number (processing ones disabled, nothing preselected), Submit for review, Resubmit (with "also mark rejected IAP items resolved") and Submit in-app purchases (with the editable version); Google Play versionCodes by number (one already live cannot be picked), Send to production as everyone, a staged share or a draft (a never-published app), the managed-publishing warning from play.managedPublishing, and the console plans of production countries and "Send changes for review" (no API) with a deep link. Every button shows the plan (dry run, --json with the plan table under serve) in the window first, then the native dialog; the real run's progress lines stream into the panel and Stop cancels it. The old "submit stays in the console" note is gone.
    • App info, "Console steps without an API": every console plan form (the new Play store settings category, App content and its cards, production countries and production release included) with a plan panel (start page and deep link, steps, answer sheet, never-press buttons, the owner's final button, Copy plan for an AI agent) and the read-only console verify (page text pasted, or the API for the App Store record and age rating). The Play forms card is folded into it.
    • Price gate: owner.prices_import passes --allow-price-changes itself after the dialog; new owner.prices_play_products, owner.prices_play_subscriptions, owner.prices_apple_subscriptions (Catalog: "Price changes") run that sync with the flag. A write that ends with exit 3 (declined: a held price change) shows "Waiting for your decision", not a failure.
    • Data safety card: the deletion question as the CSV answers it (dataDeletion, from accounts.deletionUrl, dataSafety.dataDeletion, ...; project.declarations sends dataDeletion, dataDeletionSource, dataDeletionUrl), older sidecars still shown from partialDeletion.
    • TestFlight: "Create missing groups" (apple testflight groups sync, dry run first). Dependencies: the Android SDK as play build finds it (system.tools androidSdk). project.inspect adds playRelease (managed publishing, countries, category). Job toasts: upload lines ("312/890 MB (35%), part 4/12") drive the progress bar, and the last 40 lines are in the toast's log.
    • Covered by the existing views: validate findings ("production has 0 countries", the category reminder), check warnings (stale web assets without build.prebuild, Play category, App content answers owed, privacy manifest tracking mismatch ITMS-91064, the defaultConfig versionCode / versionName source warning) and scan findings show in Checks; play images skipped languages and optional tablet sets show in the dry run of the native dialog.
    • Boot check: four send-for-review cases (ready and UNRESOLVED_ISSUES, on the old D1 and the new D3 saved state) with store answers from src/dev/submit-fixtures.ts: the Releases texts, the build and versionCode pickers, the plan panels, a console plan from the real sidecar, every app tab and Dependencies, by DOM text; the dialog is answered Cancel, nothing is sent.
  • Safety (apple testflight groups): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints would create "Team", "Friends". Creating is groups sync (or groups --yes). Flows treat bare groups as a read and groups sync as a write, and the MCP tool runs groups sync (dry by default).

  • Fix (apple resubmit, apple iap-submit): after an Invalid Binary rejection the submit kept answering 409 "Version is not ready to be submitted yet" for 25 minutes because the app version item of the UNRESOLVED_ISSUES submission was still REJECTED (pitfall 30). REJECTED items are now marked resolved before the submit (PATCH /v1/reviewSubmissionItems/{id} resolved: true): the app version item in apple resubmit (a fixed build goes in), in-app purchase / subscription items only with the new --resolve-iaps. The plan table shows "mark resolved" rows. On "not ready" the items are read again: one in scope is resolved, one out of scope stops the run at once with the item and how to resolve it; backoff retries only when nothing is left REJECTED.

  • Fix: an empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). A draft the run created and could not use is left as is, stray empty drafts get no cancel command, apple status labels them "empty draft (harmless; App Store Connect keeps it, not cancellable)" and --cancel-submission treats that 409 as harmless. apple status marks REJECTED items as not resolved.

  • Fix (apple iap-submit): resubmitting after a rejection created a second, empty draft next to the rejected submission and stopped with a wrong "list lag" message (pitfall 30). The open review submissions are read first (GET /v1/apps/{id}/reviewSubmissions, READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW, with their items): one with UNRESOLVED_ISSUES is reused (only missing items added, then resubmitted), a draft is reused, one waiting for or in review means nothing is sent; a new draft only when none is open. 409 ITEM_PART_OF_ANOTHER_SUBMISSION is reported with the other submission's id and that one is used (a draft the run created is canceled again); it is never read as a duplicate. The submit is retried with backoff and heartbeat lines while Apple answers "Version is not ready to be submitted yet" (up to 10 minutes).

  • New storebound apple resubmit --build <n> [--version x.y.z]: attaches build n (by number, VALID) to the rejected version, reuses the UNRESOLVED_ISSUES submission, adds missing IAP / subscription items and resubmits with the retry. A plan table without --yes (the owner's step); never in MCP or flows. apple status lists an UNRESOLVED_ISSUES submission with its items and their states and prints the resubmit command.

  • ITMS-91064 (pitfall 29): a real build was refused as Invalid Binary a minute after the upload because the app's PrivacyInfo.xcprivacy had NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (tracking came from an ad SDK whose own manifest declared it). Rule in every manifest: true needs a non-empty list, a non-empty list needs true; check, scan and privacy-manifest fail on either mismatch with the exact ITMS text. App Privacy "used for tracking" no longer requires the app manifest's true: check finds SDK manifests that declare tracking (Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA) and says so as info, names known tracking SDKs from the dependencies, and only warns when nothing declares it. privacy-manifest --propose [--tracking-domains] prints the app manifest with true only for appPrivacy.trackingDomains (the app's own domains); the scan proposal says false for SDK tracking. apple upload reads every manifest in the IPA (app root, Frameworks, bundles; XML or binary plists) and stops before any network call; apple build checks the export.

  • Play console steps without an API, from a real first release (2026-10-12; pitfalls 27 and 28). New agent-only console plan / console verify forms (also in the MCP console_plan / console_verify lists): play-store-settings (the App category: play listing cannot set it, so a new app showed "Not selected"; the first Edit > App or game > the Category [role=option] > Save), play-app-content (every card of /app-content/overview in order with its page, its "Start declaration" button, its plan and one batch of owner questions) and the cards play-privacy-policy, play-ads, play-sign-in-details (the demo account is typed by the owner, never printed), play-advertising-id, play-government-apps, play-financial-features, play-health-apps; play-production-countries (edits.countryAvailability is read-only) and play-production-release (Edit release > Next > Save; the owner's "Send changes for review", a real click). New config: play.category (checked against Play's game / app lists; default from an exact App Store category match), play.countries, play.managedPublishing, console.contentRating.details (the IARC follow-ups each Yes reveals), console.appContent, social, products[].randomRewards (loot boxes Yes / No from the catalog). check warns without a Play category and counts the App content answers still owed; validate adds the category reminder and "production has 0 countries"; play status shows the category as a console step; play release warns that an approved production release goes live by itself while managed publishing is off.

  • IARC content rating plan: page 1's IARC Terms of Use box is an agreement (never pressed, never ticked: the click gate now refuses "terms of use"); the questionnaire is one long page whose Yes answers reveal follow-ups; the Summary's final button is "Save", not "Submit"; App content forms deep-link to their card pages.

  • Every console plan ships inPageLoop, a ready-made in-page helper (window.__sb.run, polled through window.__log): one evaluate call times out at about 45 s on big Play pages. Play plans and console fill click the inner [role=checkbox] of material checkboxes like the material radios.

  • App Store: appstore-first-release and store-iap-setup send first IAPs with apple iap-submit --with-version (verified on a real first release), not the console; console verify apple-app-privacy reads "Published … ago by …" as the published signal.

  • Fix (play data-safety): PSL_SUPPORT_DATA_DELETION_BY_USER is Play's general "Do you provide a way for users to request that their data is deleted?". storebound answered it from partialDeletion (data deletion without deleting the account), so an app with a delete-account page declared "no". Now accounts.deletionUrl answers yes, with PSL_DATA_DELETION_URL set to partialDeletionUrl or the delete-account page. Without a page, the answer comes from the new dataSafety.dataDeletion: 'yes' | 'no' | 'auto90', or else from deletionRequest (the same question under the form's old id, no longer deprecated). check flags any answer other than yes next to a delete-account URL.

  • Price changes of existing products need --allow-price-changes (play products, play subscriptions, apple subscriptions, prices import; apple iap never changes an existing price). --yes, also the one flow run --apply --yes adds to write steps, confirms creates, first prices and other updates only. Without the flag a run writes everything else, prints price change on <id> (<from> -> <to>) needs the owner: re-run with --allow-price-changes per product and exits 3 (declined), so a flow stops with "step N declined". The terminal question (no --yes) and the desktop owner action still cover them. Dry runs mark those rows owner; flows refuse the flag in steps and never add it (lib/price-gate.ts, tests/price-gate.test.ts). Activating the purchase option of an existing Play product is a normal update now (--yes confirms it).

  • CLI wording: when console fill cannot open or fill part of a form (console UI changed, an unknown page or dialog step), it says what is left and that the calling agent does it with its browser tool (`storebound console plan

    ` / `console verify `), not "open it yourself" / "fill them yourself". Sign-in, agreements and the final Submit / Create / Publish stay the owner's. Other "by hand" output (fastlane import, catalog pull, ppp, scan) points at the config edit instead. `tests/do-it-dont-ask.test.ts` now scans the CLI's output strings too.
  • Agent rules: a first, top-level rule "Do it, don't ask" in docs/agents-core.md (and so AGENTS.md, GEMINI.md, the Copilot and Cursor rules), README ("How agents use storebound"), the store-pitfalls skill and the openings of console-autofill, play-first-release, appstore-first-release and store-declarations. The agent does API calls, uploads, builds, exports, console navigation and forms itself and runs the real command after the dry run; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. Skill text that left navigation, draft saves, exports or uploads to the owner is rewritten; tests/do-it-dont-ask.test.ts guards it. Pitfall 26.

  • Fix (play data-safety): apps with accounts got Play 400 "Invalid safety labels declaration: Line 7: Response ID missing". Play's current form asks PSL_SUPPORT_DATA_DELETION_BY_USER as a single choice with three options; storebound wrote it as a bare TRUE / FALSE. It now writes one row per option with TRUE on the picked one, and dataSafety.partialDeletion is true | false | 'auto90': true = DATA_DELETION_YES (plus PSL_DATA_DELETION_URL), false = DATA_DELETION_NO, 'auto90' = DATA_DELETION_NO_AUTO_DELETED ("No, but user data is automatically deleted within 90 days"); left out, check names it as missing.

    • Play removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE from the form, so it is no longer written and has no rule. dataSafety.deletionRequest is deprecated and optional; check prints a note to remove it. profile checks, scan and the desktop app no longer ask for it (the desktop Data safety card shows the deletion answer instead).
    • Google's empty form, exported from Play Console on 2026-10-12, ships as cli/data/play-data-safety-form.csv (embedded in the binary). Every row's Answer requirement (and label) comes from it, dataSafety.extra rows included (they were guessed as MULTIPLE_CHOICE / MAYBE_REQUIRED). New formPairIssues: every (Question ID, Response ID) pair must be in that form, or check, project.declarations, play data-safety --dry and the real run (before the POST) report ", is not in Play's current Data safety form ...; Play answers 400". Refresh the file when Play changes the form (Play Console > App content > Data safety > Export to CSV, nothing answered).
    • templates/data-safety.csv (the filled example from init) regenerated against the form. Pitfall 21.
  • Fix (play build): the Android SDK is found the way Android Studio finds it, so a non-interactive shell (no ANDROID_HOME) with a gitignored local.properties no longer ends in Gradle's "SDK location not found". Order: ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in the Gradle project's local.properties, then ~/Library/Android/sdk (macOS) / ~/Android/Sdk (Linux) / %LOCALAPPDATA%\Android\Sdk (Windows); only a folder with platforms/ or platform-tools/ counts. Gradle gets it through ANDROID_HOME and ANDROID_SDK_ROOT; local.properties is never written. None found: the build stops before Gradle and lists every place checked. The dry run and storebound doctor (also --json: androidSdk) show the SDK path and where it came from. Pitfall 25.

  • Fix (apple upload): never silent. The IPA size at the start, a line per part (every 10th from 20 parts), a heartbeat every 20 s while a part is in flight ("still uploading, 4m30s, 312/890 MB (35%), part 4/12"), the same heartbeat while --via altool runs (now in the background, stopped at the deadline) and while the build is processed (apple testflight wait too). Each part has a 15 min limit and is retried; --timeout now also covers the parts and stops with exit 7 and the hint to run the same command again (it resumes the build upload). Every App Store Connect API call has a 2 min limit (a hung connection is retried when safe, never a silent wait).

  • Fix (flow run, every confirming command): a declined confirmation is no longer a success. confirm() prints declined: <reason> and exits 3 when the owner answers no, when there is no terminal and no --yes, or under serve without the owner's dialog; flow run stops at that step with "step N declined: " (status declined), never "ok". flow run --apply --yes is the owner's approval for the run: write steps get --yes (reads, waits and owner steps do not); --yes without --apply is refused. Commands that went on with a partial write after a declined part (play products prices, testflight testers --invite, distribute --submit-review) now exit 3 too. Pitfall 24.

  • Fix (apple build, play build, Capacitor): a native build no longer ships old web assets silently. New build.prebuild (per store apple.prebuild / play.prebuild): a shell command run in the project root before the archive / bundle (e.g. npm run build && npx cap sync; printed, output streamed, nonzero exit stops the build, --dry only prints it, --no-prebuild skips it, once per flow run instead of once per store). After it, the copied ios/App/App/public/index.html / android/app/src/main/assets/public/index.html must be newer than the last git commit outside ios/ android/ node_modules/ store/ .storebound/ and <webDir>/index.html, else the build stops with "web assets are older than the source (copied ..., source ...)"; --allow-stale-web overrides. check warns when they are stale and no prebuild is set. The build output and the dry-run plan (web in --json) show the web bundle that went in: sha256 (12 hex) and mtime of the copied index.html. Pitfall 23.

    • Monorepos: play build failed with "no Gradle project (settings.gradle) in android or ." when the Capacitor app lives in apps/native (capacitor.config.ts, android/, ios/ there). apple build, play build, the staleness check and check now use the root's capacitor.config., else the one found one or two levels down (package.json workspaces, apps/, packages/*, direct child folders; node_modules skipped) and name it ("Capacitor project: apps/native"). Several: they are listed and play.build.dir / apple.build.workspace|project is asked for; an explicit config value always wins.
  • Fix (play build, play upload, play release): real AGP bundles (e.g. Capacitor 8) no longer fail with "AAB manifest without package / versionCode". The aapt2 proto manifest is read with Resources.proto's field numbers. Pitfall 22.

  • Fix (play data-safety): apps with accounts got Play 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL". When dataSafety.accountCreation is not PSL_ACM_NONE, the CSV now carries PSL_ACCOUNT_DELETION_URL = accounts.deletionUrl and PSL_SUPPORT_DATA_DELETION_BY_USER = the new dataSafety.partialDeletion (delete some or all data without deleting the account; ask the owner), plus PSL_DATA_DELETION_URL = new dataSafety.partialDeletionUrl (default accounts.deletionUrl) when that is true.

    • Required-row rules in one table (DATA_SAFETY_RULES, per data type typeRules, dataSafetyIssues): account creation -> deletion rows, a selected data type -> collected / shared, ephemeral, required / optional, purposes, PSL_HAS_OUTSIDE_APP_ACCOUNTS refused with sign-up methods. storebound check, project.declarations and play data-safety --dry say missing: <id> (<when>; set <config field>); the real run stops before the POST.
    • A dataSafety.extra row with a generated question id now replaces that question instead of adding a second one.
    • Sources: support.google.com/googleplay/android-developer/answer/10787469 , support.google.com/googleplay/android-developer/answer/13327111 . Pitfall 21.
  • Agent-first console mode. storebound console plan <form> [--json] (read-only, offline; --lookup asks the App Store Connect API for the app id) gives an AI agent everything to fill a console form with its own browser tool: the start URL (deep links from apple.appId, play.developerId, the new play.appId and the remembered ids), ordered steps (what to click by visible text), the answer sheet (each answer certain or ask-owner, with the console's exact labels, curly quotes noted), the never-press list (Publish, Submit, Create, agreements, help panels), the known UI facts per form and how to verify. storebound console verify <form> [--from-text <file|->] (read-only, exit 1 on a mismatch) checks the result: App Privacy from the page text (per-type "Used for …", linked, tracking), Play Create app from the input states, content rating and target audience from their summaries, the App Store record and age rating from the API. MCP: console_plan, console_verify (read-only; console fill stays off MCP). The console-autofill skill now puts the agent-driven mode first; console fill is the optional scripted fast path. Pitfall 20.

  • App Privacy, verified on a real console (2026-10-12): two information steps in each type dialog ("Let’s define two important terms…", "To help put tracking into context…"), purpose checkboxes without an accessible name (matched by the nearest parent text), "Definitions and Examples" never pressed. The scripted engine handles all of it.

  • Play Create app, verified through Claude in Chrome (2026-10-12): the form's own URL /console/u/0/developers/<id>/create-new-app (the engine opens it directly with a developer id); the App package name field (from play.packageName, max 150) and its read-only "Check availability" ("Package name available"), which storebound presses; the <material-radio> gotcha (the native input does not update the form's model: the engine clicks the inner [role=radio]); the app dashboard /developers/<id>/app/<appId>/app-dashboard (the app id is remembered and deep-links the App content forms); after Create app the owner adds the new app to the service account (Users and permissions > the service account > Add app), never an agent. The old note "the first AAB upload sets the package name" is gone.

  • Confirmed (2026-10-12): the earlier default copy of the owner's Google cookies DID sign the owner out of Play Console in their real Chrome (pitfall 18). The copy has been opt-in since the change below.

  • Fix (play images): a phone-only project no longer aborts with "no sevenInchScreenshots ... (render first)". Play requires the 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151); 7" / 10" tablet screenshots are optional. play images (dry and real) uploads what exists (icon, feature graphic, phone screenshots; tablet, TV and Wear sets only when rendered) and prints tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them). Missing required sets are still errors, with the fix.

    • New play.tablets?: boolean (default: true when tablet renders exist, else false). false: shots render, shots check, shots gallery, validate and play images never ask for tablet sets. true: rendered, and missing ones are errors (local renders and the listing on Play).
    • A missing optional set never deletes the screenshots Play has for it: the dry run shows keeps <type> on Play; new --prune deletes them (shown as would delete in the dry run; not exposed over MCP). Pitfall 19.
    • Languages: the ones play listing pushes. A status=draft / status=translate section is skipped (skipped de-DE: no Play listing yet (machine-translated draft, not reviewed ...), or --include-drafts), and a language Play has no listing for (404 "Listing for language 'de-DE' not found.") is skipped with an info line instead of aborting.
  • Safety (console fill): storebound no longer copies the owner's Chrome cookies by default. A copied Google session reached Play Console once, then Google answered /v3/signin/rejected: Google binds its session cookies to the device (Chrome's Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign the owner out of their real Chrome. Sources: blog.google/security/protecting-cookies-with-device-bound-session-credentials , knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies .

    • Default: storebound's own profile per store (~/.storebound/console-profile/<store>); the owner signs in once by hand (the first run prints what to do) and it is kept.
    • storebound starts that Chrome itself (--user-data-dir, --remote-debugging-port=0) and attaches over CDP: no --enable-automation, --disable-blink-features=AutomationControlled (navigator.webdriver false), none of Playwright's launch switches, Chrome's own user agent. Graceful close by CDP Browser.close.
    • --use-chrome-session google|apple: explicit opt-in to the old copy, now of only that store's cookies; Google's prints a warning about device-bound sessions. Apple's is opt-in too (Apple publishes no binding, but no guarantee).
    • Google's /signin/rejected page is said plainly: "Google refused sign-in in this window".
    • The desktop dialog no longer says it uses the Chrome sign-in. Pitfall 18.
  • App Privacy purposes (wrong answers in console fill apple-app-privacy, project.declarations and scan): Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). Now one Apple purpose per Play purpose, following Apple's definitions (developer.apple.com/app-store/app-privacy-details/): advertising -> Third-Party Advertising, analytics -> Analytics, personalization -> Product Personalization, functionality / fraud / account / communications -> App Functionality. Developer's Advertising or Marketing is No unless the owner says so; with a Play "communications" purpose console fill asks.

  • New appPrivacy.types: the owner's App Privacy answers per Apple data type id (all 35: USER_ID, DEVICE_ID, GAMEPLAY_CONTENT, ADVERTISING_DATA, CUSTOMER_SUPPORT, ENVIRONMENT_SCANNING, HANDS, HEAD ...), each { collected?, linked?, tracking?, purposes? } (THIRD_PARTY_ADVERTISING, DEVELOPERS_ADVERTISING, ANALYTICS, PRODUCT_PERSONALIZATION, APP_FUNCTIONALITY, OTHER_PURPOSES), validated against Apple's lists.

    • console fill apple-app-privacy fills them as certain answers (they win over what dataSafety suggests); types with no Play equivalent work from the block alone; anything unset stays "(you decide)".
    • scan --write proposes collected + purposes for detected types the block does not answer; never linked / tracking (the SDK disclosures do not prove them).
    • check compares the block with the iOS project, both ways: a type used for tracking needs NSUserTrackingUsageDescription and NSPrivacyTracking true (and the reverse is flagged), and the app's NSPrivacyCollectedDataTypes (manifest keys mapped to App Privacy ids) must match the types marked collected.
    • templates/app-privacy.md, the store-declarations, console-autofill and store-pitfalls skills document it.
  • console fill play-app-record: Play Console's Create app form, the Play counterpart of apple-app-record (the Play Developer API cannot create apps).

    • Account: play.developerId, the workspace account's id or the remembered one; else the only account the login sees; else the chooser entry named play.developerAccount (new optional field: the account's display name, e.g. an organisation account). Several accounts with none set, or a name that matches none or two: it stops and lists them. Remembered in ~/.storebound/console-accounts.json.
    • Navigation: All apps > the page-level Create app button, through a dedicated allowlist (createAppAllowed: exact name "Create app" / "Uygulama oluştur", button or link, on the app list, no Create app form open, not inside a form or dialog). The form's own Create app stays refused.
    • Fills App name (play.name, new, else app.name; 30 characters checked), Default language (Play Console label, e.g. "English (United States) – en-US"; matched by code in a Turkish console), App or game (profile) and Free or paid (play.free, new, else Free for IAP / ads only) as an owner check: outlined, with the note that a free app can never become paid. The Developer Program Policies and US export laws declarations are never ticked (an agreement field is outlined whatever its answer). The log and guidance say the package name is set by the first AAB upload (play upload --track internal / flow run release).
    • In --dry, the console fill list, the MCP / desktop form list and the desktop App info "Play Console forms" card.
  • shots check no longer reports i drawn with a system font for captions with ligatures ("Outfits" in en / de / nl with Montserrat). The font usage probes (one span per character, read with CDP CSS.getPlatformFontsForNode) sat side by side in one line, so Chrome shaped "f" + "i" into the fi ligature across them and the lone "i" probe reported no glyph of ours. Each probe is now its own block. The Google Fonts text= subsets were fine (they keep the GSUB ligatures); regression test with a real Montserrat text= subset (tests/glyph-ligatures.test.ts).

  • --help / -h on every command, subcommand and group (storebound shots render --help, storebound apple --help) prints the usage (the module's usage lines, the description and the options it accepts) and exits 0 before anything loads; it used to start the command (a full render). An unknown flag (--dyr) exits 2 with the usage before anything runs (cli/data/flags.json, bun tools/gen-flags.ts). storebound shots alone lists the shots commands. storebound ads apple ... is reachable again (the plain ads command used to swallow it).

  • shots render --scenes takes scene ids (the usage said numbers); an unknown id fails at once with the list of ids instead of a 30 s page timeout.

  • Faster CI: bun run ci runs as a task graph (independent steps at once, [step]-prefixed output, fail fast, time per step) with the tests split into parallel shards (CPU cores - 1, max 8) balanced by recorded durations, browser suites in shards of their own. New bun run ci:quick for the inner loop: typecheck plus the tests related to the change against origin/main (through imports) plus scrub. The desktop Rust host runs only when src-tauri or the sidecar build changed (CI=full always; Forgejo CI sets it). Fixed sleeps in tests became event waits.

  • ASO from official and public data only (no scraping, no third-party ASO services):

    • aso competitors [--term <kw>] [--storefront us,tr] [--top 10]: per keyword of the locale's keyword field (and --term) the top apps in the iTunes Search API (name, developer, rating count, first release, last update, genre localized to the storefront), the words in their names that the name / subtitle / keywords do not cover (only words of two or more developers: a one-developer word is likely a brand) and the apps new in a term's top N since the last snapshot (.storebound/aso/competitors/<date>.json). Same pace as aso rank (one call every 3.5 s, stop on 403 / 429). Competitor subtitles are not in any public API and are not shown.
    • aso suggest [--locale] [--fresh] [--offline] [--write]: keyword candidates from the own name / subtitle / description (words and two-word phrases), competitor names, the localized category names (from the Search API's genres) and, with Apple Ads credentials, the Apple Ads Platform API keyword suggestions (POST /v1/suggestions/keywords/query, relative popularity 0-100). Each candidate shows relevance (own text, competitor names), competition (top apps with the word in their name) and popularity (Apple Ads only, else "unknown"). Proposes a keyword field per locale that fits 100 bytes with the existing rules (no spaces, no duplicates, plural-insensitive, no name / subtitle words, no filler or category words; current terms kept first unless --fresh) as old -> new; --write puts it into store/appstore.md with a draft line that check --aso and aso audit report until the owner deletes it.
    • aso audit: one offline report: check --aso findings, the cross-locale free keyword room, rank trend arrows from the aso rank snapshots, competitor coverage gaps, Play short description use and keyword density, App Store conversion from the analytics snapshots, Product Page Optimization / Play listing experiment ideas (suggestions only: PPO cannot test the subtitle; Play experiments stay in Play Console) and the next 3 actions.
    • MCP: read-only aso_competitors (saves no snapshot), aso_suggest (never writes), aso_audit (offline).
    • Desktop: an ASO card on the Growth tab (aso.audit): keyword field state, keywords ranked, coverage gaps, the rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions.
    • examples/demo-app has listing files (store/appstore.md, store/listing.md) to try the ASO commands on.
  • Local font files in shots.fonts: title, body and fallback[] take, besides a Google Fonts family, a path (relative to the config) or { family, file, weight?, style?, license? } (TTF / OTF / WOFF / WOFF2, validated by extension and signature; the family comes from the name table when not given). shots fonts checks the file's cmap per language, tries the listed fallbacks in order before the automatic Noto ones (pl title: ę ł ą Ł not in ER Display -> Noto Sans Display), errors when nothing covers a character, and records the file in fonts.json with its path, sha256 and license. Render and shots video serve it in place at /fonts/local/... (never copied); the hash in fonts.css invalidates the capture cache, and render refuses a font file changed since shots fonts. shots check treats the local font and listed fallbacks as configured and warns when a local font has no license note.

  • Idempotency: every store write is an upsert; running a command twice, or again after a crash or a lost answer, leaves exactly one of each thing (table of every write path: docs/idempotency.md).

    • New cli/lib/upsert.ts: findOrCreate looks again with a fresh GET right before a create and asks a journal of recent creates (.storebound/created.json, a direct GET by id) so a re-run during the store's list lag reuses what the last run made; createOnce treats 409 / ALREADY_EXISTS as found and never sends a create blindly again after a timeout or 5xx (it looks first; only keys the store keeps unique get one more try).
    • The App Store Connect, Google Play, RevenueCat and storebound api clients retry 5xx / network errors only for requests that are safe twice (GET, PUT, PATCH, DELETE, relationship adds, state actions, price schedules); a POST create without a clear answer throws AmbiguousWriteError. Plan runners treat a 409 duplicate as done and stop on an unclear create instead of retrying.
    • Uses it in apple iap, apple subscriptions (groups, subscriptions, localizations, promotional offers, offer code campaigns, custom codes, review screenshot by MD5), apple offer-codes, apple cpp create, apple events create, apple ppo create, apple testflight groups, apple bundle, apple review-details (only changed fields), apple release --phased on, apple adhoc register / profile, apple signing ensure (a lost certificate answer keeps the key and finds the certificate by its public key), apple upload (a COMPLETE build upload is not sent again, an unfinished one is resumed), analytics setup, reviews send, revenuecat sync, play subscriptions (subscriptions and offers).
    • Review submissions (apple iap-submit, cpp submit, events submit): the draft is reused or created once, an item already in it is not added again.
    • Screenshot and preview sets keep the leading files that already match (MD5, position, delivered), replace the rest in order and fix the order if Apple shows another; a crash midway converges on the next run without duplicates.
    • Play edits: one open at a time per run, deleted on every error path, committed only on success, deleted instead of committed when nothing changed (ctl.unchanged()); redone only when nothing went live, never after a commit without a clear answer. play images compares sha256 first and replaces only changed image types; play release reuses a versionCode Play already has and skips identical track releases; play rollout commits nothing when the track is already like that; play upload is done when the versionCode is on the track already.
    • One writer per app and store: write runs take .storebound/locks/<store>-<app>.lock (dry runs and reads do not); a second run stops with who holds it; a lock of a process that is gone is taken over; --force-unlock.
    • tests/idempotency.test.ts with stateful store doubles (tests/helpers/fake-stores.ts): write commands run twice with zero writes on the second run, a crash in the middle of a screenshot set, IAP and subscription creates whose answer is lost, 409 duplicates, the list lag after a crash, Play edit error paths and an unclear commit, the lock. Pitfall 9 in docs/pitfalls.md and the store-pitfalls skill.
    • The last open rows are closed (docs/idempotency.md has none left):
      • apple listing: the App Store version is created through findOrCreate (a fresh look right before the create, the journal for the list lag, a lost answer looks first); localizations through createOnce (409 = found, then only the differing fields are PATCHed); unchanged localizations and an attached build are left alone.
      • play listing: compares again inside the write edit and PUTs only differing details and languages; nothing left to change = the edit is deleted, not committed.
      • apple appinfo: categories, age rating, content rights compared with the live values, only differences PATCHed.
      • play data-safety: Play cannot read the form back, so the sha256 of the last uploaded CSV is kept in .storebound/data-safety.sha256; the same CSV is not sent again unless --force (MCP force).
      • Twice-run tests for apple listing (plus a lost version create and a 409 localization), play listing (plus a store that became current before the edit), apple appinfo, play data-safety, apple promoted, apple win-back, apple preorder, apple testflight testers, play upload and prices import (Play).
  • Faster shots capture (web) and shots render (a 22-language game took about 50 min to capture, serially, with a new context and a full game boot per frame):

    • --concurrency N (and shots.capture.concurrency): parallel browser contexts in one Chromium; defaults from the CPU count (capture min(3, cores / 4), render min(4, cores / 2)). Output files do not depend on the order; progress lines carry language / device / frame, n/total and an ETA. Render runs ffmpeg asynchronously and makes the contact sheets after the images.
    • Per-frame timing (goto, waitFor, eval, delay, screenshot, total) in each progress line and in .storebound/capture-report.json, with time per step and the slowest frames at the end.
    • shots.capture.session: 'per-language': one page per language and device, booted once; shots switch with navigate: 'spa' (pushState + popstate, then waitFor / eval) or 'reload'; a failed precondition falls back to a fresh page (reuseTimeoutMs).
    • Input-hash cache (.storebound/capture-cache.json: shot entry, URL, frame size, session, copy text, app build from shots.capture.build or the served HTML + scripts, fonts): unchanged frames are skipped; --only-missing, --force. MCP shots_capture (concurrency, mode) and shots_render (concurrency).
  • apple listing --dry, play listing --dry and apple appinfo --dry print a real plan (also --json), read-only: the version decision of apple listing (keep, update the editable version's string while PREPARE_FOR_SUBMISSION, create, or refuse: number taken, not higher than a reviewed version, another version in review / pending release), the build to attach, and per locale / language create or update with old -> new per field (long text cut with its length, unchanged fields summarized); app info name / subtitle / privacy URL, categories, age rating and content rights the same way. Locales whose section is status=draft or status=translate (listing translate) are skipped with the reason; --include-drafts sends them. The real run prints the same plan, asks in a terminal and applies exactly it (unchanged localizations are no longer PATCHed, Play PUTs only changed languages); what's new is left out on an app's first version.

  • apple.name: the App Store name in the primary locale (at most 30 characters, validated) for when the plain name is taken on the App Store ("already being used"). app.name stays the Play and on-device name. console fill apple-app-record fills Name from it (else app.name), and apple listing, apple listing pull (a pulled name equal to it is not written into the file), the listing completeness view, check --aso, the console app picker and scan's beta-wording rule use it for every locale without its own ### name block. check reports an App Store name over 30 characters in any locale and hints when every locale uses the plain app.name (hints in --json).

  • Pitfalls from real use become guardrails: new skill store-pitfalls (read before any store task, linked from the agent rules) and docs/pitfalls.md (changelog of lessons; CONTRIBUTING: every real-use bug adds an entry and, where possible, a check or test).

    • apple iap-review names config products missing from App Store Connect's list (it lags a minute after a create) and exits 1 instead of skipping them silently; apple iap and play products re-read the list after creating and warn when a new product is not in it yet.
    • cli/lib/dry.ts dryPlan(title, rows): the standard dry-run plan table. apple iap --dry now reads localizations, price schedule and availability and prints what a real run would add; apple iap-review and play products end their dry runs with the table. A test scans cli/** for --dry branches that print only a count (allowlist with TODOs for commands being fixed elsewhere).
  • Screenshot glyphs (a Turkish render showed fallback glyphs for "ş" / "ğ" while shots check passed): the cause was not subsetting (css2 text= already takes glyphs from every subset) but the title font: Lilita One has no ş ğ İ and most of Latin Extended-A, so Chrome silently drew them with another font.

    • shots fonts checks every character of every language (copy.json + config languages) against the cmap of the faces that draw it (new pure-TS cmap reader for TTF / OTF / TTC / WOFF / WOFF2, no dependency), adds an automatic Noto fallback per missing character (Noto Sans Display for titles at a heavy weight, Noto Sans, Noto Sans