storebound/cli/lib/config.ts

1410 lines
95 KiB
TypeScript

// 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 { type GamesConfig, validateGames } from './game-services';
import { consoleIdsFromConfig, consoleLink, setConsoleIds } from './console-links';
import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
import { PROFILE_RE, setConfigAccount } from './credentials';
import { playIdsOf } from './play-ids';
import { validateCapture, validateRecord } from '../shots/devices';
import { featureBackgroundIssues, paletteIssues } from '../shots/palette';
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';
import { validateReleaseType } from './release-type';
/** 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<string, ProductListing>;
/** 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[];
/** The purchase gives a random reward (loot box, gacha pull, mystery pack). The Play IARC questionnaire asks it
* (Digital Purchases > random items): any true answers it Yes, every product false answers it No. Brazil's ClassInd
* rates such games 18 (seen on a real run, 2026-10-12). */
randomRewards?: boolean;
}
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;
/** "Do you provide a way for users to request that their data is deleted?" under the form's old id; now written as
* PSL_SUPPORT_DATA_DELETION_BY_USER (true = yes, false = no). dataDeletion and a delete-account URL take precedence. */
deletionRequest?: boolean;
/** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */
accountCreation?: string[];
/**
* Play "Do you provide a way for users to request that their data is deleted?" (PSL_SUPPORT_DATA_DELETION_BY_USER, a
* single choice Play asks once accountCreation is not PSL_ACM_NONE). It is the general question: a delete-account
* page (accounts.deletionUrl) answers it with yes, and that is the default. 'no' / 'auto90' (no, but data is deleted
* automatically within 90 days) only for an app without one.
*/
dataDeletion?: 'yes' | 'no' | 'auto90';
/**
* Whether people can delete some or all of their data without deleting the account. true: partialDeletionUrl (default
* accounts.deletionUrl) is the "Delete data URL". false: the delete-account page is given as that URL. Older configs:
* 'auto90' means dataDeletion 'auto90'.
*/
partialDeletion?: boolean | 'auto90';
/** Web page for that data-only deletion (PSL_DATA_DELETION_URL, required when partialDeletion is true). Default:
* accounts.deletionUrl, when that page also lets people delete data without deleting the account. */
partialDeletionUrl?: string;
/** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */
data: Record<string, DataUsage>;
/**
* Extra CSV rows for questions storebound does not model, appended as they are; a row with the same question id as
* a generated row replaces that generated question (every response row of it). Google does not publish every
* question id: export the form from Play Console (App content > Data safety > Export to CSV), copy the exact ids
* from it, e.g. { question: '<id from the export>', value: '...' }. The delete-account URL is generated from
* accounts.deletionUrl (PSL_ACCOUNT_DELETION_URL). Every (question, response) pair must be in Play's current form
* (cli/data/play-data-safety-form.csv); `check` and `play data-safety --dry` report one that is not.
*/
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 }
/** bg: gradient stops (2-3 colours), one colour (flat) or a CSS gradient string. image: a photo background (path
* relative to the config, cropped to cover) under a dark overlay (0-0.9, default 0.45) that keeps white ink legible. */
export interface Palette { bg: string | [string, string] | [string, string, string]; glow: string; ink: string; shadow: string; image?: string; overlay?: number }
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number
/** layout "bleed": the corner the caption sits in (top / bottom, left / right). Default "tl". */
corner?: 'tl' | 'tr' | 'bl' | 'br';
/** layout "bleed": caption size multiplier (0.6-1.4, default 1). */
captionScale?: number;
/** layout "bleed": the caption's maximum width as a share of the image width (0.2-0.9; default 0.4 landscape,
* 0.84 portrait). Lines that do not fit shrink, down to 60 %. */
captionWidth?: number;
/** layout "bleed": the caption's distance from its corner as a share of the short side (0.01-0.15, default 0.05). */
captionInset?: 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';
/** "device" (default): the frames inside device outlines on the style's background, titles above. "bleed": the
* frame fills the whole image (no device, no background) and the scene's title is a short caption in one corner
* (scene.corner), legible through a text shadow and a soft glow only. Bleed frames are captured per size at the
* size's own pixels: <frames>/<languageId>/<size>/<name>.png (feature: <frames>/<languageId>/feature/<name>);
* that includes the mac and chromebook sizes. */
layout?: 'device' | 'bleed';
/** "landscape" (only with layout "bleed"): every portrait screenshot size is rendered landscape (width and height
* swapped; the feature graphic, mac and chromebook are landscape anyway). Both stores accept landscape
* screenshots. Default "portrait". */
orientation?: 'portrait' | 'landscape';
/** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */
copy: string;
/** Raw frames: <frames>/<languageId>/<phone|tablet>/<name>.png */
frames: string;
/** true: every language needs its own frames (<frames>/<lang>/...). Default false: a language with copy but no frames
* folder uses the default language's frames (play.defaultLanguage, else the first language); `shots check` / `render`
* say so as an info line. */
perLanguageFrames?: boolean;
/** Output: <out>/<languageId>/<size>/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[];
/** Play feature graphic (1024 x 500): `frame` in the phone, `palette`. `fit` 'contain' (default) sizes the phone so
* its rotated outline stays inside the canvas with a 24 px margin; 'bleed' keeps the larger phone cropped by the
* bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width
* (still clamped to the canvas with 'contain'). */
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number;
/** Degrees added to the phone's tilt (layout "bleed": the frame image is turned), like a scene frame's `rotate`: -90 stands a
* landscape game frame upright in a phone lying on its side. Default 0. */
rotate?: number;
/** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */
title?: string;
/** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the
* palette's image. Also usable for the App Store Header and Search Results art. */
background?: string | { image: string; overlay?: number } };
/** 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 };
/** Landscape layout (Mac, Chromebook, and the Play tablet slots with playTablets): copy on one side, the frame on the
* other. Frames: <frames>/<lang>/landscape/<name>.png at `size` CSS px (default 1440 x 900 @2x = 2880 x 1800), made by
* `shots capture --device landscape` (web source: a desktop viewport, no touch, no status bar crop; ios-sim / android-emu
* have no landscape device, use a web build or a folder). frame: 'window' (a
* Mac window around the game, the default for mac) or 'none' (the bare frame, the default for Play sizes). side:
* where the copy goes (default 'left'). playTablets: render tab7 / tab10 landscape (1920 x 1080 / 2560 x 1440). */
landscape?: { frame?: 'window' | 'none'; side?: 'left' | 'right'; playTablets?: boolean; size?: { w: number; h: number; dpr?: 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 <out>/<variant>/<lang>/... (`shots render --variant b`). */
variants?: Record<string, ShotVariant>;
/** 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`. */
/** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data
* attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */
export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; landscape?: string | string[]; any?: string | string[] };
export interface ShotSpec {
/** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */
waitSelector?: ShotSelector;
/** Web: element(s) to click after waitSelector, in order (a list of selectors is ONE click with fallbacks; use
* [{...}, {...}] objects for several clicks). */
click?: ShotSelector | ShotSelector[];
/** 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[];
/** Which frames this shot makes: phone (default), tablet, both (phone + tablet), landscape (<lang>/landscape/<name>.png at
* shots.landscape.size, web source only). When the project renders a landscape size (apple.mac, play.chromebook,
* shots.landscape.playTablets), shots without a device and 'both' shots also make a landscape frame. */
device?: 'phone' | 'tablet' | 'both' | 'landscape';
/** 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<string, string | number | boolean>;
}
export type { CaptureAuth } from '../shots/auth';
import type { CaptureAuth } from '../shots/auth';
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): <dir>/<lang>/<phone|tablet>/<name>.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[];
/** Web: sign in before the shots (apps whose key screens are behind a login; use a demo account on a local or demo
* backend with fabricated data). { request: { url, method, headers, body } } with "${cred:NAME}" / "${env:NAME}"
* references (never literal secrets), or { storageState: ".storebound/auth.json" } from `storebound shots login`. */
auth?: CaptureAuth;
shots: Record<string, ShotSpec>;
}
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<string, string | Palette>;
}
export interface VideoConfig {
/** Gameplay clips: <clips>/<lang>/*.mp4|mov, or <clips>/*.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 <clips>/<lang>/NN-<name>.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<string, ClipSpec>;
}
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<string, string | number | boolean>;
/** 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<string, string | boolean | null>;
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 TemplateArea = 'dataSafety' | 'appPrivacy' | 'reviewNotes' | 'ageRating' | 'accounts' | 'reviewAccess';
export const TEMPLATE_AREAS: readonly TemplateArea[] = ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'];
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 a privacy manifest that declares it:
* the SDK's own manifest when an SDK tracks (ad network, mediation, attribution), the app's only with trackingDomains. */
export interface AppPrivacyConfig {
tracking?: boolean;
/** Domains the app's OWN code sends tracking data to: the app manifest's NSPrivacyTrackingDomains. The app manifest
* says NSPrivacyTracking true only with this non-empty list (true with an empty list is ITMS-91064 at upload);
* SDK domains stay in each SDK's manifest. Usually unset. */
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<Record<AppleDataTypeId, AppleTypeAnswer>>;
}
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<string, SubscriptionListing>;
/** 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<string, { name: string; customAppName?: string }>;
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;
};
/** The credential profile (~/.storebound/credentials/<name>.json: the Apple team key and Play service account,
* shared by every project and the desktop app) this app's store commands use. Default: the only profile on this
* machine, or the one named "default". `--account <name>` overrides it for one run. A name, never a secret. */
account?: 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;
/** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's
* Miscellaneous > user interaction questions (`console plan play-content-rating`). */
social?: SocialConfig;
/** Game Center and Play Games Services: the switches per store and ONE list of leaderboards and achievements mapped
* to both (`storebound apple game-center`, `storebound play games`; game-services skill). */
games?: GamesConfig;
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/<id>/...). 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/<developerId>/app/<appId>/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 Console > Store presence > Store settings > App category, by Play's English name ("Strategy", "Puzzle",
* "Tools"; PLAY_GAME_CATEGORIES / PLAY_APP_CATEGORIES). The Play Developer API has no category field (`play
* listing` cannot set it): an agent sets it with `storebound console plan play-store-settings`. Default: mapped
* from apple.info.categories when there is an exact match (GAMES_STRATEGY -> Strategy). */
category?: string;
/** Countries / regions of the production track (ISO 3166-1 alpha-2, e.g. ["US", "GB", "TR"]). Default: all. The API's
* edits.countryAvailability is read-only, so an agent sets it in Play Console with `storebound console plan
* play-production-countries` before the first production release; `validate` reports a track with 0 countries. */
countries?: string[];
/** Play Console > Publishing overview > Managed publishing, which only the console sets (no API). true: approved
* changes wait until the owner publishes them. Off by default on Play: a release goes live automatically as soon as
* Google approves it, and `play release` / the release plan say so. Record here what the console shows. */
managedPublishing?: 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;
/** Also render Chromebook screenshots (size key `chromebook`, 1920 x 1080, landscape layout). The Play API has no
* Chromebook image type: add them in the Play Console's main store listing (play images says so). */
chromebook?: 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_<number>" (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;
/** Shell command `play build` runs first in the project root; overrides build.prebuild for Play. */
prebuild?: string;
/** 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?: {
/** Absent / null only in a store-only config with apple.appId (analytics and reports reads); every write needs it. */
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;
/** How an approved version goes live: "afterApproval" (released as soon as App Review approves it), "manual" (waits in
* Pending Developer Release for `apple release --manual-release`) or "scheduled" (not before earliestReleaseDate).
* Applied by `apple listing` when it creates a version and by `apple submit` before submitting (old -> new in the dry
* run); `apple release --after-approval | --manual` changes it on an editable version. Unset: new versions are MANUAL
* and existing ones are left as they are. */
releaseType?: 'afterApproval' | 'manual' | 'scheduled';
/** releaseType "scheduled": the earliest release, ISO 8601 date and time (2027-03-01T09:00:00Z). */
earliestReleaseDate?: string;
/**
* 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;
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
iphoneDuo?: boolean;
/** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded
* as APP_DESKTOP to the macOS version: `apple screenshots --platform macos`. */
mac?: boolean;
/**
* macOS App Store overrides (not `mac`, which only switches Mac screenshots on). An app with an iOS and a macOS
* version often needs different version texts and review notes (the Mac app may have no Game Center, in-app
* purchases or ads). `listing`: an App Store listing file used instead of apple.listing for `apple listing
* --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`.
* `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`).
* `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts).
* `entitlements`: the Mac App Store entitlements plist of `apple build --platform macos --tauri` (e.g. "src-tauri/Entitlements.appstore.plist"),
* used instead of the Tauri config's bundle.macOS.entitlements (a project with a Developer ID channel keeps that one there); `--entitlements <plist>` wins over it.
*/
macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean; entitlements?: string };
/** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under
* <renders>/<lang>/creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to
* 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a
* universal PNG for every locale without own files. Optional: without them the App Store uses events, previews and
* screenshots. Play: the feature graphic and the promo video are the closest equivalents. */
creativeAssets?: { header?: boolean; search?: boolean; universal?: string };
/** 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;
/** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */
prebuild?: string;
/** `apple build --platform macos`: shell command run on the built .app before productbuild ({file} = the .app path);
* a nonzero exit stops the build, nothing is packaged. E.g. a project's own check that a build-time value is in the binary. */
macAppCheck?: string;
/** 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;
};
/**
* Compliance answers that are still the template's (`storebound init`) or not confirmed by the owner yet:
* 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'. While an area is listed, check
* and validate report a blocker and nothing that sends it (play data-safety, apple review-details / appinfo, console
* plan / fill) runs. Remove an entry once its answers are the owner's (derived from the code: `storebound scan
* --write`, `storebound onboard --declarations`). `[]` = everything answered. Left out (configs before 0.8.4): the
* template answers are recognised by their values (cli/lib/template-answers.ts).
*/
unanswered?: TemplateArea[];
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), case-insensitive. */
match?: string;
/** Only findings with an evidence line ("file:line text", as the report prints it) containing this text, e.g.
* "src/main.ts:12" or a code fragment. Combine with match to narrow further. */
evidence?: string;
reason: string;
}[];
};
/** Ads: `validate` checks that <app.website>/app-ads.txt is reachable and contains every line listed here. */
ads?: { appAdsTxt?: string[] };
/** Rendered store graphics dir (<renders>/<languageId>/<size>/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;
/** Listing text rules for both stores. */
listing?: {
/** Functional mentions of another platform or store that may stay in a store's text, e.g. a "Continue with
* Google" button in App Store text or "Sign in with Apple" in Play text: `{ store: 'apple' | 'play' | 'both',
* text: 'Sign in with Apple', reason: 'the sign-in button label' }`. A line that contains `text` (any case) keeps
* the terms inside it. Every other mention of Android / Google Play in App Store text (Guideline 2.3.10) or of
* iOS / App Store in Play text (Metadata policy) stops the write. The owner adds entries; storebound never does. */
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: string }[];
};
/** Release notes (`storebound whats-new`). Lines tagged `[apple]` or `[play]` (or a `[play]` line heading a block
* until the next blank line) go only into that store's notes. */
whatsNew?: {
/** Player-facing notes file the draft comes from before the git log (a CHANGELOG-like file gives the version's
* section), e.g. "docs/player-notes.md". Without it: docs/RELEASE-<version>.md, then CHANGELOG.md. */
source?: string;
/** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */
allowWords?: string[];
};
/** Uploads (`apple upload`, `play upload`). */
upload?: {
/** Shell command run before any byte is sent; `{file}` is the IPA / AAB path. A nonzero exit refuses the upload
* (ship-step error code PRE_CHECK_FAILED). `--pre-check` overrides it. */
preCheck?: string;
/** Where the pre-check evidence JSON (file, sha256, size, command, exit code, stdout tail, time, storebound
* version) is written. `--evidence` overrides it. */
evidence?: string;
};
/** Native builds (`apple build`, `play build`). */
build?: {
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a
* Capacitor app (the native build ships the web assets cap sync copied last). Printed, output streamed, a nonzero
* exit stops the build; `--no-prebuild` skips it; inside one `flow run` it runs once, not per store.
* apple.prebuild / play.prebuild override it per store. */
prebuild?: string;
};
}
// ---- 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 store's US-equivalent price (default 0.3). */
minRatio?: number;
/** Highest share of the US-equivalent price (default 1: never above it). */
maxRatio?: number;
/** Only economies whose price level (PPP factor / exchange rate) is at most this share of the US get a PPP price (default 0.5). */
maxLevel?: number;
/** Never propose a price above the current store price (default true). */
onlyDecreases?: boolean;
/** World Bank high-income economies get PPP prices too (default false: they keep the US-equivalent price). */
includeHighIncome?: boolean;
/** Old name of minRatio. */
floor?: number;
/** Old name of maxRatio. */
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<string, string[]>;
/** 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];
/** Users interacting with each other (Play IARC Miscellaneous; see ShipConfig.social). */
export interface SocialConfig {
/** Users can chat, message, post or share content with each other (in-game chat, comments, shared levels). */
interaction: boolean;
/** Users can block other users. */
block?: boolean;
/** Users can report other users or their content. */
report?: boolean;
/** Chat or shared content is moderated (people or filters). */
moderation?: boolean;
/** Interaction is limited to friends the user accepted (no strangers). */
friendsOnly?: boolean;
/** The app shares the user's location with other users. */
sharesLocation?: boolean;
}
/** A follow-up the plan does not model, keyed by the question text as the console shows it: true / false (Yes / No
* or tick / untick), a radio option label, or the labels of the checkboxes to tick. */
export type IarcOther = Record<string, boolean | string | string[]>;
/**
* Follow-up questions of the Play IARC questionnaire (one long page: each Yes reveals its follow-ups; recorded on a
* real run 2026-10-12). Asked only when the section's answer is Yes (Miscellaneous always). Option labels are the
* console's own words (the agent matches them by "starts with"). Anything left out is asked of the owner in the one
* batch at the start (`storebound console plan play-content-rating`).
*/
export interface IarcDetails {
violence?: {
/** Violence against humans / human-like characters (checkbox). */
humans?: boolean;
/** Violence against non-humans: fantasy creatures, animals, robots (checkbox). */
nonHumans?: boolean;
/** The setting option as the console labels it (a fantasy or a realistic setting ...). */
setting?: string;
/** Cartoon / childlike art style. */
childlike?: boolean;
/** The reaction checkboxes to tick (how characters react to harm), console labels ([] = none). */
reactions?: string[];
/** The presentation checkboxes to tick (how violence is shown), console labels ([] = none). */
presentation?: string[];
/** The blood level option, console label. */
blood?: string;
/** Set in a historical war. */
historicalWar?: boolean;
/** Violence against innocent / defenseless characters. */
innocents?: boolean;
/** Fierce or realistic sounds of violence. */
fierceSounds?: boolean;
other?: IarcOther;
};
fear?: { other?: IarcOther };
sexuality?: { other?: IarcOther };
gambling?: { other?: IarcOther };
language?: { other?: IarcOther };
controlledSubstances?: { other?: IarcOther };
crudeHumor?: { other?: IarcOther };
/** Digital Purchases, after "purchases of digital goods" Yes: random items (default from products[].randomRewards),
* then trading between players (default from social). */
digitalPurchases?: { lootBoxes?: boolean; playerTrading?: boolean; other?: IarcOther };
/** Miscellaneous > user interaction follow-ups (default from `social`). */
userInteraction?: { block?: boolean; report?: boolean; moderation?: boolean; friendsOnly?: boolean; other?: IarcOther };
/** The other Miscellaneous questions (always asked). */
miscellaneous?: { naziSymbols?: boolean; koreaHistory?: boolean; terrorism?: boolean; crimes?: boolean; other?: IarcOther };
}
export const IARC_DETAIL_SECTIONS = ['violence', 'fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor', 'digitalPurchases', 'userInteraction', 'miscellaneous'] as const;
export type IarcDetailSection = typeof IARC_DETAIL_SECTIONS[number];
/** Every key a details section may have and its kind (validation and the answer sheet); `other` is allowed everywhere. */
export const IARC_DETAIL_KEYS: Record<IarcDetailSection, Record<string, 'boolean' | 'label' | 'labels'>> = {
violence: { humans: 'boolean', nonHumans: 'boolean', setting: 'label', childlike: 'boolean', reactions: 'labels', presentation: 'labels', blood: 'label', historicalWar: 'boolean', innocents: 'boolean', fierceSounds: 'boolean' },
fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {},
digitalPurchases: { lootBoxes: 'boolean', playerTrading: 'boolean' },
userInteraction: { block: 'boolean', report: 'boolean', moderation: 'boolean', friendsOnly: 'boolean' },
miscellaneous: { naziSymbols: 'boolean', koreaHistory: 'boolean', terrorism: 'boolean', crimes: 'boolean' },
};
/** Play Console > Policy and programs > App content declarations without a form of their own in ConsoleConfig
* (`storebound console plan play-app-content` lists every card). */
export interface AppContentConfig {
/** Government apps: developed by or on behalf of a government. */
government?: boolean;
/** Financial features: false = "My app doesn't provide any financial features"; else the console labels to tick. */
financialFeatures?: false | string[];
/** Health apps: false = no health features; else the console labels to tick. */
healthFeatures?: false | string[];
/** Advertising ID: the app (or an SDK in it) uses the Android advertising ID. Default: yes with ads.appAdsTxt or a
* dataSafety Device or other IDs entry. */
advertisingId?: boolean;
}
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, the answers you decided (true = Yes) and their follow-ups (details). */
contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial<Record<IarcQuestion, boolean>>; details?: IarcDetails };
/** Play Target audience and content, and the Ads declaration. */
targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean };
/** Play App content declarations: government apps, financial features, health apps, advertising ID. */
appContent?: AppContentConfig;
}
export { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
/** App Store category ids with an exact Play counterpart. */
const APPLE_TO_PLAY: Record<string, string> = {
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',
GAMES_EDUCATIONAL: 'Educational', GAMES_MUSIC: 'Music', GAMES_PUZZLE: 'Puzzle', GAMES_RACING: 'Racing', GAMES_ROLE_PLAYING: 'Role Playing',
GAMES_SIMULATION: 'Simulation', GAMES_SPORTS: 'Sports', GAMES_STRATEGY: 'Strategy', GAMES_TRIVIA: 'Trivia', GAMES_WORD: 'Word',
BOOKS: 'Books & Reference', REFERENCE: 'Books & Reference', BUSINESS: 'Business', EDUCATION: 'Education', ENTERTAINMENT: 'Entertainment',
FINANCE: 'Finance', FOOD_AND_DRINK: 'Food & Drink', HEALTH_AND_FITNESS: 'Health & Fitness', LIFESTYLE: 'Lifestyle', MEDICAL: 'Medical',
MUSIC: 'Music & Audio', NAVIGATION: 'Maps & Navigation', NEWS: 'News & Magazines', PRODUCTIVITY: 'Productivity', SHOPPING: 'Shopping',
SOCIAL_NETWORKING: 'Social', SPORTS: 'Sports', TRAVEL: 'Travel & Local', UTILITIES: 'Tools', WEATHER: 'Weather', GRAPHICS_AND_DESIGN: 'Art & Design',
};
/** The Play App category and where it comes from: play.category, else an exact match of apple.info.categories (the
* first subcategory first, for games), else null. */
export function playCategoryOf(c: Pick<ShipConfig, 'play' | 'apple'>): { category: string; source: string } | null {
if (c.play?.category) return { category: c.play.category, source: 'play.category' };
const k = c.apple?.info?.categories;
for (const id of [k?.primarySubOne, k?.primary]) if (id && APPLE_TO_PLAY[id]) return { category: APPLE_TO_PLAY[id]!, source: `apple.info.categories (${id})` };
return null;
}
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<ShipConfig>): string[] {
const e: string[] = [];
const badCmd = (v: unknown) => v !== undefined && (typeof v !== 'string' || !v.trim());
if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "<shell command>" }');
if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"');
if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)');
if (badCmd(cfg.apple?.macAppCheck)) e.push('apple.macAppCheck: a shell command run on the built Mac .app ({file} = its path), e.g. "node tools/check-app.mjs {file}"');
if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)');
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/<id>');
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/<developerId>/app/<appId>/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 mo = cfg.apple?.macos;
if (mo !== undefined) {
if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true, entitlements?: "src-tauri/Entitlements.appstore.plist" }');
else {
for (const k of ['listing', 'reviewNotes', 'entitlements'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : k === 'entitlements' ? ' (path of the Mac App Store entitlements plist)' : ''}`);
for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`);
}
}
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', 'minRatio', 'maxRatio', 'maxLevel'] 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`);
e.push(...validateIarcDetails(cr?.details));
const ac = c.appContent;
if (ac !== undefined) {
if (!ac || typeof ac !== 'object') e.push('console.appContent: an object');
else {
for (const k of ['government', 'advertisingId'] as const) if (ac[k] !== undefined && typeof ac[k] !== 'boolean') e.push(`console.appContent.${k}: true | false`);
for (const k of ['financialFeatures', 'healthFeatures'] as const) {
const v = ac[k];
if (v !== undefined && v !== false && !(Array.isArray(v) && v.length && v.every(x => typeof x === 'string' && x.trim())))
e.push(`console.appContent.${k}: false (none) or a list of the console's labels to tick`);
}
}
}
return e;
}
function validateIarcDetails(d: IarcDetails | undefined): string[] {
if (d === undefined) return [];
if (!d || typeof d !== 'object' || Array.isArray(d)) return ['console.contentRating.details: an object'];
const e: string[] = [];
const text = (x: unknown) => typeof x === 'string' && !!x.trim();
const isOther = (o: unknown) => !!o && typeof o === 'object' && !Array.isArray(o)
&& Object.entries(o).every(([k, v]) => k.trim() && (typeof v === 'boolean' || text(v) || (Array.isArray(v) && v.every(text))));
for (const [sec, body] of Object.entries(d)) {
const at = `console.contentRating.details.${sec}`;
const keys = (IARC_DETAIL_KEYS as Record<string, Record<string, string>>)[sec];
if (!keys) { e.push(`${at}: unknown section (${IARC_DETAIL_SECTIONS.join(', ')})`); continue; }
if (!body || typeof body !== 'object' || Array.isArray(body)) { e.push(`${at}: an object`); continue; }
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (k === 'other') { if (!isOther(v)) e.push(`${at}.other: { '<question as the console shows it>': true | false | '<option label>' | ['<labels>'] }`); continue; }
const kind = keys[k];
if (!kind) e.push(`${at}.${k}: unknown (${[...Object.keys(keys), 'other'].join(', ')})`);
else if (kind === 'boolean' && typeof v !== 'boolean') e.push(`${at}.${k}: true | false`);
else if (kind === 'label' && !text(v)) e.push(`${at}.${k}: the option label as the console shows it`);
else if (kind === 'labels' && !(Array.isArray(v) && v.every(text))) e.push(`${at}.${k}: a list of the console's checkbox labels ([] = none)`);
}
}
return e;
}
/** Platforms of the app (app identity): app.platforms, else from the store sections. */
/** True for the macOS platform in any spelling: "macos", "mac_os" (API name MAC_OS), "mac". Pure. */
export const isMacosPlatform = (p: string | undefined | null): boolean => !!p && /^mac(_?os)?$/i.test(p.replace(/[\s-]/g, ''));
/** The App Store listing file (as written in the config, relative) for a platform: apple.macos.listing for macOS when
* set, else apple.listing, else store/appstore.md. Pure. */
export function listingPathFor(config: Pick<ShipConfig, 'apple'>, platform?: string | null): string {
const mac = isMacosPlatform(platform) ? config.apple?.macos?.listing?.trim() : '';
return mac || config.apple?.listing || 'store/appstore.md';
}
/** The App Review notes text of the config for a platform: apple.macos.reviewNotes for macOS when set, else
* apple.info.reviewNotes. Pure; reviewAccess and account deletion lines are added by reviewNotes() in lib/review-details. */
export function reviewNotesFor(config: Pick<ShipConfig, 'apple'>, platform?: string | null): string | undefined {
const mac = isMacosPlatform(platform) ? config.apple?.macos?.reviewNotes : undefined;
return mac?.trim() ? mac : config.apple?.info?.reviewNotes;
}
/** 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<ShipConfig, 'app' | 'apple'>): string => c.apple?.name?.trim() || c.app.name;
export const platformsOf = (c: Pick<ShipConfig, 'app' | 'apple' | 'play'>): 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<ShipConfig, 'app' | 'apple' | 'play'>, 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<ShipConfig, 'app'>): AppProfile => c.app.profile ?? 'game';
/** Screenshot style: shots.style, else app for the app / subscription-app profiles, else game. */
export const shotsStyle = (c: Pick<ShipConfig, 'app' | 'shots'>): '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<ShipConfig>;
if (!cfg || typeof cfg !== 'object') return ['config must be an object'];
if (cfg.account !== undefined && (typeof cfg.account !== 'string' || !PROFILE_RE.test(cfg.account))) e.push('account: a credential profile name (letters, digits, . _ -), never a key');
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');
e.push(...validateGames(cfg.games, cfg.languages ?? []));
if (cfg.social !== undefined) {
if (!cfg.social || typeof cfg.social.interaction !== 'boolean') e.push('social.interaction: true | false');
else for (const k of ['block', 'report', 'moderation', 'friendsOnly', 'sharesLocation'] as const) if (cfg.social[k] !== undefined && typeof cfg.social[k] !== 'boolean') e.push(`social.${k}: true | false`);
}
const langs = cfg.languages;
if (!Array.isArray(langs) || !langs.length) e.push('languages: at least one language');
const ids = new Set<string>();
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_<number>" (${consoleLink('play.reports-statistics', '> 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?.category !== undefined) {
const games: readonly string[] = PLAY_GAME_CATEGORIES, apps: readonly string[] = PLAY_APP_CATEGORIES;
const prof = cfg.app?.profile;
const list = prof === 'game' ? games : prof ? apps : [...games, ...apps];
if (typeof cfg.play.category !== 'string' || !list.includes(cfg.play.category))
e.push(`play.category: "${String(cfg.play.category)}" is not a Play ${prof === 'game' ? 'game' : prof ? 'app' : 'game or app'} category (${list.join(', ')})`);
}
if (cfg.play?.countries !== undefined && !(Array.isArray(cfg.play.countries) && cfg.play.countries.length && cfg.play.countries.every(c => typeof c === 'string' && /^[A-Z]{2}$/.test(c))))
e.push('play.countries: a list of ISO 3166-1 alpha-2 codes ("US", "GB"), or leave it out for all countries / regions');
if (cfg.play?.managedPublishing !== undefined && typeof cfg.play.managedPublishing !== 'boolean') e.push('play.managedPublishing: true | false (what Publishing overview > Managed publishing shows)');
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) {
// a store-only config for reads (analytics, reports) may name the app by apple.appId alone: bundleId null / absent
const idOnly = cfg.apple.bundleId == null && /^\d+$/.test(cfg.apple.appId ?? '');
if (!idOnly && !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 (${consoleLink('asc.app-info')})`);
e.push(...validateReleaseType(cfg.apple));
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, cfg.languages ?? []));
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.unanswered !== undefined && !(Array.isArray(cfg.unanswered) && cfg.unanswered.every(x => TEMPLATE_AREAS.includes(x))))
e.push(`unanswered: a list of ${TEMPLATE_AREAS.join(' | ')}`);
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())) || (x.evidence !== undefined && (typeof x.evidence !== 'string' || !x.evidence.trim())))
e.push(`scan.ignore[${i}]: { rule: '<rule id from storebound scan>', reason: 'why this is fine', store?: 'apple' | 'play', match?: '<text in the finding title>', evidence?: '<text in an evidence line (file:line text)>' }`);
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<string>();
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`);
}
if (p.randomRewards !== undefined && typeof p.randomRewards !== 'boolean') e.push(`${at}.randomRewards: true | false`);
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`);
}
if (cfg.dataSafety.partialDeletion !== undefined && typeof cfg.dataSafety.partialDeletion !== 'boolean' && cfg.dataSafety.partialDeletion !== 'auto90') e.push("dataSafety.partialDeletion: true | false | 'auto90'");
if (cfg.dataSafety.dataDeletion !== undefined && !['yes', 'no', 'auto90'].includes(cfg.dataSafety.dataDeletion)) e.push("dataSafety.dataDeletion: 'yes' | 'no' | 'auto90'");
if (cfg.dataSafety.partialDeletionUrl !== undefined && !URL_RE.test(cfg.dataSafety.partialDeletionUrl)) e.push('dataSafety.partialDeletionUrl: must be an https URL');
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: "<PSL_... id from a Play Console export>", 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.layout !== undefined && s.layout !== 'device' && s.layout !== 'bleed') e.push('shots.layout: device | bleed');
if (s.orientation !== undefined && s.orientation !== 'portrait' && s.orientation !== 'landscape') e.push('shots.orientation: portrait | landscape');
else if (s.orientation === 'landscape' && s.layout !== 'bleed') e.push("shots.orientation: 'landscape' needs shots.layout: 'bleed' (the device layouts draw portrait phones)");
for (const sc of s.scenes ?? []) {
if (sc.corner !== undefined && !['tl', 'tr', 'bl', 'br'].includes(sc.corner)) e.push(`shots.scenes ${sc.id}: corner tl | tr | bl | br`);
if (sc.captionScale !== undefined && !(sc.captionScale >= 0.6 && sc.captionScale <= 1.4)) e.push(`shots.scenes ${sc.id}: captionScale 0.6-1.4`);
if (sc.captionWidth !== undefined && !(sc.captionWidth >= 0.2 && sc.captionWidth <= 0.9)) e.push(`shots.scenes ${sc.id}: captionWidth 0.2-0.9`);
if (sc.captionInset !== undefined && !(sc.captionInset >= 0.01 && sc.captionInset <= 0.15)) e.push(`shots.scenes ${sc.id}: captionInset 0.01-0.15`);
}
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
if (s.landscape?.frame !== undefined && s.landscape.frame !== 'window' && s.landscape.frame !== 'none') e.push("shots.landscape.frame: 'window' | 'none'");
if (s.landscape?.side !== undefined && s.landscape.side !== 'left' && s.landscape.side !== 'right') e.push("shots.landscape.side: 'left' | 'right'");
if (s.feature !== undefined) {
if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name');
if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'");
if (s.feature?.rotate !== undefined && !(typeof s.feature.rotate === 'number' && Math.abs(s.feature.rotate) <= 360)) e.push('shots.feature.rotate: a number of degrees from -360 to 360');
if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5');
e.push(...featureBackgroundIssues(s.feature?.background));
if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette'));
}
for (const sc of s.scenes ?? []) if (sc.palette !== undefined) e.push(...paletteIssues(sc.palette, `shots.scenes[${sc.id}].palette`));
for (const [k, v] of Object.entries(s.variants ?? {})) for (const [id, p] of Object.entries(v.palettes ?? {})) e.push(...paletteIssues(p, `shots.variants.${k}.palettes.${id}`));
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<LocalFontSpec>;
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<Loaded> {
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 rt.importConfigModule(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);
setConfigAccount(root, config.account);
const play = playIdsOf(config, root);
setConsoleIds({ ...consoleIdsFromConfig(config), playDeveloperId: play.developerId, playAppId: play.appId });
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]);