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

@ -2,6 +2,11 @@
## Unreleased
- **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash
threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop
(`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool
`events_recent` let people and agents read what happened.
- **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped
every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each
with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour.

View file

@ -11,6 +11,8 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'],
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook; test sends a test event; --dry, --dir <folder>, --json (read-only on the stores)'],
'events': [() => import('./events'), '[--since 24h] [--app <name>] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'],
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],

View file

@ -510,6 +510,11 @@
"ops",
"show"
],
"events": [
"app",
"kind",
"since"
],
"flow": [
"apply",
"from",
@ -973,6 +978,13 @@
"weekly",
"write"
],
"watch": [
"dir",
"every",
"notify",
"once",
"stdio"
],
"web": [
"base-url",
"landing",
@ -1058,6 +1070,7 @@
"download",
"enable",
"end",
"every",
"experiment",
"expo",
"fields",
@ -1098,6 +1111,7 @@
"keychain",
"keyword",
"keywords",
"kind",
"landing",
"lang",
"languages",
@ -1140,9 +1154,11 @@
"no-wait",
"no-web",
"not-sent-for-review",
"notify",
"off",
"offline",
"older-than",
"once",
"only-decreases",
"only-keywords",
"only-missing",
@ -1444,6 +1460,9 @@
"storebound edit --ops '<json>' [--dry] [--json]",
"storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)"
],
"events": [
"storebound events [--since 24h] [--app <name>] [--kind rejected,live] [--json]"
],
"flow": [
"storebound flow list [--json] the flows of this project and their steps",
"storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem",
@ -1605,6 +1624,10 @@
"validate": [
"storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan]"
],
"watch": [
"storebound watch [--dir <folder>] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json]",
"storebound watch test [--notify ...] [--dry] sends one test event to every configured sink"
],
"web": [
"storebound web init [--out web] [--landing] [--version-code N] [--dry] [--json]",
"storebound web check [<domain>] [--json] read-only, no credentials: the live files on https://<domain>"

20
cli/events.ts Normal file
View file

@ -0,0 +1,20 @@
// `storebound events`: the events `storebound watch` recorded in ~/.storebound/events.jsonl (and events.jsonl.1), read-only.
// storebound events [--since 24h] [--app <name>] [--kind rejected,live] [--json]
// Kinds: rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply. Newest last.
import type { Args } from './lib/args';
import { done, printJson } from './lib/log';
import { textTable } from './lib/table';
import { durationMs, EVENT_KINDS, eventsFile, filterEvents, readEvents } from './lib/watch';
export function run(args: Args): Promise<void> {
const kinds = args.list('kind');
for (const k of kinds) if (!(EVENT_KINDS as readonly string[]).includes(k)) throw new Error(`--kind: ${k} is not an event kind (${EVENT_KINDS.join(', ')})`);
const since = args.get('since');
const evs = filterEvents(readEvents(), { ...(since ? { sinceMs: durationMs(since) } : {}), ...(args.get('app') ? { app: args.get('app')! } : {}), kind: kinds });
if (args.has('json')) printJson(evs);
else {
console.log(textTable(['at', 'app', 'store', 'kind', 'version', 'detail'], evs.map(e => [e.at.slice(0, 16).replace('T', ' '), e.app, e.store, e.kind, e.version ?? '', e.detail.slice(0, 90)])));
done('events', `${evs.length} event(s) in ${eventsFile()}${since ? ` since ${since}` : ''}`);
}
return Promise.resolve();
}

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' });

View file

@ -75,6 +75,11 @@ export const TOOLS: ToolDef[] = [
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' } },
args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) },
{ name: 'events_recent', title: 'Recent store events', command: ['events'], write: false, network: false,
description: 'Read-only: the store events that storebound watch recorded on this machine (rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply), newest last, each with the app, store, version, from / to state, detail and the console link. Nothing is read from the stores.',
props: { since: { type: 'string', pattern: '^\\d+(\\.\\d+)?[smhd]?$', description: 'e.g. 24h, 7d (default: all)' }, app: { type: 'string', description: 'App name (or part of it)' },
kind: { type: 'string', pattern: '^[a-z_]+(,[a-z_]+)*$', description: 'e.g. rejected or rejected,rollout_halted' } },
args: i => [...opt('since', i.since), ...opt('app', i.app), ...opt('kind', i.kind)] },
{ name: 'reviews_list', title: 'Store reviews', command: ['reviews', 'list'], write: false, network: true,
description: 'Read-only list of recent App Store and Google Play reviews (date, stars, territory / language, replied or not, text). Google Play only returns reviews from the last 7 days.',
props: { store: { type: 'string', enum: ['apple', 'play'], description: 'Default: both' }, days: { type: 'integer', minimum: 1, maximum: 365, default: 14 },

196
cli/watch.ts Normal file
View file

@ -0,0 +1,196 @@
// `storebound watch`: store events for every app on this machine, for people and agents.
// storebound watch [--dir <folder>] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json]
// storebound watch test [--notify ...] [--dry] sends one test event to every configured sink
// Apps: the machine's app folder list (~/.storebound/projects.json: folders and store-only apps, the same list as the
// desktop app) or --dir for one folder. Each pass reads release state (like `release status`), crash / ANR health
// (like `health`, at most hourly) and new reviews (like `reviews list`) per app with the credentials those commands
// use, compares with ~/.storebound/watch-state.json and emits events: rejected, approved, live, rollout_halted,
// crash_threshold, review_low (<= 2 stars), review_needs_reply (3-5 stars, no reply). The first pass of an app only
// records the baseline. Every event is appended to ~/.storebound/events.jsonl (`storebound events` reads it) and sent
// to the sinks (docs/watch.md). Read-only on the stores. STOREBOUND_HOME moves ~/.storebound.
// --every loop period, 5m or more (a bare number is minutes), with +-10 % jitter; errors back off up to 4 h
// --once one pass and exit (cron, PM2 restarts) --dry print what would be sent; nothing is sent or saved
// --json events as JSON lines on stdout
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { emptyStatus, fillAppStatus, lastLine, type Exec } from './lib/app-status';
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { consoleIdsFromConfig } from './lib/console-links';
import { profileEnv } from './lib/credentials';
import { done, log, warn } from './lib/log';
import { readRegistry } from './lib/registry';
import { runCommand } from './lib/runner';
import type { HealthSnapshot } from './lib/health';
import {
type AppRead, type AppSnap, appendEvents, durationMs, eventsFile, healthSnap, type PassTarget, readState, releaseMap, type ReviewSnap, runPass, stateFile,
testEvent, type WatchEvent, writeState,
} from './lib/watch';
import { configuredSinks, parseNotify, sendEvent, sinkConfig, type SinkConfig } from './lib/watch-sinks';
const TAG = 'watch';
const USAGE = 'usage: storebound watch [--dir <folder>] [--every 15m] [--notify os,ntfy,telegram,webhook] [--once] [--dry] [--json] | watch test [--notify ...] [--dry]';
export const HEALTH_EVERY_MS = 60 * 60_000;
const MIN_EVERY_MS = 5 * 60_000;
const MAX_BACKOFF_MS = 4 * 60 * 60_000;
const CHILD_TIMEOUT_MS = 5 * 60_000;
const REVIEW_DAYS = 3;
interface Target extends PassTarget { dir: string; virtual: boolean; env: Record<string, string> }
/** Runs a storebound child command; a hung one is cancelled after the timeout. */
const exec = (env: Record<string, string>): Exec => async (argv, cwd) => {
const run = runCommand(argv, { cwd, env, machine: true });
const timer = setTimeout(() => run.cancel(), CHILD_TIMEOUT_MS);
try { return await run.result; } finally { clearTimeout(timer); }
};
/** The apps to watch: --dir, or the registry's folders and store-only apps (a store-only app that has a folder here is read once). */
async function listTargets(args: Args): Promise<Target[]> {
const dir = args.get('dir');
if (dir) {
const d = resolve(dir);
if (!CONFIG_NAMES.some(n => existsSync(join(d, n)))) throw new Error(`--dir: no storebound.config.ts in ${d}`);
return [{ key: `dir:${d}`, name: d.split(/[\\/]/).pop() ?? d, dir: d, virtual: false, env: {} }];
}
const reg = readRegistry();
const out: Target[] = [];
for (const p of reg.projects) {
if (!CONFIG_NAMES.some(n => existsSync(join(p.path, n)))) { warn(TAG, `${p.name}: no storebound.config.ts in ${p.path}, skipped (storebound apps remove ${p.path})`); continue; }
out.push({ key: `dir:${p.path}`, name: p.name, dir: p.path, virtual: false, env: {} });
}
if (reg.storeApps?.length) {
const { checkVirtual, virtualDir } = await import('./serve');
for (const s of reg.storeApps) {
try {
if (s.store === 'apple' && !s.bundleId) throw new Error('no bundle id (storebound apps add --store apple --app-id <id> --bundle-id <id>)');
const v = checkVirtual({ name: s.name, ...(s.store === 'apple' ? { apple: { bundleId: s.bundleId, appId: s.appId } } : { play: { packageName: s.appId } }) });
out.push({ key: `store:${s.id}`, name: s.name, dir: virtualDir(v), virtual: true, env: s.account ? profileEnv(s.account) : {} });
} catch (e) { warn(TAG, `${s.name}: ${(e as Error).message}, skipped`); }
}
}
return out;
}
/** Reads one app: releases of both stores, health when due, new reviews. A part that cannot be read is left out. */
async function readApp(t: Target, prev: AppSnap | undefined, now: number): Promise<AppRead> {
const run = exec(t.env);
const errors: string[] = [];
// virtual: true skips fillAppStatus's offline `check` (only the store reads are needed here)
const e = emptyStatus(t.dir);
try { await fillAppStatus(e, t.dir, { live: true, virtual: true, exec: run }); } catch (err) { return { snap: null, errors: [(err as Error).message] }; }
const s = e.summary!;
const snap: AppSnap = { name: s.name || t.name, ids: { appleAppId: s.apple?.appId ?? null } };
if (s.play && !t.virtual && !prev?.ids.playDeveloperId) {
try { const { loadConfig } = await import('./lib/config'); Object.assign(snap.ids, consoleIdsFromConfig((await loadConfig(undefined, t.dir)).config)); } catch { /* links fall back to the console home */ }
}
const all = e.release.all ?? [];
snap.releases = {};
if (s.apple) { if (e.storeErrors.ios) errors.push(`App Store: ${e.storeErrors.ios}`); else snap.releases.apple = releaseMap(all, 'ios'); }
if (s.play) { if (e.storeErrors.android) errors.push(`Google Play: ${e.storeErrors.android}`); else snap.releases.play = releaseMap(all, 'android'); }
const due = !prev?.healthAt || now - Date.parse(prev.healthAt) >= HEALTH_EVERY_MS;
if (due && (s.apple || s.play)) {
const h = await run(['health', '--json'], t.dir);
if (h.data && typeof h.data === 'object') { snap.health = healthSnap(h.data as HealthSnapshot); snap.healthAt = new Date(now).toISOString(); }
else errors.push(`health: ${lastLine(h.out) || `exit ${h.code}`}`);
}
snap.reviews = {};
for (const [store, on] of [['apple', !!s.apple], ['play', !!s.play]] as const) {
if (!on) continue;
const r = await run(['reviews', 'list', '--store', store, '--days', String(REVIEW_DAYS), '--json'], t.dir);
if (r.code !== 0 || !Array.isArray(r.data)) { errors.push(`reviews ${store}: ${lastLine(r.out) || `exit ${r.code}`}`); continue; }
snap.reviews[store] = (r.data as { id: string; rating: number; title?: string; body?: string; date: string; reply?: unknown }[]).map((x): ReviewSnap =>
({ id: String(x.id), rating: x.rating, replied: !!x.reply, date: x.date, text: `${x.title ? x.title + ': ' : ''}${x.body ?? ''}`.slice(0, 300) }));
}
return { snap, errors };
}
const line = (e: WatchEvent): string => `${e.at.slice(0, 19)}Z ${e.app} [${e.store}] ${e.kind}${e.version ? ` ${e.version}` : ''}: ${e.detail} ${e.url}`;
function chosenSinks(args: Args): SinkConfig {
const only = args.has('notify') ? parseNotify(args.list('notify')) : undefined;
if (only) {
const have = sinkConfig();
for (const n of only) if (n !== 'os' && !have[n]) warn(TAG, `--notify ${n}: not configured (docs/watch.md), skipped`);
}
return sinkConfig(process.env, undefined, only);
}
/** Sends to the sinks; a failing sink is logged (the sink name only) and never stops anything. */
async function deliver(e: WatchEvent, c: SinkConfig, args: Args): Promise<void> {
if (args.has('dry')) { log(TAG, `dry run: would send ${e.kind} of ${e.app} to ${configuredSinks(c).join(', ') || 'no sink'}`); return; }
for (const r of await sendEvent(e, c)) if (!r.ok) warn(TAG, `${r.sink}: not sent (${r.error})`);
}
function emit(e: WatchEvent, args: Args): void {
if (args.has('json')) process.stdout.write(JSON.stringify(e) + '\n');
else console.log(line(e));
}
async function testCmd(args: Args): Promise<void> {
const c = chosenSinks(args);
const names = configuredSinks(c);
if (!names.length) { warn(TAG, 'no sink configured: set STOREBOUND_NTFY_TOPIC, STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT, STOREBOUND_WEBHOOK_URL, or pass --notify os (docs/watch.md)'); process.exitCode = 1; return; }
const e = testEvent();
emit(e, args);
if (args.has('dry')) { done(TAG, `dry run: would send the test event to ${names.join(', ')}`); return; }
const rep = await sendEvent(e, c);
for (const r of rep) (r.ok ? log : warn)(TAG, `${r.sink}: ${r.ok ? 'sent' : `not sent (${r.error})`}`);
const missing = names.filter(n => !rep.some(r => r.sink === n));
if (missing.length) log(TAG, `${missing.join(', ')}: unavailable on this machine, skipped`);
if (rep.some(r => !r.ok)) process.exitCode = 1;
done(TAG, `test event: ${rep.filter(r => r.ok).length}/${names.length} sink(s) delivered`);
}
export function everyMs(args: Args): number {
const ms = durationMs(args.str('every', '15m'), 'm');
if (ms < MIN_EVERY_MS) throw new Error('--every: 5m at least (the stores rate-limit their APIs)');
return ms;
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
if (sub === 'test') return testCmd(args);
if (sub) throw new Error(USAGE);
const every = everyMs(args);
const sinks = chosenSinks(args);
const once = args.has('once');
let stopping = false, wake: (() => void) | null = null;
const stop = (sig: string): void => {
if (stopping) process.exit(130);
stopping = true;
log(TAG, `${sig}: finishing the pass in progress, then stopping`);
wake?.();
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
let n = 0, failures = 0;
log(TAG, `${once ? 'one pass' : `every ${Math.round(every / 60_000)}m`}, sinks: ${configuredSinks(sinks).join(', ') || 'none (events go to the log and stdout)'}${args.has('dry') ? ', dry run' : ''}`);
while (!stopping) {
n++;
let ok = false, count = 0, events = 0, errs = 0;
try {
const targets = await listTargets(args);
if (!targets.length) warn(TAG, 'no app folders: storebound apps add <folder> (or --dir <folder>)');
const now = Date.now();
const r = await runPass({ targets, prev: readState(), at: new Date(now).toISOString(), concurrency: 2, read: (t, prev) => readApp(t as Target, prev, now) });
count = targets.length; events = r.events.length; errs = r.errors.length;
for (const x of r.errors) warn(TAG, `${x.app}: ${x.error.slice(0, 200)}`);
if (r.baseline.length) log(TAG, `baseline recorded for ${r.baseline.join(', ')} (no events on a first read)`);
for (const e of r.events) { emit(e, args); await deliver(e, sinks, args); }
if (!args.has('dry')) { appendEvents(r.events); writeState(r.state); }
ok = !(count > 0 && errs >= count);
} catch (e) { errs++; warn(TAG, `pass failed: ${(e as Error).message.slice(0, 300)}`); }
failures = ok ? 0 : failures + 1;
const wait = Math.min(MAX_BACKOFF_MS, every * 2 ** Math.min(failures, 6)) * (failures ? 1 : 0.9 + Math.random() * 0.2);
log(TAG, `pass ${n}: ${count} app(s), ${events} event(s), ${errs} error(s)${once || stopping ? '' : `, next in ${Math.round(wait / 60_000)}m${failures ? ` (backoff after ${failures} failed pass(es))` : ''}`}`);
if (once || stopping) break;
await new Promise<void>(res => { const t = setTimeout(res, wait); wake = () => { clearTimeout(t); res(); }; });
wake = null;
}
if (once && failures) process.exitCode = 1;
log(TAG, `stopped (state ${stateFile()}, events ${eventsFile()})`);
}

View file

@ -5,7 +5,7 @@ Every CLI command (cli/commands.ts) and the desktop place that runs it. "covered
(serve.ts, or a host command `host:`) and the window calls it; tests/desktop-coverage.test.ts fails on a gap.
Owner-only parts are owner actions in the window or owner steps with their terminal command (More tools).
**97 commands:** 91 covered, 0 gaps, 6 not for the desktop.
**99 commands:** 91 covered, 0 gaps, 8 not for the desktop.
| command | status | where in the window | methods | owner step |
|---|---|---|---|---|
@ -13,6 +13,8 @@ Owner-only parts are owner actions in the window or owner steps with their termi
| `import` | covered | App folders / first-run guide: Import from fastlane | `host:project_import_fastlane` | |
| `install` | covered | More tools: Install for an AI tool | `agent.install` | |
| `mcp` | not-for-desktop | the stdio server for AI tools; the window itself is the GUI over the same commands | | |
| `watch` | not-for-desktop | 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-for-desktop | the log that watch writes for terminals and agents; the window shows its own what-changed feed and tray notifications | | |
| `serve` | not-for-desktop | the JSON-RPC sidecar the window runs on; nothing to click | | |
| `apps` | covered | Accounts (store apps per account, key check) and App folders (list, add, remove) | `host:store_apps_refresh`, `host:account_check`, `host:projects_list`, `host:project_add`, `host:project_remove` | |
| `desktop` | not-for-desktop | opens or installs this window from a terminal | | |

67
docs/watch.md Normal file
View file

@ -0,0 +1,67 @@
# storebound watch
Store events for every app on this machine, for people and agents. Read-only on the stores.
```
storebound watch # loop, every 15 minutes
storebound watch --once # one pass (cron, PM2 restarts)
storebound watch --dir ./my-app # one folder instead of the machine's app list
storebound watch --notify os,ntfy # choose the sinks (default: every configured one)
storebound watch --dry --json # print the events that would go out; nothing is sent or saved
storebound watch test # send one test event to every configured sink
storebound events --since 24h --kind rejected --json
```
Apps come from the machine's app folder list (`storebound apps folders`, the same list as the desktop app: folders
and store-only apps) or from `--dir`. Each app is read with the credentials its commands use (the project's
`.storebound/`, its profile, or the store-only app's account).
Each pass reads release state per store (like `release status`), crash / ANR health (like `health`, at most once an
hour) and new reviews (like `reviews list`), compares with `~/.storebound/watch-state.json` and emits events. The first
pass of an app only records the baseline: no event storm. A store that cannot be read in a pass keeps its last state
and produces no event.
## Events
| kind | when |
| --- | --- |
| `rejected` | a version went from in review to rejected (App Store; link to App Review) |
| `approved` | a version passed review and waits for release (App Store pending developer release) |
| `live` | a version became live, or its rollout started |
| `rollout_halted` | a Play staged rollout was halted, or an App Store phased release paused |
| `crash_threshold` | Play crash or ANR rate (28 d) crossed the bad behavior threshold; a new App Store metric or diagnostic rated poor |
| `review_low` | a new review of 2 stars or less |
| `review_needs_reply` | a new review of 3-5 stars without a reply |
Each event is one JSON object: `{ at, app, store, kind, version, from, to, detail, url }`, where `url` is the absolute
console link for the app. Events are appended to `~/.storebound/events.jsonl` (rotated at 5 MB, one `.1` file kept).
`STOREBOUND_HOME` moves `~/.storebound`. `storebound events` and the MCP tool `events_recent` read the file.
## Sinks
All optional. Configure with environment variables or `~/.storebound/watch.json`; values are never printed.
| sink | variables |
| --- | --- |
| ntfy | `STOREBOUND_NTFY_TOPIC` (required), `STOREBOUND_NTFY_URL` (default `https://ntfy.sh`), `STOREBOUND_NTFY_TOKEN` (optional). Title, Tags and Click headers; Priority high for `rejected` and `rollout_halted` |
| Telegram | `STOREBOUND_TELEGRAM_TOKEN` and `STOREBOUND_TELEGRAM_CHAT` |
| webhook | `STOREBOUND_WEBHOOK_URL` (JSON POST of the event) |
| OS notification | `--notify os`, or `"notify": ["os"]` in watch.json (Windows toast, macOS osascript, Linux notify-send; skipped quietly when unavailable) |
```json
{ "notify": ["os"], "ntfy": { "topic": "..." }, "telegram": { "token": "...", "chat": "..." }, "webhook": { "url": "..." } }
```
Network calls time out after 10 s and are retried once. A failing sink is logged (by name) and never stops the watch.
## On a server with PM2
```
pm2 start "npx -y storebound watch --notify ntfy" --name storebound-watch
pm2 save
```
Put the credentials profiles (`~/.storebound/credentials/`) and the app folder list (`storebound apps add <folder>`)
on that server, and the sink variables in the PM2 environment (`STOREBOUND_NTFY_TOPIC=...`). The loop polls every
`--every` (5m at least, 15m default, +-10 % jitter), backs off after failed passes (up to 4 h), writes one heartbeat
line per pass and stops cleanly on SIGINT / SIGTERM. With cron use `--once`.

View file

@ -51,6 +51,10 @@ Fix the top signature first, then check its device / OS column: one model or one
installs but is not a rollback.
4. Next week: `storebound health --weekly` shows the change in percentage points next to last week.
## Watching for changes
- `storebound watch` polls every app on this machine and records store events (rejected, approved, live, rollout_halted, crash_threshold, review_low, review_needs_reply) in `~/.storebound/events.jsonl`; sinks: OS notification, ntfy, Telegram, webhook (docs/watch.md).
- `storebound events --since 24h [--app <name>] [--kind rejected] --json` (MCP `events_recent`) reads that log without touching the stores: start a session there before polling the stores yourself.
## Gotchas
- `vitals not readable`: the API is not enabled, the service account lacks the permission, or the app has no release
yet. `validate` only notes it; `storebound health` warns.

View file

@ -8,7 +8,7 @@ import { describeMethods } from '../cli/serve';
import { analyze, render } from '../tools/gen-desktop-coverage';
/** The only commands that make no sense in a window. Adding one here needs a reason in the coverage map. */
const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab'];
const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab', 'watch', 'events'];
describe('desktop coverage', () => {
const { rows, extra } = analyze();

View file

@ -17,7 +17,7 @@ import { installStores, uninstallStores } from './helpers/fake-stores';
const CLI = resolve(import.meta.dir, '../cli');
/** Never run here: long-lived servers, and the ones that install or open things on the machine. */
const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws']);
const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws', 'watch']); // watch: a loop that prints JSON lines (tests/watch.test.ts)
/** Commands that need a subcommand (or arguments) to reach their --json output. */
const ARGV: Record<string, string[]> = {
'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'],

248
tests/watch.test.ts Normal file
View file

@ -0,0 +1,248 @@
import { describe, expect, test } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TOOLS, toArgv } from '../cli/mcp-tools';
import type { HealthSnapshot } from '../cli/lib/health';
import {
type AppSnap, appendEvents, diffApp, durationMs, eventsFile, filterEvents, healthSnap, mergeSnap, readEvents, readState, releaseMap, type RelSnap, runPass, stateFile,
testEvent, type WatchEvent, writeState,
} from '../cli/lib/watch';
import { type FetchLike, ntfyRequest, osCommand, post, sendEvent, sinkConfig, telegramRequest, webhookRequest } from '../cli/lib/watch-sinks';
const AT = '2026-10-22T10:00:00.000Z';
const rel = (phase: string, state: string, fraction: number | null = null): RelSnap => ({ phase, state, fraction });
const snap = (o: Partial<AppSnap> = {}): AppSnap => ({ name: 'Demo App', ids: { appleAppId: '6400000000', playDeveloperId: '111', playAppId: '222' }, ...o });
const kinds = (es: WatchEvent[]) => es.map(e => `${e.store}:${e.kind}`);
const tmp = () => mkdtempSync(join(tmpdir(), 'sb-watch-'));
describe('watch diff', () => {
test('a first read of an app is a baseline: no events', () => {
expect(diffApp(undefined, snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]);
});
test('App Store: in review -> rejected, pending developer release, live', () => {
const was = snap({ releases: { apple: { '1.2.0': rel('review', 'IN_REVIEW') } } });
const rejected = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('rejected', 'REJECTED') } } }), AT);
expect(rejected).toHaveLength(1);
expect(rejected[0]).toMatchObject({ app: 'Demo App', store: 'apple', kind: 'rejected', version: '1.2.0', from: 'IN_REVIEW', to: 'REJECTED', at: AT });
expect(rejected[0]!.url).toBe('https://appstoreconnect.apple.com/apps/6400000000/distribution/reviewsubmissions');
const approved = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT);
expect(kinds(approved)).toEqual(['apple:approved']);
expect(approved[0]!.url).toContain('/apps/6400000000/distribution/ios/version');
const live = diffApp(snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), snap({ releases: { apple: { '1.2.0': rel('live', 'READY_FOR_SALE') } } }), AT);
expect(kinds(live)).toEqual(['apple:live']);
});
test('Google Play: review -> live, rollout starts, rollout halted', () => {
const play = (r: RelSnap) => snap({ releases: { play: { '1.2.0': r } } });
expect(kinds(diffApp(play(rel('review', 'completed')), play(rel('live', 'completed')), AT))).toEqual(['play:live']);
const started = diffApp(play(rel('review', 'completed')), play(rel('rollout', 'inProgress', 0.1)), AT);
expect(kinds(started)).toEqual(['play:live']);
expect(started[0]!.detail).toContain('10 %');
expect(started[0]!.url).toBe('https://play.google.com/console/u/0/developers/111/app/222/tracks/production');
const halted = diffApp(play(rel('rollout', 'inProgress', 0.1)), play(rel('halted', 'halted', 0.1)), AT);
expect(kinds(halted)).toEqual(['play:rollout_halted']);
});
test('no event for an unchanged phase, a rollout step, a rollout completing, or a store that was not read', () => {
const a = snap({ releases: { apple: { '1.0': rel('rollout', 'READY_FOR_SALE', 0.1) }, play: { '1.0': rel('rollout', 'inProgress', 0.1) } } });
const b = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE', 1) }, play: { '1.0': rel('rollout', 'inProgress', 0.5) } } });
expect(diffApp(a, b, AT)).toEqual([]);
expect(diffApp(a, snap(), AT)).toEqual([]);
expect(diffApp(snap(), snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]);
});
test('a new version that appears already live is an event; one that appears in review is not', () => {
const was = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE') } } });
expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('review', 'WAITING_FOR_REVIEW') } } }), AT))).toEqual([]);
expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('live', 'READY_FOR_SALE') } } }), AT))).toEqual(['apple:live']);
});
test('crash threshold: only the crossing, per metric and per store', () => {
const play = (crash: number | null, anr: number | null) => snap({ health: { play: { crash, anr } } });
const cross = diffApp(play(0.005, 0.001), play(0.012, 0.001), AT);
expect(kinds(cross)).toEqual(['play:crash_threshold']);
expect(cross[0]).toMatchObject({ from: '0.50 %', to: '1.20 %' });
expect(cross[0]!.url).toContain('/vitals/crashes');
expect(diffApp(play(0.012, 0.001), play(0.015, 0.001), AT)).toEqual([]);
expect(kinds(diffApp(play(null, null), play(0.012, 0.005), AT))).toEqual(['play:crash_threshold', 'play:crash_threshold']);
expect(diffApp(play(0.012, 0), play(0.002, 0), AT)).toEqual([]);
const apple = (poor: string[]) => snap({ health: { apple: { poor } } });
expect(kinds(diffApp(apple([]), apple(['hangRate 2 s on 1.2']), AT))).toEqual(['apple:crash_threshold']);
expect(diffApp(apple(['hangRate 2 s on 1.2']), apple(['hangRate 2 s on 1.2']), AT)).toEqual([]);
});
test('reviews: new low review, new unanswered review, nothing for seen or answered ones', () => {
const r = (id: string, rating: number, replied = false) => ({ id, rating, replied, date: '2026-10-22T09:00:00Z', text: `text of ${id}` });
const was = snap({ reviews: { apple: [r('a', 5)], play: [r('p', 1)] } });
const now = snap({ reviews: { apple: [r('a', 5), r('b', 1), r('c', 4), r('d', 5, true)], play: [r('p', 1), r('q', 2)] } });
const evs = diffApp(was, now, AT);
expect(evs.map(e => `${e.store}:${e.kind}:${e.to}`)).toEqual(['apple:review_low:1 stars', 'apple:review_needs_reply:4 stars', 'play:review_low:2 stars']);
expect(evs[0]!.detail).toContain('text of b');
expect(diffApp(was, was, AT)).toEqual([]);
});
test('every event kind is producible and carries the full shape', () => {
const all = [
...diffApp(snap({ releases: { apple: { '1': rel('review', 'IN_REVIEW') }, play: { '1': rel('rollout', 'inProgress') } } }),
snap({ releases: { apple: { '1': rel('rejected', 'REJECTED') }, play: { '1': rel('halted', 'halted') } } }), AT),
...diffApp(snap({ releases: { apple: { '2': rel('review', 'IN_REVIEW') } } }), snap({ releases: { apple: { '2': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT),
...diffApp(snap({ releases: { apple: { '3': rel('approved', 'X') } } }), snap({ releases: { apple: { '3': rel('live', 'READY_FOR_SALE') } } }), AT),
...diffApp(snap({ health: { play: { crash: 0, anr: 0 } } }), snap({ health: { play: { crash: 0.02, anr: 0 } } }), AT),
...diffApp(snap({ reviews: { play: [] } }), snap({ reviews: { play: [{ id: 'x', rating: 1, replied: false, date: AT, text: 't' }, { id: 'y', rating: 5, replied: false, date: AT, text: 't' }] } }), AT),
];
expect([...new Set(all.map(e => e.kind))].sort()).toEqual(['approved', 'crash_threshold', 'live', 'rejected', 'review_low', 'review_needs_reply', 'rollout_halted']);
for (const e of all) { expect(Object.keys(e).sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect(e.url).toMatch(/^https:\/\//); }
});
});
describe('watch snapshots and a pass', () => {
test('releaseMap keeps the furthest-along row per version; healthSnap reads rates and poor items', () => {
const rows = [
{ store: 'android', version: '1.0', build: '1', state: 'completed', phase: 'testing', fraction: null, track: 'beta' },
{ store: 'android', version: '1.0', build: '2', state: 'inProgress', phase: 'rollout', fraction: 0.2, track: 'production' },
{ store: 'ios', version: '1.0', build: '2', state: 'IN_REVIEW', phase: 'review', fraction: null, track: null },
] as Parameters<typeof releaseMap>[0];
expect(releaseMap(rows, 'android')).toEqual({ '1.0': { phase: 'rollout', state: 'inProgress', fraction: 0.2 } });
expect(Object.keys(releaseMap(rows, 'ios'))).toEqual(['1.0']);
const h: HealthSnapshot = { date: '2026-10-22',
play: { window: { start: 'a', end: 'b', days: 7 }, crash: [{ date: 'd', dims: {}, values: { userPerceivedCrashRate28dUserWeighted: 0.012 } }], anr: [], issues: [] },
apple: { metrics: [{ category: 'c', metric: 'hangRate', unit: 's', version: '1.0', value: 2, goal: 'poor' }], signatures: [], betaCrashes: [] } };
expect(healthSnap(h)).toEqual({ play: { crash: 0.012, anr: null }, apple: { poor: ['hangRate 2 s on 1.0'] } });
expect(healthSnap({ date: 'd', play: { error: 'x' } })).toEqual({});
});
test('mergeSnap keeps a part this read did not get, and the seen reviews', () => {
const old = snap({ releases: { apple: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'a', rating: 5, replied: true, date: '2026-10-01', text: '' }] } });
const m = mergeSnap(old, snap({ releases: { play: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'b', rating: 5, replied: true, date: '2026-10-02', text: '' }] } }));
expect(Object.keys(m.releases!).sort()).toEqual(['apple', 'play']);
expect(m.reviews!.play!.map(r => r.id)).toEqual(['b', 'a']);
});
test('runPass: baseline pass emits nothing, the second pass emits, an unreadable app keeps its state', async () => {
const targets = [{ key: 'dir:/a', name: 'A' }, { key: 'dir:/b', name: 'B' }];
let phase = 'review', failB = false;
const read = async (t: { key: string; name: string }) => {
if (t.name === 'B' && failB) return { snap: null, errors: ['boom'] };
return { snap: snap({ name: t.name, releases: { apple: { '1': rel(phase, phase === 'live' ? 'READY_FOR_SALE' : 'IN_REVIEW') } } }), errors: [] };
};
const one = await runPass({ targets, prev: readState(join(tmp(), 'none.json')), at: AT, read });
expect(one.events).toEqual([]);
expect(one.baseline).toEqual(['A', 'B']);
phase = 'live'; failB = true;
const two = await runPass({ targets, prev: one.state, at: AT, read });
expect(two.events.map(e => e.app)).toEqual(['A']);
expect(two.errors).toEqual([{ app: 'B', error: 'boom' }]);
expect(two.state.apps['dir:/b']!.releases!.apple!['1']!.phase).toBe('review');
});
});
describe('events.jsonl and state files', () => {
const home = () => ({ STOREBOUND_HOME: tmp() });
const ev = (o: Partial<WatchEvent> = {}): WatchEvent => ({ ...testEvent(AT), ...o });
test('paths follow STOREBOUND_HOME', () => {
const env = home();
expect(eventsFile(env)).toBe(join(env.STOREBOUND_HOME, 'events.jsonl'));
expect(stateFile(env)).toBe(join(env.STOREBOUND_HOME, 'watch-state.json'));
});
test('append writes one JSON per line and reads back; a torn line is skipped', () => {
const f = eventsFile(home());
appendEvents([ev({ app: 'A' }), ev({ app: 'B' })], f);
appendEvents([ev({ app: 'C' })], f);
writeFileSync(f, readFileSync(f, 'utf8') + '{"at":"2026\n');
expect(readEvents(f).map(e => e.app)).toEqual(['A', 'B', 'C']);
});
test('rotation at the size limit keeps one .1 file and reads both', () => {
const f = eventsFile(home());
appendEvents([ev({ app: 'old' })], f, 50);
appendEvents([ev({ app: 'new' })], f, 50);
expect(existsSync(`${f}.1`)).toBe(true);
expect(readFileSync(f, 'utf8').trim().split('\n')).toHaveLength(1);
appendEvents([ev({ app: 'newer' })], f, 50);
appendEvents([ev({ app: 'newest' })], f, 50);
expect(readEvents(f).map(e => e.app)).toEqual(['newer', 'newest']);
expect(readFileSync(`${f}.1`, 'utf8')).toContain('newer');
});
test('state round trip; an unreadable file is a fresh baseline', () => {
const f = stateFile(home());
expect(readState(f).apps).toEqual({});
writeState({ version: 1, at: AT, apps: { k: snap() } }, f);
expect(readState(f).apps.k!.name).toBe('Demo App');
writeFileSync(f, '{nope');
expect(readState(f).apps).toEqual({});
});
test('events filters: since, app, kind', () => {
const now = Date.parse(AT);
const evs = [ev({ at: '2026-10-22T09:00:00Z', app: 'Alpha', kind: 'live' }), ev({ at: '2026-10-20T09:00:00Z', app: 'Beta', kind: 'rejected' }), ev({ at: '2026-10-22T08:00:00Z', app: 'Beta', kind: 'rejected' })];
expect(filterEvents(evs, { sinceMs: durationMs('24h'), now }).map(e => e.app)).toEqual(['Alpha', 'Beta']);
expect(filterEvents(evs, { app: 'beta' })).toHaveLength(2);
expect(filterEvents(evs, { kind: ['live'] })).toHaveLength(1);
expect(filterEvents(evs, { app: 'beta', kind: ['rejected'], sinceMs: durationMs('2d'), now })).toHaveLength(1);
expect(durationMs('15', 'm')).toBe(15 * 60_000);
expect(() => durationMs('soon')).toThrow();
});
});
describe('watch sinks', () => {
const e = (o: Partial<WatchEvent> = {}): WatchEvent => ({ ...testEvent(AT), app: 'Demo App', version: '1.2.0', kind: 'rejected', detail: 'rejected', url: 'https://appstoreconnect.apple.com/apps/1', ...o });
test('sinkConfig reads env, a file, and --notify limits', () => {
expect(sinkConfig({}, null)).toEqual({});
const c = sinkConfig({ STOREBOUND_NTFY_TOPIC: 't', STOREBOUND_NTFY_URL: 'https://ntfy.example.com/', STOREBOUND_TELEGRAM_TOKEN: 'tok', STOREBOUND_TELEGRAM_CHAT: '42', STOREBOUND_WEBHOOK_URL: 'https://hook.example.com/x' }, null);
expect(c).toEqual({ ntfy: { url: 'https://ntfy.example.com', topic: 't' }, telegram: { token: 'tok', chat: '42' }, webhook: { url: 'https://hook.example.com/x' } });
expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null).ntfy!.url).toBe('https://ntfy.sh');
expect(sinkConfig({ STOREBOUND_TELEGRAM_TOKEN: 'tok' }, null).telegram).toBeUndefined();
expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null, ['os'])).toEqual({ os: true });
const file = join(tmp(), 'watch.json');
writeFileSync(file, JSON.stringify({ notify: ['os'], webhook: { url: 'https://h.example.com' } }));
expect(sinkConfig({}, file)).toEqual({ os: true, webhook: { url: 'https://h.example.com' } });
});
test('ntfy: headers (Title, Tags, Click, Priority) and body', () => {
const r = ntfyRequest(e(), { url: 'https://ntfy.sh', topic: 'my topic' });
expect(r.url).toBe('https://ntfy.sh/my%20topic');
expect(r.body).toBe('rejected');
expect(r.headers).toEqual({ Title: 'Demo App 1.2.0: Rejected', Tags: 'x,apple', Click: 'https://appstoreconnect.apple.com/apps/1', Priority: 'high' });
expect(ntfyRequest(e({ kind: 'live' }), { url: 'https://ntfy.sh', topic: 't', token: 'k' }).headers).toMatchObject({ Priority: 'default', Tags: 'rocket,apple', Authorization: 'Bearer k' });
expect(ntfyRequest(e({ kind: 'rollout_halted' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Priority).toBe('high');
expect(ntfyRequest(e({ app: 'Café\nApp' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Title).toBe('Caf? App 1.2.0: Rejected');
});
test('telegram and webhook bodies', () => {
const t = telegramRequest(e(), { token: 'TOKEN', chat: '42' });
expect(t.url).toBe('https://api.telegram.org/botTOKEN/sendMessage');
expect(JSON.parse(t.body)).toEqual({ chat_id: '42', text: 'Demo App 1.2.0: Rejected\nrejected\nhttps://appstoreconnect.apple.com/apps/1', disable_web_page_preview: true });
const w = webhookRequest(e(), { url: 'https://hook.example.com' });
expect(w.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(w.body)).toEqual(e());
});
test('os command per platform keeps the text out of the script', () => {
expect(osCommand('win32', 'T', 'M')!.cmd).toBe('powershell');
expect(osCommand('win32', 'T"; evil', 'M')!.args.join(' ')).not.toContain('evil');
expect(osCommand('darwin', 'T', 'M')).toMatchObject({ cmd: 'osascript', env: { SB_TITLE: 'T', SB_MSG: 'M' } });
expect(osCommand('linux', 'T', 'M')).toMatchObject({ cmd: 'notify-send', args: ['T', 'M'] });
expect(osCommand('freebsd', 'T', 'M')).toBeNull();
});
test('post: one retry on 5xx / network error, none on 4xx', async () => {
const calls: string[] = [];
const seq = (...rs: (number | 'throw')[]): FetchLike => async url => { calls.push(url); const r = rs.shift()!; if (r === 'throw') throw new Error('down'); return { ok: r < 300, status: r }; };
const req = { url: 'https://x.example.com', headers: {}, body: '' };
expect(await post(seq(503, 200), req, { retryMs: 1 })).toBeNull();
expect(calls).toHaveLength(2);
calls.length = 0;
expect(await post(seq('throw', 'throw'), req, { retryMs: 1 })).toBe('network error');
expect(calls).toHaveLength(2);
calls.length = 0;
expect(await post(seq(400, 200), req, { retryMs: 1 })).toBe('HTTP 400');
expect(calls).toHaveLength(1);
});
test('sendEvent: every configured sink gets it with a fake fetch; a failing sink does not stop the others', async () => {
const seen: string[] = [];
const fake: FetchLike = async url => { seen.push(url); return url.includes('telegram') ? { ok: false, status: 400 } : { ok: true, status: 200 }; };
let osCalls = 0;
const rep = await sendEvent(e(), { os: true, ntfy: { url: 'https://ntfy.sh', topic: 't' }, telegram: { token: 'T', chat: '1' }, webhook: { url: 'https://hook.example.com' } },
{ fetch: fake, os: () => { osCalls++; return true; }, platform: 'linux', retryMs: 1 });
expect(rep).toEqual([{ sink: 'os', ok: true }, { sink: 'ntfy', ok: true }, { sink: 'telegram', ok: false, error: 'HTTP 400' }, { sink: 'webhook', ok: true }]);
expect(osCalls).toBe(1);
expect(seen).toEqual(['https://ntfy.sh/t', 'https://api.telegram.org/botT/sendMessage', 'https://hook.example.com']);
// an OS notifier that is not installed is skipped quietly
expect(await sendEvent(e(), { os: true }, { os: () => false, platform: 'linux' })).toEqual([]);
});
});
describe('watch and events commands', () => {
test('mcp: events_recent is a read tool that maps to events --json', () => {
const t = TOOLS.find(x => x.name === 'events_recent')!;
expect(t).toMatchObject({ write: false, network: false, command: ['events'] });
expect(toArgv(t, { since: '24h', app: 'Demo', kind: 'rejected' }).argv).toEqual(['events', '--since', '24h', '--app', 'Demo', '--kind', 'rejected']);
});
});