187 lines
11 KiB
TypeScript
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));
|
|
}
|
|
|