// `storebound serve --stdio`: the machine interface the desktop app (apps/desktop) drives as a sidecar. // Newline-delimited JSON-RPC 2.0 on stdin / stdout, logs on stderr. Protocol types: lib/desktop-protocol.ts. // // It reuses the MCP core: the same tool list (mcp-tools.ts: schemas, validation, argv building, the forbidden-flag // rules) and the same child-process runner (lib/runner.ts). On top of that, the desktop profile adds: // - a session handshake (`session.hello`) carrying the per-session secret of the host, // - typed methods (one per tool, e.g. `play.listing`, plus project.inspect, portfolio.status, system.*), // - structured results (`data`, the command's --json output), progress notifications and cancellation, // - writes that are dry runs unless the host sends a confirm token (lib/confirm-token.ts) it minted after the owner // confirmed a native dialog showing that dry run. // Submitting for review, resubmitting and sending a Play production release exist only as owner actions (kind // "owner", OWNER_SUBMIT): a dry run unless the host's confirm token comes with it, minted after the owner pressed the // button of a native dialog that showed that dry run. Nothing else submits, releases or publishes, and the MCP server // (`storebound mcp`, the AI tools) has none of them (tests/serve.test.ts). import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import { canonical, parseSecret, TokenGuard } from './lib/confirm-token'; import { CONFIG_NAMES } from './lib/config'; import { DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, type VirtualProject, } from './lib/desktop-protocol'; import { emptyStatus, fillAppStatus, lastLine } from './lib/app-status'; import { RENDER_FILE_RE } from './lib/project-files'; import { androidSdk, detectTools } from './lib/tools'; import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner'; import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools'; import * as rt from './lib/rt'; type Id = string | number; interface Msg { jsonrpc?: string; id?: Id | null; method?: string; params?: unknown } type Json = Record; export type Send = (msg: Json) => void; export type Runner = (argv: string[], o: RunOptions) => Running; const MAX_OUTPUT = 60_000; const opt = (flag: string, v: unknown): string[] => (v === undefined || v === null || v === '' ? [] : [`--${flag}`, String(v)]); const lang = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,20}(,[A-Za-z0-9_-]{1,20}){0,99}$', description: 'Comma-separated language ids from the config (default: all)' }; /** A price sheet: any .csv file path the owner picked (read only), never an option. */ export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/; /** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has * the owner actions for those; workspace and release-train reads: the window has portfolio.status for those; the raw API reader api_get). */ export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release', 'workspace_list', 'workspace_status', 'release_status', 'release_plan', 'api_get']); /** Words no desktop method name may contain, owner actions aside (their names start with "owner."). */ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i; /** Read tools whose command prints a structured result with --json. */ export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status', 'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff', 'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current']); /** Console forms `storebound console fill` knows (cli/console/fill.ts). */ export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience']; /** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */ export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit']); /** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */ const TOKEN_ANSWERS = new Set(['console_fill']); /** * Methods only the desktop profile has (not MCP tools): offline reads of the project's own files, local writes the * owner confirms in a native dialog, and `console.fill`. That one is a write: its dry run returns the answers it would * type (data, for the window's panel); the real run opens a visible browser only with the host's confirm token, minted * after the owner confirmed a native dialog showing those answers. It runs until the owner closes the browser (no * timeout here, in the runner or in the host); a cancel kills the command, which closes its browser. Approving a reply draft is the owner's decision, so AI tools do not get * it; `project.init` writes the templates into a new folder (the host passes that folder, it has no config yet). */ export const DESKTOP_ONLY: ToolDef[] = [ { name: 'project_icon', title: 'App icon', command: ['inspect', '--icon'], write: false, network: false, description: 'The app icon (icons.composite, the rendered store icons, play.icon or the usual places) as a data: URL, at most 1.5 MB.' }, { name: 'project_listings', title: 'Listing completeness', command: ['inspect', '--listings'], write: false, network: false, description: 'Per config language and store: listing fields present and within the store limits (store/listing.md, store/appstore.md), and rendered screenshots per language.' }, { name: 'reviews_drafts', title: 'Reply drafts', command: ['reviews', 'drafts'], write: false, network: false, description: 'The local review reply drafts (.storebound/replies) with the checks `reviews send` applies: approved, sent, length, no incentives, no personal data.' }, { name: 'reviews_approve', title: 'Approve a reply draft', command: ['reviews', 'approve'], write: true, network: false, idempotent: true, description: 'Sets approved: true (or false with undo) in one local reply draft after its checks pass. Nothing is sent: replies go out with `storebound reviews send` in a terminal.', props: { file: { type: 'string', pattern: '^(apple|play)-[A-Za-z0-9._-]{1,120}\\.md$', description: 'Draft file name in .storebound/replies' }, undo: { type: 'boolean', default: false } }, required: ['file'], args: i => [String(i.file), ...(i.undo === true ? ['--undo'] : [])] }, { name: 'reviews_reply', title: 'Write a reply draft', command: ['reviews', 'reply'], write: true, network: false, idempotent: true, description: 'Writes the reply text of one local reply draft (.storebound/replies); an edited reply loses its approval. Nothing is sent.', props: { file: { type: 'string', pattern: '^(apple|play)-[A-Za-z0-9._-]{1,120}\\.md$', description: 'Draft file name in .storebound/replies' }, text: { type: 'string', pattern: '^[\\s\\S]{1,6000}$', description: 'The reply (plain text, the reviewer\'s language)' } }, required: ['file', 'text'], args: i => [String(i.file), '--text', String(i.text)] }, { name: 'project_init', title: 'Start an app folder', command: ['init'], write: true, network: false, idempotent: true, description: 'Copies the storebound templates (config, listing, screenshot copy, declarations, privacy policy) for a profile into an empty app folder; existing files are kept.', props: { profile: { type: 'string', enum: ['app', 'game', 'subscription-app'], default: 'app' } }, args: i => ['--profile', String(i.profile ?? 'app')] }, { name: 'project_declarations', title: 'Store declarations', command: ['inspect', '--declarations'], write: false, network: false, description: 'Play Data safety answers (and the changes since the last upload: Play cannot read the form back), the App Privacy answers they suggest (App Privacy has no API), App Store categories, age rating, content rights, export compliance and App Review details (contact and demo account masked) from the config, with every question only the owner can answer.' }, { name: 'project_listing_text', title: 'Listing files', command: ['inspect', '--listing-text'], write: false, network: false, description: 'The Play and App Store listing files (store/listing.md, store/appstore.md) as they are, for an export.' }, { name: 'project_renders', title: 'Store art', command: ['shots', 'gallery'], write: false, network: false, description: 'Every rendered screenshot and app preview per variant, language and size with the store check issues (the data of the review gallery; nothing is written).', props: { lang }, args: i => opt('lang', i.lang) }, { name: 'project_render', title: 'One render', command: ['inspect'], write: false, network: false, description: 'One rendered image of the renders folder as a data: URL (at most 12 MB), for the desktop gallery.', props: { file: { type: 'string', pattern: RENDER_FILE_RE.source, description: 'Path relative to the renders folder, as project.renders names it' } }, required: ['file'], args: i => ['--render', String(i.file)] }, { name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true, description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.', props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' }, fresh_profile: { type: 'boolean', default: false, description: "Skip attaching to a Chrome that runs with remote debugging. storebound's own browser profile (the owner signs in there once) is always used otherwise; the owner's Chrome cookies are never copied here" } }, required: ['form'], args: i => [String(i.form), ...opt('store', i.store), ...(i.fresh_profile === true ? ['--fresh-profile'] : [])] }, { name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true, description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.', props: { store: { type: 'string', enum: ['apple', 'play'] }, with_automatic: { type: 'boolean', default: false } }, args: i => [...opt('store', i.store), ...(i.with_automatic === true ? ['--with-automatic'] : [])] }, ]; const BUILD_NO = { type: 'string', pattern: '^\\d{1,9}(\\.\\d{1,9}){0,3}$', description: 'The build number (CFBundleVersion) the owner picked from the uploaded builds; never "the latest"' }; const VERSION = { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'App Store version (default: the one an open submission holds, else the editable one)' }; const IDS = { type: 'string', pattern: '^[A-Za-z0-9._]{1,100}(,[A-Za-z0-9._]{1,100}){0,49}$', description: 'Comma-separated product ids' }; /** `--yes` (the owner's step of apple submit / resubmit) and `--allow-price-changes` only in the real run, after the dialog. */ const real = (i: Record, flag: string): string[] => (i.dry_run === false ? [flag] : []); /** Catalog writes whose price changes of existing products are the owner's (lib/price-gate.ts): one owner action each. */ const PRICE_SYNCS: [name: string, command: string[], what: string][] = [ ['owner_prices_play_products', ['play', 'products'], 'Google Play in-app products'], ['owner_prices_play_subscriptions', ['play', 'subscriptions'], 'Google Play subscriptions'], ['owner_prices_apple_subscriptions', ['apple', 'subscriptions'], 'App Store subscriptions'], ]; /** * Owner actions (desktop only, kind "owner"): the public or irreversible steps the owner decides. Each is a dry run * unless the host's confirm token comes with it, minted after the owner pressed the button of a native dialog that * showed that dry run; only then the command's own confirmation is answered (STOREBOUND_OWNER_CONFIRMED for that one * child) and the owner-only flags (`--yes` of apple submit / resubmit, `--allow-price-changes`) are added. Submitting * for review (App Store version, its in-app purchases, a resubmit after a rejection) and the Play production release * are here (OWNER_SUBMIT), with builds picked by number. Never: releasing an approved version (--manual-release), * cancelling a submission, external TestFlight or Beta App Review, removing testers, Play's "Send changes for review" * (no API: a console plan step). Those stay in the consoles or the terminal. */ export const OWNER_TOOLS: ToolDef[] = [ { name: 'owner_play_rollout', title: 'Change the Play staged rollout', command: ['play', 'rollout'], write: true, network: true, description: 'Google Play staged rollout of a track: set the user share (percent), halt it (not a rollback), resume it, or complete it (100 %).', props: { action: { type: 'string', enum: ['percent', 'halt', 'resume', 'complete'] }, percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent (action percent, or resume at a new share)' }, track: { type: 'string', enum: ['production', 'beta', 'alpha', 'internal'], default: 'production' } }, required: ['action'], args: i => [...(i.action === 'halt' ? ['--halt'] : i.action === 'resume' ? ['--resume'] : i.action === 'complete' ? ['--complete'] : []), ...(i.percent !== undefined && (i.action === 'percent' || i.action === 'resume') ? ['--fraction', String(Number(i.percent) / 100)] : []), '--track', String(i.track ?? 'production')] }, { name: 'owner_ios_phased', title: 'Change the App Store phased release', command: ['apple', 'release'], write: true, network: true, description: 'The 7-day phased release of an App Store version: turn it on, pause it (not a rollback), resume it, or release to everyone now (complete).', props: { action: { type: 'string', enum: ['on', 'pause', 'resume', 'complete'] }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } }, required: ['action'], args: i => ['--phased', String(i.action), ...(i.version ? ['--version', String(i.version)] : [])] }, { name: 'owner_reply_send', title: 'Send one approved reply', command: ['reviews', 'send'], write: true, network: true, description: 'Sends one approved, unsent reply draft (.storebound/replies) to its store: public, the reviewer is notified. The draft checks (length, no incentives) apply.', props: { file: { type: 'string', pattern: '^(apple|play)-[A-Za-z0-9._-]{1,120}\\.md$', description: 'Draft file name in .storebound/replies' } }, required: ['file'], args: i => ['--file', String(i.file)] }, { name: 'owner_testflight_testers', title: 'Add TestFlight testers', command: ['apple', 'testflight', 'testers'], write: true, network: true, description: 'Adds the testers of the project\'s testers CSV (.storebound/testers.csv, gitignored) to their TestFlight groups; Apple emails them an invitation once the group has a build. Emails stay masked in every line. Removing testers stays a terminal command.', props: { groups: { type: 'string', pattern: '^[^,;]{1,60}(,[^,;]{1,60}){0,19}$', description: 'Comma-separated group names (default: every group in the CSV)' } }, args: i => (i.groups ? ['--group', String(i.groups)] : []) }, { name: 'owner_prices_import', title: 'Apply the price sheet', command: ['prices', 'import'], write: true, network: true, description: 'Writes the prices of the price sheet (CSV) that differ from the stores: App Store price schedules and Google Play regional prices. Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.', props: { csv: { type: 'string', pattern: PRICE_SHEET_RE.source, description: 'The price sheet: a .csv path (default: prices.csv of the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] }, only: IDS }, args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...real(i, '--allow-price-changes')] }, { name: 'owner_ppo_start', title: 'Start the Product Page Optimization test', command: ['apple', 'ppo', 'start'], write: true, network: true, description: 'Starts an App Store Product Page Optimization test: the share of App Store visitors it names sees a treatment (treatments not yet approved go to App Review with the next version first).', props: { experiment: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,64}$', description: 'Test name (default: the one editable test)' } }, args: i => opt('experiment', i.experiment) }, { name: 'owner_ppo_stop', title: 'Stop the Product Page Optimization test', command: ['apple', 'ppo', 'stop'], write: true, network: true, description: 'Stops a running App Store Product Page Optimization test. A stopped test cannot be restarted; the results stay in App Store Connect.', props: { experiment: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,64}$', description: 'Test name (default: the running test)' } }, args: i => opt('experiment', i.experiment) }, ...PRICE_SYNCS.map(([name, command, what]): ToolDef => ({ name, title: `Apply the price changes of existing ${what}`, command, write: true, network: true, description: `Runs the ${what} sync with --allow-price-changes: everything a normal sync writes plus the price changes of existing (live) products, which a normal sync holds back for the owner. Users see the new prices as soon as the store applies them.`, props: { only: IDS }, args: i => [...opt('only', i.only), ...real(i, '--allow-price-changes')] })), { name: 'owner_apple_submit', title: 'Submit for App Review', command: ['apple', 'submit'], write: true, network: true, description: 'Sends an App Store version to App Review with the build the owner picked by its number: the build is attached, the open review submission reused (a new draft only when none is open), the in-app purchases and subscriptions of the config that are ready go with it, then it is submitted (retried while Apple says "not ready yet"). After approval it goes live as the version\'s release setting says.', props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Mark REJECTED in-app purchase / subscription items of a submission back from App Review as resolved' } }, required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] }, { name: 'owner_apple_resubmit', title: 'Resubmit to App Review', command: ['apple', 'resubmit'], write: true, network: true, description: 'After a rejection: attaches the build the owner picked by its number to the rejected version, reuses the submission back from App Review (UNRESOLVED_ISSUES; missing items added), marks its REJECTED app version item resolved (in-app purchase items too with resolve_iaps) and resubmits it.', props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Also mark REJECTED in-app purchase / subscription items resolved (after fixing what App Review asked for)' } }, required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] }, { name: 'owner_apple_iap_submit', title: 'Submit in-app purchases for review', command: ['apple', 'iap-submit'], write: true, network: true, description: 'Sends the in-app purchases and subscriptions of the config that are ready to App Review in the open review submission (reused, never a second draft). The first of a type needs an app version in the same submission (with_version: the editable one goes too, and submitting submits the app).', props: { with_version: { type: 'boolean', default: false }, resolve_iaps: { type: 'boolean', default: false }, only: IDS }, args: i => [...(i.with_version === true ? ['--with-version'] : []), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...opt('only', i.only)] }, { name: 'owner_play_production', title: 'Send a release to Google Play production', command: ['play', 'release'], write: true, network: true, description: 'Puts the versionCode the owner picked (already on Play, by number) on the production track with the What\'s new of every language: completed (everyone), inProgress (a staged rollout share) or draft (a never-published app: Play takes only drafts; Send changes for review stays a Play Console step). With managed publishing off, Play publishes it by itself once Google approves it.', props: { version_code: { type: 'string', pattern: '^\\d{1,10}$', description: 'The versionCode the owner picked; never "the latest"' }, status: { type: 'string', enum: ['completed', 'inProgress', 'draft'], default: 'completed' }, percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent for inProgress' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f-][^\\x00-\\x1f]{0,49}$', description: 'Release name (default: Play\'s)' } }, required: ['version_code'], args: i => ['--version-code', String(i.version_code), '--production', String(i.status ?? 'completed'), ...(i.status === 'inProgress' && i.percent !== undefined ? ['--fraction', String(Number(i.percent) / 100)] : []), ...opt('name', i.name)] }, ]; /** Owner actions that send something to review or production: allowed only as owner actions (host token), never in * the MCP server (AI tools) or any other desktop method. */ export const OWNER_SUBMIT = new Set(['owner_apple_submit', 'owner_apple_resubmit', 'owner_apple_iap_submit', 'owner_play_production']); /** Flags no owner action may ever produce, except where OWNER_ALLOWED_ARGS names it for one action. */ export const OWNER_FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--submit-review', '--remove', '--invite', '--production', '--csv', '--allow-price-changes', '--prune', 'submit', 'publish', 'distribute']; /** The one owner action each of these flags belongs to (`--csv`: the price sheet; `--yes` / `submit`: apple submit / * resubmit, whose real run needs --yes; `--production`: the Play production release; `--allow-price-changes`: the * price owner actions). */ export const OWNER_ALLOWED_ARGS: Record = { owner_prices_import: ['--csv', '--allow-price-changes'], ...Object.fromEntries(PRICE_SYNCS.map(([name]) => [name, ['--allow-price-changes']])), owner_apple_submit: ['submit', '--yes'], owner_apple_resubmit: ['--yes'], owner_play_production: ['--production'], }; /** Flags only a real run (after the dialog, with the host's token) may carry; a dry run never does. */ export const OWNER_REAL_ONLY = ['--yes', '--allow-price-changes']; /** Desktop-only methods whose `project` is a folder without a config yet (the host picks it in a native dialog). */ export const NEW_PROJECT = new Set(['project_init', 'import_fastlane']); /** `apple_testflight_status` -> `apple.testflight_status`: first underscore becomes the namespace dot. */ export const methodName = (tool: string): string => tool.replace('_', '.'); export const DESKTOP_TOOLS: ToolDef[] = [...TOOLS.filter(t => !DESKTOP_EXCLUDED.has(t.name)), ...DESKTOP_ONLY]; const OWNER = new Set(OWNER_TOOLS.map(t => t.name)); const byMethod = new Map([...DESKTOP_TOOLS, ...OWNER_TOOLS].map(t => [methodName(t.name), t] as const)); function argsSchema(t: ToolDef): MethodDescriptor['args'] { const s = inputSchema(t) as MethodDescriptor['args']; const { dry_run: _d, ...properties } = s.properties; return { ...s, properties }; } const obj = (properties: MethodDescriptor['args']['properties'], required?: string[]): MethodDescriptor['args'] => ({ type: 'object', properties, ...(required ? { required } : {}), additionalProperties: false }); const SYSTEM: MethodDescriptor[] = [ { method: 'session.hello', title: 'Start the session', description: 'Hand over the per-session secret (hex, >= 32 bytes). Must be the first request.', kind: 'session', network: false, destructive: false, json: true, args: obj({ secret: { type: 'string' }, client: { type: 'object' } }, ['secret']) }, { method: 'system.version', title: 'Version', description: 'storebound version, platform and whether this is the compiled sidecar.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, { method: 'system.methods', title: 'Methods', description: 'Every method of this profile with its argument schema.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, { method: 'system.tools', title: 'Local tools', description: 'Optional local tools with versions and where they come from (ffmpeg, ffprobe, Chrome, Xcode / simctl, adb, git); screenshot, video and capture features need them. Also the Android SDK as play build finds it.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, { method: 'project.inspect', title: 'Inspect a project', description: 'Offline summary of a folder\'s storebound.config.ts (name, store ids, languages, products).', kind: 'read', network: false, destructive: false, json: true, args: obj({ project: { type: 'string', description: 'Absolute folder path' } }, ['project']) }, { method: 'apps.list', title: 'Apps on a store account', description: 'Every app one store account can see (App Store Connect team: GET /v1/apps with platforms and the newest version; Google Play: Reporting API apps:search over every developer account that invited the service account), optional 128 px icons. Credentials of exactly one account in params.credentials. Read-only.', kind: 'account', network: true, destructive: false, json: true, args: obj({ credentials: { type: 'object' }, args: { type: 'object' } }, ['credentials']) }, { method: 'account.check', title: 'Check a store account', description: 'Does the key work, and can it see apps: ok, no-apps, permission (with the Play Console / App Store Connect fix), revoked, api-disabled. Read-only.', kind: 'account', network: true, destructive: false, json: true, args: obj({ credentials: { type: 'object' } }, ['credentials']) }, { method: 'portfolio.status', title: 'Portfolio status', description: 'Apps (one per project folder) across iOS and Android: identity, offline config check, catalog parity; with live, release trains (current / next version on both stores) and live catalog parity from App Store and Google Play status (read-only).', kind: 'portfolio', network: true, destructive: false, json: true, args: obj({ projects: { type: 'array' }, live: { type: 'boolean', default: false } }, ['projects']) }, ]; /** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */ export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']); const PKG_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/; const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,154}$/; export function describeMethods(): MethodDescriptor[] { return [...SYSTEM, ...[...DESKTOP_TOOLS, ...OWNER_TOOLS].map(t => ({ method: methodName(t.name), title: t.title, description: t.description, kind: OWNER.has(t.name) ? 'owner' as const : t.write ? 'write' as const : 'read' as const, network: t.network, destructive: !!t.destructive, json: t.write ? JSON_WRITES.has(t.name) : JSON_TOOLS.has(t.name), args: argsSchema(t), }))]; } class RpcError extends Error { constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } } /** Absolute folder with a storebound config in it, else an error. */ export function checkProject(p: unknown): string { if (typeof p !== 'string' || !p) throw new RpcError(ERR.invalidParams, 'project: absolute folder path required'); if (!isAbsolute(p)) throw new RpcError(ERR.invalidParams, 'project: must be an absolute path'); const dir = resolve(p); let isDir = false; try { isDir = statSync(dir).isDirectory(); } catch { /* missing */ } if (!isDir) throw new RpcError(ERR.invalidParams, `project: not a folder: ${dir}`); if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new RpcError(ERR.invalidParams, `project: no storebound.config.ts in ${dir}`); return dir; } /** Checks a params.virtual object (store ids only). */ export function checkVirtual(v: unknown): VirtualProject { if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new RpcError(ERR.invalidParams, 'virtual: object expected'); const o = v as Record; for (const k of Object.keys(o)) if (!['name', 'apple', 'play'].includes(k)) throw new RpcError(ERR.invalidParams, `virtual.${k}: unknown`); const name = typeof o.name === 'string' && o.name.trim() && o.name.length <= 120 ? o.name.trim() : null; if (!name) throw new RpcError(ERR.invalidParams, 'virtual.name: 1-120 characters'); const a = o.apple as Record | null | undefined, p = o.play as Record | null | undefined; if (!a && !p) throw new RpcError(ERR.invalidParams, 'virtual: apple and / or play'); const out: VirtualProject = { name }; if (a) { if (typeof a.bundleId !== 'string' || !BUNDLE_RE.test(a.bundleId) || !a.bundleId.includes('.')) throw new RpcError(ERR.invalidParams, 'virtual.apple.bundleId: a bundle id'); if (a.appId !== undefined && a.appId !== null && (typeof a.appId !== 'string' || !/^\d{1,20}$/.test(a.appId))) throw new RpcError(ERR.invalidParams, 'virtual.apple.appId: digits'); out.apple = { bundleId: a.bundleId, appId: (a.appId as string | null | undefined) ?? null }; } if (p) { if (typeof p.packageName !== 'string' || !PKG_RE.test(p.packageName)) throw new RpcError(ERR.invalidParams, 'virtual.play.packageName: a package name'); out.play = { packageName: p.packageName }; } return out; } /** The minimal config of a store-only app (store ids, placeholder links), as storebound.config.json. */ export function virtualConfig(v: VirtualProject): Record { return { app: { name: v.name, privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com/', contactEmail: 'store-only@example.com', platforms: [...(v.apple ? ['ios'] : []), ...(v.play ? ['android'] : [])] }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], ...(v.apple ? { apple: { bundleId: v.apple.bundleId, ...(v.apple.appId ? { appId: v.apple.appId } : {}) } } : {}), ...(v.play ? { play: { packageName: v.play.packageName } } : {}), }; } let virtualRoot: string | null = null; /** A folder (in this session's temp folder) holding the generated config of a store-only app. */ export function virtualDir(v: VirtualProject): string { virtualRoot ??= mkdtempSync(join(tmpdir(), 'storebound-virtual-')); const dir = join(virtualRoot, createHash('sha256').update(canonical(v as unknown as Record)).digest('hex').slice(0, 20)); if (!existsSync(join(dir, 'storebound.config.json'))) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'storebound.config.json'), JSON.stringify(virtualConfig(v), null, 2) + '\n'); } return dir; } /** An empty folder for commands that need no project (apps list / check): no stray .storebound/credentials.json. */ let neutral: string | null = null; const neutralDir = (): string => (neutral ??= mkdtempSync(join(tmpdir(), 'storebound-account-'))); const cut = (s: string): string => (s.length > MAX_OUTPUT ? `[... first ${s.length - MAX_OUTPUT} characters cut ...]\n` + s.slice(-MAX_OUTPUT) : s); function credentialsOf(v: unknown): Record { if (v === undefined) return {}; if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new RpcError(ERR.invalidParams, 'credentials: object expected'); return pickCallEnv(v as Record); } /** Absolute existing folder without a storebound config (project.init), else an error. */ export function checkNewProject(p: unknown): string { if (typeof p !== 'string' || !p || !isAbsolute(p)) throw new RpcError(ERR.invalidParams, 'project: absolute folder path required'); const dir = resolve(p); let isDir = false; try { isDir = statSync(dir).isDirectory(); } catch { /* missing */ } if (!isDir) throw new RpcError(ERR.invalidParams, `project: not a folder: ${dir}`); if (CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new RpcError(ERR.invalidParams, `project: ${dir} already has a storebound config`); return dir; } /** One sidecar session: state is the secret (after hello), the token guard and the running requests. */ export class Session { private guard: TokenGuard | null = null; private running = new Map>(); private cancelled = new Set(); constructor(private readonly send: Send, private readonly run: Runner = runCommand, private readonly clock: () => number = Date.now) {} get ready(): boolean { return this.guard !== null; } /** Stops every running child (the host went away). */ shutdown(): void { for (const set of this.running.values()) for (const r of set) r.cancel(); } /** Handles one incoming message; resolves with the response (or null for notifications). */ async handle(m: Msg): Promise { if (typeof m !== 'object' || m === null || Array.isArray(m)) return { jsonrpc: '2.0', id: null, error: { code: ERR.invalidRequest, message: 'Invalid request' } }; if (m.id === undefined || m.id === null) { this.notification(m); return null; } const id = m.id; try { const result = await this.dispatch(String(m.method ?? ''), (m.params ?? {}) as Json, id); return { jsonrpc: '2.0', id, result }; } catch (e) { if (this.cancelled.has(id)) return { jsonrpc: '2.0', id, error: { code: ERR.cancelled, message: 'Request cancelled' } }; const err = e instanceof RpcError ? e : new RpcError(ERR.internal, (e as Error).message); return { jsonrpc: '2.0', id, error: { code: err.code, message: err.message, ...(err.data === undefined ? {} : { data: err.data }) } }; } finally { this.running.delete(id); this.cancelled.delete(id); } } private notification(m: Msg): void { const p = (m.params ?? {}) as Json; if (m.method === '$/cancelRequest' || m.method === 'notifications/cancelled') { const id = (p.id ?? p.requestId) as Id | undefined; if (id === undefined) return; this.cancelled.add(id); for (const r of this.running.get(id) ?? []) r.cancel(); } } private track(id: Id, r: Running): Running { if (this.cancelled.has(id)) r.cancel(); let set = this.running.get(id); if (!set) this.running.set(id, (set = new Set())); set.add(r); return r; } private async dispatch(method: string, params: Json, id: Id): Promise { if (typeof params !== 'object' || params === null || Array.isArray(params)) throw new RpcError(ERR.invalidParams, 'params must be an object'); if (method === 'session.hello') { if (this.guard) throw new RpcError(ERR.invalidRequest, 'session already started'); let secret: Buffer; try { secret = parseSecret(params.secret); } catch (e) { throw new RpcError(ERR.invalidParams, (e as Error).message); } this.guard = new TokenGuard(secret, this.clock); return { protocol: DESKTOP_PROTOCOL, server: { name: 'storebound', version: pkg.version, compiled: compiled() }, methods: describeMethods() }; } if (!this.guard) throw new RpcError(ERR.notInitialized, 'session.hello first'); switch (method) { case 'system.version': return { version: pkg.version, compiled: compiled(), platform: process.platform, arch: process.arch, bun: rt.bunVersion, runtime: rt.runtime }; case 'system.methods': return { methods: describeMethods() }; case 'system.tools': return localTools(); case 'project.inspect': return this.inspect(checkProject(params.project), {}, id); case 'portfolio.status': return this.portfolio(params, id); case 'apps.list': return this.account(['apps', 'list'], params, id, true); case 'account.check': return this.account(['apps', 'check'], params, id, false); } const tool = byMethod.get(method); if (!tool) throw new RpcError(ERR.methodNotFound, `Method not found: ${method}`); return this.tool(method, tool, params, id); } private async tool(method: string, t: ToolDef, params: Json, id: Id): Promise { for (const k of Object.keys(params)) if (!['project', 'args', 'dryRun', 'confirmToken', 'credentials', 'virtual'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`); let project: string; if (params.virtual !== undefined) { if (params.project !== undefined) throw new RpcError(ERR.invalidParams, 'project or virtual, not both'); if (t.write || !VIRTUAL_READS.has(t.name)) throw new RpcError(ERR.invalidParams, `${method}: needs an app folder (store-only apps have store reads only)`); project = virtualDir(checkVirtual(params.virtual)); } else project = NEW_PROJECT.has(t.name) ? checkNewProject(params.project) : checkProject(params.project); const args = (params.args ?? {}) as Json; if (typeof args !== 'object' || args === null || Array.isArray(args)) throw new RpcError(ERR.invalidParams, 'args must be an object'); if ('dry_run' in args) throw new RpcError(ERR.invalidParams, 'args.dry_run: use params.dryRun'); const problems = validateInput(t, args); if (problems.length) throw new RpcError(ERR.invalidParams, `Invalid arguments: ${problems.join('; ')}`, { problems }); const env = credentialsOf(params.credentials); let dryRun = true; if (params.dryRun === false) { if (!t.write) dryRun = false; else { if (params.confirmToken === undefined) throw new RpcError(ERR.confirmRequired, `${method} writes: dryRun false needs a confirmToken from the desktop host`); // Bound to the project string exactly as sent (the host mints over what it sends) and the args object. const v = this.guard!.verify(params.confirmToken, method, { project: params.project, args }); if (!v.ok) throw new RpcError(ERR.confirmInvalid, `confirm token rejected: ${v.error}`, { reason: v.error }); dryRun = false; } } else if (params.dryRun !== undefined && params.dryRun !== true) throw new RpcError(ERR.invalidParams, 'dryRun must be a boolean'); if (!t.write) dryRun = false; const built = toArgv(t, { ...args, ...(t.write ? { dry_run: dryRun } : {}) }); const json = t.write ? JSON_WRITES.has(t.name) : JSON_TOOLS.has(t.name); const argv = json ? [...built.argv, '--json'] : built.argv; if (OWNER.has(t.name)) { const allowed = OWNER_ALLOWED_ARGS[t.name] ?? []; if (argv.some(a => OWNER_FORBIDDEN_ARGS.includes(a) && !allowed.includes(a))) throw new RpcError(ERR.invalidParams, `${method}: not an owner action from here`); if (dryRun && argv.some(a => OWNER_REAL_ONLY.includes(a))) throw new RpcError(ERR.invalidParams, `${method}: owner flags only after the owner's dialog`); // the verified token is the owner's answer to the command's own question; nothing else can set it if (!dryRun) env.STOREBOUND_OWNER_CONFIRMED = '1'; } else if (TOKEN_ANSWERS.has(t.name) && !dryRun) env.STOREBOUND_OWNER_CONFIRMED = '1'; const r = this.track(id, this.run(argv, { cwd: project, env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })); const res = await r.result; if (res.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); return { method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out), ...(res.data === undefined ? {} : { data: res.data }), }; } /** apps.list / account.check: one account's credentials, no project folder. */ private async account(cmd: string[], params: Json, id: Id, list: boolean): Promise { for (const k of Object.keys(params)) if (!['credentials', 'args'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`); // exactly this account: no credential profile of the machine mixes in const env: Record = { ...credentialsOf(params.credentials), STOREBOUND_PROFILE: '' }; const play = !!env.PLAY_SERVICE_ACCOUNT, apple = !!(env.ASC_KEY_ID || env.ASC_ISSUER_ID || env.ASC_P8); if (play === apple) throw new RpcError(ERR.invalidParams, 'credentials: exactly one store account (a Play service account, or an App Store Connect key)'); const args = (params.args ?? {}) as Json; if (typeof args !== 'object' || args === null || Array.isArray(args)) throw new RpcError(ERR.invalidParams, 'args must be an object'); for (const k of Object.keys(args)) if (!(list && k === 'icons')) throw new RpcError(ERR.invalidParams, `args.${k}: unknown`); if (args.icons !== undefined && typeof args.icons !== 'boolean') throw new RpcError(ERR.invalidParams, 'args.icons: boolean'); const argv = [...cmd, '--store', play ? 'play' : 'apple', ...(args.icons === true ? ['--icons'] : []), '--json']; const r = await this.track(id, this.run(argv, { cwd: neutralDir(), env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })).result; if (r.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); if (r.data === undefined) throw new RpcError(ERR.internal, lastLine(r.out) || `exit ${r.code}`); return r.data; } private progress(id: Id, p: Json): void { this.send({ jsonrpc: '2.0', method: 'progress', params: { requestId: id, ...p } }); } private async inspect(project: string, env: Record, id: Id): Promise { const r = await this.track(id, this.run(['inspect', '--json'], { cwd: project, env, machine: true })).result; if (r.cancelled) throw new RpcError(ERR.cancelled, 'Request cancelled'); if (r.code !== 0 || !r.data) throw new RpcError(ERR.invalidParams, `project: ${lastLine(r.out) || 'config could not be loaded'}`); return r.data as ProjectSummary; } private async portfolio(params: Json, id: Id): Promise { const list = params.projects; if (!Array.isArray(list) || !list.length || list.length > 200) throw new RpcError(ERR.invalidParams, 'projects: 1-200 absolute folder paths'); const live = params.live === true; const env = credentialsOf(params.credentials); const out: AppStatus[] = new Array(list.length); let done = 0; const one = async (i: number): Promise => { const raw = list[i] as unknown; // a store-only app: { key: "store:...", virtual: { name, apple?, play? } } const virt = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw as { key?: unknown; virtual?: unknown } : null; const e: AppStatus = emptyStatus(virt ? String(virt.key ?? '') : String(raw)); out[i] = e; try { let dir: string; if (virt) { if (typeof virt.key !== 'string' || !STORE_KEY_RE.test(virt.key)) throw new RpcError(ERR.invalidParams, 'projects[].key: a store target key'); dir = virtualDir(checkVirtual(virt.virtual)); e.virtual = true; } else { dir = checkProject(raw); e.project = dir; } await fillAppStatus(e, dir, { live, virtual: !!virt, exec: (argv, cwd) => this.track(id, this.run(argv, { cwd, env, machine: true })).result }); } catch (err) { if (this.cancelled.has(id)) throw err; e.errors.push((err as Error).message); } this.progress(id, { n: ++done, total: list.length, project: e.project }); }; // A few projects at a time: each one is a few child processes (and API calls when live). let next = 0; await Promise.all(Array.from({ length: Math.min(3, list.length) }, async () => { while (next < list.length) await one(next++); })); if (this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); return { apps: out, at: new Date(this.clock()).toISOString() }; } } /** Store target keys of store-only apps ("store:ios:::;android::"). */ export const STORE_KEY_RE = /^store:[A-Za-z0-9:;._-]{1,400}$/; /** Optional local tools with versions; app-managed copies (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME) included; the * Android SDK as `play build` finds it without a project (ANDROID_HOME, ANDROID_SDK_ROOT, the default folder). */ export function localTools(): Json { return { tools: detectTools(), androidSdk: androidSdk() }; } export async function serveStdio(): Promise { const send: Send = o => { process.stdout.write(JSON.stringify(o) + '\n'); }; const session = new Session(send); console.error(`[storebound serve] ${pkg.version} on stdio (${DESKTOP_PROTOCOL}), waiting for session.hello`); let buf = ''; const decoder = new TextDecoder(); const reader = rt.stdinStream().getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; let msg: Msg; try { msg = JSON.parse(line) as Msg; } catch { send({ jsonrpc: '2.0', id: null, error: { code: ERR.parse, message: 'Parse error' } }); continue; } if (Array.isArray(msg)) { send({ jsonrpc: '2.0', id: null, error: { code: ERR.invalidRequest, message: 'Batches are not supported' } }); continue; } // The handshake must finish before anything else is read, so a request right behind it sees the session. if (!session.ready && msg.method === 'session.hello') { const r = await session.handle(msg); if (r) send(r); continue; } session.handle(msg).then(r => { if (r) send(r); }, e => send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: ERR.internal, message: String((e as Error).message) } })); } } // stdin closed: the host is gone; stop what still runs. session.shutdown(); process.exit(0); } export async function run(args: { has(n: string): boolean }): Promise { if (!args.has('stdio')) { console.error('usage: storebound serve --stdio (JSON-RPC for the storebound desktop app; for AI tools use `storebound mcp`)'); process.exit(2); } await serveStdio(); }