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 +49 -2
- package/package.json +1 -1
- package/src/commands/cross.ts +31 -21
- package/src/commands/daemon.ts +335 -0
- package/src/commands/league.ts +33 -5
- package/src/commands/live.ts +32 -2
- package/src/commands/upgrade.ts +8 -2
- package/src/config.ts +16 -0
- package/src/daemon-io.ts +114 -0
- package/src/daemon.ts +409 -0
- package/src/index.ts +6 -0
- package/src/match-util.ts +8 -0
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.
|
|
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": {
|
package/src/commands/cross.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/league.ts
CHANGED
|
@@ -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
|
-
|
|
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()));
|
package/src/commands/live.ts
CHANGED
|
@@ -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
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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 };
|
package/src/daemon-io.ts
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 = [
|