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:
parent
4fe26326cc
commit
78c541f2b8
16 changed files with 984 additions and 4 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
20
cli/events.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
137
cli/lib/watch-sinks.ts
Normal 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
269
cli/lib/watch.ts
Normal 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' });
|
||||
|
|
@ -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
196
cli/watch.ts
Normal 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()})`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue