// Per-project config (storebound.config.ts at the project root). Everything app-specific lives here; credentials never // do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional. import { existsSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { validateCapture, validateRecord } from '../shots/devices'; import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy'; import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events'; import { type IapOfferCodeDef, type PromotedConfig, validateIapOfferCodes, validatePromoted, validateWinBack, type WinBackDef } from './offers'; import { validateSubscriptions } from './subscriptions'; import { type TfConfig, validateTestflight } from './testflight'; import { type AppleSigning, validateSigning } from './signing'; import { type AppleBuildConfig, validateAppleBuild } from './xcode'; import { type PlayBuildConfig, validatePlayBuild } from './gradle'; import { type OriginalityConfig, validateOriginality } from './originality'; /** consumable / non_consumable: both stores. non_renewing: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time, * you track the expiry); Google Play has no such type, so it becomes a one-time product your app consumes when the * access ends (or model it as a prepaid base plan in Play Console). */ export type ProductType = 'consumable' | 'non_consumable' | 'non_renewing'; /** Which stores sell an item (app identity: one catalog, per-platform availability). Default: every store the config * has a section for (apple = iOS / iPadOS App Store, play = Google Play). */ export type Platform = 'ios' | 'android'; export interface PriceOverride { /** ISO 3166-1 alpha-2 region for Play, e.g. "TR". */ region: string; /** App Store territory (alpha-3), e.g. "TUR". Looked up from `region` when left out. */ territory?: string; /** ISO 4217 currency, e.g. "TRY". */ currency: string; /** Customer price in that currency, e.g. 129.99. Apple needs an existing price point with exactly this value. */ price: number; } export interface ProductListing { title: string; description: string } export interface Product { /** Store product id (same on both stores), e.g. "com.example.game.no_ads". */ id: string; type: ProductType; /** Base price in USD. Play converts it per region (convertRegionPrices); Apple equalizes it from the USA base. */ usd: number; overrides?: PriceOverride[]; /** Language id (see `languages`) -> title + description. Apple: title 30, description 45 characters. */ listings: Record; /** Apple only: internal reference name (defaults to the first listing title). */ referenceName?: string; /** Apple only: note for the reviewer on this product. */ reviewNote?: string; /** Apple only: review screenshot (path relative to the project root). */ reviewScreenshot?: string; familySharable?: boolean; /** Sell this product only on these platforms (default: all of app.platforms). */ platforms?: Platform[]; /** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */ offerCodes?: IapOfferCodeDef[]; } export interface Language { /** Your id for the language; also the folder name under the renders dir and the key in copy files. */ id: string; /** Play listing language code(s), e.g. "en-US" or ["es-ES", "es-419"]. Leave out to skip on Play. */ play?: string | string[]; /** App Store Connect locale, e.g. "en-US", "tr", "zh-Hans". Leave out to skip on the App Store. */ apple?: string; rtl?: boolean; } export type PlayPurpose = 'functionality' | 'analytics' | 'communications' | 'fraud' | 'advertising' | 'personalization' | 'account'; export interface DataUsage { /** Transferred to a third party that is not your service provider (ad networks usually are "shared"). */ shared: boolean; ephemeral?: boolean; /** Only true when the user can really choose (Play rejects "optional" otherwise). */ optional?: boolean; collect: PlayPurpose[]; share?: PlayPurpose[]; } export interface DataSafety { collectsData: boolean; encryptedInTransit: boolean; deletionRequest: boolean; /** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */ accountCreation?: string[]; /** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */ data: Record; /** * Extra CSV rows for questions storebound does not model, appended as they are. Google does not publish every * question id (e.g. the delete-account URL asked when accountCreation is not PSL_ACM_NONE): export the form from * Play Console (App content > Data safety > Export to CSV), copy the exact ids from it, e.g. * { question: '', value: 'https://example.com/delete-account' }. */ extra?: { question: string; response?: string; value: string; label?: string }[]; } export interface ShotBadgeSlot { y: number; side: 'l' | 'r'; icon?: string; iconBg?: string; hot?: boolean; stars?: boolean; rot?: number } export interface ShotFrame { src: string; x?: number; dy?: number; scale?: number; rotate?: number } export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string } export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number } /** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`; * `shots check` warns without one). weight / style: the @font-face descriptors (default: all weights, normal). */ export interface LocalFontSpec { family?: string; file: string; weight?: number | string; style?: 'normal' | 'italic'; license?: string } /** A Google Fonts family name, a local font path (relative to the config) or a LocalFontSpec. */ export type FontSpec = string | LocalFontSpec; export interface ShotsConfig { /** Look of the rendered screenshots: game (sunburst, sparkles, badges, bold outlined titles) or app (calm gradient, * device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles, * else game. */ style?: 'game' | 'app'; /** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */ copy: string; /** Raw frames: ///.png */ frames: string; /** Output: ///NN.png and feature/1024x500.png */ out: string; /** Optional logo image for the hero scene and the feature graphic. */ logo?: string; /** Fonts for titles / body text: a Google Fonts family (downloaded and subset by `shots fonts`), or your own font file * (a path relative to the config, TTF / OTF / WOFF / WOFF2, or { family, file, weight?, style?, license? }). * Default: game Paytone One + Montserrat, app Inter (all cover Latin Extended and Vietnamese). `fallback`: fonts you * accept for characters the title / body font lacks, tried in order before the automatic Noto fallbacks (e.g. * ['Noto Sans Display'] or a local file); a render that needs an automatic fallback is an ERROR in `shots check`. */ fonts?: { title?: FontSpec; body?: FontSpec; fallback?: FontSpec[] }; scenes: ShotScene[]; feature?: { frame: string; palette?: string | Palette }; /** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar). * `bar` = status bar height the template draws above the frame (and that capture crops off). */ phone?: { w: number; h: number; dpr?: number; bar?: number }; /** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */ tablet?: { w: number; h: number; dpr?: number; bar?: number }; /** Optional capture of raw frames (web build, iOS Simulator, Android emulator / device, or your own folder). */ capture?: ShotsCapture; /** A/B variants: other scene orders / copy, rendered into ///... (`shots render --variant b`). */ variants?: Record; /** App preview / promo video from gameplay clips (`shots record`, `shots video`). */ video?: VideoConfig; } /** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */ export interface ShotSpec { /** Web: URL path after baseUrl, "{lang}" replaced. */ path?: string; /** ios-sim / android-emu: deep link opened after launch, e.g. "mygame://shot/{shot}?lang={lang}". */ url?: string; /** Web: JS expression polled until true. */ waitFor?: string; /** Wait before the screenshot (ms). Default 1000 (web) / 2500 (simulator, emulator). */ delayMs?: number; /** Web: JS run in the page before the screenshot. */ eval?: string[]; device?: 'phone' | 'tablet' | 'both'; /** Web, capture.session "per-language": how this shot is reached in the already booted page. "spa" (default): * history.pushState to its path + a popstate event, then waitFor / eval (switch scenes from those); "reload": * page.goto in the same page. A failing waitFor there falls back to a fresh page. */ navigate?: 'spa' | 'reload'; /** ios-sim: extra launch arguments, e.g. ["-shotScene", "{shot}", "-seed", "1"] (read them with UserDefaults / your engine). */ args?: string[]; /** android-emu: intent extras for `am start` (string / int / float / boolean), "{lang}" replaced in strings. */ extras?: Record; } export interface ShotsCapture { /** Where frames come from. Default "web". */ source?: 'web' | 'ios-sim' | 'android-emu' | 'folder'; /** web: URL of the running app or game, e.g. http://127.0.0.1:5173 */ baseUrl?: string; /** web: appended to every URL, e.g. "lang={lang}&noads=1". */ query?: string; ios?: { /** Simulator name or UDID for phone shots, e.g. "iPhone 17 Pro Max" (6.9"). */ device?: string; devices?: { phone?: string; tablet?: string }; bundleId: string; /** Optional .app to install first (a Simulator build: xcodebuild -sdk iphonesimulator, Unity / Godot / Flutter export). */ app?: string; /** Launch arguments for every shot ("{lang}", "{shot}" replaced). */ args?: string[]; /** Status bar override 9:41, full battery and signal. Default true. */ statusBar?: boolean; /** After storebound boots a simulator: launch the app once and wait this long before the first shot (a cold * first launch is much slower than the next ones). Default 8000 ms; 0 turns it off. */ warmupMs?: number; }; android?: { /** adb serial (adb devices); default: the only device. */ serial?: string; serials?: { phone?: string; tablet?: string }; package: string; /** Launch activity when a shot has no deep link. Default ".MainActivity". */ activity?: string; /** Optional APK to install first. */ apk?: string; /** System UI demo mode (clean status bar). Default true. */ demoMode?: boolean; /** Per-app language via `cmd locale set-app-locales` (Android 13+). Default true. */ appLocale?: boolean; }; /** folder: where your frames are (default shots.frames): ///.png */ folder?: string; /** Rows cut from the top of a simulator / emulator screenshot (its status bar; the template draws one). A number for * every device, or per device class. Default: the frame's bar share of the height (phone 54 / 956 = 162 px on a 6.9" * iPhone, 13" iPad 24 / 1376 = 48 px). */ cropTop?: number | { phone?: number; tablet?: number }; /** Web: parallel browser contexts (one headless Chromium). Default min(3, CPU cores / 4): game boots are GPU-heavy. * `--concurrency N` overrides. */ concurrency?: number; /** Web: "per-shot" (default: new context + full boot per frame) or "per-language" (one page per language and * device, booted once; shots switch in it, see ShotSpec.navigate). */ session?: 'per-shot' | 'per-language'; /** Web, per-language: how long a reused page may take to reach a shot's waitFor before a fresh page is used. * Default 15000 ms. */ reuseTimeoutMs?: number; /** Web: files or folders of the app build (e.g. "apps/game/src" or "apps/game/dist") hashed into the capture cache, * so a code change re-captures. Default: the HTML at baseUrl and the scripts it loads. */ build?: string | string[]; shots: Record; } export interface ShotVariant { /** Scene ids in the order this variant shows them (a subset is fine). Default: the base order. */ scenes?: string[]; /** Copy file merged over the base copy per language and scene (only the scenes / fields that change). */ copy?: string; /** Palette per scene id. */ palettes?: Record; } export interface VideoConfig { /** Gameplay clips: //*.mp4|mov, or /*.mp4|mov shared by every language. Default store/video/clips. */ clips?: string; /** Target length in seconds (15-30 for App Store previews). Default 25. */ seconds?: number; /** Outputs: iphone (886x1920), ipad (1200x1600, iPad apps), youtube (1080x1920), youtube-landscape (1920x1080). */ targets?: string[]; /** Caption overlays from the screenshot copy (scene titles), one per clip. Default true. */ captions?: boolean; /** Scene ids whose titles caption the clips, in order. Default: the scene order. */ captionScenes?: string[]; /** Music / sound file mixed in (looped, faded out). Default: a silent stereo track (Apple wants an audio track). */ audio?: string; /** Poster frame time in seconds (App Store shows it before playback). Default 5. */ poster?: number; /** cover (crop to fill, default) or contain (letterbox on `background`). */ fit?: 'cover' | 'contain'; background?: string; /** Gameplay clips recorded by `storebound shots record` into //NN-.mp4. */ record?: VideoRecord; } /** How `shots record` films clips. Uses shots.capture for the web URL / simulator / emulator settings. */ export interface VideoRecord { /** Default: shots.capture.source (web when unset). folder = only list / check the clips you put there yourself. */ source?: 'web' | 'ios-sim' | 'android-emu' | 'folder'; /** Which frame size / simulator / emulator: phone (default) or tablet. */ device?: 'phone' | 'tablet'; /** Clip name -> what to film, in order. */ clips: Record; } export interface ClipSpec { /** Length in seconds (1-60). */ seconds: number; /** web: URL path after shots.capture.baseUrl ("{lang}" replaced), e.g. "/?level=3&autoplay=1". */ path?: string; /** web: JS expression polled until true before filming starts. */ waitFor?: string; /** web: JS run before filming starts. */ eval?: string[]; /** ios-sim / android-emu: deep link opened after launch. */ url?: string; /** ios-sim: extra launch arguments ("{lang}", "{shot}" = the clip name). */ args?: string[]; /** android-emu: intent extras for `am start`. */ extras?: Record; /** Wait after launch before filming (ms). Default 1000 (web) / 2500 (simulator, emulator). */ delayMs?: number; } export interface IconsConfig { /** 1024 opaque composite (the full icon). */ composite: string; /** 1024 RGBA subject on transparent background (adaptive foreground). */ foreground?: string; /** 1024 opaque background (adaptive background). */ background?: string; /** Output dir for the store icons (app-store-1024.png, play-512.png). */ out: string; /** Web icons (icon-512/192/180, favicon-64/32, maskable-512). */ web?: string; /** @capacitor/assets source dir (icon-only, icon-foreground, icon-background). */ capacitorAssets?: string; /** Command to run after writing the capacitor sources, e.g. "bunx @capacitor/assets generate". */ capacitorCommand?: string; /** Android res dir; legacy ic_launcher{,_round}.png get redrawn from the adaptive layers. */ androidRes?: string; adaptiveScale?: number; maskableScale?: number; } export interface AppleAppInfo { categories?: { primary: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string }; /** Overrides for the age rating declaration; everything else is NONE / false. */ ageRating?: Record; contentRights?: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' | 'USES_THIRD_PARTY_CONTENT'; /** Free app, available in every territory and new ones. */ free?: boolean; reviewNotes?: string; } /** What kind of app this is. Picks the `init` templates, the default screenshot style and the profile checks of * `check` / `validate`. game = free-to-play game with ads + IAP; app = utility / productivity app (optional account, * analytics, no ads); subscription-app = app with accounts and auto-renewable subscriptions. */ export type AppProfile = 'game' | 'app' | 'subscription-app'; export const PROFILES: readonly AppProfile[] = ['app', 'game', 'subscription-app']; /** Accounts in the app (sign-up / sign-in). Both stores then want account deletion: Apple 5.1.1(v) inside the app, * Google Play inside the app and on the web (the URL goes into Data safety). See the account-deletion skill. */ export interface AccountsConfig { /** The app lets people create an account (or sign in with one you create for them). */ enabled: boolean; /** Public https page where people request account + data deletion without the app (Play "Delete account URL"). */ deletionUrl?: string; /** Where deletion starts inside the app, e.g. "Settings > Account > Delete account" (goes into the review notes). */ inApp?: string; /** * How people sign up. Decides which account data `storebound scan` proposes and the Play Data safety "Account * creation" answer it suggests for dataSafety.accountCreation (ACCOUNT_SIGN_UP in declarations.ts): * 'username' = a username or player name picked in the app with no password and no email (the server keeps a token on * the device), 'username-password', 'email-password', 'email-link' (magic link / one-time code by email), 'phone' * (SMS code), 'oauth' (Sign in with Apple / Google / Facebook ...), 'other'. Only 'email-*' means an email address. */ signUp?: AccountSignUp[]; } export type AccountSignUp = 'username' | 'username-password' | 'email-password' | 'email-link' | 'phone' | 'oauth' | 'other'; export const ACCOUNT_SIGN_UPS: readonly AccountSignUp[] = ['username', 'username-password', 'email-password', 'email-link', 'phone', 'oauth', 'other']; /** How a reviewer gets past a sign-in (App Review demo account, Play App content > App access). The credentials * themselves are never in the config: "reviewDemoAccount" in .storebound/credentials.json or REVIEW_DEMO_USER / * REVIEW_DEMO_PASSWORD. See the review-access skill. */ export interface ReviewAccess { /** Some or all features need a sign-in: App Review gets demoAccountRequired + the demo account. */ signInRequired: boolean; /** Steps for the reviewer, appended to the App Review notes, e.g. "Sign in with the demo account, open Projects." */ instructions?: string; } /** App Privacy: does the app (or an SDK in it) track users across other companies' apps and websites? true needs * App Tracking Transparency (NSUserTrackingUsageDescription + the prompt) and the privacy manifest's NSPrivacyTracking. */ export interface AppPrivacyConfig { tracking?: boolean; /** Domains that receive tracking data (privacy manifest NSPrivacyTrackingDomains). */ trackingDomains?: string[]; /** The owner's App Store App Privacy answers per data type, keyed by Apple's type id (DEVICE_ID, USER_ID, * GAMEPLAY_CONTENT, ADVERTISING_DATA ...; cli/lib/app-privacy.ts). `console fill apple-app-privacy` fills them as * certain answers and they win over what storebound derives from dataSafety; types with no Play equivalent work * from here alone. `check` compares them with the iOS project (ATT string, PrivacyInfo.xcprivacy). */ types?: Partial>; } export type SubscriptionPeriod = 'P1W' | 'P1M' | 'P2M' | 'P3M' | 'P6M' | 'P1Y'; /** Introductory offer for new subscribers. Apple: subscriptionIntroductoryOffers (first-time subscribers of the group, * per territory). Play: an offer on the base plan for users who never had this subscription. */ export interface IntroOffer { /** free = free trial; pay-as-you-go = a lower price for `periods` billing periods; pay-up-front = one price for `duration`. */ type: 'free' | 'pay-as-you-go' | 'pay-up-front'; /** free / pay-up-front: P3D, P1W, P2W, P1M, P2M, P3M, P6M or P1Y. pay-as-you-go: the subscription period (default). */ duration?: string; /** pay-as-you-go: number of discounted periods (1-12). */ periods?: number; /** pay-as-you-go: price per period, pay-up-front: the whole price, in USD (Apple: equalized price points; Play: converted). */ usd?: number; /** Play offer id (lowercase letters, digits, hyphens). Default "intro". */ playOfferId?: string; } /** A promotional / developer-determined offer. Apple: subscriptionPromotionalOffers (offerCode = id). Play: a base * plan offer with offer tag = id and no acquisition targeting (your app decides who sees it). */ export interface SubscriptionOfferDef { /** Apple offer code / Play offer id: lowercase letters, digits, hyphens (Play rule; Apple accepts it too). */ id: string; /** Apple reference name (default: the id). */ name?: string; type: 'free' | 'pay-as-you-go' | 'pay-up-front'; /** free / pay-up-front: P3D ... P1Y; pay-as-you-go: the subscription period (default). */ duration?: string; /** pay-as-you-go: number of discounted periods (1-12). */ periods?: number; /** USD price (per period for pay-as-you-go, total for pay-up-front). */ usd?: number; } /** Apple subscription offer code campaign. Custom codes (e.g. "SPRING26") are created only from a terminal run that * confirms; one-time-use codes are generated by you in App Store Connect (or later with the API). */ export interface OfferCodeDef { /** Reference name of the offer code campaign. */ name: string; /** Who may redeem: NEW (never subscribed), EXISTING, EXPIRED. */ eligibility: ('NEW' | 'EXISTING' | 'EXPIRED')[]; /** true = stack with the introductory offer (STACK_WITH_INTRO_OFFERS), false = replace it (default). */ stackWithIntro?: boolean; type: 'free' | 'pay-as-you-go' | 'pay-up-front'; duration?: string; periods?: number; usd?: number; /** Custom codes: the code, how many redemptions, optional expiry (YYYY-MM-DD). */ customCodes?: { code: string; count: number; expires?: string }[]; } export interface SubscriptionListing { /** Apple display name (30) / Play title. */ title: string; /** Apple description (45) / Play description. */ description: string; /** Play only: up to 4 benefits shown on the purchase screen (40 characters each). */ benefits?: string[]; } export interface Subscription { /** Product id, same on both stores, e.g. "com.example.app.pro_monthly". */ id: string; period: SubscriptionPeriod; /** Rank inside the group, 1 = highest level of service (Apple groupLevel; up / downgrades follow it). */ level: number; /** Base price per period in USD. Apple: the USA price point and its equalizations; Play: convertRegionPrices. */ usd: number; /** Regions set by hand (Apple needs a price point with exactly that value). */ overrides?: PriceOverride[]; /** Language id -> display name + description (+ Play benefits). */ listings: Record; /** Apple only: internal reference name (defaults to the first listing title). */ referenceName?: string; /** Apple only: note for the reviewer on this subscription. */ reviewNote?: string; /** Apple only: review screenshot of the purchase screen (path relative to the project root). */ reviewScreenshot?: string; familySharable?: boolean; intro?: IntroOffer; /** More offers people get when your app decides (Apple promotional offers, signed by your server; Play offers * without acquisition targeting, picked by offer tag in your app). */ offers?: SubscriptionOfferDef[]; /** Apple subscription offer codes (redeemed in the App Store or in the app), with optional custom codes. */ offerCodes?: OfferCodeDef[]; /** Sell this subscription only on these platforms (default: all of app.platforms). */ platforms?: Platform[]; /** Play base plan id (lowercase letters, digits, hyphens). Default from the period: weekly, monthly, yearly, ... */ basePlanId?: string; /** Play grace period (ISO 8601: P3D, P7D, P14D, P30D). Default: Play's default. */ gracePeriod?: string; /** Apple win-back offers for lapsed subscribers (`storebound apple win-back`; the subscription must be approved). */ winBack?: WinBackDef[]; } /** One subscription group. Apple: a subscription group (people can hold one subscription per group and move between * its levels). Play has no groups: each subscription becomes a Play subscription with one auto-renewing base plan. */ export interface SubscriptionGroup { /** Apple reference name of the group (internal, unique per app). */ name: string; /** Language id -> group display name on the App Store (+ optional custom app name). */ listings: Record; subscriptions: Subscription[]; } /** Play target audience age groups (App content > Target audience and content). */ export type PlayAgeGroup = '0-5' | '6-8' | '9-12' | '13-15' | '16-17' | '18+'; export const PLAY_AGE_GROUPS: readonly PlayAgeGroup[] = ['0-5', '6-8', '9-12', '13-15', '16-17', '18+']; export const PLAY_BILLING_PROGRAMS = ['user-choice-billing', 'alternative-billing', 'external-offers', 'external-content-links'] as const; export interface ShipConfig { app: { name: string; /** game | app | subscription-app (see AppProfile). Default: game for configs written before 0.5.0. */ profile?: AppProfile; /** App identity: the platforms this one app ships on. Default: ios when there is an `apple` section, android when * there is a `play` section. `apple` / `play` hold the per-platform settings; languages, listings, products and * subscriptions are shared, with `platforms` on an item to sell it on one store only. */ platforms?: Platform[]; copyright?: string; privacyPolicyUrl: string; /** Terms of Use / EULA page. Needed with subscriptions (Apple 3.1.2: in the app and in the metadata). */ termsUrl?: string; supportUrl: string; marketingUrl?: string; website: string; contactEmail: string; }; /** Accounts in the app: account deletion checks (Apple 5.1.1(v), Play Data safety delete-account URL). */ accounts?: AccountsConfig; /** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */ reviewAccess?: ReviewAccess; languages: Language[]; play?: { packageName: string; defaultLanguage?: string; listing?: string; icon?: string; /** The Play developer account id (the digits in play.google.com/console/u/0/developers//...). Optional: with * one Google login in several developer accounts, `storebound console fill` finds the app by package name and * remembers the account (~/.storebound/console-accounts.json); this skips that search. */ developerId?: string; /** The Play Console app id (the digits in /console/u/0/developers//app//app-dashboard, shown * after Create app). Optional: `storebound console plan` deep-links the App content forms with it. */ appId?: string; /** The developer account's display name on Play Console's "Choose a developer account" page (e.g. an * organisation account). `storebound console fill play-app-record` picks the account by it when there is no * package in any account yet and no developerId; ambiguous or unknown -> it stops and lists the accounts. */ developerAccount?: string; /** The Play app name when it differs from app.name (Create app form; at most 30 characters). */ name?: string; /** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */ free?: boolean; /** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic, * at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when * tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and * `play images` never ask for them; true = missing tablet sets are errors. */ tablets?: boolean; /** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */ video?: string; /** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_" (Play Console > * Download reports > Statistics > Copy Cloud Storage URI). Used by `storebound analytics snapshot`. */ reportsBucket?: string; /** `storebound play build`: Gradle project folder, module, flavor (found automatically when unset). */ build?: PlayBuildConfig; /** Play Console > App content > Target audience and content: the age groups you select. Any group under 13 puts * the app under the Families policy (`storebound scan` checks ad SDKs, the advertising ID and location for it). */ targetAges?: PlayAgeGroup[]; /** Developer account type. New personal accounts (created after 2023-11-13) must run a closed test with at least * 12 opted-in testers for 14 days before they can apply for production (`storebound scan` reminds you). */ accountType?: 'personal' | 'organization'; /** When the Play developer account was created (YYYY-MM-DD), for the personal-account testing requirement. */ accountCreated?: string; /** Play billing programs the app is enrolled in that allow another payment method next to Play Billing for * digital goods (user choice billing, alternative billing only, external offers / external content links). */ billingPrograms?: ('user-choice-billing' | 'alternative-billing' | 'external-offers' | 'external-content-links')[]; }; apple?: { bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string; /** App Store name in the primary locale (at most 30 characters) when it differs from app.name, e.g. because the plain * name is taken on the App Store ("STAR DASH: Stone Age to 2100"). Default app.name. A `### name` block in the * App Store listing file wins for its locale. app.name stays the Play / on-device name. */ name?: string; /** Apple Developer Team ID (10 characters, Membership details). apple-app-site-association and ad-hoc export use it. */ teamId?: string; /** Ad-hoc / release-testing export (`storebound apple adhoc`). */ adhoc?: AdhocConfig; /** * Export compliance answer for every build (`storebound apple encryption`, `validate`). false ONLY when the app uses * no encryption beyond what the OS provides (HTTPS / TLS through the system, standard auth). Never set it to false * to skip the question: a wrong answer is a legal problem, not a store one. Unset = storebound will not answer. */ usesNonExemptEncryption?: boolean; /** Screenshot display types every locale must have (validate). Default ["APP_IPHONE_67"] (+ "APP_IPAD_PRO_3GEN_129" with ipad). */ screenshotTypes?: string[]; /** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */ ipad?: boolean; /** * Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61). * Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by * scaling the 6.5" set down when it is missing, and the 6.9" set covers the large displays. Turn it on for * pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.) */ iphoneMedium?: boolean; /** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */ testflight?: TfConfig; /** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */ signing?: AppleSigning; /** `storebound apple build`: workspace / project, scheme, configuration, output folder (found automatically when unset). */ build?: AppleBuildConfig; /** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */ promoted?: PromotedConfig; /** Custom product pages (`storebound apple cpp`; at most 70). */ customProductPages?: CustomProductPageDef[]; /** In-app events (`storebound apple events`). */ events?: AppEventDef[]; }; products?: Product[]; /** Auto-renewable subscriptions (`storebound apple subscriptions`, `storebound play subscriptions`). */ subscriptions?: SubscriptionGroup[]; /** Price sheet for `storebound prices export | import` (product x store x region). */ prices?: { /** Purchasing power parity proposals (`storebound prices ppp`). */ ppp?: PppConfig; /** CSV path. Default store/prices.csv. */ csv?: string; /** App Store base territory of every price schedule (alpha-3). Default: the current one, else USA. */ baseTerritory?: string; }; dataSafety?: DataSafety; /** App Store App Privacy answers storebound cannot derive from dataSafety (App Privacy has no API: the owner enters * them in App Store Connect). `storebound scan` proposes them from the detected SDKs and code. */ appPrivacy?: AppPrivacyConfig; /** `storebound scan`: findings the owner has looked at and accepts, by rule id (with the reason, shown in the report). */ scan?: { ignore?: { rule: string; store?: 'apple' | 'play'; /** Only findings whose title contains this text (one check of a rule that has several). */ match?: string; reason: string }[] }; /** Ads: `validate` checks that /app-ads.txt is reachable and contains every line listed here. */ ads?: { appAdsTxt?: string[] }; /** Rendered store graphics dir (///NN.png|jpg). Defaults to shots.out. */ renders?: string; shots?: ShotsConfig; icons?: IconsConfig; /** `storebound originality`: the owner's other apps to compare with (folders with a storebound.config.ts, or a * workspace folder holding several), and the score above which a pair is flagged. */ originality?: OriginalityConfig; /** Universal links / app links files, Smart App Banner and a landing page (`storebound web init | check`). */ web?: WebConfig; /** Optional RevenueCat project the catalog is mirrored into (`storebound revenuecat diff | sync`). */ revenuecat?: RevenueCatConfig; /** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config * cannot prove. Anything left out stays empty and highlighted in the page. */ console?: ConsoleConfig; } // ---- growth extras ---- export interface PppConfig { /** Dataset: worldbank (bundled World Bank ICP PPP factors, default), bigmac (The Economist Big Mac index, downloaded to .storebound/ by `prices ppp --update-bigmac`) or file (`file`). */ source?: 'worldbank' | 'bigmac' | 'file'; /** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */ file?: string; /** Lowest price as a share of the US price after the PPP adjustment (default 0.3). */ floor?: number; /** Highest share of the US price (default 1.2). */ ceiling?: number; /** Price sheet the proposal is written to (default store/prices-ppp.csv). */ out?: string; } export interface WebConfig { /** Domain that serves the files, e.g. "example.com" (no scheme, no path). */ domain: string; /** Paths the apps open as universal links / app links (AASA components, Android intent filter). Default ["/*"]. */ paths?: string[]; /** Paths that must stay in the browser (AASA "exclude": true components). */ exclude?: string[]; /** Folder the generated files are written to (default "web"); deploy its contents to the domain root. */ out?: string; /** Play app signing certificate SHA-256 fingerprints (AB:CD:... ; Play Console > Test and release > App signing). */ androidSha256?: string[]; /** Also list the app under "webcredentials" (password AutoFill) in apple-app-site-association. */ webcredentials?: boolean; /** Write the minimal landing page template (index.html with store badges, privacy and support links). */ landing?: boolean; } export interface AdhocConfig { /** ExportOptions.plist method: release-testing (Xcode 15.3+ name) or ad-hoc (older Xcode). Default release-testing. */ method?: 'release-testing' | 'ad-hoc'; /** Devices CSV (name, udid, platform) in .storebound/ (gitignored: device ids are personal data). Default .storebound/devices.csv. */ devices?: string; /** Provisioning profile name for manual signing (default: automatic signing). */ profile?: string; } export interface RevenueCatConfig { /** RevenueCat project id (Project settings), e.g. "proj1ab2c3d4". */ projectId: string; /** RevenueCat app ids of the App Store and Play Store apps in that project (Project settings > Apps). */ apps?: { apple?: string; play?: string }; /** Entitlement lookup key -> product / subscription ids that unlock it. Default: "pro" with every non-consumable, * non-renewing product and subscription. */ entitlements?: Record; /** Lookup key of the offering storebound keeps in sync (default "default"). */ offering?: string; } /** IARC questionnaire topics storebound knows (Play Console > Policy > App content > Content rating). */ export const IARC_QUESTIONS = ['violence', 'fear', 'sexuality', 'language', 'controlledSubstances', 'crudeHumor', 'gambling', 'simulatedGambling', 'lootBoxes', 'userInteraction', 'sharesLocation', 'digitalPurchases', 'unrestrictedInternet'] as const; export type IarcQuestion = typeof IARC_QUESTIONS[number]; /** IARC app categories (the first page of the questionnaire). */ export const RATING_CATEGORIES = ['game', 'social', 'other'] as const; /** Play target age groups (Target audience and content > Target age). */ export const AGE_GROUPS = ['5-under', '6-8', '9-12', '13-15', '16-17', '18-over'] as const; export type AgeGroup = typeof AGE_GROUPS[number]; export interface ConsoleConfig { /** App Store Connect > Apps > + > New App: the SKU (internal, never shown) and who in the team sees the app. */ appRecord?: { sku?: string; userAccess?: 'full' | 'limited' }; /** Play IARC questionnaire: the category and the answers you decided (true = Yes). */ contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial> }; /** Play Target audience and content, and the Ads declaration. */ targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean }; } const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; const SHA256_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/; /** Problems of the growth-extras sections (web, revenuecat, prices.ppp, apple.teamId / adhoc). Pure. */ export function validateExtras(cfg: Partial): string[] { const e: string[] = []; if (cfg.apple?.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)'); if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/'); if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers//app//app-dashboard'); if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100)) e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)'); if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)'); if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)'); const ad = cfg.apple?.adhoc; if (ad?.method !== undefined && ad.method !== 'release-testing' && ad.method !== 'ad-hoc') e.push('apple.adhoc.method: release-testing | ad-hoc'); const ppp = cfg.prices?.ppp; if (ppp) { if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file'); if (ppp.source === 'file' && !ppp.file) e.push('prices.ppp.file: needed with source "file"'); for (const k of ['floor', 'ceiling'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`); if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling'); } const w = cfg.web; if (w) { if (!DOMAIN_RE.test(w.domain ?? '')) e.push('web.domain: a domain name like example.com (no https://, no path)'); for (const p of [...(w.paths ?? []), ...(w.exclude ?? [])]) if (typeof p !== 'string' || !p.startsWith('/')) e.push(`web.paths: "${p}" must start with /`); for (const f of w.androidSha256 ?? []) if (!SHA256_RE.test(f)) e.push(`web.androidSha256: "${f}" is not a SHA-256 fingerprint (32 hex pairs, AB:CD:...)`); } const rc = cfg.revenuecat; if (rc) { if (!/^[A-Za-z0-9_-]{4,64}$/.test(rc.projectId ?? '')) e.push('revenuecat.projectId: the RevenueCat project id (Project settings)'); for (const [k, ids] of Object.entries(rc.entitlements ?? {})) { if (!/^[A-Za-z0-9._-]{1,200}$/.test(k)) e.push(`revenuecat.entitlements.${k}: letters, digits, ".", "_", "-"`); if (!Array.isArray(ids) || !ids.length) e.push(`revenuecat.entitlements.${k}: at least one product id`); } if (rc.offering !== undefined && !/^[A-Za-z0-9._-]{1,200}$/.test(rc.offering)) e.push('revenuecat.offering: letters, digits, ".", "_", "-"'); } if (cfg.console) e.push(...validateConsole(cfg.console)); return e; } function validateConsole(c: ConsoleConfig): string[] { const e: string[] = []; const r = c.appRecord, cr = c.contentRating, ta = c.targetAudience; if (r?.sku !== undefined && !/^[A-Za-z0-9._-]{1,100}$/.test(r.sku)) e.push('console.appRecord.sku: letters, digits, ".", "_", "-" (it can never change)'); if (r?.userAccess !== undefined && r.userAccess !== 'full' && r.userAccess !== 'limited') e.push('console.appRecord.userAccess: full | limited'); if (cr?.category !== undefined && !RATING_CATEGORIES.includes(cr.category)) e.push(`console.contentRating.category: one of ${RATING_CATEGORIES.join(', ')}`); for (const [k, v] of Object.entries(cr?.answers ?? {})) if (!(IARC_QUESTIONS as readonly string[]).includes(k) || typeof v !== 'boolean') e.push(`console.contentRating.answers.${k}: one of ${IARC_QUESTIONS.join(', ')}, true | false`); for (const a of ta?.ages ?? []) if (!AGE_GROUPS.includes(a)) e.push(`console.targetAudience.ages: "${a}" is not one of ${AGE_GROUPS.join(', ')}`); for (const k of ['appealsToChildren', 'containsAds'] as const) if (ta?.[k] !== undefined && typeof ta[k] !== 'boolean') e.push(`console.targetAudience.${k}: true | false`); return e; } /** Platforms of the app (app identity): app.platforms, else from the store sections. */ /** App Store name limit (characters). */ export const APPLE_NAME_MAX = 30; /** The App Store name in the primary locale: apple.name, else app.name. Per-locale `### name` blocks win over it. */ export const appleName = (c: Pick): string => c.apple?.name?.trim() || c.app.name; export const platformsOf = (c: Pick): Platform[] => c.app.platforms ?? [...(c.apple ? ['ios' as const] : []), ...(c.play ? ['android' as const] : [])]; /** Is a catalog item (product / subscription) sold on this store? Pure. */ export const sellsOn = (c: Pick, item: { platforms?: Platform[] }, store: 'apple' | 'play'): boolean => { const p: Platform = store === 'apple' ? 'ios' : 'android'; return platformsOf(c).includes(p) && (!item.platforms || item.platforms.includes(p)); }; /** The profile of a config: app.profile, else "game" (configs written before profiles existed were games). */ export const profileOf = (c: Pick): AppProfile => c.app.profile ?? 'game'; /** Screenshot style: shots.style, else app for the app / subscription-app profiles, else game. */ export const shotsStyle = (c: Pick): 'game' | 'app' => c.shots?.style ?? (profileOf(c) === 'game' ? 'game' : 'app'); /** Identity helper for typed configs: `export default defineConfig({...})`. */ export const defineConfig = (c: ShipConfig): ShipConfig => c; const ID_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/; const PRODUCT_RE = /^[A-Za-z0-9._]+$/; const URL_RE = /^https:\/\/\S+$/; /** Every problem with a config, empty when it is fine. Pure (no IO). */ export function validateConfig(c: unknown): string[] { const e: string[] = []; const cfg = c as Partial; if (!cfg || typeof cfg !== 'object') return ['config must be an object']; const app = cfg.app; if (!app) e.push('app: missing'); else { if (!app.name) e.push('app.name: missing'); for (const k of ['privacyPolicyUrl', 'supportUrl', 'website'] as const) if (!URL_RE.test(app[k] ?? '')) e.push(`app.${k}: must be an https URL`); if (app.marketingUrl && !URL_RE.test(app.marketingUrl)) e.push('app.marketingUrl: must be an https URL'); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(app.contactEmail ?? '')) e.push('app.contactEmail: not an email address'); if (app.termsUrl && !URL_RE.test(app.termsUrl)) e.push('app.termsUrl: must be an https URL'); if (app.profile !== undefined && !PROFILES.includes(app.profile)) e.push(`app.profile: one of ${PROFILES.join(', ')}`); if (app.platforms !== undefined) { if (!Array.isArray(app.platforms) || !app.platforms.length || !app.platforms.every(x => x === 'ios' || x === 'android')) e.push('app.platforms: a list of ios | android'); else { if (app.platforms.includes('ios') && !cfg.apple) e.push('app.platforms: ios needs an apple section'); if (app.platforms.includes('android') && !cfg.play) e.push('app.platforms: android needs a play section'); } } } if (cfg.accounts) { if (typeof cfg.accounts.enabled !== 'boolean') e.push('accounts.enabled: true | false'); if (cfg.accounts.deletionUrl !== undefined && !URL_RE.test(cfg.accounts.deletionUrl)) e.push('accounts.deletionUrl: must be an https URL'); if (cfg.accounts.signUp !== undefined && (!Array.isArray(cfg.accounts.signUp) || cfg.accounts.signUp.some(x => !ACCOUNT_SIGN_UPS.includes(x)))) e.push(`accounts.signUp: a list of ${ACCOUNT_SIGN_UPS.map(x => `'${x}'`).join(' | ')}`); } if (cfg.reviewAccess && typeof cfg.reviewAccess.signInRequired !== 'boolean') e.push('reviewAccess.signInRequired: true | false'); const langs = cfg.languages; if (!Array.isArray(langs) || !langs.length) e.push('languages: at least one language'); const ids = new Set(); for (const [i, l] of (langs ?? []).entries()) { if (!l.id) e.push(`languages[${i}].id: missing`); if (ids.has(l.id)) e.push(`languages[${i}].id: duplicate "${l.id}"`); ids.add(l.id); } if (cfg.play && !ID_RE.test(cfg.play.packageName ?? '')) e.push('play.packageName: not a package name (com.example.game)'); if (cfg.play?.video && !/^https:\/\/(www\.youtube\.com\/watch\?v=[\w-]{6,}|youtu\.be\/[\w-]{6,})$/.test(cfg.play.video)) e.push('play.video: a plain YouTube video URL (https://www.youtube.com/watch?v=... or https://youtu.be/...), no playlist, channel or extra parameters'); e.push(...validatePlayBuild(cfg.play?.build), ...validateOriginality(cfg.originality)); if (cfg.play?.reportsBucket && !/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(cfg.play.reportsBucket)) e.push('play.reportsBucket: the Play reports bucket, e.g. "pubsite_prod_rev_" (Play Console > Download reports > Copy Cloud Storage URI)'); if (cfg.play?.targetAges !== undefined && !(Array.isArray(cfg.play.targetAges) && cfg.play.targetAges.length && cfg.play.targetAges.every(a => PLAY_AGE_GROUPS.includes(a)))) e.push(`play.targetAges: a list of ${PLAY_AGE_GROUPS.join(' | ')}`); if (cfg.play?.accountType !== undefined && cfg.play.accountType !== 'personal' && cfg.play.accountType !== 'organization') e.push('play.accountType: personal | organization'); if (cfg.play?.accountCreated !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(cfg.play.accountCreated)) e.push('play.accountCreated: a date, YYYY-MM-DD'); if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b)))) e.push(`play.billingPrograms: a list of ${PLAY_BILLING_PROGRAMS.join(' | ')}`); if (cfg.apple) { if (!ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)'); const an = cfg.apple.name; if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name'); else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`); if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push('apple.appId: numeric Apple ID of the app (App Store Connect > App Information)'); const enc = cfg.apple.usesNonExemptEncryption; if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false'); if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67'); e.push(...validateTestflight(cfg.apple.testflight)); e.push(...validateSigning(cfg.apple.signing), ...validateAppleBuild(cfg.apple.build)); const catalogIds = new Set([...(cfg.products ?? []).map(p => p.id), ...(cfg.subscriptions ?? []).flatMap(g => (g.subscriptions ?? []).map(x => x.id))]); e.push(...validatePromoted(cfg.apple.promoted, catalogIds)); e.push(...validateCpp(cfg.apple.customProductPages, ids, Object.keys(cfg.shots?.variants ?? {}))); e.push(...validateEvents(cfg.apple.events, ids)); } if (cfg.appPrivacy) { if (cfg.appPrivacy.tracking !== undefined && typeof cfg.appPrivacy.tracking !== 'boolean') e.push('appPrivacy.tracking: true | false'); if (cfg.appPrivacy.trackingDomains && !(Array.isArray(cfg.appPrivacy.trackingDomains) && cfg.appPrivacy.trackingDomains.every(d => typeof d === 'string' && /^[a-z0-9.-]+$/i.test(d)))) e.push('appPrivacy.trackingDomains: a list of host names'); e.push(...validateAppPrivacyTypes(cfg.appPrivacy.types)); } for (const [i, x] of (cfg.scan?.ignore ?? []).entries()) if (!x || typeof x.rule !== 'string' || !x.rule || typeof x.reason !== 'string' || !x.reason.trim() || (x.store !== undefined && x.store !== 'apple' && x.store !== 'play') || (x.match !== undefined && (typeof x.match !== 'string' || !x.match.trim()))) e.push(`scan.ignore[${i}]: { rule: '', reason: 'why this is fine', store?: 'apple' | 'play', match?: '' }`); if (cfg.ads?.appAdsTxt && !cfg.ads.appAdsTxt.every(l => typeof l === 'string' && l.split(',').length >= 3)) e.push('ads.appAdsTxt: lines like "example-network.com, pub-123, DIRECT, abc123"'); const seen = new Set(); for (const [i, p] of (cfg.products ?? []).entries()) { const at = `products[${i}]`; if (!PRODUCT_RE.test(p.id ?? '')) e.push(`${at}.id: letters, digits, "." and "_" only`); if (seen.has(p.id)) e.push(`${at}.id: duplicate "${p.id}"`); seen.add(p.id); if (p.type !== 'consumable' && p.type !== 'non_consumable' && p.type !== 'non_renewing') e.push(`${at}.type: consumable | non_consumable | non_renewing`); if (p.platforms !== undefined && !(Array.isArray(p.platforms) && p.platforms.length && p.platforms.every(x => x === 'ios' || x === 'android'))) e.push(`${at}.platforms: a list of ios | android`); if (!(typeof p.usd === 'number' && p.usd > 0)) e.push(`${at}.usd: positive number`); if (!p.listings || !Object.keys(p.listings).length) e.push(`${at}.listings: at least one language`); for (const [lang, l] of Object.entries(p.listings ?? {})) { if (!ids.has(lang)) e.push(`${at}.listings.${lang}: not in languages`); if (!l.title) e.push(`${at}.listings.${lang}.title: missing`); if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`); if (!l.description) e.push(`${at}.listings.${lang}.description: missing`); } e.push(...validateIapOfferCodes(at, p.offerCodes, p.type)); for (const o of p.overrides ?? []) { if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`); if (!/^[A-Z]{3}$/.test(o.currency)) e.push(`${at}.overrides: currency "${o.currency}" must be ISO 4217 (TRY)`); if (!(o.price > 0)) e.push(`${at}.overrides: price must be positive`); } } e.push(...validateSubscriptions(cfg.subscriptions, ids, seen)); for (const [gi, g] of (cfg.subscriptions ?? []).entries()) for (const [si, sub] of (g.subscriptions ?? []).entries()) e.push(...validateWinBack(`subscriptions[${gi}].subscriptions[${si}]`, sub.winBack)); if (cfg.prices?.baseTerritory && !/^[A-Z]{3}$/.test(cfg.prices.baseTerritory)) e.push('prices.baseTerritory: App Store territory, alpha-3 (USA)'); if (cfg.dataSafety) { for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) { if (!/^PSL_[A-Z_]+$/.test(t)) e.push(`dataSafety.data.${t}: use the Play type id (PSL_DEVICE_ID, ...)`); if (!u.collect?.length) e.push(`dataSafety.data.${t}: at least one collection purpose`); if (u.shared && !u.share?.length) e.push(`dataSafety.data.${t}: shared, so it needs sharing purposes`); } for (const [i, x] of (cfg.dataSafety.extra ?? []).entries()) if (!/^[A-Z][A-Z0-9_:]+$/.test(x?.question ?? '') || typeof x.value !== 'string') e.push(`dataSafety.extra[${i}]: { question: "", value: "..." }`); } if (cfg.shots) { const s = cfg.shots; for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`); if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app'); if (!s.scenes?.length) e.push('shots.scenes: at least one scene'); const fontKeys: [string, unknown][] = [['title', s.fonts?.title], ['body', s.fonts?.body], ...(Array.isArray(s.fonts?.fallback) ? s.fonts!.fallback! : s.fonts?.fallback !== undefined ? [s.fonts.fallback] : []).map((f, i) => [`fallback[${i}]`, f] as [string, unknown])]; if (s.fonts?.fallback !== undefined && !Array.isArray(s.fonts.fallback)) e.push('shots.fonts.fallback: a list'); for (const [k, f] of fontKeys) { if (f === undefined) continue; if (typeof f === 'string') { if (!f.trim()) e.push(`shots.fonts.${k}: empty`); else if (/[\\/]|\.[a-z0-9]{2,5}$/i.test(f) && !/\.(ttf|otf|woff2?)$/i.test(f)) e.push(`shots.fonts.${k}: ${f}: a font file must be TTF, OTF, WOFF or WOFF2`); continue; } const o = f as Partial; if (!o || typeof o !== 'object' || typeof o.file !== 'string') { e.push(`shots.fonts.${k}: a Google Fonts family, a font file path or { family, file, weight?, style?, license? }`); continue; } if (!/\.(ttf|otf|woff2?)$/i.test(o.file)) e.push(`shots.fonts.${k}.file: ${o.file}: TTF, OTF, WOFF or WOFF2`); if (o.family !== undefined && (typeof o.family !== 'string' || !o.family.trim())) e.push(`shots.fonts.${k}.family: a name`); if (o.style !== undefined && o.style !== 'normal' && o.style !== 'italic') e.push(`shots.fonts.${k}.style: normal | italic`); if (o.weight !== undefined && !/^\d{3}( \d{3})?$/.test(String(o.weight))) e.push(`shots.fonts.${k}.weight: e.g. 400, 800 or "100 900"`); if (o.license !== undefined && typeof o.license !== 'string') e.push(`shots.fonts.${k}.license: text ('OFL-1.1', 'proprietary', ...)`); } for (const sc of s.scenes ?? []) if (!sc.frames?.length) e.push(`shots.scenes[${sc.id}].frames: at least one frame`); e.push(...validateCapture(s.capture)); const sceneIds = new Set((s.scenes ?? []).map(x => x.id)); for (const [k, v] of Object.entries(s.variants ?? {})) { if (!/^[a-z0-9][a-z0-9-]*$/.test(k)) e.push(`shots.variants.${k}: lowercase letters, digits, "-" (it is a folder name)`); if (cfg.languages?.some(l => l.id === k)) e.push(`shots.variants.${k}: same name as a language id`); for (const id of v.scenes ?? []) if (!sceneIds.has(id)) e.push(`shots.variants.${k}.scenes: unknown scene "${id}"`); for (const id of Object.keys(v.palettes ?? {})) if (!sceneIds.has(id)) e.push(`shots.variants.${k}.palettes: unknown scene "${id}"`); } if (s.video) { const v = s.video; if (v.seconds !== undefined && !(v.seconds >= 15 && v.seconds <= 30)) e.push('shots.video.seconds: 15-30 (App Store app preview length)'); for (const t of v.targets ?? []) if (!['iphone', 'ipad', 'youtube', 'youtube-landscape'].includes(t)) e.push(`shots.video.targets: unknown "${t}" (iphone, ipad, youtube, youtube-landscape)`); if (v.fit && !['cover', 'contain'].includes(v.fit)) e.push('shots.video.fit: cover | contain'); for (const id of v.captionScenes ?? []) if (!sceneIds.has(id)) e.push(`shots.video.captionScenes: unknown scene "${id}"`); if (v.record) e.push(...validateRecord(v.record, s.capture)); } } e.push(...validateExtras(cfg)); if (cfg.icons) { if (!cfg.icons.composite) e.push('icons.composite: missing'); if (!cfg.icons.out) e.push('icons.out: missing'); if (cfg.icons.androidRes && !(cfg.icons.foreground && cfg.icons.background)) e.push('icons.androidRes needs icons.foreground + icons.background'); } return e; } export interface Loaded { config: ShipConfig; root: string; path: string; resolve(p: string): string } export const CONFIG_NAMES = ['storebound.config.ts', 'storebound.config.js', 'storebound.config.json']; /** Finds storebound.config.* from `start` upwards (or uses `explicit`), imports and validates it. */ export async function loadConfig(explicit?: string, start = process.cwd()): Promise { let path = explicit ? resolve(explicit) : ''; if (!path) { for (let d = resolve(start); ; d = dirname(d)) { const hit = CONFIG_NAMES.map(n => join(d, n)).find(existsSync); if (hit) { path = hit; break; } if (dirname(d) === d) break; } } if (!path || !existsSync(path)) throw new Error('no storebound.config.ts found (run `storebound init` or pass --config)'); const mod = await import(path); const config = (mod.default ?? mod) as ShipConfig; const errors = validateConfig(config); if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n ')); const root = dirname(path); return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) }; } /** Play language codes of a language entry. */ export const playCodes = (l: Language): string[] => (l.play === undefined ? [] : Array.isArray(l.play) ? l.play : [l.play]);