desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -4,6 +4,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { ascKey } from '../lib/credentials';
import { errorBody } from '../lib/http';
import { noteAsc } from '../lib/quota';
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
@ -77,6 +78,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`);
throw e;
}
noteAsc(r);
const t = await r.text();
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status);
@ -89,6 +91,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
async function getAs(path: string, accept: string): Promise<J> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
noteAsc(r);
const t = await r.text();
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await rt.sleep(2000 * attempt); continue; }
if (!r.ok) throw new Error(`GET ${path} -> ${r.status} ${errorBody(t, 800)}`);
@ -101,6 +104,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
async function bytes(path: string, accept = 'application/a-gzip'): Promise<Uint8Array | null> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
noteAsc(r);
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await rt.sleep(2000 * attempt); continue; }
if (r.status === 404) { await r.body?.cancel(); return null; }
if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`);

View file

@ -14,7 +14,7 @@ import type { Ctx } from './lib/ctx';
import { REPORTING_SCOPE } from './lib/google-auth';
import {
type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable,
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString,
type IssueSample, issuesQuery, latestDailyEnd, metricQuery, parseDiagnosticLogs, parseErrorReports, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, reportsQuery, ymd, ymdString,
} from './lib/health';
import { done, log, printJson, warn } from './lib/log';
import { plainError, storeReadError } from './lib/google-errors';
@ -55,7 +55,7 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
for (const b of builds) {
log('health', `apple: diagnostic signatures of build ${b.attributes.version}`);
for (const s of ((await c.asc('GET', `/v1/builds/${b.id}/diagnosticSignatures?limit=10`).catch(none({ data: [] }))).data ?? []) as J[])
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0) });
signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0), ...(s.id ? { id: String(s.id) } : {}) });
}
signatures.sort((a, b) => b.weight - a.weight);
log('health', 'apple: TestFlight crash reports');
@ -65,6 +65,24 @@ export async function collectAppleHealth(root: string, bundleId: string, appIdHi
return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) };
}
/** One sample report of a Play error issue (`--issue <id>`, errorReports:search over the last `days`). */
export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise<IssueSample | null> {
const g = googleClient(root, REPORTING_SCOPE);
const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`;
const set = await g.json<Parameters<typeof latestDailyEnd>[0]>('GET', `${app}/crashRateMetricSet`);
const end = latestDailyEnd(set) ?? ymd(new Date(), 2);
log('health', `play: a sample report of issue ${issueId}`);
return parseErrorReports(await g.json('GET', `${app}/errorReports:search?${reportsQuery(end, days, issueId)}`));
}
/** The logs of one App Store diagnostic signature (`--signature <id>`). */
export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise<IssueSample | null> {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(id)) throw new Error('--signature: a diagnostic signature id');
const c = await ascClient(root, bundleId, appIdHint);
log('health', `apple: logs of diagnostic signature ${id}`);
return parseDiagnosticLogs(await c.getAs(`/v1/diagnosticSignatures/${encodeURIComponent(id)}/logs?limit=1`, 'application/vnd.apple.diagnostic-logs+json'));
}
/** Both stores (or the chosen one); a store that cannot be read becomes { error } instead of failing the run. */
export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean; days: number }): Promise<HealthSnapshot> {
const { config, root } = ctx.loaded;
@ -91,10 +109,23 @@ export async function run(ctx: Ctx): Promise<void> {
const weekly = a.has('weekly');
const days = a.num('days', 7);
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
const issue = a.get('issue'), signature = a.get('signature');
if (issue || signature) {
// one issue's sample stack (the desktop's Health detail): --issue <Play error issue id> or --signature <App Store id>
const { config, root } = ctx.loaded;
if (issue && !config.play) throw new Error('--issue: the config has no play section');
if (signature && !config.apple) throw new Error('--signature: the config has no apple section');
const sample = issue ? await playIssueSample(root, config.play!.packageName, issue, Math.max(days, 28)) : await appleSignatureSample(root, config.apple!.bundleId, config.apple!.appId, signature!);
if (a.has('json')) { printJson({ sample }); return; }
console.log(sample ? [`${sample.store} ${sample.type ?? ''} ${sample.version ?? ''} ${sample.device ?? ''} ${sample.os ?? ''}`.replace(/\s+/g, ' ').trim(), ...sample.stack].join('\n') : 'no sample report in the window');
return;
}
const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days });
if (a.has('json')) { printJson(h); return; }
console.log(healthTable(h));
for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`);
const play = h.play && !('error' in h.play) ? h.play : null;
for (const i of play?.issues.filter(x => x.hint) ?? []) console.log(`\nlikely cause of ${i.cause.slice(0, 80)}: ${i.hint!.cause}\nfix: ${i.hint!.fix}`);
console.log('\n' + formatFindings(healthFindings(h)));
if (weekly) {
const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health');

22
cli/lib/crash-hints.ts Normal file
View file

@ -0,0 +1,22 @@
// "Likely cause" hints for known crash signatures, shared by `storebound health` and the desktop Health tab.
// Only entries verified in a real app (or documented by the library itself) go here: a wrong hint costs more than none.
export interface CrashHint { id: string; cause: string; fix: string }
interface Rule extends CrashHint { match: (text: string) => boolean }
const RULES: Rule[] = [
{
id: 'capacitor-permission-states-npe',
match: x => /NullPointerException/.test(x) && /com\.getcapacitor\.Bridge\.getPermissionStates/.test(x),
cause: 'A Capacitor plugin\'s checkPermissions() (for example FirebaseMessaging) ran during an activity transition (focus / visibilitychange while the app resumes or rotates), when the bridge has no activity.',
fix: 'Ask for the permission state only when App.getState() says active, the page is visible and the app has been active for a short delay (e.g. 500 ms); do not call checkPermissions() from focus / visibilitychange handlers directly.',
},
];
/** The hint for an issue's text (cause, location, top frames), or null. */
export function crashHint(...texts: (string | undefined | null)[]): CrashHint | null {
const text = texts.filter(Boolean).join('\n');
const r = RULES.find(x => x.match(text));
return r ? { id: r.id, cause: r.cause, fix: r.fix } : null;
}

View file

@ -75,6 +75,8 @@ export interface ToolResult {
outcome?: Outcome;
/** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */
error?: string;
/** Store API quota the run saw (App Store Connect X-Rate-Limit, Google 429): the desktop's read scheduler slows down on it. */
quota?: import('./quota').QuotaInfo;
}
export type Outcome = 'ok' | 'findings' | 'declined' | 'failed';

View file

@ -16,6 +16,7 @@
// https://developer.apple.com/documentation/appstoreconnectapi/power-and-performance-metrics-and-logs
// GET /v1/apps/{id}/perfPowerMetrics (application/vnd.apple.xcode-metrics+json), GET /v1/builds/{id}/diagnosticSignatures
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
import { type CrashHint, crashHint } from './crash-hints';
import type { Finding, Level } from './validate-rules';
import { mdTable, pct, textTable } from './table';
import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors';
@ -89,11 +90,79 @@ export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR';
return p.toString();
}
export interface Issue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string }
export interface Issue {
/** Likely cause of a known signature (cli/lib/crash-hints.ts). */
hint?: CrashHint;
type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string;
/** The issue id (the last part of its resource name "apps/<pkg>/errorIssues/<id>"): `health --issue <id>` reads a sample report. */
id?: string;
/** versionCode of the first and last app version with the issue; Android API levels; the last report's time. */
firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string;
}
export function parseIssues(resp: { errorIssues?: { type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; lastAppVersion?: { versionCode?: string }; issueUri?: string }[] }): Issue[] {
return (resp.errorIssues ?? []).map(i => ({ type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri }));
interface ApiIssue {
name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; issueUri?: string; lastErrorReportTime?: string;
firstAppVersion?: { versionCode?: string }; lastAppVersion?: { versionCode?: string }; firstOsVersion?: { apiLevel?: string }; lastOsVersion?: { apiLevel?: string };
}
export function parseIssues(resp: { errorIssues?: ApiIssue[] }): Issue[] {
return (resp.errorIssues ?? []).map(i => {
const extra: Partial<Issue> = {};
const id = i.name?.split('/').pop();
if (id) extra.id = id;
if (i.firstAppVersion?.versionCode) extra.firstVersion = i.firstAppVersion.versionCode;
if (i.firstOsVersion?.apiLevel) extra.firstOs = i.firstOsVersion.apiLevel;
if (i.lastOsVersion?.apiLevel) extra.lastOs = i.lastOsVersion.apiLevel;
if (i.lastErrorReportTime) extra.lastSeen = i.lastErrorReportTime;
const hint = crashHint(i.cause, i.location);
if (hint) extra.hint = hint;
return { type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '',
reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri, ...extra };
});
}
/** errorReports:search query of one issue (the newest report first), over the same UTC-midnight window as the issues. */
export function reportsQuery(end: Ymd, days: number, issueId: string): string {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(issueId)) throw new Error(`--issue: an error issue id (letters, digits, - and _), not "${issueId.slice(0, 40)}"`);
const p = new URLSearchParams(issuesQuery(end, days));
p.delete('orderBy');
p.set('filter', `errorIssueId = ${issueId}`);
p.set('pageSize', '1');
return p.toString();
}
/** One sample report of an issue (Play errorReports, App Store diagnostic logs): the stack as lines, top frame first. */
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
const MAX_FRAMES = 60;
export function parseErrorReports(resp: { errorReports?: { reportText?: string; eventTime?: string; type?: string; appVersion?: { versionCode?: string }; osVersion?: { apiLevel?: string };
deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } } }[] }): IssueSample | null {
const r = resp.errorReports?.[0];
if (!r) return null;
const stack = (r.reportText ?? '').split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim()).slice(0, MAX_FRAMES);
const dev = r.deviceModel?.marketingName ?? [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/');
return { store: 'play', stack, ...(r.eventTime ? { time: r.eventTime } : {}), ...(r.appVersion?.versionCode ? { version: r.appVersion.versionCode } : {}),
...(r.osVersion?.apiLevel ? { os: `API ${r.osVersion.apiLevel}` } : {}), ...(dev ? { device: dev } : {}), ...(r.type ? { type: r.type } : {}) };
}
interface ApiFrame { symbolName?: string; binaryName?: string; offsetIntoSymbol?: number; subFrames?: ApiFrame[] }
/** GET /v1/diagnosticSignatures/{id}/logs (application/vnd.apple.diagnostic-logs+json): the first log's heaviest call
* stack, following the first sub frame down, as "binary symbol + offset" lines. */
export function parseDiagnosticLogs(resp: { productData?: { diagnosticLogs?: { callStackTree?: { callStacks?: { callStackRootFrames?: ApiFrame[] }[] }[];
diagnosticMetaData?: { appVersion?: string; buildVersion?: string; osVersion?: string; deviceType?: string; eventTime?: string } }[] }[] }): IssueSample | null {
const log = resp.productData?.flatMap(p => p.diagnosticLogs ?? [])[0];
if (!log) return null;
const stack: string[] = [];
let f: ApiFrame | undefined = log.callStackTree?.[0]?.callStacks?.[0]?.callStackRootFrames?.[0];
while (f && stack.length < MAX_FRAMES) {
stack.push(`${f.binaryName ?? '?'} ${f.symbolName ?? '???'}${f.offsetIntoSymbol !== undefined ? ` + ${f.offsetIntoSymbol}` : ''}`);
f = f.subFrames?.[0];
}
const m = log.diagnosticMetaData ?? {};
const version = [m.appVersion, m.buildVersion ? `(${m.buildVersion})` : ''].filter(Boolean).join(' ');
return { store: 'apple', stack, ...(version ? { version } : {}), ...(m.osVersion ? { os: m.osVersion } : {}), ...(m.deviceType ? { device: m.deviceType } : {}), ...(m.eventTime ? { time: m.eventTime } : {}) };
}
export interface PlayHealth {
@ -111,7 +180,7 @@ export interface AppleHealth {
/** Latest values per metric identifier (e.g. hangRate, launchTime) from perfPowerMetrics, with the app version. */
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
/** Top diagnostic signatures of the latest builds. */
signatures: { build: string; type: string; signature: string; weight: number }[];
signatures: { build: string; type: string; signature: string; weight: number; /** `health --signature <id>` reads its logs. */ id?: string }[];
/** TestFlight crash submissions in the window (count per build). */
betaCrashes: { build: string; count: number }[];
}
@ -262,7 +331,8 @@ export function healthMarkdown(h: HealthSnapshot, o: { appName: string; previous
['user-perceived ANR rate', pct(a), pct(prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), delta(a, prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), pct(PLAY_THRESHOLDS.anr)],
]), '');
if (p.issues.length) out.push('### Top issues', '', mdTable(['type', 'cause', 'location', 'reports', 'users', 'last versionCode'],
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '');
p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), '',
...p.issues.filter(i => i.hint).map(i => `- ${i.cause.slice(0, 80)}: likely cause: ${i.hint!.cause} Fix: ${i.hint!.fix}`), ...(p.issues.some(i => i.hint) ? [''] : []));
}
const ap = h.apple && !('error' in h.apple) ? h.apple : null;
if (ap) {

51
cli/lib/quota.ts Normal file
View file

@ -0,0 +1,51 @@
// Store API quota seen by a command, passed to `storebound serve` (the desktop's read scheduler) as one marked stderr
// line per change: App Store Connect's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") and 429 /
// RESOURCE_EXHAUSTED answers of the Google APIs. Outside serve (STOREBOUND_MACHINE unset) nothing is printed.
/** Prefix of the quota line on stderr; runner.ts takes it out of the output. */
export const QUOTA_MARK = '\u001estorebound-quota ';
export interface QuotaInfo {
store: 'apple' | 'play';
/** Requests per hour of this key (App Store Connect). */
limit?: number;
/** Requests left in the current hour. */
remaining?: number;
/** The store answered 429 / RESOURCE_EXHAUSTED at least once in this run. */
throttled?: boolean;
}
/** "user-hour-lim:3600;user-hour-rem:3412;" -> { limit: 3600, remaining: 3412 }; null when it is not there. */
export function parseAscRateLimit(header: string | null | undefined): { limit: number; remaining: number } | null {
if (!header) return null;
const lim = /user-hour-lim:(\d+)/i.exec(header), rem = /user-hour-rem:(\d+)/i.exec(header);
return lim && rem ? { limit: Number(lim[1]), remaining: Number(rem[1]) } : null;
}
let last = '';
function emit(q: QuotaInfo): void {
if (process.env.STOREBOUND_MACHINE !== '1') return;
const line = JSON.stringify(q);
if (line === last) return;
last = line;
process.stderr.write(QUOTA_MARK + line + '\n');
}
/** After every App Store Connect answer. */
export function noteAsc(r: { status: number; headers: { get(n: string): string | null } }): void {
const rl = parseAscRateLimit(r.headers.get('x-rate-limit'));
if (rl) emit({ store: 'apple', ...rl, ...(r.status === 429 ? { throttled: true } : {}) });
else if (r.status === 429) emit({ store: 'apple', throttled: true });
}
/** After every Google answer (Play Developer API, Reporting API). */
export function noteGoogle(status: number): void {
if (status === 429) emit({ store: 'play', throttled: true });
}
/** Folds the marked lines of one run into one value (the latest counts; throttled if any line said so). */
export function mergeQuota(a: QuotaInfo | undefined, b: QuotaInfo): QuotaInfo {
if (!a || a.store !== b.store) return b;
const remaining = b.remaining ?? a.remaining, limit = b.limit ?? a.limit;
return { store: b.store, ...(limit === undefined ? {} : { limit }), ...(remaining === undefined ? {} : { remaining }), ...(a.throttled || b.throttled ? { throttled: true } : {}) };
}

View file

@ -4,6 +4,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RESULT_MARK } from './log';
import { mergeQuota, QUOTA_MARK, type QuotaInfo } from './quota';
import { toolPath } from './tools';
import * as rt from './rt';
@ -29,7 +30,7 @@ export interface RunOptions {
}
/** `err`: the stderr lines only (for the command's own error line). */
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean }
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean; /** Store quota the run saw (cli/lib/quota.ts). */ quota?: QuotaInfo }
export interface Running { result: Promise<RunResult>; cancel(): void }
@ -73,11 +74,16 @@ export function runCommand(argv: string[], o: RunOptions): Running {
const lines: string[] = [];
const errs: string[] = [];
let data: unknown;
let quota: QuotaInfo | undefined;
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
if (kind === 'stdout' && line.startsWith(RESULT_MARK)) {
try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); }
return;
}
if (kind === 'stderr' && line.startsWith(QUOTA_MARK)) {
try { quota = mergeQuota(quota, JSON.parse(line.slice(QUOTA_MARK.length)) as QuotaInfo); } catch { /* a broken quota line is dropped */ }
return;
}
lines.push(line);
if (kind === 'stderr') errs.push(line);
o.onLine?.(line, kind);
@ -85,7 +91,7 @@ export function runCommand(argv: string[], o: RunOptions): Running {
const result = (async (): Promise<RunResult> => {
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), ...(quota ? { quota } : {}), cancelled };
})();
return { result, cancel: () => { cancelled = true; p.kill(); } };
}

