sportsing 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,7 @@ through playoffs. Times are local; preseason games are tagged `PRE`, postseason
97
97
  | `season [team]` | Your favorites' season: record, home/away splits, playoff race |
98
98
  | `bracket [--season YYYY]` | Playoff bracket (projected before the postseason) |
99
99
  | `fav [add\|rm\|list]` | Manage NBA favorites (`fav add UTAH`, `fav add Jazz`) |
100
- | `watch [team] [--wait] [--provider X] [--url L] [--smoke]` | Open the game where you can watch it — see **Where to watch** below |
100
+ | `watch [team] [--wait] [--provider X] [--url L] [--smoke] [--supervised]` | Open the game where you can watch it — see **Where to watch** below |
101
101
  | `live [--notify [--quiet]]` | Auto-refreshing live board; `--notify` alerts for favorites — see **Live fav-alerts** below |
102
102
  | `analyze <team> [team]` | AI read of the live or latest game: shooting, boards, turnovers, leaders (answered by `serve`; `--prompt` prints it) |
103
103
  | `predict <team> [team]` | AI prediction for the next game from both teams' recent form |
@@ -174,7 +174,8 @@ sportsing next # each favorite's next game + countdown, soonest first
174
174
  sportsing me # last result + next game per favorite team
175
175
  ```
176
176
 
177
- Bare `live --notify` alerts across every sport too (see **Live fav-alerts**).
177
+ Bare `live --notify` alerts across every sport too (see **Live fav-alerts**),
178
+ and `sportsing daemon` opens your teams' games when they start (see **Daemon**).
178
179
  Every other command needs a sport — a bare one (e.g. `sportsing standings`)
179
180
  prints the sports that have it and exits non-zero.
180
181
 
@@ -182,6 +183,47 @@ Favorites are stored per sport as `<sport>:<team>` (`nba:UTAH`, `nhl:UTAH`,
182
183
  `fifa:USA`) in `~/.config/sportsing/config.json`; add and remove them with each
183
184
  sport's `fav` command.
184
185
 
186
+ ## Daemon — open your teams' games automatically
187
+
188
+ `sportsing daemon` is an always-on watcher (a per-user launchd agent on macOS):
189
+ it polls every sport you have a favorite in and, when a favorite's game starts,
190
+ does what `<sport> watch` would — Fubo / League Pass **opens the game window**;
191
+ an over-the-air-only game gets a notification naming the channel; an
192
+ unwatchable one gets a notification saying why. It's set up once and survives
193
+ logins and `sportsing upgrade`.
194
+
195
+ ```sh
196
+ sportsing daemon install # write + load ~/Library/LaunchAgents/com.sportsing.daemon.plist
197
+ sportsing daemon status # is it running, what it's waiting for, what it last did
198
+ sportsing daemon logs [-f] # its log (~/Library/Logs/sportsing/daemon.log)
199
+ sportsing daemon mode notify # notify (click to watch) instead of auto-opening; `open` is the default
200
+ sportsing daemon uninstall # stop + remove it
201
+ ```
202
+
203
+ - **Status at a glance.** Bare `today` / `next` / `me` end with a one-line
204
+ footer — `daemon: on — waiting for DEN @ UTAH 7:00 PM`, `daemon: off —
205
+ sportsing daemon install`, or `daemon: stuck — last poll 47m ago`.
206
+ `daemon status` shows installed / pid + uptime / last poll / next game and
207
+ where it'll open / last game acted on, flags **STUCK** when the last poll is
208
+ older than ~3 poll intervals, and exits 0 only when it's running and healthy
209
+ (scriptable).
210
+ - **Each game is acted on once** (remembered in
211
+ `~/.cache/sportsing/daemon-state.json`): close the window and it stays closed;
212
+ a crash or restart doesn't open it twice. A game already under way that it
213
+ hasn't acted on yet (say the Mac woke mid-game) is opened then.
214
+ - **Polling** is sparse (every 10 min) until 15 minutes before a favorite's
215
+ scheduled start, then every 30s until it goes live — including a delayed
216
+ start (up to 3h late).
217
+ - **One sport failing** (e.g. FIFA without an API key) shows as a warning in
218
+ `status`; the others keep working.
219
+ - **Install from a global install** (`npm install -g sportsing` or
220
+ `bun add -g sportsing`): the agent runs that install's stable `sportsing`
221
+ bin, and `sportsing upgrade` restarts it on the new version. Preview the
222
+ plist without installing: `sportsing daemon install --dry-run`.
223
+ - The daemon opens the window as a detached `sportsing <sport> watch <team>
224
+ --supervised` (no terminal needed; closing the window ends it). Set up where
225
+ you watch first (`sportsing subscriptions set …`, see **Where to watch**).
226
+
185
227
  ## Upgrading to 0.2.0
186
228
 
187
229
  - **Breaking — bare commands need a sport.** In 0.1.x every bare command ran as a
@@ -312,6 +354,11 @@ One supervisor loop that **is** the whole setup: it opens your game and keeps th
312
354
  Claude session itself (no local model is ever spawned). `sportsing fifa agent-setup`
313
355
  prints this recipe; `sportsing fifa` and the watch nag point at it.
314
356
 
357
+ > **NBA / NHL:** just to have your team's games open when they start, you don't
358
+ > need this loop — use **`sportsing daemon install`** (see **Daemon**). It runs
359
+ > without a Claude session and works across every sport with a favorite.
360
+ > `/loop agent-setup` is for the FIFA overlay's Ask Claude / Get caught up.
361
+
315
362
  > **The cost, honestly:** the loop consumes that Claude session as the always-on
316
363
  > answerer for as long as it runs — that's the trade you're choosing. Stop the loop
317
364
  > and the heartbeat goes stale within ~90s, so the panels return to "○ No agent".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sportsing",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Sportsing — sports in your terminal: the FIFA World Cup 2026, the NBA, and the NHL. Schedules, favorites across every sport, live scores, standings, where-to-watch, browser streaming, highlights, stats, and AI analysis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,13 +19,17 @@ import { fifa, fifaHasCommand } from "../sports/fifa.ts";
19
19
  import { NBA, nba } from "../sports/nba.ts";
20
20
  import { NHL, nhl } from "../sports/nhl.ts";
21
21
  import type { Match } from "../types.ts";
22
- import { fifaAlerter } from "./live.ts";
22
+ import { matchState } from "../match-util.ts";
23
+ import { fifaAlerter, fifaDaemonGames } from "./live.ts";
24
+ import type { DaemonGame } from "../daemon.ts";
25
+ import { daemonFooter } from "../daemon-io.ts";
23
26
  import { EXAMPLE_TEAM, addDays, localDateOf, matchHasTeam, withFallback, ymd } from "./_lib.ts";
24
27
  import {
25
28
  favoriteIds,
26
29
  gameHasTeam,
27
30
  gamesOnDays,
28
31
  leagueAlerter,
32
+ leagueDaemonGames,
29
33
  leagueHasCommand,
30
34
  parseOffset,
31
35
  teamSeason,
@@ -63,16 +67,13 @@ export interface Sport {
63
67
  favoriteTeams: () => Promise<TeamRows[] | null>;
64
68
  /** Favorite-team live alerter; null when the sport has no favorites. */
65
69
  alerter: () => Promise<Alerter | null>;
70
+ /** Favorite games for `sportsing daemon`; null when the sport has no favorites. */
71
+ daemonGames: () => Promise<DaemonGame[] | null>;
66
72
  }
67
73
 
68
74
  // ── FIFA (football-data `Match`) ─────────────────────────────────────────────
69
75
 
70
- /** A football-data match status as a sport-neutral game state. */
71
- export function matchState(m: Pick<Match, "status">): GameState {
72
- if (m.status === "IN_PLAY" || m.status === "PAUSED") return "in";
73
- if (m.status === "FINISHED" || m.status === "AWARDED") return "post";
74
- return "pre";
75
- }
76
+ export { matchState };
76
77
 
77
78
  const matchRow = (m: Match): Row => ({ id: String(m.id), start: m.utcDate, state: matchState(m), line: matchLine(m) });
78
79
 
@@ -89,6 +90,7 @@ const FIFA: Sport = {
89
90
  run: fifa,
90
91
  has: fifaHasCommand,
91
92
  alerter: fifaAlerter,
93
+ daemonGames: fifaDaemonGames,
92
94
  async favoritesOn(day) {
93
95
  const favs = await getFavorites("fifa");
94
96
  if (favs.length === 0) return null;
@@ -132,6 +134,7 @@ function league(cfg: LeagueConfig, run: Sport["run"]): Sport {
132
134
  run,
133
135
  has: leagueHasCommand,
134
136
  alerter: () => leagueAlerter(cfg),
137
+ daemonGames: () => leagueDaemonGames(cfg),
135
138
  async favoritesOn(day) {
136
139
  const favs = await favoriteTeamsOf();
137
140
  if (!favs) return null;
@@ -240,7 +243,15 @@ async function perSport<T>(sports: Sport[], load: (s: Sport) => Promise<T | null
240
243
  }
241
244
 
242
245
  /** `today [--tomorrow|--yesterday|--offset N]` — your teams' games on one local day. */
243
- export async function today(sports: Sport[], args: string[]): Promise<void> {
246
+ /** Supplies the one-line daemon footer under today / next / me (null = none). */
247
+ export type Footer = () => string | null;
248
+
249
+ function printFooter(footer?: Footer): void {
250
+ const line = footer?.();
251
+ if (line) console.log("\n" + c.dim(line));
252
+ }
253
+
254
+ export async function today(sports: Sport[], args: string[], footer?: Footer): Promise<void> {
244
255
  const offset = parseOffset(args);
245
256
  const day = addDays(new Date(), offset);
246
257
  const loaded = await perSport(sports, (s) => s.favoritesOn(day));
@@ -251,16 +262,16 @@ export async function today(sports: Sport[], args: string[]): Promise<void> {
251
262
 
252
263
  const label = offset === 0 ? "Today" : offset === 1 ? "Tomorrow" : offset === -1 ? "Yesterday" : ymd(day);
253
264
  console.log(c.bold(c.cyan(`★ Your teams — ${label} (${ymd(day)})`)));
254
- if (rows.length === 0) {
255
- console.log(c.dim("\nNo games for your teams."));
256
- return;
265
+ if (rows.length === 0) console.log(c.dim("\nNo games for your teams."));
266
+ else {
267
+ console.log();
268
+ for (const { sport, row } of rows) console.log(" " + tagged(sport, row.line));
257
269
  }
258
- console.log();
259
- for (const { sport, row } of rows) console.log(" " + tagged(sport, row.line));
270
+ printFooter(footer);
260
271
  }
261
272
 
262
273
  /** `next` — each favorite team's next game, soonest first, with countdowns. */
263
- export async function next(sports: Sport[]): Promise<void> {
274
+ export async function next(sports: Sport[], footer?: Footer): Promise<void> {
264
275
  const loaded = await perSport(sports, (s) => s.favoriteTeams());
265
276
  if (!loaded) return;
266
277
  const now = Date.now();
@@ -269,18 +280,16 @@ export async function next(sports: Sport[]): Promise<void> {
269
280
  .sort((a, b) => byStart(a.row, b.row));
270
281
 
271
282
  console.log(c.bold(c.cyan("★ Your teams — Next up")));
272
- if (rows.length === 0) {
273
- console.log(c.dim("\nNo upcoming games for your teams."));
274
- return;
275
- }
283
+ if (rows.length === 0) console.log(c.dim("\nNo upcoming games for your teams."));
276
284
  for (const { sport, row } of rows) {
277
285
  console.log("\n " + tagged(sport, row.line));
278
286
  console.log(` ${c.bold(fmtDate(row.start))} ${c.green("— starts " + relativeTime(row.start))}`);
279
287
  }
288
+ printFooter(footer);
280
289
  }
281
290
 
282
291
  /** `me` — every favorite team, every sport: last result and next game. */
283
- export async function me(sports: Sport[]): Promise<void> {
292
+ export async function me(sports: Sport[], footer?: Footer): Promise<void> {
284
293
  const loaded = await perSport(sports, (s) => s.favoriteTeams());
285
294
  if (!loaded) return;
286
295
  const now = Date.now();
@@ -295,6 +304,7 @@ export async function me(sports: Sport[]): Promise<void> {
295
304
  if (!last && !next) console.log(c.dim(" no games found"));
296
305
  }
297
306
  }
307
+ printFooter(footer);
298
308
  }
299
309
 
300
310
  /** Startup line for bare `live`, e.g. "NBA: Utah Jazz · NHL: Utah Mammoth". */
@@ -342,10 +352,10 @@ export async function live(sports: Sport[], args: string[]): Promise<void> {
342
352
 
343
353
  /** Dispatch a bare `sportsing <cmd> …` (no sport prefix). */
344
354
  export async function bare(cmd: string, args: string[]): Promise<void> {
345
- if (cmd === "today" || cmd === "t") return today(SPORTS, args);
355
+ if (cmd === "today" || cmd === "t") return today(SPORTS, args, daemonFooter);
346
356
  if (cmd === "live") return live(SPORTS, args);
347
357
  const run = cmd === "next" || cmd === "n" ? next : cmd === "me" ? me : null;
348
- if (run && args.length === 0) return run(SPORTS);
358
+ if (run && args.length === 0) return run(SPORTS, daemonFooter);
349
359
  // Options on `next`/`me` (e.g. `--team`) are per-sport: say so rather than ignore them.
350
360
  const [first, ...rest] = bareHint(cmd, args, SPORTS, run !== null);
351
361
  console.error(c.red(first!));
@@ -0,0 +1,335 @@
1
+ // `sportsing daemon …` — the always-on watcher (see src/daemon.ts for the
2
+ // pure logic and src/daemon-io.ts for files + launchd).
3
+ //
4
+ // install [--dry-run] write + load the LaunchAgent (dry run: print the plist)
5
+ // uninstall unload + remove it
6
+ // status [--json] installed / running / last poll / next game / last action; exit 0 only if healthy
7
+ // logs [-f] [-n N] tail the log
8
+ // mode [open|notify] show or set what a game start does
9
+ // run [--once] [--dry-run] the poll loop launchd runs (also usable in the foreground)
10
+
11
+ import { spawn } from "child_process";
12
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { dirname } from "path";
15
+ import { c } from "../ansi.ts";
16
+ import { notify } from "../notify.ts";
17
+ import { getDaemonMode, setDaemonMode } from "../config.ts";
18
+ import { inTerminal, selfInvocation, shQuote } from "../click-to-watch.ts";
19
+ import {
20
+ DAEMON_LABEL,
21
+ DAEMON_MODES,
22
+ agentPath,
23
+ daemonProgram,
24
+ fmtDuration,
25
+ isHealthy,
26
+ launchAgentPlist,
27
+ nextUp,
28
+ parseDaemonMode,
29
+ pollDelayMs,
30
+ pruneActed,
31
+ routeWhere,
32
+ staleAfterMs,
33
+ startsToAct,
34
+ whenLabel,
35
+ type DaemonGame,
36
+ type DaemonMode,
37
+ type DaemonState,
38
+ type DaemonStatus,
39
+ type LastAction,
40
+ } from "../daemon.ts";
41
+ import { LOG_DIR, LOG_FILE, PLIST_PATH, STATE_FILE, gatherStatus, readState, systemLaunchd, writeState, type Launchd } from "../daemon-io.ts";
42
+ import { describeInstall, detectInstall } from "../upgrade.ts";
43
+ import { SPORTS, type Sport } from "./cross.ts";
44
+ import { runningScript } from "./upgrade.ts";
45
+
46
+ function usage(): void {
47
+ console.log(`${c.bold("sportsing daemon")} — always-on watcher: opens your teams' games when they start
48
+
49
+ sportsing daemon install [--dry-run] install + start the LaunchAgent (runs at login, restarts if it dies)
50
+ sportsing daemon uninstall stop + remove it
51
+ sportsing daemon status [--json] is it running, what it's waiting for, what it last did (exit 0 = healthy)
52
+ sportsing daemon logs [-f] [-n N] show (or follow) its log
53
+ sportsing daemon mode [open|notify] what a game start does: open the window (default) or notify with click-to-watch
54
+ sportsing daemon run [--once] [--dry-run] the loop itself, in the foreground (--dry-run: act on nothing)`);
55
+ }
56
+
57
+ function fail(msg: string, hint?: string): void {
58
+ console.error(c.red(msg));
59
+ if (hint) console.error(c.dim(hint));
60
+ process.exitCode = 1;
61
+ }
62
+
63
+ const tilde = (p: string) => (p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p);
64
+
65
+ // ── install / uninstall ──────────────────────────────────────────────────────
66
+
67
+ /** The plist for this install, or an error saying why there isn't one. */
68
+ export function plistForThisInstall(): { plist: string; program: string[] } | { error: string } {
69
+ const install = detectInstall(runningScript());
70
+ const program = daemonProgram(install, { execPath: process.execPath, bunOnPath: Bun.which("bun"), exists: existsSync });
71
+ if (!program) {
72
+ return {
73
+ error: `Can't install the daemon from a ${describeInstall(install)} — it needs a stable path to run. Install sportsing globally first: npm install -g sportsing (or bun add -g sportsing).`,
74
+ };
75
+ }
76
+ const plist = launchAgentPlist({ label: DAEMON_LABEL, program, logPath: LOG_FILE, path: agentPath(process.env.PATH), home: homedir() });
77
+ return { plist, program };
78
+ }
79
+
80
+ export function install(args: string[], launchd: Launchd): void {
81
+ const made = plistForThisInstall();
82
+ if ("error" in made) return fail(made.error);
83
+ if (args.includes("--dry-run")) {
84
+ console.log(c.dim(`# would write ${PLIST_PATH} and run: launchctl bootstrap gui/<uid> ${tilde(PLIST_PATH)}`));
85
+ process.stdout.write(made.plist);
86
+ return;
87
+ }
88
+ // Idempotent: unload a previous copy, rewrite the plist, load it fresh.
89
+ if (launchd.print(DAEMON_LABEL).loaded) launchd.bootout(DAEMON_LABEL);
90
+ mkdirSync(LOG_DIR, { recursive: true });
91
+ mkdirSync(dirname(PLIST_PATH), { recursive: true });
92
+ writeFileSync(PLIST_PATH, made.plist);
93
+ try {
94
+ launchd.bootstrap(PLIST_PATH);
95
+ } catch (e) {
96
+ return fail(`Wrote ${tilde(PLIST_PATH)} but couldn't load it: ${(e as Error).message}`);
97
+ }
98
+ console.log(c.green(`✓ sportsing daemon installed and started (${DAEMON_LABEL}).`));
99
+ console.log(c.dim(` runs: ${made.program.join(" ")}`));
100
+ console.log(c.dim(` log: ${tilde(LOG_FILE)}`));
101
+ console.log(c.dim(" check it with: sportsing daemon status"));
102
+ }
103
+
104
+ export function uninstall(launchd: Launchd): void {
105
+ const loaded = launchd.print(DAEMON_LABEL).loaded;
106
+ if (loaded) {
107
+ try {
108
+ launchd.bootout(DAEMON_LABEL);
109
+ } catch (e) {
110
+ return fail(`Couldn't stop the daemon: ${(e as Error).message}`);
111
+ }
112
+ }
113
+ const had = existsSync(PLIST_PATH);
114
+ rmSync(PLIST_PATH, { force: true });
115
+ console.log(loaded || had ? c.green("✓ sportsing daemon stopped and removed.") : c.dim("sportsing daemon wasn't installed — nothing to do."));
116
+ }
117
+
118
+ // ── status ───────────────────────────────────────────────────────────────────
119
+
120
+ const LEVEL_COLOR = { on: c.green, starting: c.yellow, stuck: c.red, down: c.red, off: c.yellow } as const;
121
+
122
+ /** `daemon status` text, one fact per line. */
123
+ export function statusLines(s: DaemonStatus, now: number): string[] {
124
+ const st = s.state && s.state.pid === s.pid ? s.state : null;
125
+ const head = `sportsing daemon: ${LEVEL_COLOR[s.level](c.bold(s.level))}`;
126
+ const row = (k: string, v: string) => ` ${c.dim(k.padEnd(11))} ${v}`;
127
+ const out = [head];
128
+ if (s.level === "off") {
129
+ out.push(row("installed", "no — install it with: sportsing daemon install"));
130
+ return out;
131
+ }
132
+ out.push(row("installed", s.installed ? tilde(PLIST_PATH) : "no plist (running in the foreground?)"));
133
+ if (s.pid === null) out.push(row("running", c.red("no") + c.dim(" — see: sportsing daemon logs")));
134
+ else {
135
+ const up = st ? ` · up ${fmtDuration(now - Date.parse(st.startedAt))} · v${st.version} · mode ${st.mode}` : "";
136
+ out.push(row("running", `pid ${s.pid}${up}`));
137
+ }
138
+ const last = s.state;
139
+ if (last?.heartbeat) {
140
+ const age = now - Date.parse(last.heartbeat);
141
+ const ago = `${new Date(last.heartbeat).toLocaleTimeString()} (${fmtDuration(age)} ago)`;
142
+ const stuck = s.level === "stuck" ? c.red(` — STUCK: no poll for over ${fmtDuration(staleAfterMs(last.pollMs))}`) : c.dim(` · polling every ${fmtDuration(last.pollMs)}`);
143
+ out.push(row("last poll", ago + stuck));
144
+ } else out.push(row("last poll", c.dim("none yet")));
145
+ if (last) {
146
+ const n = last.next;
147
+ out.push(row("next game", n ? `${n.icon} ${n.fixture} · ${whenLabel(n.start, now)} → ${n.where}` : c.dim("no favorite games coming up")));
148
+ const a = last.lastAction;
149
+ out.push(row("last acted", a ? `${a.icon} ${a.did} ${a.fixture} · ${whenLabel(a.at, now)}${a.detail ? c.dim(` (${a.detail})`) : ""}` : c.dim("nothing yet")));
150
+ for (const [sport, err] of Object.entries(last.errors)) out.push(row("warning", c.yellow(`${sport}: ${err}`)));
151
+ }
152
+ out.push(row("log", tilde(LOG_FILE)));
153
+ return out;
154
+ }
155
+
156
+ function status(args: string[], launchd: Launchd): void {
157
+ const now = Date.now();
158
+ const s = gatherStatus(launchd, now);
159
+ if (args.includes("--json")) console.log(JSON.stringify({ ...s, healthy: isHealthy(s) }, null, 2));
160
+ else for (const l of statusLines(s, now)) console.log(l);
161
+ if (!isHealthy(s)) process.exitCode = 1;
162
+ }
163
+
164
+ // ── logs ─────────────────────────────────────────────────────────────────────
165
+
166
+ async function logs(args: string[]): Promise<void> {
167
+ if (!existsSync(LOG_FILE)) return fail(`No daemon log yet (${tilde(LOG_FILE)}).`, "It's created when the daemon first runs: sportsing daemon install");
168
+ const nIdx = args.indexOf("-n");
169
+ const n = nIdx >= 0 ? Number(args[nIdx + 1]) : 50;
170
+ const follow = args.includes("-f") || args.includes("--follow");
171
+ const cmd = ["tail", "-n", String(Number.isInteger(n) && n > 0 ? n : 50), ...(follow ? ["-f"] : []), LOG_FILE];
172
+ const proc = Bun.spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
173
+ process.exitCode = await proc.exited;
174
+ }
175
+
176
+ // ── mode ─────────────────────────────────────────────────────────────────────
177
+
178
+ async function mode(args: string[]): Promise<void> {
179
+ if (args.length === 0) {
180
+ console.log(`daemon mode: ${c.bold(await getDaemonMode())} ${c.dim("(open = open the game window · notify = notification, click to watch)")}`);
181
+ return;
182
+ }
183
+ const m = parseDaemonMode(args[0]);
184
+ if (!m) return fail(`Unknown mode "${args[0]}". Use one of: ${DAEMON_MODES.join(", ")}.`);
185
+ await setDaemonMode(m);
186
+ console.log(c.green(`✓ daemon mode: ${m}`) + c.dim(" — takes effect from the next game start."));
187
+ }
188
+
189
+ // ── run ──────────────────────────────────────────────────────────────────────
190
+
191
+ function log(msg: string): void {
192
+ console.log(`${new Date().toISOString()} ${msg}`);
193
+ }
194
+
195
+ /** Every sport's favorite games; a sport that fails is recorded and skipped. */
196
+ async function collect(sports: Sport[]): Promise<{ games: DaemonGame[]; errors: Record<string, string> }> {
197
+ const settled = await Promise.allSettled(sports.map((s) => s.daemonGames()));
198
+ const games: DaemonGame[] = [];
199
+ const errors: Record<string, string> = {};
200
+ settled.forEach((r, i) => {
201
+ if (r.status === "fulfilled") games.push(...(r.value ?? []));
202
+ else errors[sports[i]!.label] = r.reason instanceof Error ? r.reason.message : String(r.reason);
203
+ });
204
+ return { games, errors };
205
+ }
206
+
207
+ /** Run `args` as a detached sportsing child (its own process group, so it
208
+ * outlives a daemon restart), output into the daemon's log. */
209
+ function spawnDetached(exe: string[], args: string[]): void {
210
+ const child = spawn(exe[0]!, [...exe.slice(1), ...args], { detached: true, stdio: ["ignore", "inherit", "inherit"] });
211
+ child.on("error", (e) => log(`couldn't start ${args.join(" ")}: ${e.message}`));
212
+ child.unref();
213
+ }
214
+
215
+ /** Act on one game start. */
216
+ function act(g: DaemonGame, mode: DaemonMode, exe: string[], dry: boolean): LastAction {
217
+ const base = { at: new Date().toISOString(), key: g.key, icon: g.icon, fixture: g.fixture };
218
+ const click = `${exe.map(shQuote).join(" ")} ${g.clickArgs.map(shQuote).join(" ")}`;
219
+ const title = `${g.icon} ${g.fixture} is under way`;
220
+ if (g.route.kind === "open" && mode === "open") {
221
+ if (dry) return { ...base, did: "dry-run", detail: `would open: ${g.openArgs.join(" ")}` };
222
+ spawnDetached(exe, g.openArgs);
223
+ notify(title, `Opening — ${g.route.note}`, { group: `sportsing-daemon-${g.key}` });
224
+ return { ...base, did: "opened", detail: g.route.note };
225
+ }
226
+ const body = g.route.kind === "open" ? `Click to watch — ${g.route.note}` : g.route.kind === "tune" ? `📺 ${g.route.message}` : `✗ ${g.route.message}`;
227
+ if (dry) return { ...base, did: "dry-run", detail: `would notify: ${body}` };
228
+ notify(title, body, { group: `sportsing-daemon-${g.key}`, onClick: g.route.kind === "open" ? inTerminal(click) : undefined });
229
+ return { ...base, did: "notified", detail: body };
230
+ }
231
+
232
+ /**
233
+ * The poll loop. Each poll: fetch every sport's favorite games, act on any
234
+ * live one not acted on before (recorded in the state file *before* acting,
235
+ * so a crash or restart never double-opens), record the heartbeat and next
236
+ * game, then sleep — sparse when nothing's near, tight around a start.
237
+ */
238
+ export async function run(args: string[], version: string, sports: Sport[] = SPORTS): Promise<void> {
239
+ const once = args.includes("--once");
240
+ const dry = args.includes("--dry-run");
241
+ const exe = selfInvocation();
242
+ const prev = readState();
243
+ const state: DaemonState = {
244
+ pid: process.pid,
245
+ startedAt: new Date().toISOString(),
246
+ version,
247
+ mode: await getDaemonMode(),
248
+ heartbeat: null,
249
+ pollMs: 0,
250
+ next: null,
251
+ lastAction: prev?.lastAction ?? null,
252
+ acted: prev?.acted ?? {},
253
+ errors: {},
254
+ };
255
+ const save = () => {
256
+ if (!dry) writeState(state);
257
+ };
258
+ log(`sportsing daemon ${version} started (pid ${process.pid}${dry ? ", dry run" : ""}; state ${tilde(STATE_FILE)})`);
259
+ save();
260
+
261
+ let stop = false;
262
+ let wake: (() => void) | null = null;
263
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
264
+ process.on(sig, () => {
265
+ log(`${sig} — stopping`);
266
+ stop = true;
267
+ wake?.();
268
+ });
269
+ }
270
+
271
+ while (!stop) {
272
+ state.mode = await getDaemonMode().catch(() => state.mode);
273
+ const { games, errors } = await collect(sports);
274
+ const now = Date.now();
275
+ state.errors = errors;
276
+ for (const [sport, err] of Object.entries(errors)) log(`${sport}: ${err}`);
277
+
278
+ state.acted = pruneActed(state.acted, now);
279
+ for (const g of startsToAct(games, state.acted)) {
280
+ state.acted[g.key] = new Date(now).toISOString();
281
+ save(); // persist the dedupe before acting
282
+ try {
283
+ state.lastAction = act(g, state.mode, exe, dry);
284
+ } catch (e) {
285
+ state.lastAction = { at: new Date().toISOString(), key: g.key, icon: g.icon, fixture: g.fixture, did: "failed", detail: (e as Error).message };
286
+ }
287
+ log(`${state.lastAction.did} ${g.fixture} — ${state.lastAction.detail}`);
288
+ }
289
+
290
+ const n = nextUp(games, now);
291
+ state.next = n && { key: n.key, icon: n.icon, fixture: n.fixture, start: n.start, where: routeWhere(n.route, state.mode) };
292
+ state.pollMs = pollDelayMs(games, now);
293
+ state.heartbeat = new Date(now).toISOString();
294
+ save();
295
+ const waiting = n ? `next ${n.fixture} ${new Date(n.start).toLocaleString()} (${state.next!.where})` : "no favorite games coming up";
296
+ log(`poll: ${games.length} favorite game(s); ${waiting}; next poll in ${fmtDuration(state.pollMs)}`);
297
+ if (once) break;
298
+ await new Promise<void>((r) => {
299
+ const t = setTimeout(r, state.pollMs);
300
+ wake = () => {
301
+ clearTimeout(t);
302
+ r();
303
+ };
304
+ });
305
+ }
306
+ }
307
+
308
+ // ── dispatch ─────────────────────────────────────────────────────────────────
309
+
310
+ export async function daemon(args: string[], version: string, launchd: Launchd = systemLaunchd()): Promise<void> {
311
+ const [sub, ...rest] = args;
312
+ switch (sub) {
313
+ case "install":
314
+ return install(rest, launchd);
315
+ case "uninstall":
316
+ return uninstall(launchd);
317
+ case "status":
318
+ return status(rest, launchd);
319
+ case "logs":
320
+ case "log":
321
+ return logs(rest);
322
+ case "mode":
323
+ return mode(rest);
324
+ case "run":
325
+ return run(rest, version);
326
+ case undefined:
327
+ case "help":
328
+ case "--help":
329
+ case "-h":
330
+ return usage();
331
+ default:
332
+ usage();
333
+ return fail(`Unknown daemon command: ${sub}`);
334
+ }
335
+ }
@@ -36,6 +36,7 @@ import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
36
36
  import { selfInvocation } from "../click-to-watch.ts";
