sportsing 0.2.0 → 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 +56 -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 +143 -0
- package/src/config.ts +16 -0
- package/src/daemon-io.ts +114 -0
- package/src/daemon.ts +409 -0
- package/src/index.ts +12 -0
- package/src/match-util.ts +8 -0
- package/src/upgrade.ts +182 -0
package/README.md
CHANGED
|
@@ -27,6 +27,13 @@ bun install -g sportsing # or: npm install -g sportsing
|
|
|
27
27
|
sportsing --version
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
To update later, run `sportsing upgrade`. It checks npm for the latest release
|
|
31
|
+
and reinstalls it with whichever of npm or bun installed your copy.
|
|
32
|
+
`sportsing upgrade --check` only compares your version with the latest and
|
|
33
|
+
installs nothing. A source checkout, a compiled binary or an `npx`/`bunx`
|
|
34
|
+
run isn't updated in place. For those, the command prints how to update
|
|
35
|
+
instead.
|
|
36
|
+
|
|
30
37
|
From source:
|
|
31
38
|
|
|
32
39
|
```sh
|
|
@@ -90,7 +97,7 @@ through playoffs. Times are local; preseason games are tagged `PRE`, postseason
|
|
|
90
97
|
| `season [team]` | Your favorites' season: record, home/away splits, playoff race |
|
|
91
98
|
| `bracket [--season YYYY]` | Playoff bracket (projected before the postseason) |
|
|
92
99
|
| `fav [add\|rm\|list]` | Manage NBA favorites (`fav add UTAH`, `fav add Jazz`) |
|
|
93
|
-
| `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 |
|
|
94
101
|
| `live [--notify [--quiet]]` | Auto-refreshing live board; `--notify` alerts for favorites — see **Live fav-alerts** below |
|
|
95
102
|
| `analyze <team> [team]` | AI read of the live or latest game: shooting, boards, turnovers, leaders (answered by `serve`; `--prompt` prints it) |
|
|
96
103
|
| `predict <team> [team]` | AI prediction for the next game from both teams' recent form |
|
|
@@ -167,7 +174,8 @@ sportsing next # each favorite's next game + countdown, soonest first
|
|
|
167
174
|
sportsing me # last result + next game per favorite team
|
|
168
175
|
```
|
|
169
176
|
|
|
170
|
-
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**).
|
|
171
179
|
Every other command needs a sport — a bare one (e.g. `sportsing standings`)
|
|
172
180
|
prints the sports that have it and exits non-zero.
|
|
173
181
|
|
|
@@ -175,6 +183,47 @@ Favorites are stored per sport as `<sport>:<team>` (`nba:UTAH`, `nhl:UTAH`,
|
|
|
175
183
|
`fifa:USA`) in `~/.config/sportsing/config.json`; add and remove them with each
|
|
176
184
|
sport's `fav` command.
|
|
177
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
|
+
|
|
178
227
|
## Upgrading to 0.2.0
|
|
179
228
|
|
|
180
229
|
- **Breaking — bare commands need a sport.** In 0.1.x every bare command ran as a
|
|
@@ -305,6 +354,11 @@ One supervisor loop that **is** the whole setup: it opens your game and keeps th
|
|
|
305
354
|
Claude session itself (no local model is ever spawned). `sportsing fifa agent-setup`
|
|
306
355
|
prints this recipe; `sportsing fifa` and the watch nag point at it.
|
|
307
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
|
+
|
|
308
362
|
> **The cost, honestly:** the loop consumes that Claude session as the always-on
|
|
309
363
|
> answerer for as long as it runs — that's the trade you're choosing. Stop the loop
|
|
310
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()));
|