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/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
|
@@ -3,6 +3,8 @@ import { c } from "./ansi.ts";
|
|
|
3
3
|
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
|
+
import { upgrade } from "./commands/upgrade.ts";
|
|
7
|
+
import { daemon } from "./commands/daemon.ts";
|
|
6
8
|
import pkg from "../package.json" with { type: "json" };
|
|
7
9
|
|
|
8
10
|
// Single source of truth: package.json (a hardcoded copy drifted to a stale
|
|
@@ -38,6 +40,8 @@ ${b("YOUR TEAMS")} ${c.dim("(favorites across every sport, each row tagged with
|
|
|
38
40
|
|
|
39
41
|
${b("SETTINGS")}
|
|
40
42
|
${c.green("subscriptions")} What you can watch with + home market ${c.dim("— alias: subs")}
|
|
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)")}
|
|
41
45
|
|
|
42
46
|
${b("EXAMPLES")}
|
|
43
47
|
sportsing fifa today
|
|
@@ -69,6 +73,14 @@ async function dispatch(): Promise<void> {
|
|
|
69
73
|
await subscriptions(rest);
|
|
70
74
|
return;
|
|
71
75
|
}
|
|
76
|
+
if (first === "upgrade") {
|
|
77
|
+
await upgrade(rest, VERSION);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (first === "daemon") {
|
|
81
|
+
await daemon(rest, VERSION);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
72
84
|
|
|
73
85
|
// Bare `today`/`next`/`me`/`live` run across every sport; anything else gets a hint.
|
|
74
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 = [
|
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Pure helpers behind `sportsing upgrade`: work out how the running copy was
|
|
2
|
+
// installed, compare versions, and build the reinstall command. No I/O here —
|
|
3
|
+
// the command (src/commands/upgrade.ts) does the realpath, fetch and spawn.
|
|
4
|
+
|
|
5
|
+
export const PACKAGE = "sportsing";
|
|
6
|
+
export const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
7
|
+
|
|
8
|
+
export type InstallMethod =
|
|
9
|
+
/** `npm install -g` — the script lives at `<prefix>/lib/node_modules/sportsing/…`. */
|
|
10
|
+
| { kind: "npm-global"; prefix: string }
|
|
11
|
+
/** `bun add -g` — the script lives at `<bunInstall>/install/global/node_modules/sportsing/…`. */
|
|
12
|
+
| { kind: "bun-global"; bunInstall: string }
|
|
13
|
+
/** A one-off `npx` / `bunx` run out of a temp cache. */
|
|
14
|
+
| { kind: "ephemeral" }
|
|
15
|
+
/** `bun run build`'s standalone `dist/sportsing`. */
|
|
16
|
+
| { kind: "compiled" }
|
|
17
|
+
/** A git checkout run with `bun run src/index.ts`. */
|
|
18
|
+
| { kind: "source"; root: string }
|
|
19
|
+
/** A project-local dependency or anything else we don't recognise. */
|
|
20
|
+
| { kind: "unknown" };
|
|
21
|
+
|
|
22
|
+
const PKG_SEG = `/node_modules/${PACKAGE}/`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Classify the running copy from the realpath of its entry script (`Bun.main`).
|
|
26
|
+
* Order matters: temp caches also contain `node_modules/sportsing/`, so they're
|
|
27
|
+
* ruled out before the global-install patterns.
|
|
28
|
+
*/
|
|
29
|
+
export function detectInstall(scriptPath: string): InstallMethod {
|
|
30
|
+
const p = scriptPath.replace(/\\/g, "/");
|
|
31
|
+
|
|
32
|
+
// Compiled binaries report a virtual path inside Bun's embedded filesystem.
|
|
33
|
+
if (p.includes("/$bunfs/") || p.includes("~BUN/")) return { kind: "compiled" };
|
|
34
|
+
|
|
35
|
+
if (p.includes(PKG_SEG)) {
|
|
36
|
+
// npx: ~/.npm/_npx/<hash>/node_modules/sportsing; bunx: $TMPDIR/bunx-<uid>-sportsing@…/node_modules/sportsing
|
|
37
|
+
if (p.includes("/_npx/") || /\/bunx-[^/]*\//.test(p)) return { kind: "ephemeral" };
|
|
38
|
+
|
|
39
|
+
const bun = p.match(/^(.*)\/install\/global\/node_modules\/sportsing\//);
|
|
40
|
+
if (bun) return { kind: "bun-global", bunInstall: bun[1]! };
|
|
41
|
+
|
|
42
|
+
const npm = p.match(/^(.*)\/lib\/node_modules\/sportsing\//);
|
|
43
|
+
if (npm) return { kind: "npm-global", prefix: npm[1]! };
|
|
44
|
+
|
|
45
|
+
return { kind: "unknown" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const src = p.match(/^(.*)\/src\/index\.ts$/);
|
|
49
|
+
if (src) return { kind: "source", root: src[1]! };
|
|
50
|
+
|
|
51
|
+
// A compiled binary renamed or run from a real path (older Bun / other OS).
|
|
52
|
+
if (!p.endsWith(".ts") && !p.endsWith(".js")) return { kind: "compiled" };
|
|
53
|
+
|
|
54
|
+
return { kind: "unknown" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function canSelfUpgrade(m: InstallMethod): m is Extract<InstallMethod, { kind: "npm-global" | "bun-global" }> {
|
|
58
|
+
return m.kind === "npm-global" || m.kind === "bun-global";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Human description of the install, for status lines. */
|
|
62
|
+
export function describeInstall(m: InstallMethod): string {
|
|
63
|
+
switch (m.kind) {
|
|
64
|
+
case "npm-global":
|
|
65
|
+
return `npm global install (${m.prefix})`;
|
|
66
|
+
case "bun-global":
|
|
67
|
+
return `bun global install (${m.bunInstall})`;
|
|
68
|
+
case "ephemeral":
|
|
69
|
+
return "npx/bunx temporary cache";
|
|
70
|
+
case "compiled":
|
|
71
|
+
return "compiled standalone binary";
|
|
72
|
+
case "source":
|
|
73
|
+
return `source checkout (${m.root})`;
|
|
74
|
+
case "unknown":
|
|
75
|
+
return "unrecognised install";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How to update an install `sportsing upgrade` won't touch itself. */
|
|
80
|
+
export function manualUpgradeHint(m: InstallMethod): string {
|
|
81
|
+
switch (m.kind) {
|
|
82
|
+
case "source":
|
|
83
|
+
return `This is a source checkout. Update it with git:\n cd ${m.root} && git pull && bun install`;
|
|
84
|
+
case "compiled":
|
|
85
|
+
return "This is a compiled standalone binary. Rebuild it from an updated checkout:\n git pull && bun install && bun run build";
|
|
86
|
+
case "ephemeral":
|
|
87
|
+
return `This is a temporary npx/bunx copy. Run the latest directly:\n npx ${PACKAGE}@latest (or: bunx ${PACKAGE}@latest)\nor install it globally so \`sportsing upgrade\` can manage it:\n npm install -g ${PACKAGE} (or: bun add -g ${PACKAGE})`;
|
|
88
|
+
case "unknown":
|
|
89
|
+
return `Couldn't tell how this copy was installed. Reinstall it with your package manager:\n npm install -g ${PACKAGE}@latest (or: bun add -g ${PACKAGE}@latest)`;
|
|
90
|
+
default:
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface Command {
|
|
96
|
+
cmd: string[];
|
|
97
|
+
/** Where the upgraded `sportsing` bin lands, to confirm the new version. */
|
|
98
|
+
bin: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The reinstall command for a self-upgradable install. For npm, prefer the
|
|
103
|
+
* node + npm sitting in that prefix (nvm's *active* node may be a different
|
|
104
|
+
* version) and pin `--prefix` so the package lands back where it came from.
|
|
105
|
+
* `exists` is injected so this stays pure.
|
|
106
|
+
*/
|
|
107
|
+
export function installCommand(
|
|
108
|
+
m: Extract<InstallMethod, { kind: "npm-global" | "bun-global" }>,
|
|
109
|
+
exists: (path: string) => boolean,
|
|
110
|
+
): Command {
|
|
111
|
+
const spec = `${PACKAGE}@latest`;
|
|
112
|
+
if (m.kind === "bun-global") {
|
|
113
|
+
const localBun = `${m.bunInstall}/bin/bun`;
|
|
114
|
+
return {
|
|
115
|
+
cmd: [exists(localBun) ? localBun : "bun", "add", "-g", spec],
|
|
116
|
+
bin: `${m.bunInstall}/bin/${PACKAGE}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const node = `${m.prefix}/bin/node`;
|
|
120
|
+
const npm = `${m.prefix}/bin/npm`;
|
|
121
|
+
const runner = exists(npm) ? (exists(node) ? [node, npm] : [npm]) : ["npm"];
|
|
122
|
+
return {
|
|
123
|
+
cmd: [...runner, "install", "-g", "--prefix", m.prefix, spec],
|
|
124
|
+
bin: `${m.prefix}/bin/${PACKAGE}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Semver {
|
|
129
|
+
core: [number, number, number];
|
|
130
|
+
pre: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseSemver(v: string): Semver | null {
|
|
134
|
+
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : [] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isValidVersion(v: string): boolean {
|
|
140
|
+
return parseSemver(v) !== null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** semver precedence: -1 if a < b, 0 if equal, 1 if a > b. Throws on a malformed version. */
|
|
144
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
145
|
+
const pa = parseSemver(a);
|
|
146
|
+
const pb = parseSemver(b);
|
|
147
|
+
if (!pa || !pb) throw new Error(`Not a semver version: ${!pa ? a : b}`);
|
|
148
|
+
for (let i = 0; i < 3; i++) {
|
|
149
|
+
if (pa.core[i]! !== pb.core[i]!) return pa.core[i]! < pb.core[i]! ? -1 : 1;
|
|
150
|
+
}
|
|
151
|
+
// A release outranks any of its prereleases.
|
|
152
|
+
if (!pa.pre.length || !pb.pre.length) {
|
|
153
|
+
if (pa.pre.length === pb.pre.length) return 0;
|
|
154
|
+
return pa.pre.length ? -1 : 1;
|
|
155
|
+
}
|
|
156
|
+
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
|
|
157
|
+
const x = pa.pre[i];
|
|
158
|
+
const y = pb.pre[i];
|
|
159
|
+
if (x === undefined) return -1;
|
|
160
|
+
if (y === undefined) return 1;
|
|
161
|
+
if (x === y) continue;
|
|
162
|
+
const xn = /^\d+$/.test(x);
|
|
163
|
+
const yn = /^\d+$/.test(y);
|
|
164
|
+
if (xn && yn) return Number(x) < Number(y) ? -1 : 1;
|
|
165
|
+
if (xn !== yn) return xn ? -1 : 1; // numeric identifiers sort before alphanumeric
|
|
166
|
+
return x < y ? -1 : 1;
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pull `version` out of the registry's `/<pkg>/latest` document. */
|
|
172
|
+
export function parseRegistryLatest(body: unknown): string {
|
|
173
|
+
const v = (body as { version?: unknown } | null)?.version;
|
|
174
|
+
if (typeof v !== "string" || !isValidVersion(v)) throw new Error("Registry response had no valid version.");
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Pull the version out of `sportsing --version` output (`sportsing 0.2.1`). */
|
|
179
|
+
export function parseVersionOutput(out: string): string | null {
|
|
180
|
+
const m = out.match(/sportsing\s+v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
|
|
181
|
+
return m ? m[1]! : null;
|
|
182
|
+
}
|