- specs/: Apple's App Store Connect OpenAPI 4.5.1 and the Google discovery docs for androidpublisher v3 and playdeveloperreporting v1beta1, pinned with URL, date and SHA-256 (specs/SOURCES.json) - tools/gen-api.ts: one row per operation (cli/api/apple.gen.ts 1303, cli/api/play.gen.ts 170), a scan of which operations the commands already call (cli/api/wrapped.gen.ts) and docs/api-coverage.md; CI checks freshness - cli/api/client.ts + types.ts: generic URL / parameter building, retries with Retry-After, pagination, types derived from the rows; auth reuses ascJwt and the Play service account - cli/api/gate.ts: GET runs, writes dry unless --yes in a TTY, owner-only operations need the id typed, nothing written under MCP or serve; MCP gets the read-only api_get - storebound api list | coverage | apple|play <op|METHOD path>; skill store-api, README, CHANGELOG, tests
341 lines
19 KiB
TypeScript
341 lines
19 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Generates the operation tables of every App Store Connect and Google Play API endpoint from the pinned specs/:
|
|
// cli/api/apple.gen.ts App Store Connect API (Apple's OpenAPI document)
|
|
// cli/api/play.gen.ts Google Play Android Developer API v3 + Play Developer Reporting API v1beta1 (discovery docs)
|
|
// cli/api/wrapped.gen.ts which operations storebound's own commands already call (a scan of cli/)
|
|
// docs/api-coverage.md the full table: every operation, its gate (read / write / owner-only) and its wrapper
|
|
//
|
|
// bun tools/gen-api.ts write the files
|
|
// bun tools/gen-api.ts --check exit 1 when a committed file is stale (CI)
|
|
//
|
|
// Compact on purpose: one row of strings per operation and generic call helpers in cli/api/client.ts, no function per
|
|
// endpoint, so the npm package stays small. Types come from the rows (cli/api/types.ts derives path / query params,
|
|
// request and response shapes from the literal strings).
|
|
import { createHash } from 'node:crypto';
|
|
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import { gateOf, resourceOf, type GateKind } from '../cli/api/gate';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type J = any;
|
|
|
|
export interface Sources {
|
|
apple: { file: string; title: string; version: string; url: string; downloaded: string; sha256: string };
|
|
play: { file: string; title: string; revision: string; url: string; downloaded: string; sha256: string }[];
|
|
}
|
|
|
|
const sha256 = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex');
|
|
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
|
|
// ---------------------------------------------------------------------------------------------------------- Apple
|
|
|
|
export type AppleRow = [method: string, path: string, query: string, request: string, response: string];
|
|
|
|
/** The resource `type` a JSON:API schema's `data` carries ("" when it has none), with [] when data is a list. */
|
|
function dataType(spec: J, schema: J): string {
|
|
const s = deref(spec, schema);
|
|
const data = deref(spec, s?.properties?.data);
|
|
if (!data) return '';
|
|
if (data.type === 'array') {
|
|
const item = deref(spec, data.items);
|
|
return (item?.properties?.type?.enum?.[0] ?? '') + '[]';
|
|
}
|
|
return data.properties?.type?.enum?.[0] ?? '';
|
|
}
|
|
|
|
function deref(spec: J, s: J): J {
|
|
for (let i = 0; s && s.$ref && i < 10; i++) {
|
|
const parts = String(s.$ref).replace(/^#\//, '').split('/');
|
|
s = parts.reduce((o: J, k) => o?.[k], spec);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export function appleRows(spec: J): Record<string, AppleRow> {
|
|
const rows: [string, AppleRow][] = [];
|
|
for (const [path, item] of Object.entries<J>(spec.paths)) {
|
|
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
|
|
const op = item[method];
|
|
if (!op) continue;
|
|
const params = [...(item.parameters ?? []), ...(op.parameters ?? [])].map(p => deref(spec, p));
|
|
const query = params.filter(p => p.in === 'query').map(p => p.name + (p.required ? '!' : ''));
|
|
let request = '';
|
|
const reqSchema = op.requestBody?.content?.['application/json']?.schema;
|
|
if (reqSchema) request = dataType(spec, reqSchema) || 'json';
|
|
let response = 'none';
|
|
const ok = Object.keys(op.responses ?? {}).filter(c => /^2\d\d$/.test(c)).sort()[0];
|
|
const content = ok ? op.responses[ok].content ?? deref(spec, op.responses[ok]).content : undefined;
|
|
if (content) {
|
|
const json = content['application/json'];
|
|
if (json) {
|
|
const t = dataType(spec, json.schema);
|
|
response = !t ? 'json' : t.endsWith('[]') ? `many:${t.slice(0, -2)}` : `one:${t}`;
|
|
} else response = 'raw:' + Object.keys(content).sort()[0];
|
|
}
|
|
rows.push([op.operationId, [method.toUpperCase(), path, query.join(' '), request, response]]);
|
|
}
|
|
}
|
|
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
return Object.fromEntries(rows);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------- Google
|
|
|
|
/** [method, path (with {param} / {+param}), query params ("name", "name!" required, "name*" repeated), request schema,
|
|
* response schema, flags: m = media upload, d = media download, p = pageToken paging, t = token paging, the shape of
|
|
* each {+param} ("name=apps/* /crashRateMetricSet" without the space, * one segment; several joined by spaces)]. */
|
|
export type PlayRow = [method: string, path: string, query: string, request: string, response: string, flags: string, shapes: string];
|
|
|
|
export function playRows(doc: J): Record<string, PlayRow> {
|
|
const rows: [string, PlayRow][] = [];
|
|
const walk = (r: J) => {
|
|
for (const m of Object.values<J>(r.methods ?? {})) {
|
|
const query = Object.entries<J>(m.parameters ?? {}).filter(([, p]) => p.location === 'query')
|
|
.map(([n, p]) => n + (p.required ? '!' : '') + (p.repeated ? '*' : '')).sort();
|
|
const resp = m.response?.$ref ? doc.schemas?.[m.response.$ref] : undefined;
|
|
let flags = '';
|
|
if (m.mediaUpload) flags += 'm';
|
|
if (m.supportsMediaDownload) flags += 'd';
|
|
if (m.parameters?.pageToken?.location === 'query' && resp?.properties?.nextPageToken) flags += 'p';
|
|
if (m.parameters?.token?.location === 'query' && resp?.properties?.tokenPagination) flags += 't';
|
|
// {+name} holds slashes; its pattern (^apps/[^/]+/crashRateMetricSet$) tells the vitals metric sets apart
|
|
const shapes = Object.entries<J>(m.parameters ?? {}).filter(([n, p]) => p.location === 'path' && p.pattern && m.path.includes(`{+${n}}`))
|
|
.map(([n, p]) => `${n}=${String(p.pattern).replace(/^\^|\$$/g, '').split('[^/]+').join('*')}`).sort();
|
|
rows.push([m.id, [m.httpMethod, doc.servicePath + m.path, query.join(' '), m.request?.$ref ?? '', m.response?.$ref ?? '', flags, shapes.join(' ')]]);
|
|
}
|
|
for (const s of Object.values<J>(r.resources ?? {})) walk(s);
|
|
};
|
|
walk(doc);
|
|
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
return Object.fromEntries(rows);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------- wrapped
|
|
|
|
/** Literal prefixes the commands use for Google URLs, mapped to the path form of the Play tables. */
|
|
const PLAY_PREFIXES: [RegExp, string][] = [
|
|
[/^\$\{play\.UPLOAD\}|^\$\{UPLOAD\}/, 'androidpublisher/v3/applications/X'],
|
|
[/^\$\{play\.API\}|^\$\{API\}|^\$\{base\}/, 'androidpublisher/v3/applications/X'],
|
|
[/^https:\/\/androidpublisher\.googleapis\.com\/(upload\/)?/, ''],
|
|
[/^\$\{app\}/, 'v1beta1/apps/X'],
|
|
[/^\$\{REPORTING_API\}/, 'v1beta1'],
|
|
[/^https:\/\/playdeveloperreporting\.googleapis\.com\//, ''],
|
|
];
|
|
|
|
export interface CallSite { store: 'apple' | 'play'; method: string; path: string; file: string }
|
|
|
|
/** API calls in one source file: path literals with the HTTP method next to them (GET for the read helpers). */
|
|
export function scanSource(file: string, text: string): CallSite[] {
|
|
const out: CallSite[] = [];
|
|
const start = String.raw`(?:\/v\d\/|\$\{(?:play\.)?(?:API|UPLOAD)\}|\$\{base\}|\$\{app\}|\$\{REPORTING_API\}|https:\/\/(?:androidpublisher|playdeveloperreporting)\.googleapis\.com\/|\/apps:search)`;
|
|
// a template literal may hold ${...} with quotes inside (`${x ?? ''}`); a plain string may not
|
|
const lit = new RegExp(String.raw`\x60(${start}(?:\$\{[^}]*\}|[^\x60$]|\$(?!\{))*)\x60|'(${start}[^'\n]*)'`, 'g');
|
|
for (const m of text.matchAll(lit)) {
|
|
const before = text.slice(Math.max(0, m.index - 120), m.index);
|
|
const meth = /'(GET|POST|PATCH|PUT|DELETE)'[^'`]*$/.exec(before.split('\n').slice(-2).join('\n'));
|
|
const method = meth ? meth[1]! : 'GET';
|
|
// a trailing ${...} glued to a word is a query suffix (`:commit${o.commitQuery ?? ''}`), not part of the path
|
|
let raw = (m[1] ?? m[2]!).replace(/(?<=[A-Za-z])\$\{[^}]*\}$/, '').replace(/\$\{[^}]*\}/g, s => (PLAY_PREFIXES.some(([re]) => re.test(s)) ? s : 'X'));
|
|
raw = raw.replace(/[?].*$/, '').replace(/\$\{[^}]*$/, '');
|
|
if (raw.startsWith('/v')) {
|
|
if (/^\/v\d\/X/.test(raw)) continue; // `/v1/${type}`: resource not known statically
|
|
out.push({ store: 'apple', method, path: raw, file });
|
|
continue;
|
|
}
|
|
if (raw.startsWith('/apps:search')) { out.push({ store: 'play', method, path: 'v1beta1' + raw, file }); continue; }
|
|
if (!/androidpublisher|playdeveloperreporting|play\.API|REPORTING_API/.test(text)) continue; // another `${base}`
|
|
for (const [re, rep] of PLAY_PREFIXES) {
|
|
if (!re.test(raw)) continue;
|
|
const rest = raw.replace(re, '');
|
|
if (rest.replace(/^\//, '')) out.push({ store: 'play', method, path: (rep + (rep && !rest.startsWith('/') && !rest.startsWith(':') ? '/' : '') + rest).replace(/^\//, ''), file });
|
|
break;
|
|
}
|
|
}
|
|
// c.uploadAsset('appScreenshots', ...): reserve (POST /v1/<type>) and commit (PATCH /v1/<type>/{id})
|
|
for (const m of text.matchAll(/uploadAsset\(\s*'([A-Za-z]+)'/g)) {
|
|
out.push({ store: 'apple', method: 'POST', path: `/v1/${m[1]}`, file }, { store: 'apple', method: 'PATCH', path: `/v1/${m[1]}/X`, file });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const esc = (s: string) => s.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
|
|
|
|
/** Path template -> regex over a scanned path (X for any expression); `shapes` narrows {+param}s. */
|
|
export function templateRegex(path: string, shapes = ''): RegExp {
|
|
const shape = new Map(shapes.split(' ').filter(Boolean).map(s => [s.slice(0, s.indexOf('=')), s.slice(s.indexOf('=') + 1)] as const));
|
|
const body = path.replace(/^\//, '').split(/(\{\+?[^}]+\})/).map(part => {
|
|
const m = /^\{(\+?)([^}]+)\}$/.exec(part);
|
|
if (!m) return esc(part);
|
|
if (!m[1]) return '[^/:]+';
|
|
const s = shape.get(m[2]!);
|
|
return s ? s.split('*').map(esc).join('[^/:]+') : '.+';
|
|
}).join('');
|
|
return new RegExp('^' + body + '$');
|
|
}
|
|
|
|
export function wrappedOps(sites: CallSite[], apple: Record<string, AppleRow>, play: Record<string, PlayRow>): { apple: Record<string, string[]>; play: Record<string, string[]> } {
|
|
const res = { apple: {} as Record<string, string[]>, play: {} as Record<string, string[]> };
|
|
const index = (rows: Record<string, AppleRow | PlayRow>) => Object.entries(rows).map(([id, r]) => ({ id, method: r[0], re: templateRegex(r[1], (r as string[])[6] ?? ''), fixed: r[1].split('{')[0]!.length }));
|
|
const tables = { apple: index(apple), play: index(play) };
|
|
for (const s of sites) {
|
|
const path = s.path.replace(/^\//, '');
|
|
// the most specific template wins (longest fixed prefix): /v1/apps/X matches apps_getInstance, not a {+name} catch-all
|
|
const hits = tables[s.store].filter(o => o.method === s.method && o.re.test(path)).sort((a, b) => b.fixed - a.fixed);
|
|
const best = hits[0];
|
|
if (!best) continue;
|
|
const list = (res[s.store][best.id] ??= []);
|
|
if (!list.includes(s.file)) list.push(s.file);
|
|
}
|
|
for (const store of ['apple', 'play'] as const) {
|
|
res[store] = Object.fromEntries(Object.entries(res[store]).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, v.sort()]));
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function listTs(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const p = join(dir, name);
|
|
if (statSync(p).isDirectory()) { if (name !== 'api') out.push(...listTs(p)); }
|
|
else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function scanCli(root = ROOT): CallSite[] {
|
|
return listTs(join(root, 'cli')).flatMap(p => scanSource(relative(join(root, 'cli'), p).replace(/\.ts$/, ''), readFileSync(p, 'utf8')));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------- output
|
|
|
|
const HEAD = (what: string) => `// GENERATED by tools/gen-api.ts from ${what}. Do not edit: run \`bun tools/gen-api.ts\` (CI checks it is fresh).\n`;
|
|
|
|
export function appleFile(src: Sources['apple'], rows: Record<string, AppleRow>): string {
|
|
const lines = Object.entries(rows).map(([id, r]) => ` ${id}: [${r.map(q).join(', ')}],`);
|
|
return HEAD(`specs/${src.file} (${src.title} ${src.version}, ${src.url}, downloaded ${src.downloaded})`) +
|
|
`// One row per operation: [method, path, query parameters ("name", "name!" when required; arrays are comma-joined),
|
|
// request body resource type ("type", "type[]" for a relationship list, "json" for another body, "" for none),
|
|
// response ("many:type" | "one:type" | "json" | "raw:<media type>" for another body, sent as Accept | "none")].
|
|
export const APPLE_SPEC = { title: ${q(src.title)}, version: ${q(src.version)}, sha256: ${q(src.sha256)}, base: 'https://api.appstoreconnect.apple.com' } as const;
|
|
|
|
export const APPLE_OPS = {
|
|
${lines.join('\n')}
|
|
} as const;
|
|
`;
|
|
}
|
|
|
|
export function playFile(srcs: Sources['play'], docs: J[], rows: Record<string, PlayRow>): string {
|
|
const apis = docs.map((d, i) => ` ${d.name}: { title: ${q(srcs[i]!.title)}, version: ${q(d.version)}, revision: ${q(d.revision)}, root: ${q(d.rootUrl)}, scope: ${q(Object.keys(d.auth?.oauth2?.scopes ?? {})[0] ?? '')}, sha256: ${q(srcs[i]!.sha256)} },`);
|
|
const lines = Object.entries(rows).map(([id, r]) => ` ${q(id)}: [${r.map(q).join(', ')}],`);
|
|
return HEAD(srcs.map(s => `specs/${s.file} (${s.title}, revision ${s.revision}, ${s.url}, downloaded ${s.downloaded})`).join(' and ')) +
|
|
`// One row per method: [HTTP method, path ({p} one segment, {+p} may hold slashes), query parameters ("name", "name!"
|
|
// required, "name*" repeated), request schema, response schema, flags (m media upload, d media download, p pageToken
|
|
// paging, t token paging)]. The API is the first part of the id.
|
|
export const PLAY_APIS = {
|
|
${apis.join('\n')}
|
|
} as const;
|
|
|
|
export const PLAY_OPS = {
|
|
${lines.join('\n')}
|
|
} as const;
|
|
`;
|
|
}
|
|
|
|
export function wrappedFile(w: ReturnType<typeof wrappedOps>): string {
|
|
const block = (o: Record<string, string[]>) => Object.entries(o).map(([k, v]) => ` ${q(k)}: ${q(v.join(' '))},`).join('\n');
|
|
return HEAD('a scan of the API calls in cli/ (outside cli/api)') +
|
|
`// Operation id -> the source modules (cli/<module>.ts) of storebound commands that already call it.
|
|
export const WRAPPED: { apple: Record<string, string>; play: Record<string, string> } = {
|
|
apple: {
|
|
${block(w.apple)}
|
|
},
|
|
play: {
|
|
${block(w.play)}
|
|
},
|
|
};
|
|
`;
|
|
}
|
|
|
|
export function coverageDoc(srcs: Sources, apple: Record<string, AppleRow>, play: Record<string, PlayRow>, w: ReturnType<typeof wrappedOps>): string {
|
|
const label: Record<GateKind, string> = { read: 'read', write: 'write (dry by default)', owner: 'owner only (typed confirm)' };
|
|
const all = [
|
|
...Object.entries(apple).map(([id, r]) => ({ store: 'apple' as const, id, method: r[0], path: r[1] })),
|
|
...Object.entries(play).map(([id, r]) => ({ store: 'play' as const, id, method: r[0], path: r[1] })),
|
|
];
|
|
const sections: string[] = [];
|
|
const summary: string[] = ['| API | operations | GET | other | wrapped by a command | owner only |', '|---|---:|---:|---:|---:|---:|'];
|
|
const groups: [string, typeof all][] = [
|
|
[`App Store Connect API ${srcs.apple.version}`, all.filter(o => o.store === 'apple')],
|
|
...srcs.play.map(s => [`${s.title} (revision ${s.revision})`, all.filter(o => o.store === 'play' && o.id.startsWith(s.file.split('_')[0]! + '.'))] as [string, typeof all]),
|
|
];
|
|
for (const [title, ops] of groups) {
|
|
const wrappedSet = (o: (typeof all)[number]) => w[o.store][o.id];
|
|
summary.push(`| ${title} | ${ops.length} | ${ops.filter(o => o.method === 'GET').length} | ${ops.filter(o => o.method !== 'GET').length} | ${ops.filter(wrappedSet).length} | ${ops.filter(o => gateOf(o.store, o.id, o.method) === 'owner').length} |`);
|
|
const byRes = new Map<string, typeof all>();
|
|
for (const o of ops) { const r = resourceOf(o); byRes.set(r, [...(byRes.get(r) ?? []), o]); }
|
|
const rows = [...byRes.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).flatMap(([res, list]) => [
|
|
`| **${res}** (${list.length}) | | | | |`,
|
|
...list.map(o => `| \`${o.id}\` | ${o.method} | \`${o.path}\` | ${label[gateOf(o.store, o.id, o.method)]} | ${(wrappedSet(o) ?? []).join(', ')} |`),
|
|
]);
|
|
sections.push(`## ${title}\n\n${byRes.size} resources, ${ops.length} operations.\n\n| operation | method | path | gate | wrapped by (cli/…) |\n|---|---|---|---|---|\n${rows.join('\n')}\n`);
|
|
}
|
|
return `# API coverage
|
|
|
|
<!-- GENERATED by tools/gen-api.ts from the pinned specs/ and a scan of cli/. Do not edit. -->
|
|
|
|
Every operation of the App Store Connect API and the Google Play Developer APIs is callable with
|
|
\`storebound api apple|play <operationId> [--param k=v ...]\`. "Wrapped by" names the storebound modules whose
|
|
high-level commands already call the operation. Gates: **read** runs right away; **write** prints the exact request and
|
|
only sends with \`--yes\` in an interactive terminal; **owner only** (submit, release, delete, cancel, prices, testers,
|
|
review replies, experiments) also asks you to type the operation id, and is never reachable through MCP or the desktop app.
|
|
|
|
Specs: Apple [${srcs.apple.title} ${srcs.apple.version}](${srcs.apple.url}) (downloaded ${srcs.apple.downloaded}); ${srcs.play.map(s => `[${s.title}](${s.url}) revision ${s.revision}`).join('; ')}.
|
|
|
|
${summary.join('\n')}
|
|
|
|
${sections.join('\n')}`;
|
|
}
|
|
|
|
export interface Generated { path: string; text: string }
|
|
|
|
export function generate(root = ROOT): Generated[] {
|
|
const srcs = JSON.parse(readFileSync(join(root, 'specs/SOURCES.json'), 'utf8')) as Sources;
|
|
const read = (f: string) => {
|
|
const buf = readFileSync(join(root, 'specs', f));
|
|
return { buf, sum: sha256(buf) };
|
|
};
|
|
const a = read(srcs.apple.file);
|
|
if (a.sum !== srcs.apple.sha256) throw new Error(`specs/${srcs.apple.file}: sha256 ${a.sum} does not match specs/SOURCES.json`);
|
|
const appleSpec = JSON.parse(a.buf.toString('utf8'));
|
|
const docs = srcs.play.map(s => {
|
|
const r = read(s.file);
|
|
if (r.sum !== s.sha256) throw new Error(`specs/${s.file}: sha256 ${r.sum} does not match specs/SOURCES.json`);
|
|
return JSON.parse(r.buf.toString('utf8'));
|
|
});
|
|
const apple = appleRows(appleSpec);
|
|
const play = Object.assign({}, ...docs.map(playRows)) as Record<string, PlayRow>;
|
|
const w = wrappedOps(scanCli(root), apple, play);
|
|
return [
|
|
{ path: 'cli/api/apple.gen.ts', text: appleFile(srcs.apple, apple) },
|
|
{ path: 'cli/api/play.gen.ts', text: playFile(srcs.play, docs, play) },
|
|
{ path: 'cli/api/wrapped.gen.ts', text: wrappedFile(w) },
|
|
{ path: 'docs/api-coverage.md', text: coverageDoc(srcs, apple, play, w) },
|
|
];
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const check = process.argv.includes('--check');
|
|
let stale = 0;
|
|
for (const g of generate()) {
|
|
const p = join(ROOT, g.path);
|
|
let cur = '';
|
|
try { cur = readFileSync(p, 'utf8'); } catch { /* new */ }
|
|
if (cur === g.text) continue;
|
|
if (check) { console.error(`gen-api: ${g.path} is stale: run bun tools/gen-api.ts`); stale++; continue; }
|
|
writeFileSync(p, g.text);
|
|
console.log(`gen-api: wrote ${g.path} (${(g.text.length / 1024).toFixed(0)} KB)`);
|
|
}
|
|
if (stale) process.exit(1);
|
|
if (check) console.log('gen-api: generated API tables are fresh');
|
|
}
|