Merge remote-tracking branch 'origin/main' into apple-name

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
CRTkafa 2026-10-12 03:40:51 +08:00
commit d466d4a3c2
15 changed files with 457 additions and 21 deletions

View file

@ -7,13 +7,16 @@ alwaysApply: false
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), 1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review),
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings, releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. chat). storebound's MCP server does not offer these at all.
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it 2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run (the rows that
for real only after the owner agrees. change, not just a count), then run it for real only after the owner agrees.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check` 3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it. (offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile** 4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
@ -71,6 +74,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list
- `skills/store-pitfalls/SKILL.md`: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each
- `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game - `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game
- `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages - `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages
- `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config - `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config

View file

@ -7,13 +7,16 @@ format; `storebound install --agent copilot` copies them to `.github/skills/` of
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), 1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review),
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings, releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. chat). storebound's MCP server does not offer these at all.
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it 2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run (the rows that
for real only after the owner agrees. change, not just a count), then run it for real only after the owner agrees.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check` 3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it. (offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile** 4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
@ -73,6 +76,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |
| `store-pitfalls` | Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each |
| `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game | | `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game |
| `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages | | `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages |
| `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config | | `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config |

View file

@ -8,13 +8,16 @@ file below and follow it step by step.
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), 1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review),
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings, releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. chat). storebound's MCP server does not offer these at all.
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it 2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run (the rows that
for real only after the owner agrees. change, not just a count), then run it for real only after the owner agrees.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check` 3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it. (offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile** 4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
@ -72,6 +75,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list
- `skills/store-pitfalls/SKILL.md`: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each
- `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game - `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game
- `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages - `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages
- `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config - `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config

View file

@ -17,6 +17,16 @@
to it is not written into the file), the listing completeness view, `check --aso`, the console app picker and `scan`'s 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 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`). 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 - 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 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. most of Latin Extended-A, so Chrome silently drew them with another font.

View file

@ -39,6 +39,10 @@ Try the screenshot and video pipeline on the demo game without any store account
fails on new inline `<svg>` / `<path>` drawing in `apps/desktop/src` and `site/*.html` and on SVG files that are fails on new inline `<svg>` / `<path>` drawing in `apps/desktop/src` and `site/*.html` and on SVG files that are
not vendored or rendered. These libraries are desktop not vendored or rendered. These libraries are desktop
dependencies (bundled by Vite) or devDependencies; the CLI package keeps zero runtime dependencies. dependencies (bundled by Vite) or devDependencies; the CLI package keeps zero runtime dependencies.
- **Every bug found in real use becomes a guardrail.** Add an entry to `docs/pitfalls.md` and to
`skills/store-pitfalls/SKILL.md` (symptom, cause, what to do, what guards it) and, where possible, a check or a test
with recorded store answers that fails without the fix. A `--dry` run prints the plan (`dryPlan` in
`cli/lib/dry.ts`), not only a count; `tests/pitfalls.test.ts` enforces it.
- Plain English in docs and messages, short sentences, no marketing words. - Plain English in docs and messages, short sentences, no marketing words.
## Pull requests ## Pull requests

View file