37
37
  import { isGameSport } from "../game-events.ts";
38
38
  import { leagueFeed } from "../league-alerts.ts";
39
+ import { leagueDaemonGame, leagueRoute, type DaemonGame } from "../daemon.ts";
39
40
 
40
41
  export interface LeagueConfig {
41
42
  /** CLI namespace and favorites key, e.g. "nba" (`sportsing nba …`, `nba:UTAH`). */
@@ -552,7 +553,7 @@ function pickOrReport(key: string, sport: string): { key: string; label: string;
552
553
  }
553
554
 
554
555
  /**
555
- * `watch [team] [--wait] [--overlay] [--provider X] [--url L] [--size WxH] [--smoke]` —
556
+ * `watch [team] [--wait] [--overlay] [--provider X] [--url L] [--size WxH] [--smoke] [--supervised]` —
556
557
  * open the game's stream in that provider's persistent Chrome profile, so an
557
558
  * existing login is reused. No team = your favorites.
558
559
  *
@@ -566,7 +567,8 @@ function pickOrReport(key: string, sport: string): { key: string; label: string;
566
567
  *
567
568
  * `--wait` blocks until the next game is live, then opens it. `--smoke` opens
568
569
  * the window, confirms it came up over CDP, and tears it down (bounded; for
569
- * scripts and agents).
570
+ * scripts and agents). `--supervised` lets it run with no TTY on purpose (the
571
+ * daemon opens a game this way; the window's close ends it).
570
572
  * `--overlay` adds the live-stats overlay, which follows the game you open.
571
573
  */
572
574
  async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
@@ -609,8 +611,10 @@ async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
609
611
  const target = url ?? pick.hub;
610
612
  return overlay ? smokeLeagueOverlay(cfg, target, pick.label, windowSize) : smokeWatch(target, pick.label, windowSize);
611
613
  }
