analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores

This commit is contained in:
CRTkafa 2026-10-26 10:20:27 +07:00
parent b59665c661
commit 5833776a4b
15 changed files with 651 additions and 60 deletions

View file

@ -20,7 +20,7 @@
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
// 4. the default profile (none named): store keys only
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "reportsBucket"?, "label"? };
// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account
// the broker mints tokens for, and "broker": false keeps a profile on its local key.
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
@ -46,7 +46,7 @@ function fileStore(root: string): Store {
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
* for reports too; an app's Android upload keystore stays in the project. */
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL'] as const;
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'PLAY_REPORTS_BUCKET'] as const;
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
@ -66,7 +66,9 @@ export function profileEnv(profile: string): Record<string, string> {
const out: Record<string, string> = {};
for (const k of PROFILE_KEYS) {
const env = process.env[profileEnvName(profile, k)];
const v = env || (typeof stored[k] === 'string' ? stored[k] as string : '');
// PLAY_REPORTS_BUCKET is also the profile field `reportsBucket` (a bucket name, not a secret)
const own = stored[k] ?? (k === 'PLAY_REPORTS_BUCKET' ? stored.reportsBucket : undefined);
const v = env || (typeof own === 'string' ? own : '');
if (!v) continue;
// a path in the file: relative to the profile folder (inline JSON / PEM stays as it is)
const isPath = !env && !/^\s*\{/.test(v) && !/BEGIN PRIVATE KEY/.test(v) && (k === 'PLAY_SERVICE_ACCOUNT' || k === 'ASC_P8');
@ -346,6 +348,16 @@ export function ascVendorNumber(root: string): string | null {
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
}
/** The Play reports bucket (analytics statistics, sales / earnings reports): play.reportsBucket of the config, else env
* PLAY_REPORTS_BUCKET, else the credential profile's `reportsBucket` (a store-only config has no play.reportsBucket).
* Null when none is set; a value that is not a Play reports bucket throws (a bucket name is not a secret). */
export function playReportsBucket(root: string, fromConfig?: string): string | null {
const v = (fromConfig ?? value(root, 'PLAY_REPORTS_BUCKET') ?? '').trim();
if (!v) return null;
if (!/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(v)) throw new Error(`reports bucket "${v}": the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (play.reportsBucket, env PLAY_REPORTS_BUCKET or the profile field reportsBucket)`);
return v;
}
/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore
* the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */
export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] {