// The apps on a store account, and whether the account's key works. No storebound.config.ts needed: credentials come // from the environment or .storebound/credentials.json of the current folder (the desktop app passes one account's // key per call). Read-only. // storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team / // every Play developer account that invited the // service account) // storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix // storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json, // the same list as the desktop app's App folders) // storebound apps add [] | apps remove add / remove a folder in that list (the folder itself stays) import type { Args } from './lib/args'; import { ascKey, playServiceAccount } from './lib/credentials'; import type { AccountCheck, AppsListResult } from './lib/desktop-protocol'; import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth'; import { errorBody } from './lib/http'; import { log, printJson, warn } from './lib/log'; import { appleCheck, type Bytes, dataUrl, type Get, listAppleApps, listPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps'; import { textTable } from './lib/table'; import { ascClient } from './apple/asc'; import { REPORTING_API } from './play/google'; import * as rt from './lib/rt'; import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { CONFIG_NAMES } from './lib/config'; import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry'; type Store = 'apple' | 'play'; function stores(args: Args, root: string): Store[] { const s = args.get('store'); if (s === 'apple' || s === 'play') return [s]; if (s !== undefined) throw new Error('--store: apple or play'); // every store whose credentials are set (env, or .storebound/credentials.json in this folder) const out: Store[] = []; try { ascKey(root); out.push('apple'); } catch { /* not set */ } try { playServiceAccount(root); out.push('play'); } catch { /* not set */ } if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json)'); return out; } const bytes: Bytes = async url => { const r = await fetch(url); if (!r.ok) return null; const len = Number(r.headers.get('content-length') ?? 0); if (len > MAX_ICON_BYTES) { await r.body?.cancel(); return null; } return { type: (r.headers.get('content-type') ?? 'image/png').split(';')[0]!, data: new Uint8Array(await r.arrayBuffer()) }; }; async function appleGet(root: string): Promise { const c = await ascClient(root, ''); return path => c.asc('GET', path); } function googleGet(token: () => Promise, base: string): Get { return async path => { for (let attempt = 0; ; attempt++) { const r = await fetch(path.startsWith('http') ? path : base + path, { headers: { Authorization: 'Bearer ' + (await token()) } }); const t = await r.text(); if ((r.status === 429 || r.status >= 500) && attempt < 3) { await rt.sleep(1500 * (attempt + 1)); continue; } if (!r.ok) throw new Error(`GET ${path.replace(/^https:\/\/[^/]+/, '')} -> ${r.status} ${errorBody(t, 800)}`); return t ? JSON.parse(t) : {}; } }; } /** Play icon of one package: an edit is opened, the default language's icon listed, and the edit deleted (never committed). */ function playIcon(root: string): (pkg: string) => Promise { const token = tokenSource(playServiceAccount(root), PLAY_SCOPE); const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/'; const call = async (method: string, url: string) => { const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()) } }); const t = await r.text(); if (!r.ok) throw new Error(`${method} -> ${r.status} ${errorBody(t, 300)}`); return t ? JSON.parse(t) : {}; }; return async pkg => { const base = API + encodeURIComponent(pkg); const edit = await call('POST', `${base}/edits`); try { const details = await call('GET', `${base}/edits/${edit.id}/details`); const lang = details.defaultLanguage ?? 'en-US'; const imgs = await call('GET', `${base}/edits/${edit.id}/listings/${encodeURIComponent(lang)}/icon`); const url = imgs.images?.[0]?.url; if (typeof url !== 'string' || !/^https:\/\/[^/]+\.googleusercontent\.com\//.test(url)) return null; const img = await bytes(`${url}=s128`); return img && img.data.length <= MAX_ICON_BYTES ? dataUrl(img.type, img.data) : null; } finally { await call('DELETE', `${base}/edits/${edit.id}`).catch(() => {}); } }; } const principalOf = (root: string): string | null => { try { return playServiceAccount(root).client_email; } catch { return null; } }; export async function listApps(store: Store, root: string, icons: boolean): Promise { const at = new Date().toISOString(); if (store === 'apple') { const r = await listAppleApps(await appleGet(root), { icons, bytes }); return { store: 'ios', ...r, at }; } const sa = playServiceAccount(root); const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {}); return { store: 'android', ...r, at }; } export async function checkAccount(store: Store, root: string): Promise { if (store === 'apple') { try { // one app is enough to prove the key; the team's app count is meta.paging.total const page = await (await appleGet(root))('/v1/apps?fields[apps]=bundleId&limit=1'); return appleCheck({ apps: Number(page.meta?.paging?.total ?? (page.data ?? []).length) }); } catch (e) { return appleCheck({ error: e }); } } let principal: string | null = null; try { const sa = playServiceAccount(root); principal = sa.client_email; const page = await googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API)('/apps:search?pageSize=1000'); return playCheck(principal, { apps: (page.apps ?? []).length }); } catch (e) { return playCheck(principal, { error: e }); } } const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p); /** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */ function folders(sub: string, args: Args): void { if (sub === 'folders') { const r = readRegistry(); if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; } if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; } console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found']))); console.log(` ${tilde(registryFile())}: the same list as the desktop app's App folders`); return; } const dir = resolve(args._[1] ?? '.'); if (sub === 'add') { if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`); const r = addFolder(dir); log('apps', r.added ? `added ${tilde(r.project.path)} (it shows in the desktop app too)` : `${tilde(r.project.path)} is already in the list`); return; } if (!args._[1]) throw new Error('storebound apps remove '); const removed = removeFolder(args._[1]!.match(/^[0-9a-f]{16}$/) ? args._[1]! : dir); log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`); } export async function run(args: Args): Promise { const sub = args._[0]; const root = process.cwd(); const json = args.has('json'); if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args); if (sub === 'list') { const out: AppsListResult[] = []; for (const s of stores(args, root)) { try { out.push(await listApps(s, root, args.has('icons'))); } catch (e) { // say what is wrong and how to fix it (the same words as `apps check`), not the raw API error const c = s === 'apple' ? appleCheck({ error: e }) : playCheck(principalOf(root), { error: e }); const why = `${c.message}${c.fix ? ` Fix: ${c.fix}` : ''}`; if (args.get('store')) throw new Error(why); warn('apps', `${s}: ${why}`); } } if (json) { printJson(args.get('store') ? out[0] : out); return; } for (const r of out) { log('apps', `${r.store === 'ios' ? 'App Store Connect' : 'Google Play'}: ${r.apps.length} apps`); console.log(textTable(['name', 'id', 'bundle / package', 'platforms', 'newest'], r.apps.map(a => [a.name, a.id, a.bundleId, a.platforms.join(' '), a.latest ? `${a.latest.version} ${a.latest.state}` : '']))); for (const w of r.warnings) warn('apps', w); } return; } if (sub === 'check') { const out: AccountCheck[] = []; for (const s of stores(args, root)) out.push(await checkAccount(s, root)); if (json) { printJson(args.get('store') ? out[0] : out); return; } for (const c of out) { log('apps', `${c.store === 'ios' ? 'App Store Connect' : 'Google Play'}: ${c.status} ยท ${c.message}`); if (c.fix) console.log(` fix: ${c.fix}`); if (c.docs) console.log(` ${c.docs}`); } if (out.some(c => c.status !== 'ok')) process.exitCode = 1; return; } console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [] | apps remove '); process.exit(2); }