worldcup26-cli 0.1.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 +111 -0
- package/dist/commands/country.js +63 -0
- package/dist/commands/groups.js +38 -0
- package/dist/commands/knockout.js +72 -0
- package/dist/commands/today.js +29 -0
- package/dist/data/cache.js +25 -0
- package/dist/data/fetch.js +43 -0
- package/dist/data/flags.js +61 -0
- package/dist/index.js +56 -0
- package/dist/lib/dates.js +27 -0
- package/dist/lib/format.js +82 -0
- package/dist/lib/standings.js +88 -0
- package/dist/types.js +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# worldcup26-cli ⚽
|
|
2
|
+
|
|
3
|
+
A colorful terminal CLI to follow the **FIFA World Cup 2026** (USA · Canada · Mexico).
|
|
4
|
+
View today's games, group standings, country stats, and the knockout bracket — right from your shell.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
$ worldcup --date 2026-06-11
|
|
8
|
+
|
|
9
|
+
⚽ Matches on Thu, Jun 11, 2026
|
|
10
|
+
|
|
11
|
+
🇲🇽 Mexico vs South Africa 🇿🇦 · 13:00 UTC-6 @ Mexico City [Group A]
|
|
12
|
+
🇰🇷 South Korea vs Czech Republic 🇨🇿 · 20:00 UTC-6 @ Guadalajara [Group A]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **`today`** — matches scheduled for today, with scores once played. For any other day, use the top-level `--date`.
|
|
18
|
+
- **`groups`** — all 12 group tables, or a single group with `--group`. Standings (Pts → GD → GF) are computed from results; the top 2 are highlighted.
|
|
19
|
+
- **`country`** — a team's group, current standing, and full match list, looked up by name (partial, case-insensitive).
|
|
20
|
+
- **`knockout`** — the bracket by stage (Round of 32 → Final), filterable with `--stage`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g worldcup26-cli
|
|
26
|
+
worldcup today
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or run it without installing:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx worldcup26-cli groups --group A
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> Requires **Node.js ≥ 18** (uses the built-in `fetch`).
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
worldcup today # today's fixtures
|
|
41
|
+
worldcup --date 2026-06-11 # fixtures for a specific day
|
|
42
|
+
worldcup groups [--group A] # all groups, or just one
|
|
43
|
+
worldcup country <name> # e.g. "mexico", "kor", "brazil"
|
|
44
|
+
worldcup knockout [--stage <name>] # r32 | r16 | qf | sf | third | final
|
|
45
|
+
worldcup ko --stage final # 'ko' is an alias for 'knockout'
|
|
46
|
+
|
|
47
|
+
# Global flags
|
|
48
|
+
-d, --date <YYYY-MM-DD> # show matches for a specific day
|
|
49
|
+
--refresh # bypass the local cache and re-fetch
|
|
50
|
+
--no-color # plain output (also honors the NO_COLOR env var)
|
|
51
|
+
-v, --version
|
|
52
|
+
-h, --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Data source
|
|
56
|
+
|
|
57
|
+
Match data comes from the public-domain **[openfootball/worldcup.json](https://github.com/openfootball/worldcup.json)** project — no API key required. The CLI fetches it once and caches it at `~/.worldcup-cli/cache.json` (1-hour TTL), so repeat commands are instant and it still works offline from the last successful fetch. Use `--refresh` to force an update.
|
|
58
|
+
|
|
59
|
+
Scores reflect **final/official results** (updated after matches finish) — there is no live in-progress ticker. Group standings use the common Pts → goal-difference → goals-for tiebreakers (a simplification of the full FIFA criteria).
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install
|
|
65
|
+
npm run dev -- --date 2026-06-11 # run from source via tsx
|
|
66
|
+
npm run build # compile TypeScript to dist/
|
|
67
|
+
node dist/index.js groups # run the build
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Test it locally as a global command
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm run build
|
|
74
|
+
npm link # makes `worldcup` available system-wide
|
|
75
|
+
worldcup --date 2026-06-11
|
|
76
|
+
npm unlink -g worldcup26-cli # when finished
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Alternatives without linking: `node dist/index.js <cmd>` or `npm install -g .`.
|
|
80
|
+
|
|
81
|
+
## Publishing to npm
|
|
82
|
+
|
|
83
|
+
1. **Check the name is free:** `npm view worldcup26-cli`. If taken, switch to a scoped name (e.g. `@yourname/worldcup26-cli`) in `package.json`.
|
|
84
|
+
2. **Log in:** `npm login`.
|
|
85
|
+
3. **Bump the version:** `npm version patch` (or `minor` / `major`).
|
|
86
|
+
4. **Publish:** `npm publish`.
|
|
87
|
+
- For a scoped package, the first publish needs `npm publish --access public`.
|
|
88
|
+
- The `prepublishOnly` script builds `dist/` automatically, and `"files": ["dist"]` keeps the published package lean.
|
|
89
|
+
5. Users can now `npm install -g worldcup26-cli` or `npx worldcup26-cli`.
|
|
90
|
+
|
|
91
|
+
## Project structure
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
src/
|
|
95
|
+
index.ts # commander program + global flags
|
|
96
|
+
types.ts # Match / Score / Standing types
|
|
97
|
+
data/
|
|
98
|
+
fetch.ts # fetch upstream JSON, TTL cache, offline fallback
|
|
99
|
+
cache.ts # ~/.worldcup-cli/cache.json read/write
|
|
100
|
+
flags.ts # team → flag emoji + 3-letter code
|
|
101
|
+
lib/
|
|
102
|
+
standings.ts # compute group tables from results
|
|
103
|
+
dates.ts # date parsing / "today" handling
|
|
104
|
+
format.ts # chalk rendering (match lines, tables)
|
|
105
|
+
commands/
|
|
106
|
+
today.ts groups.ts country.ts knockout.ts
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadTournament } from "../data/fetch.js";
|
|
3
|
+
import { isRealTeam, teamFlag } from "../data/flags.js";
|
|
4
|
+
import { computeStandings } from "../lib/standings.js";
|
|
5
|
+
import { matchLine, matchContext, title, subtle, header } from "../lib/format.js";
|
|
6
|
+
export function registerCountry(program) {
|
|
7
|
+
program
|
|
8
|
+
.command("country")
|
|
9
|
+
.description("Show a country's group, standing, and matches (by name)")
|
|
10
|
+
.argument("<name>", "country name or part of it, e.g. 'mexico' or 'kor'")
|
|
11
|
+
.action(async (name, _opts, cmd) => {
|
|
12
|
+
const global = cmd.optsWithGlobals();
|
|
13
|
+
const { matches } = await loadTournament({ refresh: global.refresh });
|
|
14
|
+
// Distinct real team names appearing in the data.
|
|
15
|
+
const teams = new Set();
|
|
16
|
+
for (const m of matches) {
|
|
17
|
+
if (isRealTeam(m.team1))
|
|
18
|
+
teams.add(m.team1);
|
|
19
|
+
if (isRealTeam(m.team2))
|
|
20
|
+
teams.add(m.team2);
|
|
21
|
+
}
|
|
22
|
+
const query = name.trim().toLowerCase();
|
|
23
|
+
const hits = [...teams].filter((t) => t.toLowerCase().includes(query)).sort();
|
|
24
|
+
if (hits.length === 0) {
|
|
25
|
+
process.stderr.write(chalk.red(`No team matches "${name}".\n`));
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (hits.length > 1) {
|
|
30
|
+
console.log(subtle(`Multiple teams match "${name}":`));
|
|
31
|
+
for (const t of hits)
|
|
32
|
+
console.log(` ${teamFlag(t)} ${t}`);
|
|
33
|
+
console.log(subtle(`\nBe more specific.`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const team = hits[0];
|
|
37
|
+
const teamMatches = matches
|
|
38
|
+
.filter((m) => m.team1 === team || m.team2 === team)
|
|
39
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
40
|
+
const group = teamMatches.find((m) => m.group)?.group;
|
|
41
|
+
console.log(title(`\n${teamFlag(team)} ${team}`));
|
|
42
|
+
if (group) {
|
|
43
|
+
const rows = computeStandings(matches, group);
|
|
44
|
+
const idx = rows.findIndex((r) => r.team === team);
|
|
45
|
+
const row = rows[idx];
|
|
46
|
+
console.log(subtle(`${group} · currently ${ordinal(idx + 1)}`));
|
|
47
|
+
if (row) {
|
|
48
|
+
console.log(` ${row.played} played · ${chalk.green(row.won + "W")}-${row.drawn}D-${chalk.red(row.lost + "L")} · ` +
|
|
49
|
+
`GF ${row.gf} GA ${row.ga} (${row.gd >= 0 ? "+" : ""}${row.gd}) · ${chalk.bold(row.points)} pts`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
console.log(header("Matches"));
|
|
53
|
+
for (const m of teamMatches) {
|
|
54
|
+
console.log(` ${matchLine(m)}`);
|
|
55
|
+
console.log(` ${matchContext(m)}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function ordinal(n) {
|
|
60
|
+
const s = ["th", "st", "nd", "rd"];
|
|
61
|
+
const v = n % 100;
|
|
62
|
+
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadTournament } from "../data/fetch.js";
|
|
3
|
+
import { computeStandings, listGroups } from "../lib/standings.js";
|
|
4
|
+
import { header, standingsTable, title, subtle } from "../lib/format.js";
|
|
5
|
+
/** Accept "A", "a", or "Group A" and normalise to the canonical "Group A". */
|
|
6
|
+
function normaliseGroup(input) {
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
const letter = trimmed.replace(/^group\s*/i, "").toUpperCase();
|
|
9
|
+
return `Group ${letter}`;
|
|
10
|
+
}
|
|
11
|
+
export function registerGroups(program) {
|
|
12
|
+
program
|
|
13
|
+
.command("groups")
|
|
14
|
+
.description("Show group standings — all groups, or one with --group")
|
|
15
|
+
.option("-g, --group <letter>", "show only this group (e.g. A or 'Group A')")
|
|
16
|
+
.action(async (opts, cmd) => {
|
|
17
|
+
const global = cmd.optsWithGlobals();
|
|
18
|
+
const { matches } = await loadTournament({ refresh: global.refresh });
|
|
19
|
+
const allGroups = listGroups(matches);
|
|
20
|
+
let groups = allGroups;
|
|
21
|
+
if (opts.group) {
|
|
22
|
+
const target = normaliseGroup(opts.group);
|
|
23
|
+
if (!allGroups.includes(target)) {
|
|
24
|
+
process.stderr.write(chalk.red(`Unknown group "${opts.group}". Available: ${allGroups.map((g) => g.replace("Group ", "")).join(", ")}\n`));
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
groups = [target];
|
|
29
|
+
}
|
|
30
|
+
console.log(title(`\n🏆 World Cup 2026 — Group Standings`));
|
|
31
|
+
for (const group of groups) {
|
|
32
|
+
const rows = computeStandings(matches, group);
|
|
33
|
+
console.log(header(group));
|
|
34
|
+
console.log(standingsTable(rows, 2));
|
|
35
|
+
}
|
|
36
|
+
console.log(subtle(`\n${chalk.green("▲")} top 2 advance directly. The 8 best 3rd-placed teams also reach the Round of 32.`));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadTournament } from "../data/fetch.js";
|
|
3
|
+
import { matchLine, title, subtle, header } from "../lib/format.js";
|
|
4
|
+
/** Knockout rounds in tournament order, as labelled by OpenFootball. */
|
|
5
|
+
const STAGE_ORDER = [
|
|
6
|
+
"Round of 32",
|
|
7
|
+
"Round of 16",
|
|
8
|
+
"Quarter-final",
|
|
9
|
+
"Semi-final",
|
|
10
|
+
"Match for third place",
|
|
11
|
+
"Final",
|
|
12
|
+
];
|
|
13
|
+
/** Short aliases the user can pass to --stage. */
|
|
14
|
+
const ALIASES = {
|
|
15
|
+
r32: "Round of 32",
|
|
16
|
+
"round-of-32": "Round of 32",
|
|
17
|
+
r16: "Round of 16",
|
|
18
|
+
"round-of-16": "Round of 16",
|
|
19
|
+
qf: "Quarter-final",
|
|
20
|
+
quarter: "Quarter-final",
|
|
21
|
+
"quarter-final": "Quarter-final",
|
|
22
|
+
"quarter-finals": "Quarter-final",
|
|
23
|
+
sf: "Semi-final",
|
|
24
|
+
semi: "Semi-final",
|
|
25
|
+
"semi-final": "Semi-final",
|
|
26
|
+
"semi-finals": "Semi-final",
|
|
27
|
+
third: "Match for third place",
|
|
28
|
+
"third-place": "Match for third place",
|
|
29
|
+
final: "Final",
|
|
30
|
+
};
|
|
31
|
+
function resolveStage(input) {
|
|
32
|
+
const key = input.trim().toLowerCase();
|
|
33
|
+
if (ALIASES[key])
|
|
34
|
+
return ALIASES[key];
|
|
35
|
+
const exact = STAGE_ORDER.find((s) => s.toLowerCase() === key);
|
|
36
|
+
return exact ?? null;
|
|
37
|
+
}
|
|
38
|
+
export function registerKnockout(program) {
|
|
39
|
+
program
|
|
40
|
+
.command("knockout")
|
|
41
|
+
.alias("ko")
|
|
42
|
+
.description("Show the knockout bracket by stage (--stage to filter)")
|
|
43
|
+
.option("-s, --stage <name>", "filter to one stage (e.g. r16, qf, sf, final)")
|
|
44
|
+
.action(async (opts, cmd) => {
|
|
45
|
+
const global = cmd.optsWithGlobals();
|
|
46
|
+
const { matches } = await loadTournament({ refresh: global.refresh });
|
|
47
|
+
// Knockout matches have no group label.
|
|
48
|
+
const knockout = matches.filter((m) => !m.group);
|
|
49
|
+
let stages = STAGE_ORDER;
|
|
50
|
+
if (opts.stage) {
|
|
51
|
+
const resolved = resolveStage(opts.stage);
|
|
52
|
+
if (!resolved) {
|
|
53
|
+
process.stderr.write(chalk.red(`Unknown stage "${opts.stage}". Try: r32, r16, qf, sf, third, final.\n`));
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
stages = [resolved];
|
|
58
|
+
}
|
|
59
|
+
console.log(title(`\n🏆 World Cup 2026 — Knockout Stage`));
|
|
60
|
+
for (const stage of stages) {
|
|
61
|
+
const games = knockout
|
|
62
|
+
.filter((m) => m.round === stage)
|
|
63
|
+
.sort((a, b) => (a.num ?? 0) - (b.num ?? 0));
|
|
64
|
+
if (games.length === 0)
|
|
65
|
+
continue;
|
|
66
|
+
console.log(header(stage));
|
|
67
|
+
for (const m of games)
|
|
68
|
+
console.log(` ${matchLine(m)}`);
|
|
69
|
+
}
|
|
70
|
+
console.log(subtle(`\nPlaceholders like "1A" / "2B" / "3A/B/C/D/F" resolve once the groups finish.`));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { loadTournament } from "../data/fetch.js";
|
|
2
|
+
import { todayISO, formatDate } from "../lib/dates.js";
|
|
3
|
+
import { matchLine, title, subtle } from "../lib/format.js";
|
|
4
|
+
/** Print every match on a given "YYYY-MM-DD" date. Shared by `today` and the root `--date` flag. */
|
|
5
|
+
export async function renderGames(date, refresh) {
|
|
6
|
+
const { matches } = await loadTournament({ refresh });
|
|
7
|
+
const games = matches
|
|
8
|
+
.filter((m) => m.date === date)
|
|
9
|
+
.sort((a, b) => (a.time ?? "").localeCompare(b.time ?? ""));
|
|
10
|
+
console.log(title(`\n⚽ Matches on ${formatDate(date)}`));
|
|
11
|
+
if (games.length === 0) {
|
|
12
|
+
console.log(subtle(`\nNo games scheduled. The tournament runs Jun 11 – Jul 19, 2026.`));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log();
|
|
16
|
+
for (const m of games) {
|
|
17
|
+
const tag = m.group ? subtle(`[${m.group}]`) : subtle(`[${m.round}]`);
|
|
18
|
+
console.log(` ${matchLine(m)} ${tag}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function registerToday(program) {
|
|
22
|
+
program
|
|
23
|
+
.command("today")
|
|
24
|
+
.description("Show today's matches (use the top-level --date for another day)")
|
|
25
|
+
.action(async (_opts, cmd) => {
|
|
26
|
+
const global = cmd.optsWithGlobals();
|
|
27
|
+
await renderGames(todayISO(), Boolean(global.refresh));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
const CACHE_DIR = join(homedir(), ".worldcup-cli");
|
|
5
|
+
const CACHE_FILE = join(CACHE_DIR, "cache.json");
|
|
6
|
+
/** Read the cached tournament, or null if there is no (readable) cache. */
|
|
7
|
+
export async function readCache() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(CACHE_FILE, "utf8");
|
|
10
|
+
const entry = JSON.parse(raw);
|
|
11
|
+
if (!entry?.data?.matches)
|
|
12
|
+
return null;
|
|
13
|
+
return entry;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Persist the tournament to the on-disk cache, stamping the fetch time. */
|
|
20
|
+
export async function writeCache(data, fetchedAt) {
|
|
21
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
22
|
+
const entry = { fetchedAt, data };
|
|
23
|
+
await writeFile(CACHE_FILE, JSON.stringify(entry), "utf8");
|
|
24
|
+
}
|
|
25
|
+
export const cacheLocation = CACHE_FILE;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { readCache, writeCache } from "./cache.js";
|
|
3
|
+
const DATA_URL = "https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json";
|
|
4
|
+
/** Cache time-to-live: results don't change often, so an hour is plenty. */
|
|
5
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Load the tournament data, preferring a fresh local cache and falling back
|
|
8
|
+
* gracefully when offline:
|
|
9
|
+
* 1. Fresh cache (younger than the TTL, unless --refresh) -> use it.
|
|
10
|
+
* 2. Otherwise fetch upstream, then update the cache.
|
|
11
|
+
* 3. On network failure, fall back to a stale cache with a warning.
|
|
12
|
+
* 4. With no cache at all and no network, throw a clear error.
|
|
13
|
+
*/
|
|
14
|
+
export async function loadTournament(opts = {}) {
|
|
15
|
+
const cached = await readCache();
|
|
16
|
+
if (!opts.refresh && cached && Date.now() - cached.fetchedAt < TTL_MS) {
|
|
17
|
+
return cached.data;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(DATA_URL);
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
23
|
+
const data = (await res.json());
|
|
24
|
+
if (!data?.matches?.length)
|
|
25
|
+
throw new Error("upstream returned no matches");
|
|
26
|
+
await writeCache(data, Date.now());
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (cached) {
|
|
31
|
+
const age = Math.round((Date.now() - cached.fetchedAt) / 60000);
|
|
32
|
+
process.stderr.write(chalk.dim(`! could not reach data source (${describe(err)}); using cached data (~${age} min old)\n`));
|
|
33
|
+
return cached.data;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Unable to fetch World Cup data and no local cache is available.\n` +
|
|
36
|
+
` Source: ${DATA_URL}\n` +
|
|
37
|
+
` Reason: ${describe(err)}\n` +
|
|
38
|
+
` Check your internet connection and try again.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function describe(err) {
|
|
42
|
+
return err instanceof Error ? err.message : String(err);
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const TEAMS = {
|
|
2
|
+
Argentina: { flag: "🇦🇷", code: "ARG" },
|
|
3
|
+
Algeria: { flag: "🇩🇿", code: "ALG" },
|
|
4
|
+
Australia: { flag: "🇦🇺", code: "AUS" },
|
|
5
|
+
Austria: { flag: "🇦🇹", code: "AUT" },
|
|
6
|
+
Belgium: { flag: "🇧🇪", code: "BEL" },
|
|
7
|
+
"Bosnia & Herzegovina": { flag: "🇧🇦", code: "BIH" },
|
|
8
|
+
Brazil: { flag: "🇧🇷", code: "BRA" },
|
|
9
|
+
Canada: { flag: "🇨🇦", code: "CAN" },
|
|
10
|
+
"Cape Verde": { flag: "🇨🇻", code: "CPV" },
|
|
11
|
+
Colombia: { flag: "🇨🇴", code: "COL" },
|
|
12
|
+
Croatia: { flag: "🇭🇷", code: "CRO" },
|
|
13
|
+
"Curaçao": { flag: "🇨🇼", code: "CUW" },
|
|
14
|
+
"Czech Republic": { flag: "🇨🇿", code: "CZE" },
|
|
15
|
+
"DR Congo": { flag: "🇨🇩", code: "COD" },
|
|
16
|
+
Ecuador: { flag: "🇪🇨", code: "ECU" },
|
|
17
|
+
Egypt: { flag: "🇪🇬", code: "EGY" },
|
|
18
|
+
England: { flag: "🏴", code: "ENG" },
|
|
19
|
+
France: { flag: "🇫🇷", code: "FRA" },
|
|
20
|
+
Germany: { flag: "🇩🇪", code: "GER" },
|
|
21
|
+
Ghana: { flag: "🇬🇭", code: "GHA" },
|
|
22
|
+
Haiti: { flag: "🇭🇹", code: "HAI" },
|
|
23
|
+
Iran: { flag: "🇮🇷", code: "IRN" },
|
|
24
|
+
Iraq: { flag: "🇮🇶", code: "IRQ" },
|
|
25
|
+
"Ivory Coast": { flag: "🇨🇮", code: "CIV" },
|
|
26
|
+
Japan: { flag: "🇯🇵", code: "JPN" },
|
|
27
|
+
Jordan: { flag: "🇯🇴", code: "JOR" },
|
|
28
|
+
Mexico: { flag: "🇲🇽", code: "MEX" },
|
|
29
|
+
Morocco: { flag: "🇲🇦", code: "MAR" },
|
|
30
|
+
Netherlands: { flag: "🇳🇱", code: "NED" },
|
|
31
|
+
"New Zealand": { flag: "🇳🇿", code: "NZL" },
|
|
32
|
+
Norway: { flag: "🇳🇴", code: "NOR" },
|
|
33
|
+
Panama: { flag: "🇵🇦", code: "PAN" },
|
|
34
|
+
Paraguay: { flag: "🇵🇾", code: "PAR" },
|
|
35
|
+
Portugal: { flag: "🇵🇹", code: "POR" },
|
|
36
|
+
Qatar: { flag: "🇶🇦", code: "QAT" },
|
|
37
|
+
"Saudi Arabia": { flag: "🇸🇦", code: "KSA" },
|
|
38
|
+
Scotland: { flag: "🏴", code: "SCO" },
|
|
39
|
+
Senegal: { flag: "🇸🇳", code: "SEN" },
|
|
40
|
+
"South Africa": { flag: "🇿🇦", code: "RSA" },
|
|
41
|
+
"South Korea": { flag: "🇰🇷", code: "KOR" },
|
|
42
|
+
Spain: { flag: "🇪🇸", code: "ESP" },
|
|
43
|
+
Sweden: { flag: "🇸🇪", code: "SWE" },
|
|
44
|
+
Switzerland: { flag: "🇨🇭", code: "SUI" },
|
|
45
|
+
Tunisia: { flag: "🇹🇳", code: "TUN" },
|
|
46
|
+
Turkey: { flag: "🇹🇷", code: "TUR" },
|
|
47
|
+
Uruguay: { flag: "🇺🇾", code: "URU" },
|
|
48
|
+
USA: { flag: "🇺🇸", code: "USA" },
|
|
49
|
+
Uzbekistan: { flag: "🇺🇿", code: "UZB" },
|
|
50
|
+
};
|
|
51
|
+
const DEFAULT = { flag: "🏳️", code: "?" };
|
|
52
|
+
export function teamFlag(team) {
|
|
53
|
+
return (TEAMS[team] ?? DEFAULT).flag;
|
|
54
|
+
}
|
|
55
|
+
export function teamCode(team) {
|
|
56
|
+
return (TEAMS[team] ?? DEFAULT).code;
|
|
57
|
+
}
|
|
58
|
+
/** True when the name is a real team (not a knockout placeholder like "2A"). */
|
|
59
|
+
export function isRealTeam(team) {
|
|
60
|
+
return team in TEAMS;
|
|
61
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { registerToday, renderGames } from "./commands/today.js";
|
|
6
|
+
import { registerGroups } from "./commands/groups.js";
|
|
7
|
+
import { registerCountry } from "./commands/country.js";
|
|
8
|
+
import { registerKnockout } from "./commands/knockout.js";
|
|
9
|
+
import { isValidISODate } from "./lib/dates.js";
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require("../package.json");
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name("worldcup")
|
|
15
|
+
.description(pkg.description)
|
|
16
|
+
.version(pkg.version, "-v, --version", "print the version")
|
|
17
|
+
.option("-d, --date <YYYY-MM-DD>", "show matches for a specific day")
|
|
18
|
+
.option("--refresh", "bypass the local cache and re-fetch from the data source")
|
|
19
|
+
.option("--no-color", "disable colored output")
|
|
20
|
+
.showHelpAfterError();
|
|
21
|
+
// Honor --no-color (chalk already respects the NO_COLOR env var on its own).
|
|
22
|
+
program.hook("preAction", (thisCommand) => {
|
|
23
|
+
if (thisCommand.opts().color === false)
|
|
24
|
+
chalk.level = 0;
|
|
25
|
+
});
|
|
26
|
+
// Bare `worldcup` shows help; `worldcup --date <day>` shows that day's matches.
|
|
27
|
+
program.action(async () => {
|
|
28
|
+
const opts = program.opts();
|
|
29
|
+
if (!opts.date) {
|
|
30
|
+
program.help();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isValidISODate(opts.date)) {
|
|
34
|
+
process.stderr.write(chalk.red(`Invalid date "${opts.date}". Use YYYY-MM-DD.\n`));
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await renderGames(opts.date, Boolean(opts.refresh));
|
|
39
|
+
});
|
|
40
|
+
registerToday(program);
|
|
41
|
+
registerGroups(program);
|
|
42
|
+
registerCountry(program);
|
|
43
|
+
registerKnockout(program);
|
|
44
|
+
program.addHelpText("after", `
|
|
45
|
+
Examples:
|
|
46
|
+
$ worldcup today
|
|
47
|
+
$ worldcup --date 2026-06-11
|
|
48
|
+
$ worldcup groups --group A
|
|
49
|
+
$ worldcup country mexico
|
|
50
|
+
$ worldcup knockout --stage final
|
|
51
|
+
|
|
52
|
+
Data: openfootball/worldcup.json (public domain). Cached at ~/.worldcup-cli/.`);
|
|
53
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
54
|
+
process.stderr.write(chalk.red((err instanceof Error ? err.message : String(err)) + "\n"));
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Return today's date as a local "YYYY-MM-DD" string. */
|
|
2
|
+
export function todayISO() {
|
|
3
|
+
const now = new Date();
|
|
4
|
+
const y = now.getFullYear();
|
|
5
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
6
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
7
|
+
return `${y}-${m}-${d}`;
|
|
8
|
+
}
|
|
9
|
+
/** Validate a user-supplied "YYYY-MM-DD" string (loosely). */
|
|
10
|
+
export function isValidISODate(value) {
|
|
11
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
12
|
+
return false;
|
|
13
|
+
const d = new Date(`${value}T00:00:00`);
|
|
14
|
+
return !Number.isNaN(d.getTime());
|
|
15
|
+
}
|
|
16
|
+
/** Human-friendly date label, e.g. "Thu, Jun 11 2026". */
|
|
17
|
+
export function formatDate(iso) {
|
|
18
|
+
const d = new Date(`${iso}T00:00:00`);
|
|
19
|
+
if (Number.isNaN(d.getTime()))
|
|
20
|
+
return iso;
|
|
21
|
+
return d.toLocaleDateString("en-US", {
|
|
22
|
+
weekday: "short",
|
|
23
|
+
month: "short",
|
|
24
|
+
day: "numeric",
|
|
25
|
+
year: "numeric",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { teamCode, teamFlag } from "../data/flags.js";
|
|
3
|
+
import { isPlayed, matchWinner } from "./standings.js";
|
|
4
|
+
import { formatDate } from "./dates.js";
|
|
5
|
+
/** Build the "2 - 1" score with extra-time / penalty annotations. */
|
|
6
|
+
export function scoreString(match) {
|
|
7
|
+
const s = match.score;
|
|
8
|
+
if (!s?.ft)
|
|
9
|
+
return "";
|
|
10
|
+
const base = `${s.ft[0]} - ${s.ft[1]}`;
|
|
11
|
+
if (s.p)
|
|
12
|
+
return `${base} (${s.p[0]}-${s.p[1]} pens)`;
|
|
13
|
+
if (s.et)
|
|
14
|
+
return `${base} (a.e.t.)`;
|
|
15
|
+
return base;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Render a single match on one line.
|
|
19
|
+
* - Played: 🇲🇽 Mexico 2 - 1 South Africa 🇿🇦 (FT)
|
|
20
|
+
* - Fixture: 🇲🇽 Mexico vs South Africa 🇿🇦 · 13:00 UTC-6 @ Mexico City
|
|
21
|
+
*/
|
|
22
|
+
export function matchLine(match) {
|
|
23
|
+
const left = `${teamFlag(match.team1)} ${match.team1}`;
|
|
24
|
+
const right = `${match.team2} ${teamFlag(match.team2)}`;
|
|
25
|
+
if (isPlayed(match)) {
|
|
26
|
+
const w = matchWinner(match);
|
|
27
|
+
const leftStyled = w === 1 ? chalk.green.bold(left) : w === 2 ? chalk.dim(left) : left;
|
|
28
|
+
const rightStyled = w === 2 ? chalk.green.bold(right) : w === 1 ? chalk.dim(right) : right;
|
|
29
|
+
const score = chalk.bold(scoreString(match));
|
|
30
|
+
return `${leftStyled.padEnd(28)} ${score} ${rightStyled} ${chalk.dim("(FT)")}`;
|
|
31
|
+
}
|
|
32
|
+
const when = [match.time, match.ground ? `@ ${match.ground}` : ""].filter(Boolean).join(" ");
|
|
33
|
+
return `${left.padEnd(28)} ${chalk.dim("vs")} ${right} ${chalk.dim("· " + when)}`;
|
|
34
|
+
}
|
|
35
|
+
/** A short header line, e.g. "── Group A ──". */
|
|
36
|
+
export function header(text) {
|
|
37
|
+
return chalk.cyan.bold(`\n── ${text} ──`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Render a group standings table. The top `qualify` rows are highlighted green
|
|
41
|
+
* (they advance directly); the rest are plain.
|
|
42
|
+
*/
|
|
43
|
+
export function standingsTable(rows, qualify = 2) {
|
|
44
|
+
const nameWidth = Math.max(4, ...rows.map((r) => r.team.length));
|
|
45
|
+
const head = chalk.dim(` ${pad("Team", nameWidth)} ${"P".padStart(2)} ${"W".padStart(2)} ${"D".padStart(2)} ${"L".padStart(2)} ${"GF".padStart(3)} ${"GA".padStart(3)} ${"GD".padStart(3)} ${"Pts".padStart(3)}`);
|
|
46
|
+
const lines = rows.map((r, i) => {
|
|
47
|
+
const rank = i + 1;
|
|
48
|
+
const cells = ` ${pad(r.team, nameWidth)} ${num(r.played)} ${num(r.won)} ${num(r.drawn)} ${num(r.lost)} ` +
|
|
49
|
+
`${num(r.gf, 3)} ${num(r.ga, 3)} ${signed(r.gd)} ${num(r.points, 3)}`;
|
|
50
|
+
const marker = rank <= qualify ? chalk.green("▲") : " ";
|
|
51
|
+
const line = `${marker}${cells}`;
|
|
52
|
+
return rank <= qualify ? chalk.green(line) : line;
|
|
53
|
+
});
|
|
54
|
+
return [head, ...lines].join("\n");
|
|
55
|
+
}
|
|
56
|
+
function pad(s, width) {
|
|
57
|
+
return s.padEnd(width);
|
|
58
|
+
}
|
|
59
|
+
function num(n, width = 2) {
|
|
60
|
+
return String(n).padStart(width);
|
|
61
|
+
}
|
|
62
|
+
function signed(n) {
|
|
63
|
+
const s = n > 0 ? `+${n}` : String(n);
|
|
64
|
+
return s.padStart(3);
|
|
65
|
+
}
|
|
66
|
+
/** Title used at the top of each command's output. */
|
|
67
|
+
export function title(text) {
|
|
68
|
+
return chalk.bold.white(text);
|
|
69
|
+
}
|
|
70
|
+
export function subtle(text) {
|
|
71
|
+
return chalk.dim(text);
|
|
72
|
+
}
|
|
73
|
+
/** Label like "Thu, Jun 11 2026 — Group A" for a match's context. */
|
|
74
|
+
export function matchContext(match) {
|
|
75
|
+
const parts = [formatDate(match.date)];
|
|
76
|
+
if (match.group)
|
|
77
|
+
parts.push(match.group);
|
|
78
|
+
else
|
|
79
|
+
parts.push(match.round);
|
|
80
|
+
return chalk.dim(parts.join(" · "));
|
|
81
|
+
}
|
|
82
|
+
export { teamCode };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** A match is "played" once it has a full-time score. */
|
|
2
|
+
export function isPlayed(match) {
|
|
3
|
+
return Array.isArray(match.score?.ft);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Decide the result from a team1 perspective for the points/knockout winner.
|
|
7
|
+
* Penalties (`p`) trump extra time (`et`) which trumps full time (`ft`).
|
|
8
|
+
* Returns 1 (team1), 2 (team2), or 0 (draw — only possible at full time).
|
|
9
|
+
*/
|
|
10
|
+
export function matchWinner(match) {
|
|
11
|
+
const s = match.score;
|
|
12
|
+
if (!s)
|
|
13
|
+
return 0;
|
|
14
|
+
const decisive = s.p ?? s.et ?? s.ft;
|
|
15
|
+
if (!decisive)
|
|
16
|
+
return 0;
|
|
17
|
+
const [a, b] = decisive;
|
|
18
|
+
if (a > b)
|
|
19
|
+
return 1;
|
|
20
|
+
if (b > a)
|
|
21
|
+
return 2;
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
/** All distinct group labels present in the data, sorted (Group A .. Group L). */
|
|
25
|
+
export function listGroups(matches) {
|
|
26
|
+
const groups = new Set();
|
|
27
|
+
for (const m of matches)
|
|
28
|
+
if (m.group)
|
|
29
|
+
groups.add(m.group);
|
|
30
|
+
return [...groups].sort();
|
|
31
|
+
}
|
|
32
|
+
/** Group matches belonging to a single group label. */
|
|
33
|
+
export function matchesInGroup(matches, group) {
|
|
34
|
+
return matches.filter((m) => m.group === group);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute a group's standings table from its played matches.
|
|
38
|
+
* Points: win = 3, draw = 1, loss = 0. Sorted by Points, then GD, then GF.
|
|
39
|
+
*
|
|
40
|
+
* Note: the official FIFA tiebreakers (head-to-head, fair play, drawing of
|
|
41
|
+
* lots) are simplified here to the common three — adequate for a CLI.
|
|
42
|
+
*/
|
|
43
|
+
export function computeStandings(matches, group) {
|
|
44
|
+
const rows = new Map();
|
|
45
|
+
const ensure = (team) => {
|
|
46
|
+
let row = rows.get(team);
|
|
47
|
+
if (!row) {
|
|
48
|
+
row = { team, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, gd: 0, points: 0 };
|
|
49
|
+
rows.set(team, row);
|
|
50
|
+
}
|
|
51
|
+
return row;
|
|
52
|
+
};
|
|
53
|
+
for (const m of matchesInGroup(matches, group)) {
|
|
54
|
+
// Seed every fixture so teams with no result yet still appear.
|
|
55
|
+
ensure(m.team1);
|
|
56
|
+
ensure(m.team2);
|
|
57
|
+
if (!isPlayed(m) || !m.score?.ft)
|
|
58
|
+
continue;
|
|
59
|
+
const [g1, g2] = m.score.ft;
|
|
60
|
+
const r1 = ensure(m.team1);
|
|
61
|
+
const r2 = ensure(m.team2);
|
|
62
|
+
r1.played++;
|
|
63
|
+
r2.played++;
|
|
64
|
+
r1.gf += g1;
|
|
65
|
+
r1.ga += g2;
|
|
66
|
+
r2.gf += g2;
|
|
67
|
+
r2.ga += g1;
|
|
68
|
+
if (g1 > g2) {
|
|
69
|
+
r1.won++;
|
|
70
|
+
r2.lost++;
|
|
71
|
+
r1.points += 3;
|
|
72
|
+
}
|
|
73
|
+
else if (g2 > g1) {
|
|
74
|
+
r2.won++;
|
|
75
|
+
r1.lost++;
|
|
76
|
+
r2.points += 3;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
r1.drawn++;
|
|
80
|
+
r2.drawn++;
|
|
81
|
+
r1.points += 1;
|
|
82
|
+
r2.points += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const row of rows.values())
|
|
86
|
+
row.gd = row.gf - row.ga;
|
|
87
|
+
return [...rows.values()].sort((a, b) => b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.team.localeCompare(b.team));
|
|
88
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "worldcup26-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A colorful terminal CLI to follow the FIFA World Cup 2026 — today's games, group standings, country stats, and the knockout bracket.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"worldcup": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"worldcup",
|
|
17
|
+
"world-cup",
|
|
18
|
+
"2026",
|
|
19
|
+
"football",
|
|
20
|
+
"soccer",
|
|
21
|
+
"fifa",
|
|
22
|
+
"cli",
|
|
23
|
+
"terminal"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.3.0",
|
|
33
|
+
"commander": "^12.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.14.0",
|
|
37
|
+
"tsx": "^4.16.0",
|
|
38
|
+
"typescript": "^5.5.0"
|
|
39
|
+
}
|
|
40
|
+
}
|