Merge remote-tracking branch 'origin/main' into apple-name
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
d466d4a3c2
15 changed files with 457 additions and 21 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
61
cli/lib/dry.ts
Normal 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
25
cli/lib/store-lag.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
27
docs/pitfalls.md
Normal 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.
|
||||||
72
skills/store-pitfalls/SKILL.md
Normal file
72
skills/store-pitfalls/SKILL.md
Normal 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
166
tests/pitfalls.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue