sportsing 0.2.0 → 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 +7 -0
- package/package.json +1 -1
- package/src/commands/upgrade.ts +137 -0
- package/src/index.ts +6 -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sportsing",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -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/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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";
|
|
6
7
|
import pkg from "../package.json" with { type: "json" };
|
|
7
8
|
|
|
8
9
|
// Single source of truth: package.json (a hardcoded copy drifted to a stale
|
|
@@ -38,6 +39,7 @@ ${b("YOUR TEAMS")} ${c.dim("(favorites across every sport, each row tagged with
|
|
|
38
39
|
|
|
39
40
|
${b("SETTINGS")}
|
|
40
41
|
${c.green("subscriptions")} What you can watch with + home market ${c.dim("— alias: subs")}
|
|
42
|
+
${c.green("upgrade")} Install the latest release ${c.dim("(--check: just compare versions)")}
|
|
41
43
|
|
|
42
44
|
${b("EXAMPLES")}
|
|
43
45
|
sportsing fifa today
|
|
@@ -69,6 +71,10 @@ async function dispatch(): Promise<void> {
|
|
|
69
71
|
await subscriptions(rest);
|
|
70
72
|
return;
|
|
71
73
|
}
|
|
74
|
+
if (first === "upgrade") {
|
|
75
|
+
await upgrade(rest, VERSION);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
72
78
|
|
|
73
79
|
// Bare `today`/`next`/`me`/`live` run across every sport; anything else gets a hint.
|
|
74
80
|
await bare(first, rest);
|
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
|
+
}
|