storebound watch: store events for every app on the machine (rejected, approved, live, halted, crash threshold, reviews) to ntfy / Telegram / webhook / OS notification, loop or --once, baseline first, events.jsonl; storebound events and MCP events_recent; docs/watch.md; tests

This commit is contained in:
CRTkafa 2026-10-22 22:38:15 +08:00
parent 4fe26326cc
commit 78c541f2b8
16 changed files with 984 additions and 4 deletions

View file

@ -20,7 +20,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = [];
const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', ...booleans]);
const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', 'once', ...booleans]);
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; }

View file

@ -50,6 +50,8 @@ export const DESKTOP_COVERAGE: Record<string, Coverage> = {
'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' },
'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' },
'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] },
'watch': { not: 'a server loop for people and agents (PM2, cron); the window already reads the stores in the background and notifies from the tray' },
'events': { not: 'the log that watch writes for terminals and agents; the window shows its own what-changed feed and tray notifications' },
'desktop': { not: 'opens or installs this window from a terminal' },
'accounts': { where: 'Accounts screen (profiles shared with the CLI)', methods: ['host:accounts_list', 'host:account_add_play', 'host:account_add_apple', 'host:account_update'] },
'doctor': { where: 'Dependencies screen', methods: ['system.tools', 'host:tools_info'] },

137
cli/lib/watch-sinks.ts Normal file
View file

@ -0,0 +1,137 @@
// Where `storebound watch` sends events: OS notification, ntfy, Telegram, a webhook. All optional, configured from the
// environment or ~/.storebound/watch.json (never printed; logs name the sink, not its URL, topic or token):
// STOREBOUND_NTFY_URL (default https://ntfy.sh) + STOREBOUND_NTFY_TOPIC [+ STOREBOUND_NTFY_TOKEN]
// STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT
// STOREBOUND_WEBHOOK_URL
// watch.json: { "notify": ["os"], "ntfy": { "url", "topic", "token" }, "telegram": { "token", "chat" }, "webhook": { "url" } }
// Builders are pure (tests check them); `send` takes the fetch and the OS runner as parameters.
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { type EventKind, watchConfigFile, type WatchEvent } from './watch';
export type SinkName = 'os' | 'ntfy' | 'telegram' | 'webhook';
export const SINK_NAMES: readonly SinkName[] = ['os', 'ntfy', 'telegram', 'webhook'];
export interface SinkConfig {
os?: true;
ntfy?: { url: string; topic: string; token?: string };
telegram?: { token: string; chat: string };
webhook?: { url: string };
}
type Env = Record<string, string | undefined>;
/** The sinks that are configured (os only when the file lists it). `only` limits and, for os, enables. */
export function sinkConfig(env: Env = process.env, file: string | null = watchConfigFile(env), only?: SinkName[]): SinkConfig {
let f: { notify?: string[]; ntfy?: Record<string, string>; telegram?: Record<string, string>; webhook?: Record<string, string> } = {};
if (file) { try { f = JSON.parse(readFileSync(file, 'utf8')); } catch { /* no file */ } }
const out: SinkConfig = {};
const topic = env.STOREBOUND_NTFY_TOPIC || f.ntfy?.topic;
if (topic) out.ntfy = { url: (env.STOREBOUND_NTFY_URL || f.ntfy?.url || 'https://ntfy.sh').replace(/\/+$/, ''), topic, ...(env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token ? { token: (env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token)! } : {}) };
const tg = { token: env.STOREBOUND_TELEGRAM_TOKEN || f.telegram?.token, chat: env.STOREBOUND_TELEGRAM_CHAT || f.telegram?.chat };
if (tg.token && tg.chat) out.telegram = { token: tg.token, chat: tg.chat };
const hook = env.STOREBOUND_WEBHOOK_URL || f.webhook?.url;
if (hook) out.webhook = { url: hook };
if (Array.isArray(f.notify) && f.notify.includes('os')) out.os = true;
if (only) {
const picked: SinkConfig = {};
for (const n of only) if (n === 'os') picked.os = true; else if (out[n]) (picked as Record<string, unknown>)[n] = out[n];
return picked;
}
return out;
}
export const configuredSinks = (c: SinkConfig): SinkName[] => SINK_NAMES.filter(n => !!c[n]);
/** `--notify os,ntfy` -> names; throws on an unknown one. */
export function parseNotify(list: string[]): SinkName[] {
for (const n of list) if (!(SINK_NAMES as readonly string[]).includes(n)) throw new Error(`--notify: ${n} is not a sink (${SINK_NAMES.join(', ')})`);
return list as SinkName[];
}
const LABEL: Record<EventKind, string> = {
rejected: 'Rejected', approved: 'Approved', live: 'Live', rollout_halted: 'Rollout halted', crash_threshold: 'Crash threshold',
review_low: 'Low review', review_needs_reply: 'Review needs a reply',
};
const TAGS: Record<EventKind, string> = {
rejected: 'x', approved: 'white_check_mark', live: 'rocket', rollout_halted: 'octagonal_sign', crash_threshold: 'warning', review_low: 'star', review_needs_reply: 'speech_balloon',
};
const URGENT: ReadonlySet<EventKind> = new Set<EventKind>(['rejected', 'rollout_halted']);
/** HTTP header values are Latin-1 text on one line. */
const header = (s: string): string => s.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7e]/g, '?').slice(0, 250);
export const titleOf = (e: WatchEvent): string => `${e.app}${e.version ? ` ${e.version}` : ''}: ${LABEL[e.kind]}`;
export const textOf = (e: WatchEvent): string => `${e.detail}\n${e.url}`;
export interface HttpRequest { url: string; headers: Record<string, string>; body: string }
export function ntfyRequest(e: WatchEvent, c: NonNullable<SinkConfig['ntfy']>): HttpRequest {
return { url: `${c.url}/${encodeURIComponent(c.topic)}`, body: e.detail,
headers: { Title: header(titleOf(e)), Tags: `${TAGS[e.kind]},${e.store}`, Click: e.url, Priority: URGENT.has(e.kind) ? 'high' : 'default', ...(c.token ? { Authorization: `Bearer ${c.token}` } : {}) } };
}
export function telegramRequest(e: WatchEvent, c: NonNullable<SinkConfig['telegram']>): HttpRequest {
return { url: `https://api.telegram.org/bot${c.token}/sendMessage`, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: c.chat, text: `${titleOf(e)}\n${textOf(e)}`.slice(0, 4000), disable_web_page_preview: true }) };
}
export function webhookRequest(e: WatchEvent, c: NonNullable<SinkConfig['webhook']>): HttpRequest {
return { url: c.url, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(e) };
}
/** The command of an OS notification (title and message travel in the environment, never inside a script string). */
export function osCommand(platform: string, title: string, message: string): { cmd: string; args: string[]; env: Record<string, string> } | null {
const env = { SB_TITLE: title.slice(0, 120), SB_MSG: message.slice(0, 400) };
if (platform === 'win32') {
const script = [
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
'$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
'$t = $x.GetElementsByTagName(\'text\')',
'$t.Item(0).AppendChild($x.CreateTextNode($env:SB_TITLE)) > $null',
'$t.Item(1).AppendChild($x.CreateTextNode($env:SB_MSG)) > $null',
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(\'storebound\').Show([Windows.UI.Notifications.ToastNotification]::new($x))',
].join('; ');
return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', script], env };
}
if (platform === 'darwin') return { cmd: 'osascript', args: ['-e', 'display notification (system attribute "SB_MSG") with title (system attribute "SB_TITLE")'], env };
if (platform === 'linux') return { cmd: 'notify-send', args: [env.SB_TITLE, env.SB_MSG], env };
return null;
}
export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>;
export type OsRunner = (c: { cmd: string; args: string[]; env: Record<string, string> }) => boolean;
export const runOs: OsRunner = c => {
try { return spawnSync(c.cmd, c.args, { env: { ...process.env, ...c.env }, timeout: 10_000, stdio: 'ignore', windowsHide: true }).status === 0; } catch { return false; }
};
/** POST with a timeout and one retry (network error, 429 or 5xx). Resolves to an error text, or null when sent. */
export async function post(fetchFn: FetchLike, r: HttpRequest, o: { timeoutMs?: number; retryMs?: number } = {}): Promise<string | null> {
let last = '';
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) await new Promise(res => setTimeout(res, o.retryMs ?? 1500));
try {
const res = await fetchFn(r.url, { method: 'POST', headers: r.headers, body: r.body, signal: AbortSignal.timeout(o.timeoutMs ?? 10_000) });
if (res.ok) return null;
last = `HTTP ${res.status}`;
if (res.status < 500 && res.status !== 429) return last;
} catch (e) { last = (e as Error).name === 'TimeoutError' ? 'timed out' : 'network error'; }
}
return last;
}
export interface SendReport { sink: SinkName; ok: boolean; error?: string }
/** Sends one event to every sink in the config. A failing sink is a report row, never an exception. */
export async function sendEvent(e: WatchEvent, c: SinkConfig, d: { fetch?: FetchLike; os?: OsRunner; platform?: string; timeoutMs?: number; retryMs?: number } = {}): Promise<SendReport[]> {
const fetchFn = d.fetch ?? (fetch as unknown as FetchLike);
const out: SendReport[] = [];
const http = async (sink: SinkName, r: HttpRequest): Promise<void> => { const err = await post(fetchFn, r, d); out.push(err ? { sink, ok: false, error: err } : { sink, ok: true }); };
if (c.os) {
const cmd = osCommand(d.platform ?? process.platform, titleOf(e), e.detail);
// unavailable (no notifier on this machine): skipped quietly, not a failure
if (cmd) { const ok = (d.os ?? runOs)(cmd); if (ok) out.push({ sink: 'os', ok: true }); }
}
if (c.ntfy) await http('ntfy', ntfyRequest(e, c.ntfy));
if (c.telegram) await http('telegram', telegramRequest(e, c.telegram));
if (c.webhook) await http('webhook', webhookRequest(e, c.webhook));
return out;
}

