storebound/cli/serve.ts
CRT ece6644522 console fill: the content rating's Save draft is clicked too (the owner agreed)
Play content rating (IARC): storebound now clicks Save draft, then Next,
on each page, like App Privacy's draft saves. Google keeps one draft per
app and IARC gets nothing before Submit, which stays the owner's click.
FORM_BUTTONS, the flow gate (AUTO_DRAFT_SAVE), the console-autofill
skill and the guardrail test are updated; the test answers the open
questions as the owner and checks that Submit on the Summary is never
clicked.
2026-10-12 06:57:26 +08:00

506 lines
39 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.
// Nothing here submits for review, releases or publishes: those methods do not exist (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 { 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';
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]$/;
/** 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. */
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']);
/** 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: "Use storebound's own browser profile (sign in there) instead of the owner's Chrome sign-in (a temporary copy of only the console cookies, deleted at the end)" } },
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'] : [])] },
];
/**
* Owner actions (desktop only, kind "owner"): changes the owner decides because users see them at once. 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). Never: submitting for review, releasing an approved version (--manual-release), cancelling a submission,
* external TestFlight or Beta App Review, removing testers. 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: { type: 'string', pattern: '^[A-Za-z0-9._]{1,100}(,[A-Za-z0-9._]{1,100}){0,49}$', description: 'Comma-separated product ids' } },
args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only)] },
{ 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) },
];
/** Flags no owner action may ever produce (`--csv` only for the price sheet of owner.prices_import). */
export const OWNER_FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--submit-review', '--remove', '--invite', '--production', '--csv', 'submit', 'publish', 'distribute'];
const OWNER_ALLOWED_ARGS: Record<string, string[]> = { owner_prices_import: ['--csv'] };
/** 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.', 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<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: Bun.version };
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_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`);
// 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<unknown> {
for (const k of Object.keys(params)) if (!['credentials', 'args'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`);
const env = credentialsOf(params.credentials);
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<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. */
export function localTools(): Json {
return { tools: detectTools() };
}
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 = Bun.stdin.stream().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();
}