storebound/cli/api.ts

187 lines
11 KiB
TypeScript

// 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 <operationId | METHOD path> [--param k=v ...] [--body file.json] [--upload file]
// [--out file] [--all-pages] [--json] [--dry] [--yes [--confirm <operationId>]]
// storebound api get apple|play <operationId> ... 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 <operationId | METHOD path> [--param k=v ...] [--body file.json] [--upload file]
[--out file] [--all-pages] [--json] [--dry] [--yes [--confirm <operationId>]]
storebound api get apple|play <operationId> [--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 <operationId> (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<string, { store: Store; resource: string; operations: number; wrapped: number }>();
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<string, ParamValue> {
const out: Record<string, ParamValue> = {};
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<string, string> = { 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<string> {
return ((await rt.readLine()) ?? '').trim();
}
export async function run(args: Args): Promise<void> {
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} <operationId | METHOD path>`);
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 <file>`);
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));
}