#!/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 { const rows: [string, AppleRow][] = []; for (const [path, item] of Object.entries(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 { const rows: [string, PlayRow][] = []; const walk = (r: J) => { for (const m of Object.values(r.methods ?? {})) { const query = Object.entries(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(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(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/) and commit (PATCH /v1//{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, play: Record): { apple: Record; play: Record } { const res = { apple: {} as Record, play: {} as Record }; const index = (rows: Record) => 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 { 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:" 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 { 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): string { const block = (o: Record) => 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/.ts) of storebound commands that already call it. export const WRAPPED: { apple: Record; play: Record } = { apple: { ${block(w.apple)} }, play: { ${block(w.play)} }, }; `; } export function coverageDoc(srcs: Sources, apple: Record, play: Record, w: ReturnType): string { const label: Record = { 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(); 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 Every operation of the App Store Connect API and the Google Play Developer APIs is callable with \`storebound api apple|play [--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; 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'); }