// storebound api: every App Store Connect and Google Play Developer API operation, from the pinned specs. // // storebound api list [--store apple|play] [--search text] [--json] // storebound api coverage [--store apple|play] [--json] // storebound api apple|play [--param k=v ...] [--body file.json] [--upload file] // [--out file] [--all-pages] [--json] [--dry] [--yes [--confirm ]] // storebound api get apple|play ... GET only (what the MCP tool api_get runs) // // GETs run right away. Writes print the exact request and are only sent with --yes in an interactive terminal; // owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments: cli/api/gate.ts) // also need the operation id typed at the prompt. Under MCP or the desktop app no write is ever sent. import { readFileSync, writeFileSync } from 'node:fs'; import { bypassOn } from './lib/bypass'; import { resolve } from 'node:path'; import type { Args } from './lib/args'; import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client'; import { decide, dedicatedCommand, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate'; import { APPLE_SPEC } from './api/apple.gen'; import { PLAY_APIS } from './api/play.gen'; import { WRAPPED } from './api/wrapped.gen'; import type { ParamValue } from './api/types'; import * as rt from './lib/rt'; import { printJson, promptOut } from './lib/log'; import { stdinIsTTY } from './lib/tty'; const STORES: Store[] = ['apple', 'play']; export const USAGE = `usage: storebound api list [--store apple|play] [--search text] [--json] storebound api coverage [--store apple|play] [--json] storebound api apple|play [--param k=v ...] [--body file.json] [--upload file] [--out file] [--all-pages] [--json] [--dry] [--yes [--confirm ]] storebound api get apple|play [--param k=v ...] [--all-pages] [--json] (GET only) GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the operation id. Agents without a terminal: --yes --confirm (must equal the operation id; plain writes only, never owner-only operations), e.g. storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance Full table: docs/api-coverage.md`; export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string } export function rows(store?: Store): Row[] { return (store ? [store] : STORES).flatMap(s => allOps(s).map(o => ({ store: s, id: o.id, method: o.method, path: o.path, gate: gateOf(s, o.id, o.method), wrapped: WRAPPED[s][o.id] ?? '', }))); } export function search(list: Row[], text: string): Row[] { const words = text.toLowerCase().split(/\s+/).filter(Boolean); return list.filter(r => words.every(w => `${r.id} ${r.method} ${r.path}`.toLowerCase().includes(w))); } export interface Coverage { apis: { api: string; title: string; operations: number; get: number; write: number; owner: number; wrapped: number }[]; resources: { store: Store; resource: string; operations: number; wrapped: number }[]; } export function coverage(store?: Store): Coverage { const list = rows(store); const groups: [string, string, Row[]][] = []; if (!store || store === 'apple') groups.push(['appstoreconnect', `${APPLE_SPEC.title} ${APPLE_SPEC.version}`, list.filter(r => r.store === 'apple')]); if (!store || store === 'play') for (const [name, a] of Object.entries(PLAY_APIS)) groups.push([name, `${a.title} (${a.revision})`, list.filter(r => r.store === 'play' && r.id.startsWith(name + '.'))]); const res = new Map(); for (const r of list) { const k = `${r.store} ${resourceOf(r)}`; const cur = res.get(k) ?? { store: r.store, resource: resourceOf(r), operations: 0, wrapped: 0 }; cur.operations++; if (r.wrapped) cur.wrapped++; res.set(k, cur); } return { apis: groups.map(([api, title, rs]) => ({ api, title, operations: rs.length, get: rs.filter(r => r.method === 'GET').length, write: rs.filter(r => r.gate === 'write').length, owner: rs.filter(r => r.gate === 'owner').length, wrapped: rs.filter(r => r.wrapped).length })), resources: [...res.values()], }; } /** --param k=v (repeatable; a repeated name becomes a list). */ export function parseParams(values: string[]): Record { const out: Record = {}; for (const kv of values) { const eq = kv.indexOf('='); if (eq <= 0) throw new Error(`--param ${kv}: expected name=value`); const k = kv.slice(0, eq), v = kv.slice(eq + 1); const cur = out[k]; out[k] = cur === undefined ? v : [...(Array.isArray(cur) ? cur : [String(cur)]), v]; } return out; } const UPLOAD_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', apk: 'application/vnd.android.package-archive' }; /** The exact request as text, the credential replaced by a placeholder. */ export function describeRequest(req: BuiltRequest): string { const lines = [`${req.method} ${req.url}`, `Authorization: Bearer <${req.op.store === 'apple' ? 'App Store Connect API key JWT' : 'Google service account token'}, not shown>`]; if (req.contentType) lines.push(`Content-Type: ${req.contentType}`); if (typeof req.body === 'string') lines.push('', JSON.stringify(JSON.parse(req.body), null, 2)); else if (req.body) lines.push('', `<${req.body.length} bytes of file data>`); return lines.join('\n'); } async function readLine(): Promise { return ((await rt.readLine()) ?? '').trim(); } export async function run(args: Args): Promise { const [sub, ...rest] = args._; const json = args.has('json'); const store = args.get('store') as Store | undefined; if (store && !STORES.includes(store)) throw new Error('--store: apple or play'); if (!sub || args.has('help')) { console.log(USAGE); return; } if (sub === 'list') { const list = args.get('search') ? search(rows(store), args.get('search')!) : rows(store); if (json) { printJson(list); return; } for (const r of list) console.log(`${r.store.padEnd(5)} ${r.method.padEnd(6)} ${r.id.padEnd(60)} ${r.path}${r.gate === 'owner' ? ' [owner only]' : ''}${r.wrapped ? ` (wrapped: ${r.wrapped})` : ''}`); console.error(`${list.length} operation(s)`); return; } if (sub === 'coverage') { const c = coverage(store); if (json) { printJson(c); return; } for (const a of c.apis) console.log(`${a.title}: ${a.operations} operations (${a.get} GET, ${a.write} write, ${a.owner} owner only), ${a.wrapped} wrapped by storebound commands`); console.log(`\n${'resource'.padEnd(62)} ops wrapped`); for (const r of c.resources) console.log(`${(r.store + ' ' + r.resource).padEnd(62)} ${String(r.operations).padStart(3)} ${String(r.wrapped).padStart(7)}`); const total = c.apis.reduce((n, a) => n + a.operations, 0); console.log(`\n${total} operations, every one callable with storebound api; full table: docs/api-coverage.md`); return; } const getOnly = sub === 'get'; const st = (getOnly ? rest.shift() : sub) as Store; if (!STORES.includes(st)) { console.error(USAGE); throw new Error(`unknown api subcommand: ${sub}`); } const [ref, maybePath] = rest; if (!ref) throw new Error(`storebound api ${getOnly ? 'get ' : ''}${st} `); const { op, params: fromPath } = resolveOp(st, ref, maybePath); const params = { ...fromPath, ...parseParams(args.many('param')) }; const body = args.get('body') ? JSON.parse(readFileSync(resolve(args.get('body')!), 'utf8')) as unknown : undefined; const uploadFile = args.get('upload'); const upload = uploadFile ? { bytes: new Uint8Array(readFileSync(resolve(uploadFile))), contentType: UPLOAD_TYPES[uploadFile.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } : undefined; const out = args.get('out'); const req = buildRequest(op, params, body, { upload, download: !!out && op.flags.includes('d') }); const kind = gateOf(st, op.id, op.method); const decision = decide({ kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly, tty: stdinIsTTY() && !!process.stdout.isTTY, agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1', confirm: args.get('confirm'), opId: op.id, bypass: bypassOn(), }); if (decision.action === 'refuse') { const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null; if (instead) console.error(`api: dedicated command: ${instead}`); throw new Error(`${op.id}: ${decision.why}`); } if (decision.action === 'dry') { const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null; if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`); if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, ...(instead ? { instead } : {}), request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } }); else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`); return; } if (decision.action === 'confirm') { console.log(describeRequest(req)); promptOut().write(`\nThis ${ownerReason(st, op.id, op.method)}. Type the operation id (${op.id}) to send it: `); if ((await readLine()) !== op.id) { console.log('not sent'); process.exitCode = 1; return; } } const root = process.env.STOREBOUND_PROJECT || process.cwd(); const auth = await authFor(st, root); const onRetry = (m: string) => console.error(`api: ${m}`); const all = args.has('all-pages'); if (all && op.method !== 'GET' && !(op.store === 'play' && /[pt]/.test(op.flags))) throw new Error(`${op.id}: --all-pages is for paged reads`); const r = all ? await sendAll(req, auth, { onRetry, onPage: n => { if (!json) console.error(`api: page ${n}`); } }) : { ...(await send(req, auth, { onRetry })), pages: 1 }; if (!json) console.error(`${req.method} ${req.url.replace(/\?.*$/, '')} -> ${r.status}${r.pages > 1 ? ` · ${r.pages} pages` : ''}${r.rateLimit ? ` · rate limit ${r.rateLimit}` : ''}`); if (r.bytes) { if (!out) throw new Error(`${op.id}: binary response (${r.bytes.length} bytes): pass --out `); writeFileSync(resolve(out), r.bytes); if (json) printJson({ status: r.status, out, bytes: r.bytes.length }); else console.log(`wrote ${out} (${r.bytes.length} bytes)`); return; } if (out) { writeFileSync(resolve(out), JSON.stringify(r.json, null, 2) + '\n'); if (!json) console.log(`wrote ${out}`); return; } if (json) printJson(r.json); else console.log(JSON.stringify(r.json, null, 2)); }