@ -6,13 +6,16 @@ This extension ships Agent Skills (skills/) and the storebound MCP server for mo
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), 1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review),
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings, releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. chat). storebound's MCP server does not offer these at all.
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it 2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run (the rows that
for real only after the owner agrees. change, not just a count), then run it for real only after the owner agrees.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check` 3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it. (offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile** 4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
@ -72,6 +75,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |
| `store-pitfalls` | Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each |
| `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game | | `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game |
| `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages | | `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages |
| `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config | | `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config |

View file

@ -5,11 +5,16 @@
// Size: an App Store screenshot size (iPhone or iPad, e.g. 1320 x 2868 or 1290 x 2796); Apple processes the upload // Size: an App Store screenshot size (iPhone or iPad, e.g. 1320 x 2868 or 1290 x 2796); Apple processes the upload
// after it is committed and fails other sizes with IMAGE_INCORRECT_DIMENSIONS, so the size is checked before the // after it is committed and fails other sizes with IMAGE_INCORRECT_DIMENSIONS, so the size is checked before the
// upload and the delivery state is read back after it (an upload is only "done" when Apple says COMPLETE). // upload and the delivery state is read back after it (an upload is only "done" when Apple says COMPLETE).
// Config products missing from the store's list (it lags a minute after `apple iap` creates them) are named with a
// warning and the exit code is 1, so nothing is skipped silently (docs/pitfalls.md). --dry ends with the plan table.
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { dryPlan, type PlanRow } from '../lib/dry';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { reportMissing } from '../lib/store-lag';
import { IMAGE_SPECS, imageInfo } from '../shots/spec'; import { IMAGE_SPECS, imageInfo } from '../shots/spec';
import { ascClient, md5 } from './asc'; import { ascClient, md5 } from './asc';
@ -39,28 +44,27 @@ export async function run(ctx: Ctx): Promise<void> {
const defs = new Map((config.products ?? []).map(p => [p.id, p])); const defs = new Map((config.products ?? []).map(p => [p.id, p]));
const shotOf = (id: string) => c.maybe(`/v2/inAppPurchases/${id}/appStoreReviewScreenshot`).then(r => r?.data as Shot | undefined); const shotOf = (id: string) => c.maybe(`/v2/inAppPurchases/${id}/appStoreReviewScreenshot`).then(r => r?.data as Shot | undefined);
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const seen = new Set(iaps.map((p: any) => p.attributes.productId)); const want = (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id);
const missingInStore = [...defs.keys()].filter(id => !seen.has(id)); const missing = reportMissing('apple', 'products', want, iaps.map(p => p.attributes.productId as string), { fail: true });
if (missingInStore.length) const rows: PlanRow[] = missing.map(id => ({ item: id, action: 'skip', detail: 'not in the store\'s list yet (run apple iap first, or re-run in a minute)' }));
warn('apple', `not in App Store Connect yet: ${missingInStore.join(', ')} (created moments ago? the store's list can lag a minute; run again)`);
let problems = 0; let problems = 0;
for (const p of iaps) { for (const p of iaps) {
const sku: string = p.attributes.productId; const sku: string = p.attributes.productId;
const def = defs.get(sku); const def = defs.get(sku);
const file = def?.reviewScreenshot ? resolve(def.reviewScreenshot) const file = def?.reviewScreenshot ? resolve(def.reviewScreenshot)
: ['png', 'jpg', 'jpeg'].map(e => resolve(join(config.apple?.reviewShots ?? 'store/iap-review', `${sku}.${e}`))).find(existsSync); : ['png', 'jpg', 'jpeg'].map(e => resolve(join(config.apple?.reviewShots ?? 'store/iap-review', `${sku}.${e}`))).find(existsSync);
if (!file || !existsSync(file)) { warn('apple', `${sku}: no review screenshot (set products[].reviewScreenshot)`); problems++; continue; } if (!file || !existsSync(file)) { warn('apple', `${sku}: no review screenshot (set products[].reviewScreenshot)`); rows.push({ item: sku, action: 'skip', detail: 'no review screenshot' }); problems++; continue; }
const buf = readFileSync(file); const buf = readFileSync(file);
const bad = reviewShotProblem(buf); const bad = reviewShotProblem(buf);
const cur = await shotOf(p.id); const cur = await shotOf(p.id);
const curState = stateOf(cur); const curState = stateOf(cur);
if (bad) { if (bad) {
warn('apple', `${sku}: ${relative(root, file)}: ${bad}${cur ? `; App Store Connect has ${curState}${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : ''}`); warn('apple', `${sku}: ${relative(root, file)}: ${bad}${cur ? `; App Store Connect has ${curState}${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : ''}`);
problems++; continue; rows.push({ item: sku, action: 'skip', detail: bad }); problems++; continue;
} }
if (cur && cur.attributes.sourceFileChecksum === md5(buf) && curState !== 'FAILED') { log('apple', `${sku}: screenshot up to date (${curState})`); continue; } if (cur && cur.attributes.sourceFileChecksum === md5(buf) && curState !== 'FAILED') { log('apple', `${sku}: screenshot up to date (${curState})`); rows.push({ item: sku, action: 'keep' }); continue; }
const why = !cur ? 'none in App Store Connect' : curState === 'FAILED' ? `the one there FAILED${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : 'a different file is there'; const why = !cur ? 'none in App Store Connect' : curState === 'FAILED' ? `the one there FAILED${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : 'a different file is there';
if (dry) { log('apple', `${sku}: would upload ${relative(root, file)} (${why})`); continue; } if (dry) { log('apple', `${sku}: would upload ${relative(root, file)} (${why})`); rows.push({ item: sku, action: cur ? 'replace' : 'upload', detail: `${relative(root, file)} (${why})` }); continue; }
if (cur) await c.asc('DELETE', `/v1/inAppPurchaseAppStoreReviewScreenshots/${cur.id}`); if (cur) await c.asc('DELETE', `/v1/inAppPurchaseAppStoreReviewScreenshots/${cur.id}`);
await c.uploadAsset('inAppPurchaseAppStoreReviewScreenshots', { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: p.id } } }, file); await c.uploadAsset('inAppPurchaseAppStoreReviewScreenshots', { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: p.id } } }, file);
// Apple processes the asset after the commit: read the state back (COMPLETE, or FAILED with the reason) // Apple processes the asset after the commit: read the state back (COMPLETE, or FAILED with the reason)
@ -90,6 +94,7 @@ export async function run(ctx: Ctx): Promise<void> {
else if (stateOf(shot) !== 'COMPLETE') lack.push(`Apple to finish processing the screenshot (${stateOf(shot)}; check again in a minute)`); else if (stateOf(shot) !== 'COMPLETE') lack.push(`Apple to finish processing the screenshot (${stateOf(shot)}; check again in a minute)`);
log('apple', `${p.attributes.productId}: MISSING_METADATA${lack.length ? `: needs ${lack.join(', ')}` : ' (all parts present: Apple may still be updating the state)'}`); log('apple', `${p.attributes.productId}: MISSING_METADATA${lack.length ? `: needs ${lack.join(', ')}` : ' (all parts present: Apple may still be updating the state)'}`);
} }
if (dry) console.log(dryPlan('apple iap-review', rows));
if (problems) process.exitCode = 1; if (problems) process.exitCode = 1;
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)'); done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)');
} }

