647 lines
61 KiB
TypeScript
647 lines
61 KiB
TypeScript
// `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, errorLine, outcomeOf,
|
|
} 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, notSentProp, 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<string, unknown>;
|
|
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]$/;
|
|
/** A live art thumbnail: https on Apple's or Google's image CDN only. */
|
|
export const ART_THUMB_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\/[^\s"'<>]{1,1500}$/;
|
|
/** The picked image of art.replace (absolute, PNG / JPEG) and the app folder file it replaces (relative, no ..). */
|
|
export const ART_FROM_RE = /^\/[^\x00-\x1f]{1,1000}\.([pP][nN][gG]|[jJ][pP][eE]?[gG])$/;
|
|
export const ART_TO_RE = /^(?![/-])(?!.*\.\.)[A-Za-z0-9 _./-]{1,500}\.(png|jpe?g|PNG|JPE?G)$/;
|
|
|
|
/** 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(['games_status', 'apple_game_center_status', 'play_games_status', 'check', 'report', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', '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', 'project_editable', 'listing_live', 'art_live', 'art_thumb']);
|
|
|
|
/** 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', 'project_edit', 'listing_set', 'art_replace']);
|
|
/** 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', 'play_upload', 'play_share', 'apple_upload', 'apple_signing_ensure', 'apple_adhoc_register']);
|
|
|
|
/** The one desktop exception to FORBIDDEN_ARGS: `scan --write-only` (scan.write, the Fix button of a privacy-labels
|
|
* finding) writes the detected declarations into the config, only after the native dialog showed its diff and the
|
|
* host minted a token. The MCP server (AI tools) never gets it (tests/fix-actions.test.ts). */
|
|
export const DESKTOP_LOCAL_WRITES: Record<string, readonly string[]> = { scan_write: ['--write-only'] };
|
|
|
|
/**
|
|
* 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: 'project_editable', title: 'Editable values', command: ['edit', '--show'], write: false, network: false,
|
|
description: 'What the editors show: listing fields per store and language as the listing files hold them, the config sections the editors change, the App Review contact (masked), the store limits and whether the files are uncommitted in git.' },
|
|
{ name: 'project_edit', title: 'Save edits to the app folder', command: ['edit'], write: true, network: false, idempotent: true,
|
|
description: 'Writes edits of the app folder\'s sources of truth: config values by path (the rest of the file, comments and formatting kept), listing fields per store and language (store/listing.md, store/appstore.md), the App Review contact (.storebound/credentials.json). The dry run shows old -> new, the diff per file and the checks (store limits, required fields, keyword and metadata rules, the whole config validated); an error stops the write. Nothing goes to a store and nothing is committed.',
|
|
props: { ops: { type: 'string', pattern: '^[\\[{][\\s\\S]{1,400000}$', description: 'The edits as JSON (one edit or a list; see `storebound edit`)' } },
|
|
required: ['ops'], args: i => ['--ops', String(i.ops)] },
|
|
{ name: 'listing_live', title: 'Live listing texts', command: ['listing', 'live'], write: false, network: true,
|
|
description: 'Read-only: the live listing texts per language on both stores (Google Play title, short and full description; App Store name, subtitle, description, keywords, promotional text, what\'s new of the editable version, else the newest).' },
|
|
{ name: 'listing_set', title: 'Change listing texts on a store', command: ['listing', 'set'], write: true, network: true,
|
|
description: 'Changes the given text fields of one language straight on one store (no listing files: store-only apps): Google Play one edit with a PATCH of that listing, committed; App Store the localization of the app info being prepared and of the editable version (promotional text also on the live one). The dry run reads the store and shows old -> new per field with the checks (store limits, keyword and metadata rules); an error stops it. Not public until the store reviews it.',
|
|
props: { store: { type: 'string', enum: ['play', 'apple'] }, lang: { type: 'string', pattern: '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$', description: 'Store language code (en-US, tr-TR, zh-Hans)' },
|
|
fields: { type: 'string', pattern: '^\\{[\\s\\S]{1,60000}$', description: 'The fields as JSON, e.g. {"short":"..."}' } },
|
|
required: ['store', 'lang', 'fields'], args: i => ['--store', String(i.store), '--lang', String(i.lang), '--fields', String(i.fields)] },
|
|
{ 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: 'art_live', title: 'Live store art', command: ['art', 'live'], write: false, network: true,
|
|
description: 'Read-only: the store art that is live per store, language and set (App Store screenshot sets and previews of the version being prepared, else the live one, and the icon; Play icon, feature graphic and screenshots per language) with thumbnail URLs and whether the app folder has the same file (pulled.json).' },
|
|
{ name: 'art_thumb', title: 'One live thumbnail', command: ['art', 'thumb'], write: false, network: true,
|
|
description: 'One thumbnail of the live store art as a data: URL (at most 2 MB), from the App Store or Google Play image CDN only.',
|
|
props: { url: { type: 'string', pattern: ART_THUMB_RE.source, description: 'A thumb URL from art.live' } },
|
|
required: ['url'], args: i => ['--url', String(i.url)] },
|
|
{ name: 'art_replace', title: 'Replace one store image', command: ['art', 'replace'], write: true, network: false, idempotent: true,
|
|
description: 'Replaces one image of the app folder (a screenshot in the renders folder, or a frame a scene shows) with a PNG / JPEG file the owner picked. The dry run shows old -> new size and the store checks (size, alpha, bytes); an error stops it. Nothing goes to a store: upload with the Play / App Store apply after its dry run.',
|
|
props: { from: { type: 'string', pattern: ART_FROM_RE.source, description: 'The picked file (absolute path)' },
|
|
to: { type: 'string', pattern: ART_TO_RE.source, description: 'The image to replace, relative to the app folder' } },
|
|
required: ['from', 'to'], args: i => ['--from', String(i.from), '--to', String(i.to)] },
|
|
{ name: 'scan_write', title: 'Write the detected declarations', command: ['scan', DESKTOP_LOCAL_WRITES.scan_write![0]!], write: true, network: false, idempotent: true,
|
|
description: 'The Fix of a scan finding (privacy-labels): writes the Data safety / App Privacy answers the scan detected from the SDKs into the config (dataSafety / appPrivacy blocks; the rest of the file kept). The dry run shows the config diff; "ask the owner" answers stay questions. Nothing goes to a store and nothing is committed.' },
|
|
{ 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'] : [])] },
|
|
// Build, sign and test-distribution commands (docs/desktop-coverage.md: the More tools tab). Their own question is
|
|
// answered by the host token (TOKEN_ANSWERS) after the native dialog showed the dry run.
|
|
{ name: 'play_build', title: 'Build the Android app bundle', command: ['play', 'build'], write: true, network: false,
|
|
description: 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound. The dry run shows the plan; nothing goes to a store.',
|
|
props: { flavor: { type: 'string', pattern: '^[A-Za-z0-9_]{1,60}$', description: 'Gradle product flavor' }, module: { type: 'string', pattern: '^[A-Za-z0-9_:-]{1,60}$', description: 'Gradle module (default app)' },
|
|
version_name: { type: 'string', pattern: '^[0-9A-Za-z.+-]{1,40}$', description: 'versionName (default: from the project)' } },
|
|
args: i => [...opt('flavor', i.flavor), ...opt('module', i.module), ...opt('version-name', i.version_name)] },
|
|
{ name: 'play_upload', title: 'Upload an AAB to a testing track', command: ['play', 'upload'], write: true, network: true,
|
|
description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it. Production: an update of a published app is `storebound play upload --track production --yes` in a terminal or pipeline (no owner step); the first-ever production release stays the owner\'s.',
|
|
props: { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' },
|
|
name: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,50}$', description: 'Release name (default: the version)' } },
|
|
args: i => [...opt('track', i.track), ...opt('status', i.status), ...opt('name', i.name)] },
|
|
{ name: 'play_share', title: 'Internal app sharing link', command: ['play', 'share'], write: true, network: true,
|
|
description: 'Uploads one AAB or APK to Google Play internal app sharing and prints the share link (anyone allowed to use the link can install it for 60 days).',
|
|
props: { aab: { type: 'string', pattern: '^(?![/-])(?!.*\\.\\.)[A-Za-z0-9 _./-]{1,500}\\.aab$', description: 'AAB path relative to the app folder' },
|
|
apk: { type: 'string', pattern: '^(?![/-])(?!.*\\.\\.)[A-Za-z0-9 _./-]{1,500}\\.apk$', description: 'APK path relative to the app folder' } },
|
|
args: i => [...opt('aab', i.aab), ...opt('apk', i.apk)] },
|
|
{ name: 'play_app_id', title: 'Play Console app id', command: ['play', 'app-id'], write: false, network: true,
|
|
description: 'Read-only: the Play Console app id (play.appId) for the package name, from the known ids, else the agent steps to find it.' },
|
|
{ name: 'apple_build', title: 'Build the iOS app', command: ['apple', 'build'], write: true, network: true,
|
|
description: 'xcodebuild archive + exportArchive with a generated ExportOptions.plist, the next build number and API key signing (Capacitor, React Native, Flutter, Unity). The dry run shows the plan; nothing goes to a store.',
|
|
props: { scheme: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,100}$', description: 'Xcode scheme (default: from the config or the project)' }, configuration: { type: 'string', pattern: '^[A-Za-z0-9_ -]{1,40}$', description: 'Build configuration (default Release)' } },
|
|
args: i => [...opt('scheme', i.scheme), ...opt('configuration', i.configuration)] },
|
|
{ name: 'apple_upload', title: 'Upload an IPA', command: ['apple', 'upload'], write: true, network: true,
|
|
description: 'Uploads the newest exported IPA to App Store Connect (API build upload, or altool), build number checked first, and waits for processing. It shows up in TestFlight; submitting stays an owner action.',
|
|
props: { via: { type: 'string', enum: ['api', 'altool'], default: 'api' }, no_wait: { type: 'boolean', default: false, description: 'Do not wait for processing' } },
|
|
args: i => [...opt('via', i.via), ...(i.no_wait === true ? ['--no-wait'] : [])] },
|
|
{ name: 'apple_signing_ensure', title: 'Set up signing', command: ['apple', 'signing', 'ensure'], write: true, network: true, idempotent: true,
|
|
description: 'Creates what signing is missing on the Apple Developer team: bundle id capabilities, a distribution certificate (key kept in .storebound/signing) and the App Store profiles, installed for Xcode. The dry run lists the items.' },
|
|
{ name: 'apple_adhoc_register', title: 'Register test devices', command: ['apple', 'adhoc', 'register'], write: true, network: true, idempotent: true,
|
|
description: 'Registers the devices of apple.adhoc.devices (or the given UDID) on the Apple Developer team. Each device uses one of the yearly slots of its product family.',
|
|
props: { udid: { type: 'string', pattern: '^[A-Fa-f0-9-]{24,40}$', description: 'One device UDID' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,50}$', description: 'Its device name' } },
|
|
args: i => [...opt('udid', i.udid), ...opt('name', i.name)] },
|
|
{ name: 'apple_adhoc_export', title: 'Export an ad-hoc IPA', command: ['apple', 'adhoc', 'export'], write: true, network: false,
|
|
description: 'xcodebuild -exportArchive of an archive with the ad-hoc / release-testing ExportOptions.plist; writes the IPA locally.',
|
|
props: { archive: { type: 'string', pattern: '^(?!-)(?!.*\\.\\.)[^\\x00-\\x1f]{1,1000}\\.xcarchive$', description: 'The .xcarchive path' } }, required: ['archive'], args: i => opt('archive', i.archive) },
|
|
{ name: 'shots_fonts', title: 'Download font subsets', command: ['shots', 'fonts'], write: true, network: true, idempotent: true,
|
|
description: 'Downloads the font subsets the screenshot copy needs (CJK, Arabic, Cyrillic ...) into the app folder.' },
|
|
{ name: 'shots_jpeg', title: 'JPEG copies of the Play renders', command: ['shots', 'jpeg'], write: true, network: false, idempotent: true,
|
|
description: 'Writes JPEG copies (quality 92) of the Play renders next to them, for faster Play uploads.',
|
|
props: { variant: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$', description: 'A/B variant id from shots.variants (default: the base renders)' }, all_variants: { type: 'boolean', default: false, description: 'The base renders and every variant' } },
|
|
args: i => [...opt('variant', i.variant), ...(i.all_variants === true ? ['--variants'] : [])] },
|
|
{ name: 'agent_install', title: 'Install for an AI tool', command: ['install'], write: true, network: false, idempotent: true,
|
|
description: 'Installs the storebound skills and the MCP server for one AI tool (Claude, Codex, Cursor, Copilot, Windsurf, Gemini, OpenCode) for this app folder.',
|
|
props: { agent: { type: 'string', enum: ['claude', 'codex', 'cursor', 'copilot', 'windsurf', 'gemini', 'opencode'] }, no_mcp: { type: 'boolean', default: false }, no_skills: { type: 'boolean', default: false } },
|
|
required: ['agent'], args: i => [...opt('agent', i.agent), ...(i.no_mcp === true ? ['--no-mcp'] : []), ...(i.no_skills === true ? ['--no-skills'] : [])] },
|
|
];
|
|
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 PLATFORM = { type: 'string', enum: ['ios', 'macos', 'tvos', 'visionos'], description: 'App Store platform of the version (default: the one that has it; when several do, the command asks)' };
|
|
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<string, unknown>, 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' }, not_sent_for_review: notSentProp },
|
|
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'), ...(i.not_sent_for_review === true ? ['--not-sent-for-review'] : [])] },
|
|
{ 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}$' }, platform: PLATFORM },
|
|
required: ['action'], args: i => ['--phased', String(i.action), ...(i.version ? ['--version', String(i.version)] : []), ...(i.platform ? ['--platform', String(i.platform)] : [])] },
|
|
{ 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, platform: PLATFORM, 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), ...opt('platform', i.platform), ...(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, platform: PLATFORM, 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), ...opt('platform', i.platform), ...(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)' }, not_sent_for_review: notSentProp },
|
|
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), ...(i.not_sent_for_review === true ? ['--not-sent-for-review'] : [])] },
|
|
];
|
|
/** 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<string, string[]> = {
|
|
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', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live', 'art_live', 'art_thumb']);
|
|
/** Writes a store-only app may run (no app folder: they send only what the call carries, never the generated config's
|
|
* placeholder links). The confirm token binds them to the store ids (`virtual`) instead of a folder. */
|
|
export const VIRTUAL_WRITES = new Set(['listing_set']);
|
|
|
|
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<string, unknown>;
|
|
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<string, unknown> | null | undefined, p = o.play as Record<string, unknown> | 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<string, unknown> {
|
|
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<string, unknown>)).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<string, string> {
|
|
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<string, unknown>);
|
|
}
|
|
|
|
/** 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<Id, Set<Running>>();
|
|
private cancelled = new Set<Id>();
|
|
|
|
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<Json | null> {
|
|
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<unknown> {
|
|
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<ToolResult> {
|
|
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_WRITES.has(t.name) : !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, params.virtual !== undefined ? { virtual: params.virtual, args } : { 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');
|
|
const outcome = outcomeOf(t.write ? '' : t.name, res.code, res.data !== undefined);
|
|
const error = outcome === 'failed' ? errorLine(res.err ?? '', res.out) : '';
|
|
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 }), outcome, ...(error ? { error } : {}), ...(res.quota ? { quota: res.quota } : {}),
|
|
};
|
|
}
|
|
|
|
/** apps.list / account.check: one account's credentials, no project folder. */
|
|
private async account(cmd: string[], params: Json, id: Id, list: boolean): Promise<unknown> {
|
|
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<string, string> = { ...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, errorLine(r.err ?? '', r.out) || `storebound ${argv[0]} ${argv[1] ?? ''} stopped without an error message (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<string, string>, id: Id): Promise<ProjectSummary> {
|
|
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<PortfolioResult> {
|
|
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<void> => {
|
|
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:<account>:<app id>:<bundle id>;android:<account>:<package>"). */
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|