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.
@@ -2,11 +2,13 @@ import { c } from "../ansi.ts";
2
2
  import { getMatches, NoKeyError } from "../api.ts";
3
3
  import { matchLine, fmtTimeOnly, stageLabel } from "../format.ts";
4
4
  import { ymd, addDays, localDateOf, sortByDate } from "./_lib.ts";
5
- import { getApiKey, getFavorites } from "../config.ts";
5
+ import { getApiKey, getFavorites, getStreamProvider } from "../config.ts";
6
+ import type { DaemonGame } from "../daemon.ts";
7
+
6
8
  import { diffEvents, type MatchEvent } from "../events.ts";
7
9
  import { LIVE_REFRESH_MS, raise, type Alert, type Alerter } from "../alerts.ts";
8
10
  import { inTerminal, selfInvocation, watchCommand } from "../click-to-watch.ts";
9
- import { matchHasTeam } from "../match-util.ts";
11
+ import { matchHasTeam, matchState } from "../match-util.ts";
10
12
  import type { Match } from "../types.ts";
11
13
 
12
14
  export async function live(args: string[] = []) {
@@ -153,6 +155,34 @@ export async function fifaAlerter(): Promise<Alerter | null> {
153
155
  return { label: "FIFA", teams: favorites, poll: async () => feed(await matchesToday()) };
154
156
  }
155
157
 
158
+ /** FIFA's favorite matches today for `sportsing daemon`; null with no FIFA
159
+ * favorites. Always routed "open" via the configured provider, exactly like
160
+ * `fifa watch <fav> --wait` — FIFA has no subscriptions/watchability resolver
161
+ * (that's NBA/NHL only), so there's no over-the-air or unwatchable branch. */
162
+ export async function fifaDaemonGames(): Promise<DaemonGame[] | null> {
163
+ const favorites = await getFavorites("fifa");
164
+ if (favorites.length === 0) return null;
165
+ if (!(await getApiKey())) throw new Error("needs an API key — run `sportsing fifa setup`");
166
+ const provider = (await getStreamProvider("fifa")) ?? "fubo";
167
+ return (await matchesToday()).flatMap((m): DaemonGame[] => {
168
+ const fav = favorites.find((f) => matchHasTeam(m, f.trim().toLowerCase()))?.trim();
169
+ if (!fav) return [];
170
+ return [
171
+ {
172
+ key: `fifa:${m.id}`,
173
+ sport: "fifa",
174
+ icon: "⚽",
175
+ fixture: `${m.homeTeam.tla ?? m.homeTeam.name} v ${m.awayTeam.tla ?? m.awayTeam.name}`,
176
+ start: m.utcDate,
177
+ state: matchState(m),
178
+ openArgs: ["fifa", "watch", fav, "--wait", "--supervised"],
179
+ clickArgs: ["fifa", "watch", fav],
180
+ route: { kind: "open", note: provider },
181
+ },
182
+ ];
183
+ });
184
+ }
185
+
156
186
  /**
157
187
  * The click-to-watch command for a kickoff event, or undefined when none applies.
158
188
  * Returns `<exe…> fifa watch <fav>` (shell-quoted) where `fav` is the favourite
@@ -0,0 +1,143 @@
1
+ import { existsSync, realpathSync } from "fs";
2
+ import { c } from "../ansi.ts";
3
+ import { restartDaemonIfLoaded, systemLaunchd, type Launchd } from "../daemon-io.ts";
4
+ import {
5
+ REGISTRY_LATEST_URL,
6
+ canSelfUpgrade,
7
+ compareVersions,
8
+ describeInstall,
9
+ detectInstall,
10
+ installCommand,
11
+ manualUpgradeHint,
12
+ parseRegistryLatest,
13
+ parseVersionOutput,
14
+ } from "../upgrade.ts";
15
+
16
+ const FETCH_TIMEOUT_MS = 10_000;
17
+
18
+ function usage(): void {
19
+ console.log(`${c.bold("sportsing upgrade")} — install the latest npm release
20
+
21
+ sportsing upgrade reinstall sportsing@latest with the package manager that installed it
22
+ sportsing upgrade --check just report current vs latest; installs nothing`);
23
+ }
24
+
25
+ function fail(msg: string): void {
26
+ console.error(c.red(msg));
27
+ process.exitCode = 1;
28
+ }
29
+
30
+ async function fetchLatest(): Promise<string> {
31
+ let res: Response;
32
+ try {
33
+ res = await fetch(REGISTRY_LATEST_URL, {
34
+ headers: { "User-Agent": "sportsing", Accept: "application/json" },
35
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
36
+ });
37
+ } catch (e) {
38
+ const why = e instanceof Error && e.name === "TimeoutError" ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s` : String((e as Error)?.message ?? e);
39
+ throw new Error(`Couldn't reach the npm registry (${why}).`);
40
+ }
41
+ if (!res.ok) throw new Error(`npm registry returned ${res.status} ${res.statusText}.`);
42
+ return parseRegistryLatest(await res.json());
43
+ }
44
+
45
+ /** Realpath of the running entry script, so npm/bun bin symlinks resolve to the package. */
46
+ export function runningScript(): string {
47
+ try {
48
+ return realpathSync(Bun.main);
49
+ } catch {
50
+ return Bun.main; // compiled binaries report a virtual /$bunfs path
51
+ }
52
+ }
53
+
54
+ /** Ask the freshly installed copy its version (falls back to null if it can't be run). */
55
+ function installedVersion(bin: string): string | null {
56
+ if (!existsSync(bin)) return null;
57
+ try {
58
+ // Run it under this same Bun so the bin's `#!/usr/bin/env bun` shebang doesn't matter.
59
+ const r = Bun.spawnSync([process.execPath, realpathSync(bin), "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
60
+ return r.exitCode === 0 ? parseVersionOutput(r.stdout.toString()) : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // `sportsing upgrade [--check]` — self-update to the latest npm release.
67
+ export async function upgrade(args: string[], current: string, launchd: Launchd = systemLaunchd()): Promise<void> {
68
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") return usage();
69
+ const unknown = args.filter((a) => a !== "--check");
70
+ if (unknown.length) {
71
+ usage();
72
+ return fail(`Unknown argument: ${unknown.join(" ")}`);
73
+ }
74
+ const check = args.includes("--check");
75
+ const install = detectInstall(runningScript());
76
+
77
+ // Refuse unsupported installs up front — no network needed to say "not from here".
78
+ if (!check && !canSelfUpgrade(install)) {
79
+ console.error(c.yellow(`sportsing upgrade can't update a ${describeInstall(install)}.`));
80
+ console.error(manualUpgradeHint(install));
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ let latest: string;
86
+ try {
87
+ latest = await fetchLatest();
88
+ } catch (e) {
89
+ return fail(`Error: ${(e as Error).message} Nothing was changed.`);
90
+ }
91
+
92
+ const cmp = compareVersions(current, latest);
93
+ const status =
94
+ cmp < 0
95
+ ? c.yellow(`update available: ${current} → ${latest}`)
96
+ : cmp === 0
97
+ ? c.green(`up to date (${current})`)
98
+ : c.dim(`running ${current}, ahead of the latest release ${latest}`);
99
+
100
+ if (check) {
101
+ console.log(`sportsing ${current} ${c.dim("latest")} ${latest} — ${status}`);
102
+ console.log(c.dim(`installed as: ${describeInstall(install)}`));
103
+ if (cmp < 0) {
104
+ console.log(canSelfUpgrade(install) ? c.dim("run `sportsing upgrade` to install it") : c.dim(manualUpgradeHint(install)));
105
+ }
106
+ return;
107
+ }
108
+
109
+ if (cmp >= 0) {
110
+ console.log(`sportsing is ${status}.`);
111
+ return;
112
+ }
113
+ if (!canSelfUpgrade(install)) return; // narrowed above; keeps the type checker honest
114
+
115
+ const { cmd, bin } = installCommand(install, existsSync);
116
+ console.log(c.dim(`Upgrading sportsing ${current} → ${latest} via: ${cmd.join(" ")}`));
117
+ let code: number;
118
+ try {
119
+ const proc = Bun.spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
120
+ code = await proc.exited;
121
+ } catch (e) {
122
+ return fail(`Error: couldn't run ${cmd[0]} (${(e as Error).message}). Nothing was changed.`);
123
+ }
124
+ if (code !== 0) {
125
+ return fail(`Error: ${cmd.join(" ")} exited ${code}. The upgrade failed; your existing install was left as-is.`);
126
+ }
127
+
128
+ const now = installedVersion(bin);
129
+ if (!now) {
130
+ console.error(c.yellow(`Install finished, but couldn't confirm the new version from ${bin}. Check with: sportsing --version`));
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ if (compareVersions(now, current) <= 0) {
135
+ return fail(`Install finished, but ${bin} still reports ${now}. Check which sportsing is on your PATH.`);
136
+ }
137
+ console.log(c.green(`✓ sportsing ${current} → ${now}`));
138
+
139
+ // A loaded daemon is still running the old code: restart it onto the new.
140
+ const restart = restartDaemonIfLoaded(launchd);
141
+ if (restart === "restarted") console.log(c.green("✓ restarted sportsing daemon on the new version"));
142
+ else if (restart !== "not-loaded") console.error(c.yellow(`Couldn't restart the daemon (${restart.error}) — run: sportsing daemon install`));
143
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { mkdir, chmod } from "fs/promises";
4
+ import { daemonModeOf, type DaemonMode } from "./daemon.ts";
4
5
  import { SUBSCRIPTIONS, HOME_MARKETS, DEFAULT_HOME_MARKET, isSubscription, type Subscription } from "./watchability.ts";
5
6
 
6
7
  const CONFIG_DIR = join(homedir(), ".config", "sportsing");
@@ -32,6 +33,8 @@ interface Config {
32
33
  subscriptions?: string[];
33
34
  /** HOME_MARKETS id for blackouts / local channels; defaults to Utah. */
34
35
  homeMarket?: string;
36
+ /** `sportsing daemon` settings. */
37
+ daemon?: { mode?: string };
35
38
  }
36
39
 
37
40
  /** Default overlay panel visibility — nothing on by default, so a fresh stream
@@ -279,4 +282,17 @@ export async function setHomeMarket(market: string): Promise<void> {
279
282
  await writeConfig(cfg);
280
283
  }
281
284
 
285
+ // ── Daemon ───────────────────────────────────────────────────────────────────
286
+
287
+ /** What the daemon does when a favorite's game starts (`open` by default). */
288
+ export async function getDaemonMode(): Promise<DaemonMode> {
289
+ return daemonModeOf((await readConfig()).daemon?.mode);
290
+ }
291
+
292
+ export async function setDaemonMode(mode: DaemonMode): Promise<void> {
293
+ const cfg = await readConfig();
294
+ cfg.daemon = { ...(cfg.daemon ?? {}), mode };
295
+ await writeConfig(cfg);
296
+ }
297
+
282
298
  export { CONFIG_FILE };
@@ -0,0 +1,114 @@
1
+ // The daemon's IO edge: where its files live, its state file (written
2
+ // atomically), and launchd. Kept apart from src/commands/daemon.ts so the
3
+ // bare today/next/me footer and `sportsing upgrade` can use it without pulling
4
+ // in the sport registry. launchctl is behind the `Launchd` interface so tests
5
+ // and smokes use fakes — nothing in the test suite ever talks to launchd.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { dirname, join } from "path";
10
+ import { CACHE_DIR } from "./config.ts";
11
+ import {
12
+ DAEMON_LABEL,
13
+ evaluateStatus,
14
+ footerLine,
15
+ parseDaemonState,
16
+ parseLaunchctlPrint,
17
+ type DaemonState,
18
+ type DaemonStatus,
19
+ type ServiceInfo,
20
+ } from "./daemon.ts";
21
+ import { pidAlive } from "./liveness.ts";
22
+
23
+ export const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${DAEMON_LABEL}.plist`);
24
+ export const LOG_DIR = join(homedir(), "Library", "Logs", "sportsing");
25
+ export const LOG_FILE = join(LOG_DIR, "daemon.log");
26
+ export const STATE_FILE = join(CACHE_DIR, "daemon-state.json");
27
+
28
+ /** The daemon's state file, or null if absent/malformed. */
29
+ export function readState(file = STATE_FILE): DaemonState | null {
30
+ try {
31
+ return parseDaemonState(JSON.parse(readFileSync(file, "utf8")));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Write the state file atomically (temp file + rename), so a reader never
38
+ * sees a half-written file and a crash mid-write leaves the old one. */
39
+ export function writeState(s: DaemonState, file = STATE_FILE): void {
40
+ mkdirSync(dirname(file), { recursive: true });
41
+ const tmp = `${file}.${process.pid}.tmp`;
42
+ writeFileSync(tmp, JSON.stringify(s, null, 2) + "\n");
43
+ renameSync(tmp, file);
44
+ }
45
+
46
+ /** launchctl, injectable. All calls target the `gui/<uid>` domain. */
47
+ export interface Launchd {
48
+ /** The job's launchd info, or `{ loaded: false }` when it isn't loaded. */
49
+ print(label: string): ServiceInfo;
50
+ /** Load and start a plist. Throws on failure. */
51
+ bootstrap(plistPath: string): void;
52
+ /** Unload a job. Throws on failure. */
53
+ bootout(label: string): void;
54
+ /** Kill and restart a loaded job. Throws on failure. */
55
+ kickstart(label: string): void;
56
+ }
57
+
58
+ function launchctl(args: string[]): { code: number; out: string } {
59
+ const r = Bun.spawnSync(["launchctl", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
60
+ return { code: r.exitCode ?? 1, out: r.stdout.toString() + r.stderr.toString() };
61
+ }
62
+
63
+ function must(args: string[]): void {
64
+ const r = launchctl(args);
65
+ if (r.code !== 0) throw new Error(`launchctl ${args.join(" ")} failed (${r.code}): ${r.out.trim()}`);
66
+ }
67
+
68
+ /** The real launchctl. */
69
+ export function systemLaunchd(uid = process.getuid?.() ?? 501): Launchd {
70
+ const domain = `gui/${uid}`;
71
+ return {
72
+ print(label) {
73
+ const r = launchctl(["print", `${domain}/${label}`]);
74
+ return r.code === 0 ? parseLaunchctlPrint(r.out) : { loaded: false };
75
+ },
76
+ bootstrap: (plistPath) => must(["bootstrap", domain, plistPath]),
77
+ bootout: (label) => must(["bootout", `${domain}/${label}`]),
78
+ kickstart: (label) => must(["kickstart", "-k", `${domain}/${label}`]),
79
+ };
80
+ }
81
+
82
+ /** The daemon's status. With `launchd`, it's the authority on whether the job
83
+ * runs; without it (the cheap footer) the state file's pid is probed. */
84
+ export function gatherStatus(launchd: Launchd | null, now = Date.now()): DaemonStatus {
85
+ const state = readState();
86
+ const installed = existsSync(PLIST_PATH);
87
+ let service: ServiceInfo;
88
+ if (launchd) service = launchd.print(DAEMON_LABEL);
89
+ else service = state && pidAlive(state.pid) ? { loaded: true, pid: state.pid } : { loaded: false };
90
+ return evaluateStatus({ installed, service, state, now });
91
+ }
92
+
93
+ /** The one-line footer for bare today / next / me. Never throws. */
94
+ export function daemonFooter(): string | null {
95
+ try {
96
+ return footerLine(gatherStatus(null), Date.now());
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * After `sportsing upgrade`: restart a loaded daemon so it runs the new code.
104
+ * Returns what happened, for the upgrade's output.
105
+ */
106
+ export function restartDaemonIfLoaded(launchd: Launchd): "restarted" | "not-loaded" | { error: string } {
107
+ if (!launchd.print(DAEMON_LABEL).loaded) return "not-loaded";
108
+ try {
109
+ launchd.kickstart(DAEMON_LABEL);
110
+ return "restarted";
111
+ } catch (e) {
112
+ return { error: e instanceof Error ? e.message : String(e) };
113
+ }
114
+ }