sportsing 0.1.2 → 0.2.1
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 +167 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/upgrade.ts +137 -0
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +41 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/upgrade.ts +182 -0
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
package/src/commands/live.ts
CHANGED
|
@@ -2,14 +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 { getFavorites } from "../config.ts";
|
|
5
|
+
import { getApiKey, getFavorites } from "../config.ts";
|
|
6
6
|
import { diffEvents, type MatchEvent } from "../events.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
|
|
8
|
+
import { inTerminal, selfInvocation, watchCommand } from "../click-to-watch.ts";
|
|
8
9
|
import { matchHasTeam } from "../match-util.ts";
|
|
9
10
|
import type { Match } from "../types.ts";
|
|
10
11
|
|
|
11
|
-
const REFRESH_MS = 60_000; // free tier note: scores are delayed; 60s is plenty.
|
|
12
|
-
|
|
13
12
|
export async function live(args: string[] = []) {
|
|
14
13
|
const wantNotify = args.includes("--notify");
|
|
15
14
|
const wantQuiet = args.includes("--quiet");
|
|
@@ -27,7 +26,7 @@ export async function live(args: string[] = []) {
|
|
|
27
26
|
await getMatches({ status: "IN_PLAY" }, 0);
|
|
28
27
|
} catch (e) {
|
|
29
28
|
if (e instanceof NoKeyError) {
|
|
30
|
-
console.error(c.yellow("Live scores need an API key. Run `sportsing setup` first."));
|
|
29
|
+
console.error(c.yellow("Live scores need an API key. Run `sportsing fifa setup` first."));
|
|
31
30
|
return;
|
|
32
31
|
}
|
|
33
32
|
throw e;
|
|
@@ -35,7 +34,7 @@ export async function live(args: string[] = []) {
|
|
|
35
34
|
|
|
36
35
|
let favorites: string[] = [];
|
|
37
36
|
if (wantNotify) {
|
|
38
|
-
favorites = await getFavorites();
|
|
37
|
+
favorites = await getFavorites("fifa");
|
|
39
38
|
if (favorites.length === 0) {
|
|
40
39
|
// --notify with no favourites would silently never alert; say so once.
|
|
41
40
|
// All on stderr so --quiet keeps stdout clean for backgrounding.
|
|
@@ -52,21 +51,10 @@ export async function live(args: string[] = []) {
|
|
|
52
51
|
console.error(c.dim(`Favorite-team alerts running (${who}) — Ctrl-C to stop.`));
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
// diffEvents only emits transitions between prev→cur, so an event fires once
|
|
57
|
-
// and never re-alerts on a later unchanged tick (AGT-507 idempotency).
|
|
58
|
-
let prevSnapshot: Match[] = [];
|
|
54
|
+
const feed = wantNotify ? fifaFeed(favorites, selfInvocation()) : null;
|
|
59
55
|
|
|
60
56
|
const tick = async () => {
|
|
61
|
-
const
|
|
62
|
-
const today = ymd(now);
|
|
63
|
-
// Query ±1 UTC day, then keep only matches on today's local calendar date
|
|
64
|
-
// (a local day straddles two UTC days — see today.ts).
|
|
65
|
-
const { matches: raw } = await getMatches(
|
|
66
|
-
{ dateFrom: ymd(addDays(now, -1)), dateTo: ymd(addDays(now, 1)) },
|
|
67
|
-
20_000,
|
|
68
|
-
);
|
|
69
|
-
const matches = raw.filter((m) => localDateOf(m.utcDate) === today);
|
|
57
|
+
const matches = await matchesToday();
|
|
70
58
|
|
|
71
59
|
// Full-screen scoreboard — skipped in --quiet so the command can be
|
|
72
60
|
// backgrounded without alt-screen clears corrupting the parent shell.
|
|
@@ -102,26 +90,13 @@ export async function live(args: string[] = []) {
|
|
|
102
90
|
}
|
|
103
91
|
}
|
|
104
92
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// a baseline (a match already in play when you start raises no kickoff).
|
|
109
|
-
for (const e of diffEvents(prevSnapshot, matches, favorites)) {
|
|
110
|
-
const { title, body } = formatEvent(e);
|
|
111
|
-
// Kickoff alerts are click-to-watch: clicking launches `watch <fav team>`
|
|
112
|
-
// (terminal-notifier -execute). notify() degrades to a plain, non-clickable
|
|
113
|
-
// notification when terminal-notifier is absent. Goal/full-time stay informational.
|
|
114
|
-
// process.execPath is the sportsing binary when distributed (compiled); under
|
|
115
|
-
// `bun run` dev it's the Bun runtime, so the click command only works compiled.
|
|
116
|
-
const onClick = kickoffWatchCommand(e, matches, favorites, process.execPath);
|
|
117
|
-
notify(title, body, { group: `sportsing-${e.matchId}`, sound: e.kind === "goal", onClick });
|
|
118
|
-
}
|
|
119
|
-
prevSnapshot = matches;
|
|
120
|
-
}
|
|
93
|
+
// The first tick only establishes a baseline (a match already in play when
|
|
94
|
+
// you start raises no kickoff).
|
|
95
|
+
if (feed) for (const a of feed(matches)) raise(a);
|
|
121
96
|
};
|
|
122
97
|
|
|
123
98
|
await tick();
|
|
124
|
-
const interval = setInterval(() => tick().catch((e) => console.error(c.red(String(e)))),
|
|
99
|
+
const interval = setInterval(() => tick().catch((e) => console.error(c.red(String(e)))), LIVE_REFRESH_MS); // free tier: scores are delayed; 60s is plenty
|
|
125
100
|
process.on("SIGINT", () => {
|
|
126
101
|
clearInterval(interval);
|
|
127
102
|
process.stdout.write("\n");
|
|
@@ -133,14 +108,54 @@ function stageTag(m: Match): string {
|
|
|
133
108
|
return c.dim(" " + stageLabel(m));
|
|
134
109
|
}
|
|
135
110
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
111
|
+
/** Today's matches (local calendar day). football-data filters by UTC date, so
|
|
112
|
+
* query ±1 UTC day and keep the local day (a local day straddles two UTC days). */
|
|
113
|
+
async function matchesToday(): Promise<Match[]> {
|
|
114
|
+
const now = new Date();
|
|
115
|
+
const today = ymd(now);
|
|
116
|
+
const { matches } = await getMatches({ dateFrom: ymd(addDays(now, -1)), dateTo: ymd(addDays(now, 1)) }, 20_000);
|
|
117
|
+
return matches.filter((m) => localDateOf(m.utcDate) === today);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A stateful fav-alert feed: hand it each tick's matches and it returns the
|
|
122
|
+
* alerts for new favourite events since the previous tick. diffEvents only
|
|
123
|
+
* emits prev→cur transitions, so an event fires once and never re-alerts on a
|
|
124
|
+
* later unchanged tick (AGT-507 idempotency); the first tick is the baseline.
|
|
125
|
+
*
|
|
126
|
+
* Kickoff alerts are click-to-watch: clicking opens a Terminal running
|
|
127
|
+
* `fifa watch <fav team>` (terminal-notifier -execute; notify() drops the click
|
|
128
|
+
* when terminal-notifier is absent). Goal/full-time stay informational.
|
|
129
|
+
*/
|
|
130
|
+
export function fifaFeed(favorites: string[], exe: string[]): (matches: Match[]) => Alert[] {
|
|
131
|
+
let prev: Match[] = [];
|
|
132
|
+
return (matches) => {
|
|
133
|
+
const alerts = diffEvents(prev, matches, favorites).map((e): Alert => {
|
|
134
|
+
const { title, body } = formatEvent(e);
|
|
135
|
+
const onClick = kickoffWatchCommand(e, matches, favorites, exe);
|
|
136
|
+
return {
|
|
137
|
+
title,
|
|
138
|
+
body,
|
|
139
|
+
options: { group: `sportsing-${e.matchId}`, sound: e.kind === "goal", onClick: onClick && inTerminal(onClick) },
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
prev = matches;
|
|
143
|
+
return alerts;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** FIFA's alerter for bare `sportsing live --notify`; null with no FIFA favorites. */
|
|
148
|
+
export async function fifaAlerter(): Promise<Alerter | null> {
|
|
149
|
+
const favorites = await getFavorites("fifa");
|
|
150
|
+
if (favorites.length === 0) return null;
|
|
151
|
+
if (!(await getApiKey())) throw new Error("FIFA alerts need an API key — run `sportsing fifa setup`.");
|
|
152
|
+
const feed = fifaFeed(favorites, selfInvocation());
|
|
153
|
+
return { label: "FIFA", teams: favorites, poll: async () => feed(await matchesToday()) };
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
/**
|
|
142
157
|
* The click-to-watch command for a kickoff event, or undefined when none applies.
|
|
143
|
-
* Returns `<exe
|
|
158
|
+
* Returns `<exe…> fifa watch <fav>` (shell-quoted) where `fav` is the favourite
|
|
144
159
|
* term that put this match on the alert list — so clicking the kickoff alert opens
|
|
145
160
|
* that team's broadcast. Only kickoff events are clickable (goal/full-time are
|
|
146
161
|
* informational); returns undefined if the match or a matching favourite is gone.
|
|
@@ -149,14 +164,14 @@ export function kickoffWatchCommand(
|
|
|
149
164
|
e: MatchEvent,
|
|
150
165
|
matches: Match[],
|
|
151
166
|
favorites: string[],
|
|
152
|
-
exe: string,
|
|
167
|
+
exe: string[],
|
|
153
168
|
): string | undefined {
|
|
154
169
|
if (e.kind !== "kickoff") return undefined;
|
|
155
170
|
const match = matches.find((m) => m.id === e.matchId);
|
|
156
171
|
if (!match) return undefined;
|
|
157
172
|
const fav = favorites.find((f) => matchHasTeam(match, f.trim().toLowerCase()));
|
|
158
173
|
if (!fav) return undefined;
|
|
159
|
-
return
|
|
174
|
+
return watchCommand(exe, "fifa", fav.trim());
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
/** Notification title + body for a fav match event. */
|
package/src/commands/me.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Match, StandingsTable } from "../types.ts";
|
|
|
8
8
|
// `sportsing fifa me` — a personalized dashboard for your favorite teams:
|
|
9
9
|
// last result, next match + countdown, and current group position.
|
|
10
10
|
export async function me(_args: string[]): Promise<void> {
|
|
11
|
-
const favorites = await getFavorites();
|
|
11
|
+
const favorites = await getFavorites("fifa");
|
|
12
12
|
if (favorites.length === 0) return noFavoritesHint();
|
|
13
13
|
|
|
14
14
|
const matches = await withFallback(
|
package/src/commands/predict.ts
CHANGED
|
@@ -49,7 +49,7 @@ export async function predict(args: string[]) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
process.stderr.write(c.dim("Posted to the ask bus — waiting for your Claude agent to answer…\n"));
|
|
52
|
-
process.stderr.write(c.dim("(keep one serving: /loop sportsing serve)\n"));
|
|
52
|
+
process.stderr.write(c.dim("(keep one serving: /loop sportsing fifa serve)\n"));
|
|
53
53
|
const id = await postQuestion({
|
|
54
54
|
source: "predict",
|
|
55
55
|
question: prompt,
|
|
@@ -60,7 +60,7 @@ export async function predict(args: string[]) {
|
|
|
60
60
|
const prediction = await waitForAnswer(id, 180_000);
|
|
61
61
|
if (prediction === null) {
|
|
62
62
|
console.error(c.yellow("No Claude agent answered within 3 minutes."));
|
|
63
|
-
console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing serve"));
|
|
63
|
+
console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing fifa serve"));
|
|
64
64
|
console.error(c.dim("Or run with --prompt to predict elsewhere."));
|
|
65
65
|
process.exitCode = 1;
|
|
66
66
|
return;
|
package/src/commands/scorers.ts
CHANGED
|
@@ -8,7 +8,7 @@ export async function scorers() {
|
|
|
8
8
|
list = (await getScorers()).scorers;
|
|
9
9
|
} catch (e) {
|
|
10
10
|
if (e instanceof NoKeyError) {
|
|
11
|
-
console.error(c.yellow("Top scorers need live data. Run `sportsing setup` to add a free API key."));
|
|
11
|
+
console.error(c.yellow("Top scorers need live data. Run `sportsing fifa setup` to add a free API key."));
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
throw e;
|
package/src/commands/setup.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { c } from "../ansi.ts";
|
|
|
2
2
|
import { setApiKey, getApiKey, CONFIG_FILE } from "../config.ts";
|
|
3
3
|
|
|
4
4
|
export async function setup(args: string[]) {
|
|
5
|
-
// Non-interactive: `sportsing setup <key>`
|
|
5
|
+
// Non-interactive: `sportsing fifa setup <key>`
|
|
6
6
|
const inline = args.find((a) => !a.startsWith("--"));
|
|
7
7
|
|
|
8
|
-
console.log(c.bold(c.cyan("⚽ sportsing setup")));
|
|
8
|
+
console.log(c.bold(c.cyan("⚽ sportsing fifa setup")));
|
|
9
9
|
console.log(
|
|
10
10
|
"\nLive data comes from " +
|
|
11
11
|
c.underline("football-data.org") +
|
|
@@ -36,5 +36,5 @@ export async function setup(args: string[]) {
|
|
|
36
36
|
console.log(c.green("ok ✓"));
|
|
37
37
|
await setApiKey(key);
|
|
38
38
|
console.log(c.dim(`Saved to ${CONFIG_FILE}`));
|
|
39
|
-
console.log("\nTry: " + c.bold("sportsing today") + " · " + c.bold("sportsing next") + " · " + c.bold("sportsing bracket"));
|
|
39
|
+
console.log("\nTry: " + c.bold("sportsing fifa today") + " · " + c.bold("sportsing fifa next") + " · " + c.bold("sportsing fifa bracket"));
|
|
40
40
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { c } from "../ansi.ts";
|
|
2
|
+
import {
|
|
3
|
+
getSubscriptions,
|
|
4
|
+
setSubscriptions,
|
|
5
|
+
getHomeMarket,
|
|
6
|
+
setHomeMarket,
|
|
7
|
+
parseSubscriptions,
|
|
8
|
+
parseHomeMarket,
|
|
9
|
+
CONFIG_FILE,
|
|
10
|
+
} from "../config.ts";
|
|
11
|
+
import { SUBSCRIPTIONS, SUBSCRIPTION_LABELS, HOME_MARKETS } from "../watchability.ts";
|
|
12
|
+
|
|
13
|
+
// `sportsing subscriptions [set <ids…> | market <id>]` — what you can watch
|
|
14
|
+
// with, and where you live (for blackouts and local channels). Cross-sport:
|
|
15
|
+
// the same Fubo / antenna covers every league.
|
|
16
|
+
export async function subscriptions(args: string[]): Promise<void> {
|
|
17
|
+
const [sub, ...rest] = args;
|
|
18
|
+
|
|
19
|
+
if (!sub || sub === "list" || sub === "show") return show();
|
|
20
|
+
|
|
21
|
+
if (sub === "set") {
|
|
22
|
+
if (rest.length === 0) return usage("subscriptions set <" + SUBSCRIPTIONS.join("|") + "|none>…");
|
|
23
|
+
const none = rest.length === 1 && rest[0]!.trim().toLowerCase() === "none";
|
|
24
|
+
const { subscriptions: subs, invalid } = none ? { subscriptions: [], invalid: [] } : parseSubscriptions(rest);
|
|
25
|
+
if (invalid.length) {
|
|
26
|
+
console.error(c.red(`Unknown subscription: ${invalid.join(", ")}`) + c.dim(` (choose from ${SUBSCRIPTIONS.join(", ")})`));
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await setSubscriptions(subs);
|
|
31
|
+
console.log(c.green("✓ Saved.") + c.dim(` (${CONFIG_FILE})`));
|
|
32
|
+
return show();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sub === "market") {
|
|
36
|
+
const input = rest.join(" ").trim();
|
|
37
|
+
if (!input) return usage("subscriptions market <" + Object.keys(HOME_MARKETS).join("|") + ">");
|
|
38
|
+
const market = parseHomeMarket(input);
|
|
39
|
+
if (!market) {
|
|
40
|
+
console.error(c.red(`Unknown home market: ${input}`) + c.dim(` (choose from ${Object.keys(HOME_MARKETS).join(", ")})`));
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await setHomeMarket(market);
|
|
45
|
+
console.log(c.green("✓ Saved.") + c.dim(` (${CONFIG_FILE})`));
|
|
46
|
+
return show();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
usage("subscriptions [set <ids…> | market <id>]");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function show(): Promise<void> {
|
|
53
|
+
const [subs, market] = await Promise.all([getSubscriptions(), getHomeMarket()]);
|
|
54
|
+
console.log(c.bold(c.cyan("📺 Subscriptions")));
|
|
55
|
+
if (subs.length === 0) {
|
|
56
|
+
console.log(c.dim(" none yet — e.g. ") + c.bold("sportsing subscriptions set fubo nba-league-pass local-ota"));
|
|
57
|
+
} else {
|
|
58
|
+
for (const s of subs) console.log(` ${s} ${c.dim("— " + SUBSCRIPTION_LABELS[s])}`);
|
|
59
|
+
}
|
|
60
|
+
console.log(c.bold("Home market: ") + (HOME_MARKETS[market]?.name ?? market) + c.dim(` (${Object.keys(HOME_MARKETS).join(", ")})`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function usage(form: string): void {
|
|
64
|
+
console.error(c.red(`Usage: sportsing ${form}`));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
package/src/commands/table.ts
CHANGED
|
@@ -11,7 +11,7 @@ export async function table(args: string[]) {
|
|
|
11
11
|
} catch (e) {
|
|
12
12
|
if (e instanceof NoKeyError) {
|
|
13
13
|
console.error(
|
|
14
|
-
c.yellow("Group tables need live data. Run `sportsing setup` to add a free API key."),
|
|
14
|
+
c.yellow("Group tables need live data. Run `sportsing fifa setup` to add a free API key."),
|
|
15
15
|
);
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "fs";
|
|
2
|
+
import { c } from "../ansi.ts";
|
|
3
|
+
import {
|
|
4
|
+
REGISTRY_LATEST_URL,
|
|
5
|
+
canSelfUpgrade,
|
|
6
|
+
compareVersions,
|
|
7
|
+
describeInstall,
|
|
8
|
+
detectInstall,
|
|
9
|
+
installCommand,
|
|
10
|
+
manualUpgradeHint,
|
|
11
|
+
parseRegistryLatest,
|
|
12
|
+
parseVersionOutput,
|
|
13
|
+
} from "../upgrade.ts";
|
|
14
|
+
|
|
15
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
function usage(): void {
|
|
18
|
+
console.log(`${c.bold("sportsing upgrade")} — install the latest npm release
|
|
19
|
+
|
|
20
|
+
sportsing upgrade reinstall sportsing@latest with the package manager that installed it
|
|
21
|
+
sportsing upgrade --check just report current vs latest; installs nothing`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fail(msg: string): void {
|
|
25
|
+
console.error(c.red(msg));
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchLatest(): Promise<string> {
|
|
30
|
+
let res: Response;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(REGISTRY_LATEST_URL, {
|
|
33
|
+
headers: { "User-Agent": "sportsing", Accept: "application/json" },
|
|
34
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
35
|
+
});
|
|
36
|
+
} catch (e) {
|
|
37
|
+
const why = e instanceof Error && e.name === "TimeoutError" ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s` : String((e as Error)?.message ?? e);
|
|
38
|
+
throw new Error(`Couldn't reach the npm registry (${why}).`);
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) throw new Error(`npm registry returned ${res.status} ${res.statusText}.`);
|
|
41
|
+
return parseRegistryLatest(await res.json());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Realpath of the running entry script, so npm/bun bin symlinks resolve to the package. */
|
|
45
|
+
function runningScript(): string {
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(Bun.main);
|
|
48
|
+
} catch {
|
|
49
|
+
return Bun.main; // compiled binaries report a virtual /$bunfs path
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ask the freshly installed copy its version (falls back to null if it can't be run). */
|
|
54
|
+
function installedVersion(bin: string): string | null {
|
|
55
|
+
if (!existsSync(bin)) return null;
|
|
56
|
+
try {
|
|
57
|
+
// Run it under this same Bun so the bin's `#!/usr/bin/env bun` shebang doesn't matter.
|
|
58
|
+
const r = Bun.spawnSync([process.execPath, realpathSync(bin), "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
59
|
+
return r.exitCode === 0 ? parseVersionOutput(r.stdout.toString()) : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `sportsing upgrade [--check]` — self-update to the latest npm release.
|
|
66
|
+
export async function upgrade(args: string[], current: string): Promise<void> {
|
|
67
|
+
if (args.includes("--help") || args.includes("-h") || args[0] === "help") return usage();
|
|
68
|
+
const unknown = args.filter((a) => a !== "--check");
|
|
69
|
+
if (unknown.length) {
|
|
70
|
+
usage();
|
|
71
|
+
return fail(`Unknown argument: ${unknown.join(" ")}`);
|
|
72
|
+
}
|
|
73
|
+
const check = args.includes("--check");
|
|
74
|
+
const install = detectInstall(runningScript());
|
|
75
|
+
|
|
76
|
+
// Refuse unsupported installs up front — no network needed to say "not from here".
|
|
77
|
+
if (!check && !canSelfUpgrade(install)) {
|
|
78
|
+
console.error(c.yellow(`sportsing upgrade can't update a ${describeInstall(install)}.`));
|
|
79
|
+
console.error(manualUpgradeHint(install));
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let latest: string;
|
|
85
|
+
try {
|
|
86
|
+
latest = await fetchLatest();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return fail(`Error: ${(e as Error).message} Nothing was changed.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cmp = compareVersions(current, latest);
|
|
92
|
+
const status =
|
|
93
|
+
cmp < 0
|
|
94
|
+
? c.yellow(`update available: ${current} → ${latest}`)
|
|
95
|
+
: cmp === 0
|
|
96
|
+
? c.green(`up to date (${current})`)
|
|
97
|
+
: c.dim(`running ${current}, ahead of the latest release ${latest}`);
|
|
98
|
+
|
|
99
|
+
if (check) {
|
|
100
|
+
console.log(`sportsing ${current} ${c.dim("latest")} ${latest} — ${status}`);
|
|
101
|
+
console.log(c.dim(`installed as: ${describeInstall(install)}`));
|
|
102
|
+
if (cmp < 0) {
|
|
103
|
+
console.log(canSelfUpgrade(install) ? c.dim("run `sportsing upgrade` to install it") : c.dim(manualUpgradeHint(install)));
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (cmp >= 0) {
|
|
109
|
+
console.log(`sportsing is ${status}.`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!canSelfUpgrade(install)) return; // narrowed above; keeps the type checker honest
|
|
113
|
+
|
|
114
|
+
const { cmd, bin } = installCommand(install, existsSync);
|
|
115
|
+
console.log(c.dim(`Upgrading sportsing ${current} → ${latest} via: ${cmd.join(" ")}`));
|
|
116
|
+
let code: number;
|
|
117
|
+
try {
|
|
118
|
+
const proc = Bun.spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
119
|
+
code = await proc.exited;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return fail(`Error: couldn't run ${cmd[0]} (${(e as Error).message}). Nothing was changed.`);
|
|
122
|
+
}
|
|
123
|
+
if (code !== 0) {
|
|
124
|
+
return fail(`Error: ${cmd.join(" ")} exited ${code}. The upgrade failed; your existing install was left as-is.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const now = installedVersion(bin);
|
|
128
|
+
if (!now) {
|
|
129
|
+
console.error(c.yellow(`Install finished, but couldn't confirm the new version from ${bin}. Check with: sportsing --version`));
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (compareVersions(now, current) <= 0) {
|
|
134
|
+
return fail(`Install finished, but ${bin} still reports ${now}. Check which sportsing is on your PATH.`);
|
|
135
|
+
}
|
|
136
|
+
console.log(c.green(`✓ sportsing ${current} → ${now}`));
|
|
137
|
+
}
|
package/src/commands/watch.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { c } from "../ansi.ts";
|
|
2
2
|
import { findCurrentMatch, resolveWatchTarget, type EspnEvent } from "../espn.ts";
|
|
3
3
|
import { getStreamProvider } from "../config.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { launchStream, pickProvider, spawnStreamWindow } from "../stream.ts";
|
|
5
5
|
import { runOverlayStream, type WatchLang } from "../overlay.ts";
|
|
6
6
|
import { freePort, attachToPage } from "../cdp.ts";
|
|
7
7
|
import { writeWatchPidfile } from "../liveness.ts";
|
|
@@ -47,13 +47,13 @@ export async function watch(args: string[]) {
|
|
|
47
47
|
const terms = positionalTerms(args);
|
|
48
48
|
|
|
49
49
|
// Default to Fubo (English/Fox). Peacock is Spanish-only (Telemundo).
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
console.error(c.red(`Unknown provider "${key}". Known: ${Object.keys(PROVIDERS).join(", ")}.`));
|
|
50
|
+
const pick = pickProvider(providerFlag ?? (await getStreamProvider("fifa")) ?? "fubo", "fifa");
|
|
51
|
+
if (!pick.ok) {
|
|
52
|
+
console.error(c.red(pick.error));
|
|
54
53
|
process.exitCode = 1;
|
|
55
54
|
return;
|
|
56
55
|
}
|
|
56
|
+
const provider = pick;
|
|
57
57
|
|
|
58
58
|
// `watch` opens a window and BLOCKS until you close it (or Ctrl-C). With no
|
|
59
59
|
// controlling TTY — an agent/build smoke-test, `< /dev/null` — nothing ever
|
|
@@ -167,15 +167,23 @@ async function waitForLive(terms: string[]): Promise<EspnEvent> {
|
|
|
167
167
|
const ms = Date.parse(target.date) - Date.now();
|
|
168
168
|
const eta = ms > 0 ? `kicks off in ${fmtEta(ms)}` : "at/just past kickoff — waiting for it to flip live";
|
|
169
169
|
console.log(c.dim(` ${eta}.`));
|
|
170
|
-
|
|
171
|
-
else if (ms <= 15 * 60_000) pollMs = 30_000;
|
|
170
|
+
pollMs = waitPollMs(ms);
|
|
172
171
|
}
|
|
173
172
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
174
173
|
}
|
|
175
174
|
}
|
|
176
175
|
|
|
176
|
+
/** How long `watch --wait` sleeps between polls, given the ms until the target
|
|
177
|
+
* starts: 60s normally, tightening to 30s inside 15 minutes and 15s inside 5
|
|
178
|
+
* (and once past the start, while waiting for the game to flip live). */
|
|
179
|
+
export function waitPollMs(msToStart: number): number {
|
|
180
|
+
if (msToStart <= 5 * 60_000) return 15_000;
|
|
181
|
+
if (msToStart <= 15 * 60_000) return 30_000;
|
|
182
|
+
return 60_000;
|
|
183
|
+
}
|
|
184
|
+
|
|
177
185
|
/** Coarse human ETA: "1h 4m", "12m 30s", or "45s". */
|
|
178
|
-
function fmtEta(ms: number): string {
|
|
186
|
+
export function fmtEta(ms: number): string {
|
|
179
187
|
const s = Math.max(0, Math.floor(ms / 1000));
|
|
180
188
|
const h = Math.floor(s / 3600);
|
|
181
189
|
const m = Math.floor((s % 3600) / 60);
|
|
@@ -189,7 +197,7 @@ function fmtEta(ms: number): string {
|
|
|
189
197
|
* then tears it down and exits. Bounded (CDP attach has its own timeout) and
|
|
190
198
|
* leaves no survivors — win.close() reaps the ui-leaf/Chrome tree.
|
|
191
199
|
*/
|
|
192
|
-
async function smokeWatch(url: string, label: string, windowSize: { width: number; height: number } | undefined): Promise<void> {
|
|
200
|
+
export async function smokeWatch(url: string, label: string, windowSize: { width: number; height: number } | undefined): Promise<void> {
|
|
193
201
|
const port = await freePort();
|
|
194
202
|
const win = await spawnStreamWindow(url, label, { debugPort: port, windowSize });
|
|
195
203
|
if (!win) {
|
|
@@ -209,13 +217,13 @@ async function smokeWatch(url: string, label: string, windowSize: { width: numbe
|
|
|
209
217
|
}
|
|
210
218
|
|
|
211
219
|
/** Parse a `WxH` size string into a window size, or undefined if absent/invalid. */
|
|
212
|
-
function parseSize(s: string | null): { width: number; height: number } | undefined {
|
|
220
|
+
export function parseSize(s: string | null): { width: number; height: number } | undefined {
|
|
213
221
|
const m = s?.match(/^(\d+)x(\d+)$/);
|
|
214
222
|
return m ? { width: Number(m[1]), height: Number(m[2]) } : undefined;
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
/** Positional args, dropping flags and the values consumed by --url / --provider / --size. */
|
|
218
|
-
function positionalTerms(args: string[]): string[] {
|
|
226
|
+
export function positionalTerms(args: string[]): string[] {
|
|
219
227
|
const out: string[] = [];
|
|
220
228
|
for (let i = 0; i < args.length; i++) {
|
|
221
229
|
const a = args[i]!;
|