View file

@ -4,11 +4,16 @@
// storebound apple iap [--dry] [--only id1,id2] // storebound apple iap [--dry] [--only id1,id2]
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version, // Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
// from the IAP page in the web UI (see the store-iap-setup skill). // from the IAP page in the web UI (see the store-iap-setup skill).
// --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list
// is read again: a product created a moment ago can be missing from it for a minute, and `apple iap-review` would
// then skip it, so that gap is named (docs/pitfalls.md).
import { sellsOn } from '../lib/config'; import { sellsOn } from '../lib/config';
import { APPLE_IAP_LIMITS } from '../lib/copy'; import { APPLE_IAP_LIMITS } from '../lib/copy';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { dryPlan, type PlanRow } from '../lib/dry';
import { done, log, step } from '../lib/log'; import { done, log, step } from '../lib/log';
import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { appleTerritory, nearestPrices, samePrice } from '../lib/money'; import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
@ -35,17 +40,20 @@ export async function run(ctx: Ctx): Promise<void> {
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`); throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
} }
const rows: PlanRow[] = [];
const created: string[] = [];
for (const [i, p] of products.entries()) { for (const [i, p] of products.entries()) {
const tag = step(i + 1, products.length, p.id); const tag = step(i + 1, products.length, p.id);
const found = await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`); const found = await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`);
let id: string = found.data[0]?.id; let id: string = found.data[0]?.id;
if (dry) { log('apple', `${tag}: ${id ? `exists (${found.data[0].attributes.state})` : 'missing'} (dry)`); continue; } if (dry) { rows.push(await planProduct(p, id, found.data[0]?.attributes?.state)); continue; }
if (!id) { if (!id) {
const first = Object.values(p.listings)[0]!; const first = Object.values(p.listings)[0]!;
id = (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases', id = (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases',
attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type], attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type],
familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) }, familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) },
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id; relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id;
created.push(p.id);
log('apple', `${tag}: created`); log('apple', `${tag}: created`);
} else log('apple', `${tag}: exists`); } else log('apple', `${tag}: exists`);
@ -82,5 +90,30 @@ export async function run(ctx: Ctx): Promise<void> {
log('apple', ` available in ${territories.length} territories + new ones`); log('apple', ` available in ${territories.length} territories + new ones`);
} }
} }
done('apple', dry ? 'iap checked (dry run)' : 'iap done (next: apple iap-review for the review screenshots)'); if (dry) {
console.log(dryPlan('apple iap', rows));
const absent = rows.filter(r => r.action === 'create').map(r => r.item);
if (absent.length) log('apple', `not on App Store Connect: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`);
done('apple', 'iap checked (dry run)');
return;
}
if (created.length) {
await Bun.sleep(Number(process.env.STOREBOUND_LIST_SETTLE_MS ?? 3000));
const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string);
if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them');
}
done('apple', 'iap done (next: apple iap-review for the review screenshots)');
/** What a real run would do to one product, from read-only calls. */
async function planProduct(p: (typeof products)[number], id: string | undefined, state: string | undefined): Promise<PlanRow> {
const locales = Object.keys(p.listings).map(l => langs.get(l)?.apple).filter((l): l is string => !!l);
const prices = [`USA ${p.usd}`, ...(p.overrides ?? []).map(o => `${appleTerritory(o)} ${o.price}`)].join(', ');
if (!id) return { item: p.id, action: 'create', detail: `${APPLE_IAP_TYPE[p.type]}; localizations ${locales.join(', ')}; prices ${prices}; ${territories.length} territories + new ones` };
const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale));
const add = locales.filter(l => !have.has(l));
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
const parts = [add.length ? `localizations ${add.join(', ')}` : '', sched?.included?.length ? '' : `prices ${prices}`, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean);
return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' };
}
} }

61
cli/lib/dry.ts Normal file
View file

@ -0,0 +1,61 @@
// Dry-run plans every write command shows the same way, so the owner sees WHAT would change, not only how many
// things ("listing: 20 locales" is not a plan). Pure. `tests/dry-plan.test.ts` scans cli/** for --dry branches that
// print nothing but a count (docs/pitfalls.md, "Dry runs that print no plan").
import { textTable } from './table';
export interface PlanRow {
/** What is changed: a product id, a locale, a file, a build. */
item: string;
/** create, update, upload, replace, delete, keep ... */
action: string;
/** The concrete change: fields, values, old -> new. */
detail?: string;
}
/**
* Standard dry-run plan: a title line and one aligned row per change. Rows whose action is "keep" are counted in the
* title but left out of the table, so a long "nothing to do" list does not hide the real changes.
*/
export function dryPlan(title: string, rows: PlanRow[]): string {
const change = rows.filter(r => r.action !== 'keep');
const kept = rows.length - change.length;
const head = `dry run: ${title}: ${change.length ? `${change.length} change(s)` : 'nothing to change'}${kept ? `, ${kept} unchanged` : ''}; nothing written`;
if (!change.length) return head;
return `${head}\n${textTable(['item', 'action', 'detail'], change.map(r => [r.item, r.action, r.detail ?? '']))}`;
}
/** A source line that leaves a --dry branch (`if (dry) return` / `if (a.has('dry')) { ...; return; }`) at the top level of a function. */
const DRY = String.raw`(?:dry|(?:ctx\.)?a(?:rgs)?\.has\('dry'\))`;
const EXIT = new RegExp(String.raw`^ (?:\} )?if \(${DRY}\) (?:\{ )?(?:.*\b)?return\b`);
/** Output that is a plan by itself: the shared table, the request lister, JSON, or printing the content that would be written. */
const RICH = /dryPlan\(|execute\(|printJson\(|describeReq\(|console\.log\((?!['`])|process\.stdout\.write\(/;
const OUT = /\b(?:log|done|say|warn|console\.log)\(\s*(?:[A-Za-z_'".]+,\s*)?(['`])(.*)\1/;
/** Interpolations that only count or name sections: `${x.length}`, `${n}`, `${Object.keys(listing).join(', ')}`, `${dry ? ...}`. */
const countOnly = (e: string): boolean => /(?:\.length|\.size|\bn|count|total)\s*$/.test(e.trim()) || /Object\.keys\(/.test(e) || /\bdry\s*\?|^\s*(?:TAG|tag)\s*$/.test(e);
/** True when the line prints something that names the concrete change (not only a count). */
export function isPlanLine(line: string): boolean {
if (RICH.test(line)) return true;
const m = OUT.exec(line);
if (!m) return false;
const parts = [...m[2]!.matchAll(/\$\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g)].map(x => x[1]!);
if (parts.some(p => !countOnly(p))) return true;
// a literal that spells out the one request ("would POST /v1/analyticsReportRequests {accessType=ONGOING}")
return !parts.length && /\bwould\b/.test(m[2]!) && !/nothing|\d+ \w+\(s\)/.test(m[2]!);
}
/**
* --dry exits in one source file that print nothing but a count before them (scanning back to the start of the
* enclosing top-level function, at most 40 lines). Returns 1-based line numbers. Pure; heuristic by design.
*/
export function countOnlyDryExits(source: string): number[] {
const lines = source.split('\n');
const out: number[] = [];
lines.forEach((l, i) => {
if (!EXIT.test(l)) return;
let from = i;
while (from > 0 && i - from < 40 && !/^(?:export )?(?:async )?function |^\}/.test(lines[from - 1]!)) from--;
if (!lines.slice(from, i + 1).some(isPlanLine)) out.push(i + 1);
});
return out;
}

25
cli/lib/store-lag.ts Normal file
View file

@ -0,0 +1,25 @@
// Store lists lag right after a create: App Store Connect's /inAppPurchasesV2 (and Play's oneTimeProducts list) can
// leave out a product created a minute ago. A command that walks the store's list then silently skips it. These
// helpers compare the config's product ids with what the store returned, so the gap is said out loud
// (docs/pitfalls.md, "Store lists lag right after create").
import { warn } from './log';
export const LAG_HINT = "the store's list can lag a minute after creating; re-run";
/** Config ids the store's list does not contain, in config order. Pure. */
export function missingFromStore(want: string[], got: Iterable<string>): string[] {
const have = new Set(got);
return [...new Set(want)].filter(id => !have.has(id));
}
/**
* Warns with the missing ids and the lag hint. `fail` sets a non-zero exit code (the command could not do its job for
* them, e.g. a dry-run plan that would skip them). Returns the missing ids.
*/
export function reportMissing(tag: string, what: string, want: string[], got: Iterable<string>, o: { fail: boolean }): string[] {
const missing = missingFromStore(want, got);
if (!missing.length) return missing;
warn(tag, `${missing.length} of ${want.length} ${what} not in the store's list: ${missing.join(', ')} (${LAG_HINT})`);
if (o.fail) process.exitCode = 1;
return missing;
}

View file

@ -10,10 +10,14 @@
// confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go. // confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go.
// Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it // Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it
// when the access ends). Products with `platforms` that leave out android are skipped. // when the access ends). Products with `platforms` that leave out android are skipped.
// --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product
// created a moment ago can be missing from it for a minute, and that gap is named (docs/pitfalls.md).
import { playCodes, sellsOn } from '../lib/config'; import { playCodes, sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
import { done, log, step, warn } from '../lib/log'; import { done, log, step, warn } from '../lib/log';
import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money';
import { playClient } from './client'; import { playClient } from './client';
@ -116,7 +120,15 @@ export async function run(ctx: Ctx): Promise<void> {
log('play', `${tag}: ${what}${dry ? ' (dry)' : ''}`); log('play', `${tag}: ${what}${dry ? ' (dry)' : ''}`);
plan.push({ p, tag, body, version: conv.regionVersion?.version ?? '2022/02', cur, changes, regions: regions.length, listings: listings.length }); plan.push({ p, tag, body, version: conv.regionVersion?.version ?? '2022/02', cur, changes, regions: regions.length, listings: listings.length });
} }
if (dry) { done('play', 'products checked (dry run, nothing written)'); return; } if (dry) {
console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' }
: !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` }
: { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner confirms)' : 'update', detail: x.changes.map(c => c.text).join('; ') })));
const absent = plan.filter(x => !x.cur).map(x => x.p.id);
if (absent.length) log('play', `not on Google Play: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`);
done('play', 'products checked (dry run, nothing written)');
return;
}
// prices, regions and activation of existing products: the owner confirms the diff above first // prices, regions and activation of existing products: the owner confirms the diff above first
const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner)); const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner));
@ -146,5 +158,7 @@ export async function run(ctx: Ctx): Promise<void> {
log('play', `${x.tag}: purchase option activated`); log('play', `${x.tag}: purchase option activated`);
} catch (e) { warn('play', `${x.tag}: activate failed: ${(e as Error).message}`); } } catch (e) { warn('play', `${x.tag}: activate failed: ${(e as Error).message}`); }
} }
const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id);
if (created.length) reportMissing('play', 'new products', created, (await listOneTimeProducts(play)).map(p => p.productId), { fail: false });
done('play', 'products done'); done('play', 'products done');
} }

View file

@ -2,13 +2,16 @@
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), 1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review),
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings, releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. chat). storebound's MCP server does not offer these at all.
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it 2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run (the rows that
for real only after the owner agrees. change, not just a count), then run it for real only after the owner agrees.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check` 3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it. (offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile** 4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**

27
docs/pitfalls.md Normal file
View file

@ -0,0 +1,27 @@
# Pitfalls: lessons from real use
A changelog of mistakes found while shipping real apps and games with storebound, newest first. Each entry says what
went wrong, why, and what now guards it. The agent-facing short version is `skills/store-pitfalls/SKILL.md`; keep the
two in step.
Rule (CONTRIBUTING.md): every bug found in real use adds an entry here and, where possible, a check or a test that
fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 8 | 2026-10 | Capacitor plugins in a monorepo live in `apps/native/node_modules`, so `scan` missed them. | `scan` / `privacy-manifest` resolve workspace packages (`cli/lib/scan/project.ts`, `cli/lib/node-packages.ts`). |
| 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`). |
| 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`). |
## Adding an entry
1. A row above (next number, month, one-sentence lesson, the guard with file or test names).
2. A section in `skills/store-pitfalls/SKILL.md`: symptom → cause → what to do → which command guards it. Then
`bun tools/gen-adapters.ts`.
3. Where the code can know it: a check (`check`, `validate`, `scan`, a warning in the command) and a test with
recorded store answers that fails without the fix.

View file

@ -0,0 +1,72 @@
---
name: store-pitfalls
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins. Read it before any store task, and when a store command does something you did not expect.
---
# Store pitfalls
Each entry: **symptom** → cause → what to do → what guards it now. The full changelog of lessons with dates is
`docs/pitfalls.md` in the storebound repository. When you hit a new one, tell the owner and add it there (and here).
## 1. A product you just created is "not there"
- **Symptom:** `apple iap-review` uploads nothing for a product `apple iap` created a minute ago; a list looks short.
- **Cause:** App Store Connect's `/inAppPurchasesV2` list (and Play's product list) can lag for a short while after a
create. A command that walks the list skips what is not in it yet.
- **Do:** wait a minute and re-run. Never assume a product was handled because the command finished.
- **Guard:** `apple iap-review` compares the config's products with the store's list, names the missing ones
("the store's list can lag a minute after creating; re-run") and exits 1. `apple iap` and `play products` read the
list again after creating and warn about any new product that is not in it yet; their dry runs name products that
are not on the store.
## 2. A dry run that only prints a count
- **Symptom:** `--dry` says "listing: 20 locales" or "3 change(s)", but not which fields would change to what.
- **Cause:** the command logged a summary instead of the plan.
- **Do:** never ask the owner to approve a count. Show the rows that change (item, action, old → new). If a command
only prints a count, say so and show the owner the diff from `pull` / `--json` output instead.
- **Guard:** `cli/lib/dry.ts` `dryPlan(title, rows)` prints the standard plan table (`apple iap`, `apple iap-review`,
`play products` use it); `tests/pitfalls.test.ts` scans `cli/**` for `--dry` branches that print nothing but a count.
## 3. "The app name you entered is already being used"
- **Symptom:** creating the App Store record or saving the name fails although you own no app with that name.
- **Cause:** App Store app names are unique per storefront, across all developers.
- **Do:** ask the owner for a variant (a short suffix such as "Example Notes: Lists") and set `apple.name` in the config;
keep `app.name` for Play and the brand. Do not pick the suffix yourself.
- **Guard:** `apple.name` overrides the App Store name.
## 4. Keyword field "too long" at fewer than 100 characters
- **Symptom:** App Store Connect rejects keywords that look short enough, mostly in Turkish, German, CJK.
- **Cause:** the 100 limit counts UTF-8 bytes, not characters ("ş" is 2 bytes, a CJK character 3).
- **Do:** shorten by bytes; drop words, not commas.
- **Guard:** `storebound check` counts bytes for keywords.
## 5. Data safety says "email collected" for an app that never asks for one
- **Symptom:** the proposal marks email (or phone) as collected because the app has accounts.
- **Cause:** account features do not imply email collection (anonymous ids, Game Center, Play Games sign-in).
- **Do:** answer only what the code proves; ask the owner for the rest (rule 5).
- **Guard:** `storebound scan` marks email only with evidence (an email field, an email sign-up call) and otherwise
says "ask owner".
## 6. Boxes or wrong glyphs in localized screenshots
- **Symptom:** Turkish, Polish or Vietnamese captions show fallback glyphs (ş, ğ, ł, ệ) while the English ones are fine.
- **Cause:** the font (or the Google Fonts subset that was downloaded) does not cover those characters, and the
browser silently draws them with another font.
- **Do:** check every language's characters before rendering, pick a font that covers them or let the fallback fill in.
- **Guard:** `storebound shots fonts` / `shots check` check each language's characters against the fonts' glyphs.
## 7. Console automation stops at sign-in or loses the session
- **Symptom:** `console fill` waits on a sign-in page, cannot find fields, or is signed out on the next run.
- **Cause:** the App Store Connect sign-in runs inside an `idmsa.apple.com` iframe; "User Access" is a plain heading,
not a labeled group; Chrome drops session cookies unless the last session is restored.
- **Do:** let the owner sign in in the opened window (never type credentials); re-run after the sign-in.
- **Guard:** the console session handles the iframe and the plain heading and starts Chrome with
`--restore-last-session`; a partial miss fills every field found and leaves only the missing ones to the owner.
## 8. `scan` misses Capacitor plugins in a monorepo
- **Symptom:** SDKs or privacy manifests of Capacitor plugins are not found.
- **Cause:** the plugins live in `apps/native/node_modules` (a workspace package), not in the root `node_modules`.
- **Do:** run `scan` from the project root; check that the stack line lists the plugins.
- **Guard:** `storebound scan` and `privacy-manifest` resolve packages through workspace folders too.
## Adding a lesson
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md).

166
tests/pitfalls.test.ts Normal file
View file

@ -0,0 +1,166 @@
// Guardrails for lessons from real use (docs/pitfalls.md, skills/store-pitfalls): the store's product list lagging
// right after a create, and dry runs that print a count instead of a plan. Recorded App Store Connect / Google Play
// answers; every id is a placeholder.
import { afterEach, describe, expect, test } from 'bun:test';
import { Glob } from 'bun';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { countOnlyDryExits, dryPlan, isPlanLine } from '../cli/lib/dry';
import { LAG_HINT, missingFromStore } from '../cli/lib/store-lag';
import { run as iapReview } from '../cli/apple/iap-review';
import { run as iap } from '../cli/apple/iap';
import { run as playProducts } from '../cli/play/products';
import type { Product } from '../cli/lib/config';
import { ascEnv, BASE, capture, mockFetch, notFound, playEnv, project, restore } from './helpers/store-mock';
const env = { ...process.env };
afterEach(() => { restore(); if (env.STOREBOUND_LIST_SETTLE_MS === undefined) delete process.env.STOREBOUND_LIST_SETTLE_MS; });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = (data: unknown[]): any => ({ data, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
const product = (id: string): Product => ({ id, type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes ads' } } });
// a PNG header of an App Store screenshot size (the size check reads IHDR only)
const png = (w = 1290, h = 2796) => { const b = Buffer.alloc(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); b.writeUInt32BE(13, 8); b.write('IHDR', 12); b.writeUInt32BE(w, 16); b.writeUInt32BE(h, 20); b[24] = 8; b[25] = 2; return b; };
const PRO = 'com.example.notes.pro', GEMS = 'com.example.notes.gems';
const CONFIG = { ...BASE, products: [product(PRO), { ...product(GEMS), type: 'consumable' as const }] };
describe('store list lag after create', () => {
test('missingFromStore keeps config order and ignores duplicates', () => {
expect(missingFromStore(['a', 'b', 'c', 'b'], ['c'])).toEqual(['a', 'b']);
expect(missingFromStore(['a'], new Set(['a']))).toEqual([]);
});
test('apple iap-review --dry: a product missing from /inAppPurchasesV2 is named with the lag hint and exits 1', async () => {
ascEnv();
const p = project(CONFIG, { [`store/iap-review/${PRO}.png`]: png(), [`store/iap-review/${GEMS}.png`]: png() });
// as answered a few seconds after `apple iap` created both: only the older product is listed
const calls = mockFetch([
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() },
]);
const r = await capture(() => iapReview(p.ctx(['--dry'])));
expect(r.err).toContain(`1 of 2 products not in the store's list: ${GEMS} (${LAG_HINT})`);
expect(r.out).toMatch(new RegExp(`${GEMS.replace(/\./g, '\\.')}\\s+skip\\s+not in the store's list yet`));
expect(r.out).toMatch(new RegExp(`${PRO.replace(/\./g, '\\.')}\\s+upload\\s+store/iap-review/${PRO.replace(/\./g, '\\.')}\\.png \\(none in App Store Connect\\)`));
expect(r.exit).toBe(1);
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
});
test('apple iap-review --dry: every product listed exits 0', async () => {
ascEnv();
const p = project({ ...CONFIG, products: [product(PRO)] }, { [`store/iap-review/${PRO}.png`]: png() });
mockFetch([
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() },
]);
const r = await capture(() => iapReview(p.ctx(['--dry'])));
expect(r.err).not.toContain(LAG_HINT);
expect(r.exit).toBeNull();
});
test('apple iap --dry prints a plan row per product (create with type, locales and prices; update with what is missing)', async () => {
ascEnv();
const p = project({ ...CONFIG, products: [product(PRO), { ...product(GEMS), type: 'consumable', overrides: [{ region: 'TR', currency: 'TRY', price: 49.99 }] }] });
const calls = mockFetch([
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) },
{ match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${PRO}`), answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'MISSING_METADATA' } }]) },
{ match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${GEMS}`), answer: page([]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/iapPriceSchedule/, answer: notFound() },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } },
]);
const r = await capture(() => iap(p.ctx(['--dry'])));
expect(r.out).toContain('dry run: apple iap: 2 change(s); nothing written');
expect(r.out).toMatch(/com\.example\.notes\.pro\s+update\s+localizations en-US; prices USA 2\.99 \(state MISSING_METADATA\)/);
expect(r.out).toMatch(/com\.example\.notes\.gems\s+create\s+CONSUMABLE; localizations en-US; prices USA 2\.99, TUR 49\.99; 2 territories/);
expect(r.out).toContain(`not on App Store Connect: ${GEMS}`);
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
});
test('apple iap: a product created this run but missing from the list read after it is named', async () => {
ascEnv();
process.env.STOREBOUND_LIST_SETTLE_MS = '0';
const p = project({ ...CONFIG, products: [product(GEMS)] });
const calls = mockFetch([
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }]) },
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?filter/, answer: page([]) },
{ method: 'POST', match: /^\/v2\/inAppPurchases$/, answer: { data: { type: 'inAppPurchases', id: 'i-gems', attributes: { productId: GEMS } } } },
{ match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseLocalizations/, answer: page([]) },
{ method: 'POST', match: /^\/v1\/inAppPurchaseLocalizations$/, answer: { data: { type: 'inAppPurchaseLocalizations', id: 'l1' } } },
{ match: /^\/v2\/inAppPurchases\/i-gems\/iapPriceSchedule/, answer: notFound() },
{ match: /^\/v2\/inAppPurchases\/i-gems\/pricePoints\?filter\[territory\]=USA/, answer: page([{ type: 'inAppPurchasePricePoints', id: 'pp-usa-299', attributes: { customerPrice: '2.99' } }]) },
{ method: 'POST', match: /^\/v1\/inAppPurchasePriceSchedules$/, answer: { data: { type: 'inAppPurchasePriceSchedules', id: 'ps' } } },
{ match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseAvailability/, answer: notFound() },
{ method: 'POST', match: /^\/v1\/inAppPurchaseAvailabilities$/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } },
// the full list read right after the create does not have it yet
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([]) },
]);
const r = await capture(() => iap(p.ctx([])));
expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/v2/inAppPurchases', '/v1/inAppPurchaseLocalizations', '/v1/inAppPurchasePriceSchedules', '/v1/inAppPurchaseAvailabilities']);
expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`);
expect(r.out).toContain('wait a minute before apple iap-review');
expect(r.exit).toBeNull();
});
test('play products: a product created this run but missing from the list read after it is named; --dry shows the plan table', async () => {
playEnv();
const p = project({ ...CONFIG, products: [product(GEMS)] });
const API = '/androidpublisher/v3/applications/com.example.notes';
const conv = { convertedRegionPrices: { US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } } },
convertedOtherRegionsPrice: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 790000000 } }, regionVersion: { version: '2026/01' } };
const routes = () => mockFetch([
{ match: new RegExp(`^${API}/oneTimeProducts\\?`), answer: { oneTimeProducts: [] } },
{ method: 'POST', match: new RegExp(`^${API}/pricing:convertRegionPrices$`), answer: conv },
{ method: 'PATCH', match: new RegExp(`^${API}/onetimeproducts/`), answer: {} },
{ method: 'POST', match: /purchaseOptions:batchUpdateStates$/, answer: {} },
]);
routes();
let r = await capture(() => playProducts(p.ctx(['--dry'])));
expect(r.out).toMatch(/dry run: play products: 1 change\(s\); nothing written\n.*\n.*\ncom\.example\.notes\.gems\s+create\s+1 regions, 1 listings, USD 2\.99/);
expect(r.out).toContain(`not on Google Play: ${GEMS}`);
const calls = routes();
r = await capture(() => playProducts(p.ctx([])));
expect(calls.filter(c => c.method === 'PATCH')).toHaveLength(1);
expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`);
});
});
describe('dry runs show a plan, not a count', () => {
test('dryPlan: title with counts, kept rows left out of the table', () => {
expect(dryPlan('apple iap', [{ item: 'a', action: 'keep' }])).toBe('dry run: apple iap: nothing to change, 1 unchanged; nothing written');
expect(dryPlan('play products', [{ item: 'com.example.a', action: 'create', detail: '174 regions' }, { item: 'b', action: 'keep' }]).split('\n')).toEqual([
'dry run: play products: 1 change(s), 1 unchanged; nothing written',
'item action detail',
'------------- ------ -----------',
'com.example.a create 174 regions',
]);
});
test('the scanner flags a --dry exit after a count-only line and accepts a plan', () => {
const countOnly = ['export async function run(ctx: Ctx): Promise<void> {', ' const listing = read();',
" log('apple', `listing: ${Object.keys(listing).length} locales (${Object.keys(listing).join(', ')})`);", " if (a.has('dry')) return;", ' await write();', '}'].join('\n');
expect(countOnlyDryExits(countOnly)).toEqual([4]);
const counted = ['export async function run(): Promise<void> {', " if (dry) { done(TAG, `dry run: ${reqs.length} change(s)`); return; }", '}'].join('\n');
expect(countOnlyDryExits(counted)).toEqual([2]);
const planned = ['export async function run(): Promise<void> {', " console.log(dryPlan('apple listing', rows));", " if (dry) return;", '}'].join('\n');
expect(countOnlyDryExits(planned)).toEqual([]);
expect(isPlanLine(" log('apple', `${tag}: would upload ${file}`);")).toBe(true);
expect(isPlanLine(" log(TAG, 'would POST /v1/analyticsReportRequests {accessType=ONGOING} (dry)');")).toBe(true);
expect(isPlanLine(" done(TAG, 'dry run: nothing changed');")).toBe(false);
});
// TODO: remove each entry once its branch lands (the test fails on a stale entry).
const ALLOW: Record<string, string> = {
'cli/apple/listing.ts': 'TODO: `apple listing --version` dry run prints "listing: N locales" only; being fixed in another branch',
};
test('no write command in cli/** leaves its --dry branch after printing only a count', () => {
const root = join(import.meta.dir, '..');
const flagged: string[] = [];
for (const f of [...new Glob('cli/**/*.ts').scanSync(root)].sort()) {
const lines = countOnlyDryExits(readFileSync(join(root, f), 'utf8'));
if (lines.length) flagged.push(`${f}:${lines.join(',')}`);
}
const files = flagged.map(x => x.split(':')[0]!);
expect(flagged.filter(x => !ALLOW[x.split(':')[0]!])).toEqual([]);
expect(Object.keys(ALLOW).filter(f => !files.includes(f))).toEqual([]);
});
});