269
cli/lib/watch.ts Normal file
View file

@ -0,0 +1,269 @@
// `storebound watch` core, pure and offline: the snapshot an app is reduced to, the diff of two snapshots into events,
// the state file (~/.storebound/watch-state.json) and the event log (~/.storebound/events.jsonl, rotated at 5 MB).
// STOREBOUND_HOME moves both (the same folder as settings.json, cli/lib/bypass.ts). Network and process work lives in
// cli/watch.ts and cli/lib/watch-sinks.ts.
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { type ConsoleIds, consoleUrl } from './console-links';
import { lastValue, PLAY_THRESHOLDS, type HealthSnapshot, weightedAverage } from './health';
import type { StoreRelease } from './desktop-protocol';
import { PHASE_RANK } from './portfolio';
export const EVENT_KINDS = ['rejected', 'approved', 'live', 'rollout_halted', 'crash_threshold', 'review_low', 'review_needs_reply'] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export type WatchStore = 'apple' | 'play';
export interface WatchEvent {
at: string;
app: string;
store: WatchStore;
kind: EventKind;
version: string | null;
from: string | null;
to: string | null;
detail: string;
/** The absolute console deep link for the app (App Store Connect / Play Console). */
url: string;
}
/** One version on one store, reduced to what the diff needs. */
export interface RelSnap { phase: string; state: string; fraction: number | null }
/** A review the watch has seen (text trimmed; the author is never kept). */
export interface ReviewSnap { id: string; rating: number; replied: boolean; date: string; text: string }
/** What one app looked like at the last read. A part that is absent was not read (never guessed). */
export interface AppSnap {
name: string;
ids: ConsoleIds;
releases?: { apple?: Record<string, RelSnap>; play?: Record<string, RelSnap> };
/** Play: 28-day user-perceived rates (null = no data); Apple: the metrics / diagnostics rated poor. */
health?: { play?: { crash: number | null; anr: number | null }; apple?: { poor: string[] } };
healthAt?: string;
reviews?: { apple?: ReviewSnap[]; play?: ReviewSnap[] };
}
export interface WatchState { version: 1; at: string; apps: Record<string, AppSnap> }
export const STATE_VERSION = 1 as const;
export const emptyState = (): WatchState => ({ version: STATE_VERSION, at: '', apps: {} });
export const MAX_SEEN_REVIEWS = 300;
export const LOW_STARS = 2;
export const ROTATE_BYTES = 5 * 1024 * 1024;
// ---- files ----
export const watchHome = (env: Record<string, string | undefined> = process.env): string => env.STOREBOUND_HOME ?? join(homedir(), '.storebound');
export const stateFile = (env: Record<string, string | undefined> = process.env): string => join(watchHome(env), 'watch-state.json');
export const eventsFile = (env: Record<string, string | undefined> = process.env): string => join(watchHome(env), 'events.jsonl');
export const watchConfigFile = (env: Record<string, string | undefined> = process.env): string => join(watchHome(env), 'watch.json');
export function readState(file = stateFile()): WatchState {
try {
const s = JSON.parse(readFileSync(file, 'utf8')) as WatchState;
if (s && s.version === STATE_VERSION && s.apps && typeof s.apps === 'object') return s;
} catch { /* none yet, or unreadable: a new baseline */ }
return emptyState();
}
/** Atomic write (temp file, rename). */
export function writeState(s: WatchState, file = stateFile()): void {
mkdirSync(dirname(file), { recursive: true });
const tmp = `${file}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(s) + '\n');
renameSync(tmp, file);
}
/** Appends events as JSON lines; a log of `maxBytes` or more is moved to <file>.1 first (one old file is kept). */
export function appendEvents(events: WatchEvent[], file = eventsFile(), maxBytes = ROTATE_BYTES): void {
if (!events.length) return;
mkdirSync(dirname(file), { recursive: true });
try {
if (existsSync(file) && statSync(file).size >= maxBytes) {
rmSync(`${file}.1`, { force: true });
renameSync(file, `${file}.1`);
}
} catch { /* rotation is best effort; appending still works */ }
appendFileSync(file, events.map(e => JSON.stringify(e)).join('\n') + '\n');
}
/** Every event of the log (the old file first), oldest first. Unreadable lines are skipped. */
export function readEvents(file = eventsFile()): WatchEvent[] {
const out: WatchEvent[] = [];
for (const f of [`${file}.1`, file]) {
let text = '';
try { text = readFileSync(f, 'utf8'); } catch { continue; }
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try { const e = JSON.parse(line) as WatchEvent; if (e && typeof e.at === 'string' && typeof e.kind === 'string') out.push(e); } catch { /* a torn line */ }
}
}
return out;
}
/** `events` filters: since (ms back from now), app (case-insensitive name match), kind. Newest last. */
export function filterEvents(evs: WatchEvent[], o: { sinceMs?: number; now?: number; app?: string; kind?: string[] } = {}): WatchEvent[] {
const cut = o.sinceMs !== undefined ? (o.now ?? Date.now()) - o.sinceMs : null;
const app = o.app?.toLowerCase();
return evs.filter(e => (cut === null || Date.parse(e.at) >= cut) && (!app || e.app.toLowerCase() === app || e.app.toLowerCase().includes(app))
&& (!o.kind?.length || o.kind.includes(e.kind)));
}
/** "15m", "24h", "2d"; a bare number is `bare` ("m" for --every, "s" otherwise). */
export function durationMs(s: string, bare: 's' | 'm' = 's'): number {
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)?$/.exec(s.trim());
if (!m) throw new Error(`bad duration "${s}" (use 15m, 24h, 2d)`);
return Math.round(Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[(m[2] ?? bare) as 's' | 'm' | 'h' | 'd']);
}
// ---- reducing store reads to a snapshot ----
/** Per version of one store, the furthest-along row (live before rollout before review ...). */
export function releaseMap(rows: StoreRelease[], store: 'ios' | 'android'): Record<string, RelSnap> {
const out: Record<string, RelSnap & { rank: number }> = {};
for (const r of rows) {
if (r.store !== store || !r.version) continue;
const rank = PHASE_RANK[r.phase];
if (!out[r.version] || rank < out[r.version]!.rank) out[r.version] = { phase: r.phase, state: r.state, fraction: r.fraction, rank };
}
return Object.fromEntries(Object.entries(out).map(([v, { rank: _r, ...s }]) => [v, s]));
}
/** The Play rates the health findings judge (28-day user-weighted, else the window average), and Apple's poor items. */
export function healthSnap(h: HealthSnapshot): NonNullable<AppSnap['health']> {
const out: NonNullable<AppSnap['health']> = {};
if (h.play && !('error' in h.play)) {
out.play = {
crash: lastValue(h.play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(h.play.crash, 'userPerceivedCrashRate'),
anr: lastValue(h.play.anr, 'userPerceivedAnrRate28dUserWeighted') ?? weightedAverage(h.play.anr, 'userPerceivedAnrRate'),
};
}
if (h.apple && !('error' in h.apple)) {
out.apple = { poor: [
...h.apple.metrics.filter(m => m.goal === 'poor').map(m => `${m.metric} ${m.value} ${m.unit} on ${m.version}`),
...h.apple.signatures.filter(s => s.weight >= 0.25).map(s => `${s.type} build ${s.build}: ${s.signature.slice(0, 80)}`),
] };
}
return out;
}
/** Keeps what the new read has and what the old one had for a part this read could not get. Pure. */
export function mergeSnap(prev: AppSnap | undefined, next: AppSnap): AppSnap {
const seen = (a?: ReviewSnap[], b?: ReviewSnap[]): ReviewSnap[] | undefined => {
if (!a && !b) return undefined;
const byId = new Map<string, ReviewSnap>();
for (const r of [...(b ?? []), ...(a ?? [])]) byId.set(r.id, r);
return [...byId.values()].sort((x, y) => y.date.localeCompare(x.date)).slice(0, MAX_SEEN_REVIEWS);
};
const releases = { ...(prev?.releases ?? {}), ...(next.releases ?? {}) };
const health = { ...(prev?.health ?? {}), ...(next.health ?? {}) };
const reviews = { apple: seen(next.reviews?.apple, prev?.reviews?.apple), play: seen(next.reviews?.play, prev?.reviews?.play) };
const hasR = reviews.apple || reviews.play;
return { name: next.name, ids: { ...prev?.ids, ...Object.fromEntries(Object.entries(next.ids).filter(([, v]) => v)) },
...(Object.keys(releases).length ? { releases } : {}), ...(Object.keys(health).length ? { health } : {}),
...(next.healthAt ?? prev?.healthAt ? { healthAt: next.healthAt ?? prev?.healthAt } : {}),
...(hasR ? { reviews: { ...(reviews.apple ? { apple: reviews.apple } : {}), ...(reviews.play ? { play: reviews.play } : {}) } } : {}) };
}
// ---- the diff ----
const REACHING = new Set(['live', 'rollout', 'halted', 'released']);
const pct = (x: number | null): string => (x === null ? 'n/a' : `${(x * 100).toFixed(2)} %`);
const storeName = (s: WatchStore): string => (s === 'apple' ? 'App Store' : 'Google Play');
/** The console page that explains each kind, per store. Apple has no deep link for reviews or crashes in the table:
* the version page / TestFlight stand in (the app's own pages, never a made-up path). */
export function eventUrl(kind: EventKind, store: WatchStore, ids: ConsoleIds): string {
if (store === 'apple') return consoleUrl(kind === 'rejected' ? 'asc.app-review' : kind === 'crash_threshold' ? 'asc.testflight' : 'asc.version', ids);
return consoleUrl(kind === 'crash_threshold' ? 'play.vitals' : kind === 'review_low' || kind === 'review_needs_reply' ? 'play.dashboard' : 'play.production', ids);
}
const snippet = (s: string, n = 140): string => s.replace(/\s+/g, ' ').trim().slice(0, n);
/**
* Events between two snapshots of one app. `prev` undefined = the app's first read: nothing (baseline). A part that
* either side does not have is skipped, so a store that could not be read never produces an event, and a part read
* for the first time is a baseline too. Pure.
*/
export function diffApp(prev: AppSnap | undefined, next: AppSnap, at: string): WatchEvent[] {
if (!prev) return [];
const out: WatchEvent[] = [];
const ids = { ...prev.ids, ...next.ids };
const ev = (store: WatchStore, kind: EventKind, version: string | null, from: string | null, to: string | null, detail: string): void => {
out.push({ at, app: next.name, store, kind, version, from, to, detail, url: eventUrl(kind, store, ids) });
};
for (const store of ['apple', 'play'] as const) {
const p = prev.releases?.[store], n = next.releases?.[store];
if (p && n) for (const [version, now] of Object.entries(n)) {
const was = p[version];
if (was?.phase === now.phase) continue;
const from = was?.state ?? null, to = now.state, v = version;
if (now.phase === 'rejected') ev(store, 'rejected', v, from, to, `${storeName(store)} ${v}: review ended in ${to}${store === 'apple' ? ' (the reason is in the Resolution Center)' : ''}`);
else if (now.phase === 'approved') ev(store, 'approved', v, from, to, `${storeName(store)} ${v}: passed review, waiting for release (${to})`);
else if ((now.phase === 'live' || now.phase === 'rollout') && !REACHING.has(was?.phase ?? '')) {
ev(store, 'live', v, from, to, `${storeName(store)} ${v} is ${now.phase === 'live' ? 'live' : `rolling out${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`}`);
} else if (now.phase === 'halted') ev(store, 'rollout_halted', v, from, to, `${storeName(store)} ${v}: rollout halted${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`);
}
}
const pp = prev.health?.play, np = next.health?.play;
if (pp && np) {
for (const [key, label, limit] of [['crash', 'user-perceived crash rate', PLAY_THRESHOLDS.crash], ['anr', 'user-perceived ANR rate', PLAY_THRESHOLDS.anr]] as const) {
const was = pp[key], now = np[key];
if (now !== null && now >= limit && (was === null || was < limit)) {
ev('play', 'crash_threshold', null, pct(was), pct(now), `${label} (28 d) ${pct(now)} crossed the Play bad behavior threshold ${pct(limit)}`);
}
}
}
const pa = prev.health?.apple, na = next.health?.apple;
if (pa && na) for (const item of na.poor) if (!pa.poor.includes(item)) ev('apple', 'crash_threshold', null, null, 'poor', `App Store diagnostics: ${item}`);
for (const store of ['apple', 'play'] as const) {
const p = prev.reviews?.[store], n = next.reviews?.[store];
if (!p || !n) continue;
const seen = new Set(p.map(r => r.id));
for (const r of n) {
if (seen.has(r.id)) continue;
if (r.rating >= 1 && r.rating <= LOW_STARS) ev(store, 'review_low', null, null, `${r.rating} stars`, `${r.rating}-star review${r.replied ? '' : ', no reply yet'}: ${snippet(r.text)}`);
else if (!r.replied) ev(store, 'review_needs_reply', null, null, `${r.rating} stars`, `${r.rating}-star review without a reply: ${snippet(r.text)}`);
}
}
return out;
}
export interface PassTarget { key: string; name: string }
export interface AppRead { snap: AppSnap | null; errors: string[] }
/**
* One pass over the targets: `read` gives each app's new snapshot; the result is the new state, the events and the
* errors. An app whose read failed entirely keeps its old snapshot. Apps in the state that are no longer targets are
* kept (a folder removed for a while does not lose its baseline). Pure given `read`.
*/
export async function runPass(o: { targets: PassTarget[]; prev: WatchState; at: string; read: (t: PassTarget, prev: AppSnap | undefined) => Promise<AppRead>; concurrency?: number }):
Promise<{ state: WatchState; events: WatchEvent[]; errors: { app: string; error: string }[]; baseline: string[] }> {
const events: WatchEvent[] = [];
const errors: { app: string; error: string }[] = [];
const baseline: string[] = [];
const apps: Record<string, AppSnap> = { ...o.prev.apps };
let next = 0;
const worker = async (): Promise<void> => {
while (next < o.targets.length) {
const t = o.targets[next++]!;
const before = o.prev.apps[t.key];
let r: AppRead;
try { r = await o.read(t, before); } catch (e) { r = { snap: null, errors: [(e as Error).message] }; }
for (const e of r.errors) errors.push({ app: t.name, error: e });
if (!r.snap) continue;
events.push(...diffApp(before, r.snap, o.at));
if (!before) baseline.push(t.name);
apps[t.key] = mergeSnap(before, r.snap);
}
};
await Promise.all(Array.from({ length: Math.max(1, Math.min(o.concurrency ?? 2, o.targets.length)) }, worker));
return { state: { version: STATE_VERSION, at: o.at, apps }, events, errors, baseline };
}
/** A made-up event for `watch test`. */
export const testEvent = (at = new Date().toISOString()): WatchEvent =>
({ at, app: 'storebound test', store: 'apple', kind: 'live', version: '1.0.0', from: 'READY_FOR_SALE', to: 'READY_FOR_SALE', detail: 'This is a test event from storebound watch test', url: 'https://appstoreconnect.apple.com/apps' });