612
- // watch blocks until the window is closed; with no TTY nothing ever closes it.
613
- if (process.stdin.isTTY !== true) {
614
+ // watch blocks until the window is closed; with no TTY nothing ever closes it
615
+ // — unless `--supervised`: the daemon deliberately runs it detached, with no
616
+ // TTY, to open a game window that the user closes.
617
+ if (process.stdin.isTTY !== true && !args.includes("--supervised")) {
614
618
  console.error(c.yellow("`watch` is interactive — it opens a stream window and blocks until you close it."));
615
619
  console.error(c.dim(`Run it in a terminal, or use \`sportsing ${cfg.sport} watch --smoke\` to just confirm the window opens.`));
616
620
  process.exitCode = 1;
@@ -619,7 +623,7 @@ async function watch(cfg: LeagueConfig, args: string[]): Promise<void> {
619
623
 
620
624
  if (scope === undefined) scope = await watchScope(cfg, args);
621
625
  if (!scope) {
622
- console.error(c.red(`Usage: sportsing ${cfg.sport} watch <team> [--wait] [--overlay] [--provider ${pick.key}] [--url <link>] [--smoke]`));
626
+ console.error(c.red(`Usage: sportsing ${cfg.sport} watch <team> [--wait] [--overlay] [--provider ${pick.key}] [--url <link>] [--smoke] [--supervised]`));
623
627
  console.error(c.dim(`Or add a favorite (sportsing ${cfg.sport} fav add ${EXAMPLE_TEAM[cfg.sport] ?? "<team>"}) and omit the team.`));
624
628
  process.exitCode = 1;
625
629
  return;
@@ -673,6 +677,30 @@ export async function leagueAlerter(cfg: LeagueConfig): Promise<Alerter | null>
673
677
  return { label: cfg.label, teams: alerts.teams, poll: async () => alerts.feed(await liveGames(cfg)) };
674
678
  }
675
679
 
680
+ /**
681
+ * The league's favorite games for `sportsing daemon`: live and nearby ones
682
+ * from the scoreboards (local yesterday → tomorrow), each routed the way
683
+ * `watch` would. When none of those is upcoming, the favorites' next scheduled
684
+ * game is added from their season schedules (5-min cache) so status can say
685
+ * what it's waiting for. Null with no (resolvable) favorites.
686
+ */
687
+ export async function leagueDaemonGames(cfg: LeagueConfig): Promise<DaemonGame[] | null> {
688
+ const favs = await getFavorites(cfg.sport);
689
+ if (favs.length === 0) return null;
690
+ const ids = favoriteIds(cfg, await getTeams(cfg.league), favs);
691
+ if (ids.size === 0) return null;
692
+ const [all, watch, fallback] = await Promise.all([liveGames(cfg), watchContext(cfg), watchFallback(cfg)]);
693
+ const nearby = all.filter((g) => gameHasTeam(g, ids));
694
+ const now = Date.now();
695
+ let later: Game | null = null;
696
+ if (!nearby.some((g) => g.state === "pre" && Date.parse(g.date) >= now)) {
697
+ later = firstUpcoming(await scopedSeasons(cfg, ids), now);
698
+ if (later && nearby.some((g) => g.id === later!.id)) later = null;
699
+ }
700
+ const games = later ? [...nearby, later] : nearby;
701
+ return games.map((g) => leagueDaemonGame(cfg.sport, cfg.icon, g, ids, leagueRoute(g, watch, fallback)));
702
+ }
703
+
676
704
  /** Full-screen live board: in play, later today, finished today. */
677
705
  function renderLiveBoard(cfg: LeagueConfig, games: Game[]): void {
678
706
  const { live, upcoming, done } = liveBoard(games, ymd(new Date()));
@@ -2,11 +2,13 @@ import { c } from "../ansi.ts";
2
2
  import { getMatches, NoKeyError } from "../api.ts";
3
3
  import { matchLine, fmtTimeOnly, stageLabel } from "../format.ts";
4
4
  import { ymd, addDays, localDateOf, sortByDate } from "./_lib.ts";
5
- import { getApiKey, getFavorites } from "../config.ts";
5
+ import { getApiKey, getFavorites, getStreamProvider } from "../config.ts";
6
+ import type { DaemonGame } from "../daemon.ts";
7
+
6
8
  import { diffEvents, type MatchEvent } from "../events.ts";
7
9
  import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
8
10
  import { inTerminal, selfInvocation, watchCommand } from "../click-to-watch.ts";
9
- import { matchHasTeam } from "../match-util.ts";
11
+ import { matchHasTeam, matchState } from "../match-util.ts";
10
12
  import type { Match } from "../types.ts";
11
13
 
12
14
  export async function live(args: string[] = []) {
@@ -153,6 +155,34 @@ export async function fifaAlerter(): Promise<Alerter | null> {
153
155
  return { label: "FIFA", teams: favorites, poll: async () => feed(await matchesToday()) };
154
156
  }
155
157
 
158
+ /** FIFA's favorite matches today for `sportsing daemon`; null with no FIFA
159
+ * favorites. Always routed "open" via the configured provider, exactly like
160
+ * `fifa watch <fav> --wait` — FIFA has no subscriptions/watchability resolver
161
+ * (that's NBA/NHL only), so there's no over-the-air or unwatchable branch. */
162
+ export async function fifaDaemonGames(): Promise<DaemonGame[] | null> {
163
+ const favorites = await getFavorites("fifa");
164
+ if (favorites.length === 0) return null;
165
+ if (!(await getApiKey())) throw new Error("needs an API key — run `sportsing fifa setup`");
166
+ const provider = (await getStreamProvider("fifa")) ?? "fubo";
167
+ return (await matchesToday()).flatMap((m): DaemonGame[] => {
168
+ const fav = favorites.find((f) => matchHasTeam(m, f.trim().toLowerCase()))?.trim();
169
+ if (!fav) return [];
170
+ return [
171
+ {
172
+ key: `fifa:${m.id}`,
173
+ sport: "fifa",
174
+ icon: "⚽",
175
+ fixture: `${m.homeTeam.tla ?? m.homeTeam.name} v ${m.awayTeam.tla ?? m.awayTeam.name}`,
176
+ start: m.utcDate,
177
+ state: matchState(m),
178
+ openArgs: ["fifa", "watch", fav, "--wait", "--supervised"],
179
+ clickArgs: ["fifa", "watch", fav],
180
+ route: { kind: "open", note: provider },
181
+ },
182
+ ];
183
+ });
184
+ }
185
+
156
186
  /**
157
187
  * The click-to-watch command for a kickoff event, or undefined when none applies.
158
188
  * Returns `<exe…> fifa watch <fav>` (shell-quoted) where `fav` is the favourite
@@ -1,5 +1,6 @@
1
1
  import { existsSync, realpathSync } from "fs";
2
2
  import { c } from "../ansi.ts";
3
+ import { restartDaemonIfLoaded, systemLaunchd, type Launchd } from "../daemon-io.ts";
3
4
  import {
4
5
  REGISTRY_LATEST_URL,
5
6
  canSelfUpgrade,
@@ -42,7 +43,7 @@ async function fetchLatest(): Promise<string> {
42
43
  }
43
44
 
44
45
  /** Realpath of the running entry script, so npm/bun bin symlinks resolve to the package. */
45
- function runningScript(): string {
46
+ export function runningScript(): string {
46
47
  try {
47
48
  return realpathSync(Bun.main);
48
49
  } catch {
@@ -63,7 +64,7 @@ function installedVersion(bin: string): string | null {
63
64
  }
64
65
 
65
66
  // `sportsing upgrade [--check]` — self-update to the latest npm release.
66
- export async function upgrade(args: string[], current: string): Promise<void> {
67
+ export async function upgrade(args: string[], current: string, launchd: Launchd = systemLaunchd()): Promise<void> {
67
68
  if (args.includes("--help") || args.includes("-h") || args[0] === "help") return usage();
68
69
  const unknown = args.filter((a) => a !== "--check");
69
70
  if (unknown.length) {
@@ -134,4 +135,9 @@ export async function upgrade(args: string[], current: string): Promise<void> {
134
135
  return fail(`Install finished, but ${bin} still reports ${now}. Check which sportsing is on your PATH.`);
135
136
  }
136
137
  console.log(c.green(`✓ sportsing ${current} → ${now}`));
138
+
139
+ // A loaded daemon is still running the old code: restart it onto the new.
140
+ const restart = restartDaemonIfLoaded(launchd);
141
+ if (restart === "restarted") console.log(c.green("✓ restarted sportsing daemon on the new version"));
142
+ else if (restart !== "not-loaded") console.error(c.yellow(`Couldn't restart the daemon (${restart.error}) — run: sportsing daemon install`));
137
143
  }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { mkdir, chmod } from "fs/promises";
4
+ import { daemonModeOf, type DaemonMode } from "./daemon.ts";
4
5
  import { SUBSCRIPTIONS, HOME_MARKETS, DEFAULT_HOME_MARKET, isSubscription, type Subscription } from "./watchability.ts";
5
6
 
6
7
  const CONFIG_DIR = join(homedir(), ".config", "sportsing");
@@ -32,6 +33,8 @@ interface Config {
32
33
  subscriptions?: string[];
33
34
  /** HOME_MARKETS id for blackouts / local channels; defaults to Utah. */
34
35
  homeMarket?: string;
36
+ /** `sportsing daemon` settings. */
37
+ daemon?: { mode?: string };
35
38
  }
36
39
 
37
40
  /** Default overlay panel visibility — nothing on by default, so a fresh stream
@@ -279,4 +282,17 @@ export async function setHomeMarket(market: string): Promise<void> {
279
282
  await writeConfig(cfg);
280
283
  }
281
284
 
285
+ // ── Daemon ───────────────────────────────────────────────────────────────────
286
+
287
+ /** What the daemon does when a favorite's game starts (`open` by default). */
288
+ export async function getDaemonMode(): Promise<DaemonMode> {
289
+ return daemonModeOf((await readConfig()).daemon?.mode);
290
+ }
291
+
292
+ export async function setDaemonMode(mode: DaemonMode): Promise<void> {
293
+ const cfg = await readConfig();
294
+ cfg.daemon = { ...(cfg.daemon ?? {}), mode };
295
+ await writeConfig(cfg);
296
+ }
297
+
282
298
  export { CONFIG_FILE };
@@ -0,0 +1,114 @@
1
+ // The daemon's IO edge: where its files live, its state file (written
2
+ // atomically), and launchd. Kept apart from src/commands/daemon.ts so the
3
+ // bare today/next/me footer and `sportsing upgrade` can use it without pulling
4
+ // in the sport registry. launchctl is behind the `Launchd` interface so tests
5
+ // and smokes use fakes — nothing in the test suite ever talks to launchd.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { dirname, join } from "path";
10
+ import { CACHE_DIR } from "./config.ts";
11
+ import {
12
+ DAEMON_LABEL,
13
+ evaluateStatus,
14
+ footerLine,
15
+ parseDaemonState,
16
+ parseLaunchctlPrint,
17
+ type DaemonState,
18
+ type DaemonStatus,
19
+ type ServiceInfo,
20
+ } from "./daemon.ts";
21
+ import { pidAlive } from "./liveness.ts";
22
+
23
+ export const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${DAEMON_LABEL}.plist`);
24
+ export const LOG_DIR = join(homedir(), "Library", "Logs", "sportsing");
25
+ export const LOG_FILE = join(LOG_DIR, "daemon.log");
26
+ export const STATE_FILE = join(CACHE_DIR, "daemon-state.json");
27
+
28
+ /** The daemon's state file, or null if absent/malformed. */
29
+ export function readState(file = STATE_FILE): DaemonState | null {
30
+ try {
31
+ return parseDaemonState(JSON.parse(readFileSync(file, "utf8")));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Write the state file atomically (temp file + rename), so a reader never
38
+ * sees a half-written file and a crash mid-write leaves the old one. */
39
+ export function writeState(s: DaemonState, file = STATE_FILE): void {
40
+ mkdirSync(dirname(file), { recursive: true });
41
+ const tmp = `${file}.${process.pid}.tmp`;
42
+ writeFileSync(tmp, JSON.stringify(s, null, 2) + "\n");
43
+ renameSync(tmp, file);
44
+ }
45
+
46
+ /** launchctl, injectable. All calls target the `gui/<uid>` domain. */
47
+ export interface Launchd {
48
+ /** The job's launchd info, or `{ loaded: false }` when it isn't loaded. */
49
+ print(label: string): ServiceInfo;
50
+ /** Load and start a plist. Throws on failure. */
51
+ bootstrap(plistPath: string): void;
52
+ /** Unload a job. Throws on failure. */
53
+ bootout(label: string): void;
54
+ /** Kill and restart a loaded job. Throws on failure. */
55
+ kickstart(label: string): void;
56
+ }
57
+
58
+ function launchctl(args: string[]): { code: number; out: string } {
59
+ const r = Bun.spawnSync(["launchctl", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
60
+ return { code: r.exitCode ?? 1, out: r.stdout.toString() + r.stderr.toString() };
61
+ }
62
+
63
+ function must(args: string[]): void {
64
+ const r = launchctl(args);
65
+ if (r.code !== 0) throw new Error(`launchctl ${args.join(" ")} failed (${r.code}): ${r.out.trim()}`);
66
+ }
67
+
68
+ /** The real launchctl. */
69
+ export function systemLaunchd(uid = process.getuid?.() ?? 501): Launchd {
70
+ const domain = `gui/${uid}`;
71
+ return {
72
+ print(label) {
73
+ const r = launchctl(["print", `${domain}/${label}`]);
74
+ return r.code === 0 ? parseLaunchctlPrint(r.out) : { loaded: false };
75
+ },
76
+ bootstrap: (plistPath) => must(["bootstrap", domain, plistPath]),
77
+ bootout: (label) => must(["bootout", `${domain}/${label}`]),
78
+ kickstart: (label) => must(["kickstart", "-k", `${domain}/${label}`]),
79
+ };
80
+ }
81
+
82
+ /** The daemon's status. With `launchd`, it's the authority on whether the job
83
+ * runs; without it (the cheap footer) the state file's pid is probed. */
84
+ export function gatherStatus(launchd: Launchd | null, now = Date.now()): DaemonStatus {
85
+ const state = readState();
86
+ const installed = existsSync(PLIST_PATH);
87
+ let service: ServiceInfo;
88
+ if (launchd) service = launchd.print(DAEMON_LABEL);
89
+ else service = state && pidAlive(state.pid) ? { loaded: true, pid: state.pid } : { loaded: false };
90
+ return evaluateStatus({ installed, service, state, now });
91
+ }
92
+
93
+ /** The one-line footer for bare today / next / me. Never throws. */
94
+ export function daemonFooter(): string | null {
95
+ try {
96
+ return footerLine(gatherStatus(null), Date.now());
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * After `sportsing upgrade`: restart a loaded daemon so it runs the new code.
104
+ * Returns what happened, for the upgrade's output.
105
+ */
106
+ export function restartDaemonIfLoaded(launchd: Launchd): "restarted" | "not-loaded" | { error: string } {
107
+ if (!launchd.print(DAEMON_LABEL).loaded) return "not-loaded";
108
+ try {
109
+ launchd.kickstart(DAEMON_LABEL);
110
+ return "restarted";
111
+ } catch (e) {
112
+ return { error: e instanceof Error ? e.message : String(e) };
113
+ }
114
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,409 @@
1
+ // The always-on `sportsing daemon`: a per-user launchd agent that polls every
2
+ // sport with a favorite and, when a favorite's game starts, opens it the way
3
+ // `<sport> watch` would (or notifies, per `daemon.mode`). Everything here is
4
+ // pure — start detection, dedupe, poll cadence, the launchd plist, the
5
+ // `launchctl print` parse, and the status/staleness verdict — so it's
6
+ // unit-tested without launchd, the real config, or the network. The command
7
+ // (src/commands/daemon.ts) does the IO.
8
+
9
+ import type { Game, GameState } from "./game.ts";
10
+ import type { InstallMethod } from "./upgrade.ts";
11
+ import { planWatch, watchOf, type WatchContext } from "./watch-route.ts";
12
+
13
+ /** launchd label, plist basename, and `launchctl … gui/<uid>/<label>` target. */
14
+ export const DAEMON_LABEL = "com.sportsing.daemon";
15
+
16
+ export const DAEMON_MODES = ["open", "notify"] as const;
17
+ export type DaemonMode = (typeof DAEMON_MODES)[number];
18
+ export const DEFAULT_DAEMON_MODE: DaemonMode = "open";
19
+
20
+ /** A stored/typed mode, or null when it isn't one. */
21
+ export function parseDaemonMode(v: unknown): DaemonMode | null {
22
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
23
+ return (DAEMON_MODES as readonly string[]).includes(s) ? (s as DaemonMode) : null;
24
+ }
25
+
26
+ /** Stored mode, defaulting to `open` for unset/unknown values. */
27
+ export function daemonModeOf(stored: unknown): DaemonMode {
28
+ return parseDaemonMode(stored) ?? DEFAULT_DAEMON_MODE;
29
+ }
30
+
31
+ // ── Games ────────────────────────────────────────────────────────────────────
32
+
33
+ /** What a game start does, as `<sport> watch` would decide it. */
34
+ export type Route =
35
+ /** A stream to open; `note` says which service and why. */
36
+ | { kind: "open"; note: string }
37
+ /** Over the air only — nothing to open; `message` names the channel. */
38
+ | { kind: "tune"; message: string }
39
+ /** Can't be watched (or placed); `message` says why. */
40
+ | { kind: "none"; message: string };
41
+
42
+ /** One favorite-team game as the daemon sees it, sport-neutral. */
43
+ export interface DaemonGame {
44
+ /** `<sport>:<game id>` — the dedupe key. */
45
+ key: string;
46
+ sport: string;
47
+ icon: string;
48
+ /** "DEN @ UTAH" (away @ home). */
49
+ fixture: string;
50
+ /** ISO scheduled start. */
51
+ start: string;
52
+ state: GameState;
53
+ /** argv (after the sportsing executable) that opens it with no TTY. */
54
+ openArgs: string[];
55
+ /** argv for an interactive `watch` (click-to-watch, in a Terminal window). */
56
+ clickArgs: string[];
57
+ route: Route;
58
+ }
59
+
60
+ /**
61
+ * How an ESPN-league game start is routed — the same decision `<sport> watch`
62
+ * makes: with subscriptions, the watchability resolver (Fubo / League Pass
63
+ * open, over-the-air names the channel, unwatchable says why); without them,
64
+ * the configured / league-default provider, if any.
65
+ */
66
+ export function leagueRoute(g: Game, watch: WatchContext | null, fallback: string | null): Route {
67
+ if (!watch) {
68
+ return fallback
69
+ ? { kind: "open", note: `${fallback} (no subscriptions set)` }
70
+ : { kind: "none", message: "no streaming provider — run `sportsing subscriptions set …`" };
71
+ }
72
+ const plan = planWatch(watchOf(g, watch), fallback);
73
+ return plan.kind === "open" ? { kind: "open", note: plan.note } : plan;
74
+ }
75
+
76
+ /** The daemon's view of an ESPN-league game; `favIds` picks the team `watch` follows. */
77
+ export function leagueDaemonGame(
78
+ sport: string,
79
+ icon: string,
80
+ g: Game,
81
+ favIds: ReadonlySet<string>,
82
+ route: Route,
83
+ ): DaemonGame {
84
+ // Either favorite's id finds the same game; prefer home, as `watch` does.
85
+ const team = [g.home, g.away].find((t) => favIds.has(t.id))?.id ?? g.home.id;
86
+ return {
87
+ key: `${sport}:${g.id}`,
88
+ sport,
89
+ icon,
90
+ fixture: `${g.away.abbreviation} @ ${g.home.abbreviation}`,
91
+ start: g.date,
92
+ state: g.state,
93
+ openArgs: [sport, "watch", team, "--supervised"],
94
+ clickArgs: [sport, "watch", team],
95
+ route,
96
+ };
97
+ }
98
+
99
+ /** Short "where it'll open" text for status lines. */
100
+ export function routeWhere(r: Route, mode: DaemonMode): string {
101
+ if (r.kind === "open") return mode === "open" ? `opens ${r.note}` : `notifies (click to open ${r.note})`;
102
+ if (r.kind === "tune") return `notifies: ${r.message}`;
103
+ return `notifies: ✗ ${r.message}`;
104
+ }
105
+
106
+ // ── Cadence ──────────────────────────────────────────────────────────────────
107
+
108
+ /** Poll interval when no favorite game is close. */
109
+ export const SPARSE_POLL_MS = 10 * 60_000;
110
+ /** Poll interval from LEAD_MS before a scheduled start until it goes live. */
111
+ export const TIGHT_POLL_MS = 30_000;
112
+ /** How long before a scheduled start polling tightens. */
113
+ export const LEAD_MS = 15 * 60_000;
114
+ /** How long past its scheduled start a not-yet-live game is still awaited
115
+ * (delayed starts, a lagging feed) — matches `watch`'s START_GRACE_MS. */
116
+ export const START_GRACE_MS = 3 * 60 * 60_000;
117
+
118
+ /** The not-yet-started game the daemon is waiting for: the earliest `pre`
119
+ * game that's upcoming or still within START_GRACE_MS of its start. */
120
+ export function nextUp(games: DaemonGame[], now: number): DaemonGame | null {
121
+ return (
122
+ games
123
+ .filter((g) => g.state === "pre" && Date.parse(g.start) >= now - START_GRACE_MS)
124
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start))[0] ?? null
125
+ );
126
+ }
127
+
128
+ /**
129
+ * How long to sleep before the next poll: tight (30s) from LEAD_MS before the
130
+ * next game's scheduled start until it flips live — including past the start
131
+ * for a delayed one — else sparse, waking early enough to be tight by LEAD_MS.
132
+ */
133
+ export function pollDelayMs(games: DaemonGame[], now: number): number {
134
+ const n = nextUp(games, now);
135
+ if (!n) return SPARSE_POLL_MS;
136
+ const untilLead = Date.parse(n.start) - LEAD_MS - now;
137
+ if (untilLead <= 0) return TIGHT_POLL_MS;
138
+ return Math.min(SPARSE_POLL_MS, Math.max(TIGHT_POLL_MS, untilLead));
139
+ }
140
+
141
+ // ── State ────────────────────────────────────────────────────────────────────
142
+
143
+ export interface NextGame {
144
+ key: string;
145
+ icon: string;
146
+ fixture: string;
147
+ start: string;
148
+ /** routeWhere() text. */
149
+ where: string;
150
+ }
151
+
152
+ export interface LastAction {
153
+ at: string;
154
+ key: string;
155
+ icon: string;
156
+ fixture: string;
157
+ /** What was done: "opened", "notified", "dry-run", "failed". */
158
+ did: string;
159
+ detail: string;
160
+ }
161
+
162
+ /** The daemon's persisted state (heartbeat, dedupe set, what it's doing). */
163
+ export interface DaemonState {
164
+ pid: number;
165
+ startedAt: string;
166
+ version: string;
167
+ mode: DaemonMode;
168
+ /** Last completed poll. */
169
+ heartbeat: string | null;
170
+ /** The sleep chosen after that poll. */
171
+ pollMs: number;
172
+ next: NextGame | null;
173
+ lastAction: LastAction | null;
174
+ /** Game key → when it was acted on. A game is acted on at most once. */
175
+ acted: Record<string, string>;
176
+ /** Sport label → error from the last poll (that sport was skipped). */
177
+ errors: Record<string, string>;
178
+ }
179
+
180
+ /** How long acted-on keys are remembered (well past any game's length). */
181
+ export const ACTED_TTL_MS = 7 * 24 * 60 * 60_000;
182
+
183
+ /** Acted-on keys younger than ACTED_TTL_MS. */
184
+ export function pruneActed(acted: Record<string, string>, now: number): Record<string, string> {
185
+ return Object.fromEntries(Object.entries(acted).filter(([, at]) => now - Date.parse(at) < ACTED_TTL_MS));
186
+ }
187
+
188
+ /** Live favorite games not yet acted on — what this poll should open. */
189
+ export function startsToAct(games: DaemonGame[], acted: Record<string, string>): DaemonGame[] {
190
+ const seen = new Set<string>();
191
+ return games.filter((g) => {
192
+ if (g.state !== "in" || Object.hasOwn(acted, g.key) || seen.has(g.key)) return false;
193
+ seen.add(g.key);
194
+ return true;
195
+ });
196
+ }
197
+
198
+ /** Parse a state file's JSON; null for anything malformed. */
199
+ export function parseDaemonState(raw: unknown): DaemonState | null {
200
+ const s = raw as Partial<DaemonState> | null;
201
+ if (!s || typeof s !== "object" || typeof s.pid !== "number" || typeof s.startedAt !== "string") return null;
202
+ return {
203
+ pid: s.pid,
204
+ startedAt: s.startedAt,
205
+ version: typeof s.version === "string" ? s.version : "?",
206
+ mode: daemonModeOf(s.mode),
207
+ heartbeat: typeof s.heartbeat === "string" ? s.heartbeat : null,
208
+ pollMs: typeof s.pollMs === "number" && s.pollMs > 0 ? s.pollMs : SPARSE_POLL_MS,
209
+ next: s.next ?? null,
210
+ lastAction: s.lastAction ?? null,
211
+ acted: s.acted && typeof s.acted === "object" ? s.acted : {},
212
+ errors: s.errors && typeof s.errors === "object" ? s.errors : {},
213
+ };
214
+ }
215
+
216
+ // ── Status ───────────────────────────────────────────────────────────────────
217
+
218
+ /** A heartbeat older than this means the loop is wedged: ~3 poll intervals,
219
+ * never under 3 minutes (a tight-poll fetch can legitimately take a while). */
220
+ export function staleAfterMs(pollMs: number): number {
221
+ return Math.max(3 * pollMs, 3 * 60_000);
222
+ }
223
+
224
+ export interface ServiceInfo {
225
+ /** launchd has the job loaded. */
226
+ loaded: boolean;
227
+ /** Its running process, if any. */
228
+ pid?: number;
229
+ }
230
+
231
+ export type DaemonLevel =
232
+ /** Not installed (no plist, nothing loaded). */
233
+ | "off"
234
+ /** Installed, but no process is running it. */
235
+ | "down"
236
+ /** Running, but it hasn't completed a poll yet. */
237
+ | "starting"
238
+ /** Running, heartbeat stale. */
239
+ | "stuck"
240
+ /** Running and polling. */
241
+ | "on";
242
+
243
+ export interface DaemonStatus {
244
+ level: DaemonLevel;
245
+ installed: boolean;
246
+ pid: number | null;
247
+ state: DaemonState | null;
248
+ /** ms since the last heartbeat, when there is one. */
249
+ heartbeatAgeMs: number | null;
250
+ }
251
+
252
+ /**
253
+ * The daemon's health. `service` comes from launchd (`daemon status`) or, for
254
+ * the cheap footer, from probing the state file's pid. The state only counts
255
+ * when it belongs to the running process.
256
+ */
257
+ export function evaluateStatus(o: { installed: boolean; service: ServiceInfo; state: DaemonState | null; now: number }): DaemonStatus {
258
+ const pid = o.service.pid ?? null;
259
+ const base = { installed: o.installed, pid, state: o.state, heartbeatAgeMs: null as number | null };
260
+ if (!o.installed && !o.service.loaded) return { ...base, level: "off" };
261
+ if (pid === null) return { ...base, level: "down" };
262
+ const mine = o.state && o.state.pid === pid ? o.state : null;
263
+ if (!mine?.heartbeat) {
264
+ const since = mine ? o.now - Date.parse(mine.startedAt) : 0;
265
+ return { ...base, level: since > staleAfterMs(TIGHT_POLL_MS) ? "stuck" : "starting" };
266
+ }
267
+ const age = o.now - Date.parse(mine.heartbeat);
268
+ return { ...base, heartbeatAgeMs: age, level: age > staleAfterMs(mine.pollMs) ? "stuck" : "on" };
269
+ }
270
+
271
+ /** `daemon status` exits 0 only for this. */
272
+ export function isHealthy(s: DaemonStatus): boolean {
273
+ return s.level === "on";
274
+ }
275
+
276
+ /** "7:00 PM" today, else "Sat 7:00 PM" (local time). */
277
+ export function whenLabel(iso: string, now: number): string {
278
+ const d = new Date(iso);
279
+ const time = d.toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
280
+ if (d.toDateString() === new Date(now).toDateString()) return time;
281
+ return `${d.toLocaleString(undefined, { weekday: "short" })} ${time}`;
282
+ }
283
+
284
+ /** "45s", "12m", "3h 5m", "2d 4h". */
285
+ export function fmtDuration(ms: number): string {
286
+ const s = Math.max(0, Math.round(ms / 1000));
287
+ if (s < 60) return `${s}s`;
288
+ const m = Math.floor(s / 60);
289
+ if (m < 60) return `${m}m`;
290
+ const h = Math.floor(m / 60);
291
+ if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
292
+ const d = Math.floor(h / 24);
293
+ return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
294
+ }
295
+
296
+ /** The one-line footer under bare today / next / me. */
297
+ export function footerLine(s: DaemonStatus, now: number): string {
298
+ switch (s.level) {
299
+ case "off":
300
+ return "daemon: off — sportsing daemon install";
301
+ case "down":
302
+ return "daemon: down — installed but not running (sportsing daemon status)";
303
+ case "starting":
304
+ return "daemon: starting — sportsing daemon status";
305
+ case "stuck": {
306
+ const age = s.heartbeatAgeMs === null ? "no poll yet" : `last poll ${fmtDuration(s.heartbeatAgeMs)} ago`;
307
+ return `daemon: stuck — ${age} (sportsing daemon logs)`;
308
+ }
309
+ case "on": {
310
+ const n = s.state?.next;
311
+ return n ? `daemon: on — waiting for ${n.fixture} ${whenLabel(n.start, now)}` : "daemon: on — no favorite games coming up";
312
+ }
313
+ }
314
+ }
315
+
316
+ /** Pull `state` and `pid` out of `launchctl print gui/<uid>/<label>`. */
317
+ export function parseLaunchctlPrint(out: string): ServiceInfo & { state?: string } {
318
+ const state = out.match(/^\s*state = (.+)$/m)?.[1]?.trim();
319
+ const pidStr = out.match(/^\s*pid = (\d+)/m)?.[1];
320
+ const pid = pidStr ? Number(pidStr) : undefined;
321
+ return { loaded: true, ...(state ? { state } : {}), ...(pid && state === "running" ? { pid } : {}) };
322
+ }
323
+
324
+ // ── launchd agent ────────────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * ProgramArguments for `sportsing daemon run`, pinned to the stable paths of
328
+ * how this copy was installed — so the agent keeps working across
329
+ * `sportsing upgrade` (which reinstalls into the same prefix). The bin is a
330
+ * Bun script, so it's run by an absolute Bun (launchd's PATH has no Bun):
331
+ * the bun-global install's own, else the `bun` on the installing shell's PATH
332
+ * (a stable shim like ~/.bun/bin/bun), else the running one. Null for
333
+ * installs with no stable path (npx/bunx caches, unrecognised).
334
+ */
335
+ export function daemonProgram(
336
+ m: InstallMethod,
337
+ o: { execPath: string; bunOnPath: string | null; exists: (p: string) => boolean },
338
+ ): string[] | null {
339
+ const bun = o.bunOnPath ?? o.execPath;
340
+ const run = ["daemon", "run"];
341
+ switch (m.kind) {
342
+ case "npm-global":
343
+ return [bun, `${m.prefix}/bin/sportsing`, ...run];
344
+ case "bun-global": {
345
+ const own = `${m.bunInstall}/bin/bun`;
346
+ return [o.exists(own) ? own : bun, `${m.bunInstall}/bin/sportsing`, ...run];
347
+ }
348
+ case "source":
349
+ return [bun, `${m.root}/src/index.ts`, ...run];
350
+ case "compiled":
351
+ return [o.execPath, ...run];
352
+ default:
353
+ return null;
354
+ }
355
+ }
356
+
357
+ /** PATH for the agent: the installing shell's PATH (so terminal-notifier,
358
+ * osascript and Chrome resolve as they do for you), plus the system dirs,
359
+ * de-duplicated in order. */
360
+ export function agentPath(shellPath: string | undefined, extra: string[] = []): string {
361
+ const sys = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
362
+ const parts = [...extra, ...(shellPath ?? "").split(":"), ...sys].filter(Boolean);
363
+ return [...new Set(parts)].join(":");
364
+ }
365
+
366
+ function xml(s: string): string {
367
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
368
+ }
369
+
370
+ /**
371
+ * The LaunchAgent plist: run at login, restart if it exits (throttled), log
372
+ * stdout+stderr to `logPath`. AbandonProcessGroup keeps a game window the
373
+ * daemon opened alive across a daemon restart (e.g. after an upgrade).
374
+ */
375
+ export function launchAgentPlist(o: { label: string; program: string[]; logPath: string; path: string; home: string }): string {
376
+ const args = o.program.map((a) => ` <string>${xml(a)}</string>`).join("\n");
377
+ return `<?xml version="1.0" encoding="UTF-8"?>
378
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
379
+ <plist version="1.0">
380
+ <dict>
381
+ <key>Label</key>
382
+ <string>${xml(o.label)}</string>
383
+ <key>ProgramArguments</key>
384
+ <array>
385
+ ${args}
386
+ </array>
387
+ <key>RunAtLoad</key>
388
+ <true/>
389
+ <key>KeepAlive</key>
390
+ <true/>
391
+ <key>ThrottleInterval</key>
392
+ <integer>30</integer>
393
+ <key>AbandonProcessGroup</key>
394
+ <true/>
395
+ <key>WorkingDirectory</key>
396
+ <string>${xml(o.home)}</string>
397
+ <key>EnvironmentVariables</key>
398
+ <dict>
399
+ <key>PATH</key>
400
+ <string>${xml(o.path)}</string>
401
+ </dict>
402
+ <key>StandardOutPath</key>
403
+ <string>${xml(o.logPath)}</string>
404
+ <key>StandardErrorPath</key>
405
+ <string>${xml(o.logPath)}</string>
406
+ </dict>
407
+ </plist>
408
+ `;
409
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { ApiError } from "./api.ts";
4
4
  import { SPORTS as REGISTRY, bare } from "./commands/cross.ts";
5
5
  import { subscriptions } from "./commands/subscriptions.ts";
6
6
  import { upgrade } from "./commands/upgrade.ts";
7
+ import { daemon } from "./commands/daemon.ts";
7
8
  import pkg from "../package.json" with { type: "json" };
8
9
 
9
10
  // Single source of truth: package.json (a hardcoded copy drifted to a stale
@@ -40,6 +41,7 @@ ${b("YOUR TEAMS")} ${c.dim("(favorites across every sport, each row tagged with
40
41
  ${b("SETTINGS")}
41
42
  ${c.green("subscriptions")} What you can watch with + home market ${c.dim("— alias: subs")}
42
43
  ${c.green("upgrade")} Install the latest release ${c.dim("(--check: just compare versions)")}
44
+ ${c.green("daemon")} Always-on watcher that opens your teams' games ${c.dim("(install · status · logs · mode · uninstall)")}
43
45
 
44
46
  ${b("EXAMPLES")}
45
47
  sportsing fifa today
@@ -75,6 +77,10 @@ async function dispatch(): Promise<void> {
75
77
  await upgrade(rest, VERSION);
76
78
  return;
77
79
  }
80
+ if (first === "daemon") {
81
+ await daemon(rest, VERSION);
82
+ return;
83
+ }
78
84
 
79
85
  // Bare `today`/`next`/`me`/`live` run across every sport; anything else gets a hint.
80
86
  await bare(first, rest);
package/src/match-util.ts CHANGED
@@ -3,8 +3,16 @@
3
3
  // like events.ts can share one source of truth without events.ts taking a
4
4
  // dependency on the I/O-bearing command layer.
5
5
 
6
+ import type { GameState } from "./game.ts";
6
7
  import type { Match } from "./types.ts";
7
8
 
9
+ /** A football-data match status as a sport-neutral game state. */
10
+ export function matchState(m: Pick<Match, "status">): GameState {
11
+ if (m.status === "IN_PLAY" || m.status === "PAUSED") return "in";
12
+ if (m.status === "FINISHED" || m.status === "AWARDED") return "post";
13
+ return "pre";
14
+ }
15
+
8
16
  /** True if either side of the match matches `needle` (lowercased) by name/tla/shortName. */
9
17
  export function matchHasTeam(m: Match, needle: string): boolean {
10
18
  const fields = [