storebound/cli/apple/promoted.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

62 lines
4.8 KiB
TypeScript

// Promoted in-app purchases on the App Store product page, from apple.promoted.
// storebound apple promoted [--enable] [--dry] [--yes] [--json]
// Status (what is promoted, enabled, its review state), then: creates the missing promoted purchases disabled,
// updates visibleForAllUsers, sets the display order. --enable also switches them on, which shows them on the
// product page once approved: that asks first and is not an MCP tool. Each promoted purchase needs its promotional
// image (App Store Connect) and the app must handle purchases started on the App Store (StoreKit PurchaseIntent).
// At most 20 at a time. Pure parts and sources: cli/lib/offers.ts.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { planPromoted, type PromotedNow, promotedOrderReq } from '../lib/offers';
import { textTable } from '../lib/table';
import { ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi';
const TAG = 'promoted';
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
const dry = a.has('dry');
const cfg = config.apple?.promoted;
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const products = new Map<string, { apiId: string; kind: 'iap' | 'sub' }>();
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) products.set(p.attributes.productId, { apiId: p.id, kind: 'iap' });
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) products.set(s.attributes.productId, { apiId: s.id, kind: 'sub' });
const byApiId = new Map([...products.entries()].map(([pid, v]) => [v.apiId, pid] as const));
const raw = await c.asc('GET', `/v1/apps/${appId}/promotedPurchases?include=inAppPurchaseV2,subscription&limit=50`);
const now: PromotedNow[] = (raw.data ?? []).map((p: J) => ({ id: p.id, enabled: !!p.attributes.enabled, visibleForAllUsers: !!p.attributes.visibleForAllUsers, state: p.attributes.state,
productId: byApiId.get(p.relationships?.inAppPurchaseV2?.data?.id ?? p.relationships?.subscription?.data?.id ?? '') ?? '?' }));
const status = now.map((p, i) => [i + 1, p.productId, p.enabled ? 'enabled' : 'off', p.visibleForAllUsers ? 'everyone' : 'app decides', p.state ?? '']);
if (!cfg) {
if (a.has('json')) printJson({ promoted: now });
else console.log(now.length ? textTable(['#', 'product', 'enabled', 'visible to', 'state'], status) : 'no promoted in-app purchases');
done(TAG, 'apple.promoted is not set: status only');
return;
}
const plan = planPromoted(appId, cfg, now, products, { enable: a.has('enable') });
const order = promotedOrderReq(appId, cfg, now);
for (const u of plan.unknown) warn(TAG, `${u}: not on App Store Connect (storebound apple iap / apple subscriptions)`);
if (plan.extra.length) log(TAG, `promoted on the store but not in apple.promoted (left as they are): ${plan.extra.join(', ')}`);
if (now.length + plan.create.length > 20) throw new Error(`${now.length + plan.create.length} promoted purchases: at most 20`);
const reqs = [...plan.create, ...plan.update];
if (a.has('json')) printJson({ dryRun: dry, promoted: now, requests: [...reqs, ...(order ? [order] : [])].map(r => describeReq(r)), unknown: plan.unknown, extra: plan.extra });
else if (now.length) console.log(textTable(['#', 'product', 'enabled', 'visible to', 'state'], status));
if (!reqs.length && !order) { done(TAG, 'promoted purchases up to date'); return; }
if (dry) { await execute(c.asc, [...reqs, ...(order ? [order] : [])], { dry, tag: TAG }); done(TAG, 'dry run: nothing changed'); return; }
const enabling = reqs.some(r => JSON.stringify(r.body).includes('"enabled":true'));
if (enabling && !(await confirm(ctx, 'Switch promoted purchases on? Once approved they show on the App Store product page.'))) return;
await execute(c.asc, reqs, { dry: false, tag: TAG });
// the order covers the purchases that exist now (new ones included)
if (plan.create.length || order) {
const fresh = await c.asc('GET', `/v1/apps/${appId}/promotedPurchases?include=inAppPurchaseV2,subscription&limit=50`);
const after: PromotedNow[] = (fresh.data ?? []).map((p: J) => ({ id: p.id, enabled: !!p.attributes.enabled, visibleForAllUsers: !!p.attributes.visibleForAllUsers,
productId: byApiId.get(p.relationships?.inAppPurchaseV2?.data?.id ?? p.relationships?.subscription?.data?.id ?? '') ?? '?' }));
const o = promotedOrderReq(appId, cfg, after);
if (o) await execute(c.asc, [o], { dry: false, tag: TAG });
}
done(TAG, `promoted purchases updated${a.has('enable') ? '' : ' (not switched on: --enable when the images are approved)'}`);
}