// Types of the `storebound serve --stdio` protocol (desktop profile). Type-only, no runtime code: the desktop app // (apps/desktop) imports this file so both sides agree on shapes. Newline-delimited JSON-RPC 2.0 on stdio. // // Session: the first request must be `session.hello` with the per-session secret (hex, >= 32 bytes) the host // generated. Every other method answers -32002 before that. One hello per process. // Progress: notifications `progress` { requestId, line?, stream?, n?, total?, project? } while a request runs. // Cancel: notification `$/cancelRequest` { id } (or MCP-style `notifications/cancelled` { requestId }); the request // then answers -32800. // Writes: every write method is a dry run unless `dryRun: false` comes with a `confirmToken` minted by the host for // exactly (method, { project, args }) (cli/lib/confirm-token.ts). No method submits, releases or publishes anything. export const DESKTOP_PROTOCOL = 'storebound-desktop/1'; export const ERR = { parse: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internal: -32603, notInitialized: -32002, confirmRequired: -32010, confirmInvalid: -32011, cancelled: -32800, } as const; export interface JsonSchemaLite { type: string; description?: string; enum?: (string | number)[]; default?: unknown; minimum?: number; maximum?: number; pattern?: string } export interface MethodDescriptor { method: string; title: string; description: string; /** owner: a desktop-only write the owner decides (rollout, phased release, sending one reply); always a dry run * unless the host's confirm token comes with it, and then the command's own confirmation is answered by that token. */ kind: 'read' | 'write' | 'session' | 'system' | 'portfolio' | 'account' | 'owner'; /** Talks to a store API or the internet. */ network: boolean; /** Replaces existing data on a store (screenshots, whole forms). */ destructive: boolean; /** Result carries structured `data` (the command's --json output). */ json: boolean; /** Schema of `params.args` (tool methods), or of `params` (other methods). */ args: { type: 'object'; properties: Record; required?: string[]; additionalProperties: false }; } export interface HelloParams { secret: string; client?: { name: string; version: string } } export interface HelloResult { protocol: typeof DESKTOP_PROTOCOL; server: { name: 'storebound'; version: string; compiled: boolean }; methods: MethodDescriptor[] } export interface Credentials { PLAY_SERVICE_ACCOUNT?: string; ASC_KEY_ID?: string; ASC_ISSUER_ID?: string; ASC_P8?: string } export interface ToolParams { /** Absolute path of a folder that contains storebound.config.ts. */ project: string; args?: Record; /** Write methods: false runs for real and needs confirmToken. Default true. */ dryRun?: boolean; confirmToken?: string; /** Per-call credentials (kept in memory, passed to the command's env only). Not part of the token. */ credentials?: Credentials; } export interface ToolResult { method: string; ok: boolean; exitCode: number; dryRun: boolean; /** The CLI line it ran, for logs ("storebound play listing --dry"). */ command: string; /** Text output (log lines), trimmed to the last 60 000 characters. */ output: string; /** Structured result of a --json capable command. */ data?: unknown; /** What the exit code means: 0 = ok; a documented findings code with the --json result printed (scan / check / * validate / health exit 1 when they find problems) = findings; 3 = declined; anything else = failed. Missing in * results cached by older versions (outcomeOf below decides then). */ outcome?: Outcome; /** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */ error?: string; /** Store API quota the run saw (App Store Connect X-Rate-Limit, Google 429): the desktop's read scheduler slows down on it. */ quota?: import('./quota').QuotaInfo; } export type Outcome = 'ok' | 'findings' | 'declined' | 'failed'; /** Read tools whose exit code 1 means "found problems" (the result is still printed with --json): by design, so CI fails. */ export const FINDINGS_EXIT: Readonly> = { scan: [1], check: [1], validate: [1], health: [1], check_aso: [1], shots_check: [1], catalog_diff: [1], aso_audit: [1], }; /** lib/ctx DECLINED_EXIT: the owner's answer was missing or "no". */ export const DECLINED_CODE = 3; /** The outcome of a run (`tool`: the MCP tool name or the desktop method, dots or underscores). */ export function outcomeOf(tool: string, code: number, hasData: boolean): Outcome { if (code === 0) return 'ok'; if (code === DECLINED_CODE) return 'declined'; if (hasData && (FINDINGS_EXIT[tool.replace(/\./g, '_')] ?? []).includes(code)) return 'findings'; return 'failed'; } /** Is this line noise rather than a message (blank, a stack frame, a lone brace, a usage line)? */ const noise = (l: string): boolean => !l.trim() || /^\s+at\s/.test(l) || /^[\s{}[\](),;]*$/.test(l) || /^\s*(usage|options|examples?):/i.test(l) || /^\s{2,}--?\w/.test(l); /** A log line without its "12:00:00 [tag] " prefix. */ const bare = (l: string): string => l.trim().replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '').replace(/^\S+ \[[^\]]+\] /, ''); /** * The command's own error line: the last "error: ..." line of stderr (the CLI's top-level catch), else the first * meaningful stderr line (unknown command / option: the message comes before the usage), else the last meaningful * output line. '' when the command said nothing. Pure. */ export function errorLine(stderr: string, output = ''): string { const err = stderr.split('\n'); const tagged = err.filter(l => /^\s*error:\s*\S/i.test(bare(l))).at(-1); if (tagged) return bare(tagged).replace(/^error:\s*/i, '').slice(0, 500); const first = err.find(l => !noise(l)); if (first) return bare(first).slice(0, 500); const last = output.split('\n').filter(l => !noise(l)).at(-1); return last ? bare(last).slice(0, 500) : ''; } export interface ProgressParams { requestId: string | number; line?: string; stream?: 'stdout' | 'stderr'; n?: number; total?: number; project?: string } export interface ProjectSummary { dir: string; config: string; name: string; languages: string[]; play: { packageName: string } | null; apple: { bundleId: string; appId: string | null } | null; /** app.platforms (default: from the apple / play sections). */ platforms: ('ios' | 'android')[]; /** One-time in-app products in the config (one shared catalog). */ products: number; productIds: string[]; /** Auto-renewable subscriptions in the config (all groups). */ subscriptions: number; subscriptionIds: string[]; /** Product and subscription ids each store should sell (items may be limited to one platform). */ catalog: { ios: string[]; android: string[] }; shots: boolean; testflightGroups: number; /** App profile from the config (app, game, subscription-app). */ profile?: string; /** Absolute path of the app icon the config or the usual places point at (project.icon reads it), or null. */ icon?: string | null; /** A/B screenshot variant ids (shots.variants: store listing experiments, Product Page Optimization treatments). */ variants?: string[]; /** play.reportsBucket is set (Play statistics for analytics snapshots). */ playReports?: boolean; /** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number * of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */ playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- /** One product across platforms. id = its project folder (one storebound.config.ts per app). */ export interface AppIdentity { id: string; name: string; platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string } | null }; } /** Where a version is on one store. */ /** Google Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an * older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with * publicPage unknown ("Couldn't check the store page"). `sent` is no longer produced (older cached reads). */ export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other'; export interface StoreRelease { store: 'ios' | 'android'; version: string | null; /** iOS build number (CFBundleVersion) / highest Android versionCode. */ build: string | null; /** The store's own state word (READY_FOR_SALE, inProgress ...). */ state: string; phase: ReleasePhase; /** Rollout share (0-1): Android staged rollout user fraction, iOS phased release day -> 1, 2, 5, 10, 20, 50, 100 %. */ fraction: number | null; /** Android track (production, beta, internal ...). */ track: string | null; /** iOS phased release: day 1-7 and its state (ACTIVE, PAUSED, COMPLETE); null without one. */ phased?: { state: string; day: number | null } | null; /** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */ publicPage?: 'public' | 'notPublic' | 'unknown'; /** Google Play production in review while the store page shows an older live version: that version. */ liveVersion?: string | null; /** Google Play production in review with managed publishing on (play.managedPublishing): approval is not readable. */ managed?: boolean; /** The store page could not be read now: the phase is from the last known page read at this time. */ pageStaleAt?: string | null; /** When the store record was made (iOS version createdDate), if the store says. */ date?: string | null; } /** A release train: one version (e.g. 2.3.0) across both stores. */ export interface ReleaseTrain { version: string; ios: StoreRelease | null; android: StoreRelease | null } /** In-app catalog parity between the config and both stores. */ export interface CatalogParity { checked: 'config' | 'live'; products: number; subscriptions: number; /** Live: ids on one store only, and config ids missing on a store the app is on. */ onlyIos: string[]; onlyAndroid: string[]; missingIos: string[]; missingAndroid: string[]; ok: boolean; } export interface PortfolioParams { projects: string[]; live?: boolean; credentials?: Credentials } export interface AppStatus { /** Project folder as given. */ project: string; ok: boolean; app: AppIdentity | null; summary: ProjectSummary | null; check: { ok: boolean; problems: string[]; warnings: string[] } | null; /** Release trains; `source: none` offline (no store data read). `all`: every store release read (live only). */ release: { source: 'none' | 'live'; current: ReleaseTrain | null; next: ReleaseTrain | null; all?: StoreRelease[] }; catalog: CatalogParity | null; /** A store-only app read through a generated config (no app folder). */ virtual?: boolean; /** live only: per-platform read errors ("unknown, never false"). */ storeErrors: { ios?: string; android?: string }; /** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */ staleStores?: { ios?: string; android?: string }; errors: string[]; } export interface PortfolioResult { apps: AppStatus[]; at: string } // ---- Offline project reads (D2) ---- /** One listing field of one language on one store: length against the store limit. */ export interface ListingField { field: string; length: number; limit: number; required: boolean; state: 'ok' | 'missing' | 'over' | 'empty-optional' } /** Listing completeness of one language (config language id) on both stores. */ export interface ListingLocale { lang: string; rtl: boolean; play: { code: string | null; fields: ListingField[] } | null; apple: { code: string | null; fields: ListingField[] } | null; /** Rendered screenshots found for this language (renders folder), null when there is no renders folder. */ screenshots: number | null; /** Required fields present and within limits, 0-1 (both stores). */ complete: number; } export interface ListingsResult { files: { play: string | null; apple: string | null }; /** File-level problems (missing file, unreadable section). */ problems: string[]; locales: ListingLocale[]; } /** A local review reply draft (.storebound/replies/-.md) with its checks. */ export interface ReplyDraft { file: string; store: 'apple' | 'play'; reviewId: string; approved: boolean; sent: string | null; reply: string; rating: number | null; title: string | null; body: string | null; date: string | null; issues: { level: 'error' | 'warn'; msg: string }[]; } export interface DraftsResult { dir: string; drafts: ReplyDraft[]; problems: string[] } /** project.icon: the icon as a data: URL (PNG / JPEG / WebP / SVG, at most 1.5 MB), or null. */ export interface IconResult { path: string | null; dataUrl: string | null } // ---- Store accounts and the apps on them (D3: many studios, many accounts) ---- /** One app record on a store, as `storebound apps list` reads it. */ export interface StoreApp { store: 'ios' | 'android'; /** App Store Connect app id (numeric) / Play package name. */ id: string; /** iOS bundle id / Android package name. */ bundleId: string; name: string; /** App Store version platforms (IOS, MAC_OS, TV_OS, VISION_OS); ["ANDROID"] on Play. */ platforms: string[]; sku?: string | null; primaryLocale?: string | null; /** Newest App Store version and its state (App Store only). */ latest?: { version: string; state: string; platform?: string | null } | null; /** 128 px icon as a data: URL (with --icons), else null. */ icon?: string | null; } export interface AppsListResult { store: 'ios' | 'android'; apps: StoreApp[]; warnings: string[]; at: string } /** Health of one store account: does the key work, and can it see apps. */ export interface AccountCheck { store: 'ios' | 'android'; status: 'ok' | 'no-apps' | 'permission' | 'revoked' | 'api-disabled' | 'error'; /** Play: the service account email (to invite in Play Console). Never a secret. */ principal: string | null; apps: number | null; message: string; /** What to do, in words. */ fix: string | null; docs: string | null; } /** * A store-only app (no app folder yet): the sidecar reads it through a generated minimal config (store ids only) in * its temp folder. Read methods only. */ export interface VirtualProject { name: string; apple?: { bundleId: string; appId?: string | null } | null; play?: { packageName: string } | null } // ---- Desktop: every CLI area in the window (prices, declarations, renders, experiments, analytics) ---- /** prices.current (`prices export --json`): the current prices on both stores, one row per product x store x region. */ export interface PriceRowData { product: string; store: 'apple' | 'play'; region: string; territory?: string; currency: string; price: number; startDate?: string; kind?: 'manual' | 'auto' } export interface PricesCurrent { csv: string; products: string[]; stores: ('apple' | 'play')[]; missing: { product: string; store: 'apple' | 'play' }[]; rows: PriceRowData[] } /** prices.diff (`prices import --dry --json`): the sheet against the stores. */ export interface PriceDiffRow { product: string; store: 'apple' | 'play'; region: string; startDate?: string; currency: string; from: number | null; to: number; change: 'new' | 'change' | 'same' } export interface PricesDiff { csv: string; diff: PriceDiffRow[]; changed: string[] } /** One "ask the owner" item: the config does not prove the answer (AGENTS.md rule 5). */ export interface DeclAsk { area: 'dataSafety' | 'appPrivacy' | 'appInfo' | 'encryption' | 'review'; id: string; text: string; fix: string } /** project.declarations (`inspect --declarations --json`): store declarations from the config and local files. */ export interface DeclarationsResult { profile: string; stores: { apple: boolean; play: boolean }; dataSafety: null | { collectsData: boolean; encryptedInTransit: boolean; accountCreation: string[]; /** dataSafety.partialDeletion (PSL_SUPPORT_DATA_DELETION_BY_USER; asked once there are accounts); null: not answered. */ partialDeletion: boolean | 'auto90' | null; /** PSL_SUPPORT_DATA_DELETION_BY_USER ("can users request that their data is deleted?") as the CSV answers it * (null: not answered), where the answer comes from (dataSafety.dataDeletion, accounts.deletionUrl, ...) and the * Delete data URL it sends. Optional: older sidecars do not send them. */ dataDeletion?: 'yes' | 'no' | 'auto90' | null; dataDeletionSource?: string | null; dataDeletionUrl?: string | null; types: { id: string; category: string; shared: boolean; ephemeral: boolean; optional: boolean; collect: string[]; share: string[] }[]; /** Extra CSV rows of questions storebound does not model. */ extra: number; /** The Play CSV the upload sends (it replaces the whole form). */ csv: string; rows: number; /** Play cannot read the form back: changes since the CSV storebound uploaded last (null: never uploaded from here). */ uploaded: { at: string; changes: { question: string; response: string; from: string; to: string }[] } | null; }; /** App Store App Privacy (no API): the answers the Data safety answers suggest; the owner enters them in App Store Connect. */ appPrivacy: null | { items: { apple: string; category: string; from: string[]; purposes: string[]; thirdParty: boolean }[]; fromDataSafety: boolean }; appInfo: null | { categories: { primary: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string } | null; /** Overrides of the age rating declaration (every other question: none / false). */ ageRating: { question: string; value: string }[]; contentRights: string | null; free: boolean; }; /** apple.usesNonExemptEncryption (null: not answered; storebound never guesses). */ encryption: boolean | null; /** contact / demoAccount: masked ("d***@example.com"), null when missing; never the password. */ review: { signInRequired: boolean | null; accounts: boolean; contact: string | null; demoAccount: string | null; notes: string; notesBytes: number; notesMax: number }; ask: DeclAsk[]; } /** project.listing_text: the listing files as they are (for export). */ export interface ListingText { play: { path: string; text: string } | null; apple: { path: string; text: string } | null } /** project.render: one rendered image as a data: URL (null when missing or too big). */ export interface RenderImage { file: string; dataUrl: string | null; bytes: number } /** project.renders (`shots gallery --json`): every render per variant, language and size, with the store check issues. */ export interface RenderIssue { level: 'error' | 'warn'; code: string; msg: string } export interface RenderTile { src: string; kind: 'image' | 'video'; poster?: string; name: string; w: number; h: number; bytes: number; issues: RenderIssue[] } export interface RendersResult { title: string; generated: string; /** The renders folder relative to the app folder. */ dir?: string; variants: { id: string; label: string; langs: { lang: string; rows: { size: string; label: string; store: 'apple' | 'play'; tiles: RenderTile[]; issues: RenderIssue[] }[] }[] }[]; totals: { images: number; videos: number; errors: number; warnings: number }; } /** apple.ppo_status (`apple ppo status --json`). */ export interface PpoTest { id: string; name: string; state: string; trafficProportion?: number; startDate?: string | null; endDate?: string | null; treatments: { id: string; name: string; locales: number }[] } export interface PpoStatus { tests: PpoTest[] } /** analytics.compare (`analytics compare --json`): daily series from the snapshots and the store changes from git. */ export interface AnalyticsTotals { impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number } export interface AnalyticsCompare { snapshots: number; since: string; series: { apple: { date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number }[]; play: { date: string; visitors: number; acquisitions: number; installs: number }[]; }; effects: { event: { date: string; commit: string; subject: string; files: string[] }; store: 'apple' | 'play'; before: AnalyticsTotals; after: AnalyticsTotals; daysBefore: number; daysAfter: number }[]; /** Per store with no days in any snapshot: why the newest snapshot has none (no report request yet, no bucket). */ errors?: { apple?: string; play?: string }; } /** aso.audit (`aso audit --json`, offline, cli/lib/aso-audit.ts AsoAudit): the parts the Growth tab's ASO card shows. */ export interface AsoTrendRow { storefront: string; keyword: string; rank: number | null; previous: number | null; change: number | null; arrow: 'โ†‘' | 'โ†“' | 'โ†’' | 'new' | 'lost' | 'ยท'; best: number | null; snapshots: number } export interface AsoAuditView { generated: string; keywordField: { errors: number; warnings: number; hints: number; findings: { level: 'error' | 'warn' | 'info'; where: string; msg: string }[] }; drafts: string[]; ranks: { snapshots: number; latest: string | null; rows: AsoTrendRow[]; dates: string[]; series: { storefront: string; keyword: string; ranks: (number | null)[] }[] }; competitors: { date: string | null; gaps: { storefront: string; locale: string; gaps: { word: string; apps: number; developers: number }[] }[] }; experiments: { store: 'apple' | 'play'; what: string; why: string; how: string }[]; next: { what: string; why: string; command?: string }[]; } /** console.fill (`console fill
--json`, cli/console/fill.ts): the answers it types into one console form. The * dry run has the answers only; the real run (it ends when the owner closes the browser) adds what happened. */ export type ConsoleForm = 'apple-app-record' | 'apple-age-rating' | 'apple-app-privacy' | 'play-app-record' | 'play-content-rating' | 'play-target-audience'; export interface ConsoleAnswer { id: string; label: string; value: string; certain: boolean; source: string; note?: string } export interface ConsoleFillResult { form: ConsoleForm; store: 'apple' | 'play'; title: string; url: string; answers: ConsoleAnswer[]; /** Answers only the owner can give (highlighted in the browser). */ uncertain: number; guidance: string[]; /** Real run only: fields filled, fields highlighted, whether it fell back to highlighting only, where it stopped. */ filled?: number; highlighted?: number; fallback?: unknown; stoppedAt?: string | null; }