diff --git a/CHANGELOG.md b/CHANGELOG.md index 0220309..cfd3fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cli/commands.ts b/cli/commands.ts index 5b0f6ae..715c8b0 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -11,6 +11,8 @@ export const PLAIN: Record Promise, 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 , --json (read-only on the stores)'], + 'events': [() => import('./events'), '[--since 24h] [--app ] [--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 [] | remove : 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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index f581df0..af740bc 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 '' [--dry] [--json]", "storebound edit --show --json every value the desktop's editors show (listings, config sections, git state)" ], + "events": [ + "storebound events [--since 24h] [--app ] [--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 ] [--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 [] [--json] read-only, no credentials: the live files on https://" diff --git a/cli/events.ts b/cli/events.ts new file mode 100644 index 0000000..c4d72be --- /dev/null +++ b/cli/events.ts @@ -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 ] [--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 { + 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(); +} diff --git a/cli/lib/args.ts b/cli/lib/args.ts index e744174..5e68b04 100644 --- a/cli/lib/args.ts +++ b/cli/lib/args.ts @@ -20,7 +20,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args { const repeated = new Map(); 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; } diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index 8628439..1a9f620 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -50,6 +50,8 @@ export const DESKTOP_COVERAGE: Record = { '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'] }, diff --git a/cli/lib/watch-sinks.ts b/cli/lib/watch-sinks.ts new file mode 100644 index 0000000..83c5c69 --- /dev/null +++ b/cli/lib/watch-sinks.ts @@ -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; + +/** 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; telegram?: Record; webhook?: Record } = {}; + 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)[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 = { + 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 = { + 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 = new Set(['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; body: string } + +export function ntfyRequest(e: WatchEvent, c: NonNullable): 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): 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): 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 } | 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; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>; +export type OsRunner = (c: { cmd: string; args: string[]; env: Record }) => 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 { + 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 { + const fetchFn = d.fetch ?? (fetch as unknown as FetchLike); + const out: SendReport[] = []; + const http = async (sink: SinkName, r: HttpRequest): Promise => { 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; +} diff --git a/cli/lib/watch.ts b/cli/lib/watch.ts new file mode 100644 index 0000000..b037b37 --- /dev/null +++ b/cli/lib/watch.ts @@ -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; play?: Record }; + /** 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 } + +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 = process.env): string => env.STOREBOUND_HOME ?? join(homedir(), '.storebound'); +export const stateFile = (env: Record = process.env): string => join(watchHome(env), 'watch-state.json'); +export const eventsFile = (env: Record = process.env): string => join(watchHome(env), 'events.jsonl'); +export const watchConfigFile = (env: Record = 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 .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 { + const out: Record = {}; + 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 { + const out: NonNullable = {}; + 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(); + 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; 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 = { ...o.prev.apps }; + let next = 0; + const worker = async (): Promise => { + 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' }); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index ea8e7f1..48d241e 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -75,6 +75,11 @@ export const TOOLS: ToolDef[] = [ description: 'Weekly health summary as Markdown in .storebound/health/.md (plus .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 }, diff --git a/cli/watch.ts b/cli/watch.ts new file mode 100644 index 0000000..0f12a41 --- /dev/null +++ b/cli/watch.ts @@ -0,0 +1,196 @@ +// `storebound watch`: store events for every app on this machine, for people and agents. +// storebound watch [--dir ] [--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 ] [--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 } + +/** Runs a storebound child command; a hung one is cancelled after the timeout. */ +const exec = (env: Record): 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 { + 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 --bundle-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 { + 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 { + 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 { + 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 { + 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 (or --dir )'); + 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(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()})`); +} diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index ae4a1af..493ec96 100644 --- a/docs/desktop-coverage.md +++ b/docs/desktop-coverage.md @@ -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 | | | diff --git a/docs/watch.md b/docs/watch.md new file mode 100644 index 0000000..813ca7d --- /dev/null +++ b/docs/watch.md @@ -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 `) +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`. diff --git a/skills/store-health/SKILL.md b/skills/store-health/SKILL.md index 8795ad2..8f56f93 100644 --- a/skills/store-health/SKILL.md +++ b/skills/store-health/SKILL.md @@ -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 ] [--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. diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index c12236b..36f198b 100644 --- a/tests/desktop-coverage.test.ts +++ b/tests/desktop-coverage.test.ts @@ -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(); diff --git a/tests/json-stdout.test.ts b/tests/json-stdout.test.ts index 168f185..dddfd1b 100644 --- a/tests/json-stdout.test.ts +++ b/tests/json-stdout.test.ts @@ -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 = { 'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'], diff --git a/tests/watch.test.ts b/tests/watch.test.ts new file mode 100644 index 0000000..e1f9eea --- /dev/null +++ b/tests/watch.test.ts @@ -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 => ({ 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[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 => ({ ...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 => ({ ...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']); + }); +});