View file

@ -58,6 +58,10 @@ export const TOOLS: ToolDef[] = [
description: 'Read-only crash / ANR vitals from the Play Developer Reporting API (user-perceived rates vs the bad behavior thresholds 1.09 % / 0.47 %, worst device models, top issues) and App Store power and performance metrics, diagnostic signatures and TestFlight crash reports. Exit 1 when a metric is over a store threshold.',
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 7 } },
args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('days', i.days)] },
{ name: 'health_issue', title: 'Crash issue sample', command: ['health'], write: false, network: true,
description: 'Read-only sample report of one crash / ANR issue: a Play error issue (issue: its id from health --json) via errorReports:search, or the logs of an App Store diagnostic signature (signature: its id). The stack trace as lines, with version, device and OS.',
props: { issue: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'Play error issue id' }, signature: { type: 'string', pattern: '^[A-Za-z0-9_-]{1,200}$', description: 'App Store diagnostic signature id' } },
args: i => [...opt('issue', i.issue), ...opt('signature', i.signature)] },
{ name: 'health_report', title: 'Weekly health report', command: ['health', '--weekly'], write: true, network: true, idempotent: true,
description: 'Weekly health summary as Markdown in .storebound/health/<date>.md (plus <date>.json for the next week-over-week comparison). Local files only; dry_run prints the report.',
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } },

View file

@ -3,6 +3,7 @@ import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
@ -35,6 +36,7 @@ export async function playClient(root: string, packageName: string) {
if (!safe) throw new AmbiguousWriteError(`${method} ${short()} -> no answer (${(e as Error).message})`);
throw e;
}
noteGoogle(r.status);
const text = await r.text();
if (r.ok) return (text ? JSON.parse(text) : {}) as T;
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; }

View file

@ -3,6 +3,7 @@
import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import * as rt from '../lib/rt';
export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1';
@ -13,6 +14,7 @@ export function googleClient(root: string, scope: string) {
for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body) });
noteGoogle(r.status);
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
return r;
}

View file

@ -49,7 +49,7 @@ export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_r
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
/** Read tools whose command prints a structured result with --json. */
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current']);
@ -233,7 +233,7 @@ const SYSTEM: MethodDescriptor[] = [
];
/** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']);
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status']);
const PKG_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/;
const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,154}$/;
@ -445,7 +445,7 @@ export class Session {
const error = outcome === 'failed' ? errorLine(res.err ?? '', res.out) : '';
return {
method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out),
...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}),
...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}), ...(res.quota ? { quota: res.quota } : {}),
};
}