storebound/cli/apps.ts
CRT 36a8bf9625 CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
2026-10-13 04:03:28 +08:00

153 lines
7.7 KiB
TypeScript

// 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
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';
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<Get> {
const c = await ascClient(root, '');
return path => c.asc('GET', path);
}
function googleGet(token: () => Promise<string>, 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<string | null> {
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<AppsListResult> {
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<AccountCheck> {
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 }); }
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
const root = process.cwd();
const json = args.has('json');
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]');
process.exit(2);
}