campus-stats 0.4.3 → 0.6.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/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { cachePath, emptyCache, mergeSyncResult } from "./cache.js";
3
- import { dataBundleUrl, GITLAB_PROJECT_ID, } from "./data-bundle.js";
3
+ import { describeCredentials, resolveCredentials, } from "./credentials.js";
4
+ import { dataBundleUrl } from "./data-bundle.js";
4
5
  import { readStore, resolveDbPath, resolveStoreMode, runMigrate, writeCacheToStore, writeSyncToStore, } from "./db/store-api.js";
5
6
  import { FANTASY_PLAYER_STATS_LIMIT, listWomenCompetitions, syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
6
7
  import { CampusClient } from "./client.js";
7
- import { INJURIES_STATUS_MESSAGE } from "./injuries.js";
8
8
  import { proposeIdentitiesForCompetition } from "./identity/propose.js";
9
9
  import { setIdentityStatus } from "./identity/store.js";
10
- import { FbrefSource } from "./sources/fbref.js";
10
+ import { createFootballSource, KNOWN_SOURCE_IDS } from "./sources/create.js";
11
+ import { listStatsBombEndpoints, } from "./sources/statsbomb-endpoints.js";
11
12
  import { StatsBombSource } from "./sources/statsbomb.js";
12
13
  let storeMode = "json";
13
14
  let dbPath = ".campus/campus.sqlite";
@@ -20,6 +21,8 @@ Usage:
20
21
  campus update [--with-players] [--player-stats-limit <n>]
21
22
  campus pull [--url <cache.json url>]
22
23
  campus available
24
+ campus credentials
25
+ campus endpoints
23
26
  campus competitions
24
27
  campus seasons --competition <name>
25
28
  campus teams --competition <name>
@@ -29,7 +32,6 @@ Usage:
29
32
  campus lineups [--match <id>] [--team <name>]
30
33
  campus squad --competition <name> --team <name> [--season <name>]
31
34
  campus fantasy-points [--competition <name>] [--player <name>] [--match <id>]
32
- campus injuries
33
35
  campus identities [--status resolved|pending|rejected]
34
36
  campus identities propose --competition <name>
35
37
  campus identities confirm --id <identityId>
@@ -39,8 +41,15 @@ Usage:
39
41
  Options:
40
42
  --fantasy / --all Sync all StatsBomb women's comps (clubs + national teams)
41
43
  --competition <name> Competition display name (e.g. "Liga F")
42
- --source <id> Data source for sync (default: statsbomb)
43
- --with-players StatsBomb only: also sync v1 player match stats
44
+ --source <id> Data source (default: statsbomb). Free or paid if login set.
45
+ --sb-user <email> StatsBomb paid API username (or SB_USERNAME)
46
+ --sb-password <pass> StatsBomb paid API password (or SB_PASSWORD)
47
+ --with-players StatsBomb: also sync lineups/events → v1 player stats
48
+ --with-paid-player-match-stats Paid API probe: official player-match aggregates
49
+ --with-paid-team-match-stats Paid API probe: team-match aggregates
50
+ --with-paid-player-season-stats Paid API probe: player season aggregates
51
+ --with-paid-team-season-stats Paid API probe: team season aggregates
52
+ --with-paid-360 Paid API probe: 360 frames (needs 360 licence)
44
53
  --player-stats-limit <n> Max matches to enrich (default 5; fantasy default 25)
45
54
  --url <url> Override data-bundle URL for pull
46
55
  --season <name> Season label (e.g. "2023/2024")
@@ -55,7 +64,9 @@ Options:
55
64
  --json Force JSON cache (default)
56
65
  --help Show this help
57
66
 
58
- Periodic refresh: see docs/cron.md (GitLab CI schedule + campus pull).
67
+ Sources: ${KNOWN_SOURCE_IDS.join(", ")}. Free by default; StatsBomb paid login → paid API.
68
+ See: campus endpoints · campus credentials
69
+ Periodic refresh: see docs/cron.md (GitHub Actions schedule + campus pull).
59
70
  `);
60
71
  process.exit(1);
61
72
  }
@@ -84,56 +95,144 @@ function requireCompetition(cache, name) {
84
95
  }
85
96
  return chosen;
86
97
  }
98
+ async function resolveCliCredentials(args) {
99
+ return resolveCredentials({
100
+ statsbomb: {
101
+ username: getFlag(args, "--sb-user"),
102
+ password: getFlag(args, "--sb-password"),
103
+ },
104
+ });
105
+ }
106
+ function resolvePaidExtras(args) {
107
+ return {
108
+ playerMatchStats: hasFlag(args, "--with-paid-player-match-stats"),
109
+ teamMatchStats: hasFlag(args, "--with-paid-team-match-stats"),
110
+ playerSeasonStats: hasFlag(args, "--with-paid-player-season-stats"),
111
+ teamSeasonStats: hasFlag(args, "--with-paid-team-season-stats"),
112
+ frames360: hasFlag(args, "--with-paid-360"),
113
+ };
114
+ }
115
+ function paidExtrasRequested(extras) {
116
+ return Boolean(extras.playerMatchStats ||
117
+ extras.teamMatchStats ||
118
+ extras.playerSeasonStats ||
119
+ extras.teamSeasonStats ||
120
+ extras.frames360);
121
+ }
87
122
  async function cmdSync(args) {
88
123
  const fantasy = hasFlag(args, "--fantasy") || hasFlag(args, "--all");
89
124
  const competition = getFlag(args, "--competition");
90
125
  const withPlayers = hasFlag(args, "--with-players");
126
+ const paidExtras = resolvePaidExtras(args);
91
127
  const limitRaw = getFlag(args, "--player-stats-limit");
92
128
  const playerStatsLimit = limitRaw
93
129
  ? Number(limitRaw)
94
130
  : fantasy
95
131
  ? FANTASY_PLAYER_STATS_LIMIT
96
132
  : 5;
133
+ const creds = await resolveCliCredentials(args);
134
+ const sbCredentials = creds.statsbombPaidReady
135
+ ? {
136
+ username: creds.statsbomb.username,
137
+ password: creds.statsbomb.password,
138
+ apiBaseUrl: creds.statsbomb.apiBaseUrl,
139
+ }
140
+ : undefined;
141
+ if (paidExtrasRequested(paidExtras) && !sbCredentials) {
142
+ throw new Error("Paid extras flags need SB_USERNAME / SB_PASSWORD (or --sb-user / --sb-password). " +
143
+ "See docs/statsbomb-endpoints.md.");
144
+ }
97
145
  if (fantasy) {
98
146
  if (competition) {
99
147
  throw new Error("Use either --fantasy/--all or --competition, not both.");
100
148
  }
101
- console.error("Syncing fantasy bundle (all StatsBomb women's competitions)…");
149
+ const mode = sbCredentials ? "paid API" : "Open Data";
150
+ console.error(`Syncing fantasy bundle (all StatsBomb women's competitions, ${mode})…`);
102
151
  const result = await syncFantasyBundle({
103
152
  includePlayerStats: withPlayers,
104
153
  playerStatsLimit,
105
154
  latestSeasonPlayersOnly: true,
155
+ credentials: sbCredentials,
156
+ paidExtras,
106
157
  onProgress: (msg) => console.error(msg),
107
158
  });
108
159
  const cache = await writeSyncToStore(storeMode, dbPath, result);
109
- printSyncSummary("statsbomb", result, cache);
160
+ printSyncSummary(`statsbomb:${sbCredentials ? "paid" : "open-data"}`, result, cache);
110
161
  return;
111
162
  }
112
163
  if (!competition)
113
164
  usage();
114
165
  const sourceId = (getFlag(args, "--source") ?? "statsbomb").toLowerCase();
115
- let source;
116
- if (sourceId === "statsbomb") {
117
- source = new StatsBombSource({
118
- includePlayerStats: withPlayers,
119
- playerStatsLimit,
120
- });
121
- }
122
- else if (sourceId === "fbref") {
123
- if (withPlayers) {
124
- throw new Error("FBref player stats are not available yet (see docs/fbref-player-stats-deferred.md).");
125
- }
126
- source = new FbrefSource();
166
+ if (sourceId === "fbref" && withPlayers) {
167
+ throw new Error("FBref player stats are not available yet (see docs/fbref-player-stats-deferred.md).");
127
168
  }
128
- else {
129
- throw new Error(`Unknown source: "${sourceId}". Use statsbomb or fbref.`);
169
+ if (sourceId === "fbref" && paidExtrasRequested(paidExtras)) {
170
+ throw new Error("Paid extras apply only to --source statsbomb.");
130
171
  }
131
- console.error(`Syncing "${competition}" from ${source.id}…`);
172
+ const source = createFootballSource(sourceId, {
173
+ statsbomb: {
174
+ includePlayerStats: withPlayers,
175
+ playerStatsLimit,
176
+ credentials: sbCredentials,
177
+ paidExtras,
178
+ },
179
+ credentials: sbCredentials,
180
+ });
181
+ const modeLabel = sourceId === "statsbomb"
182
+ ? sbCredentials
183
+ ? "statsbomb:paid"
184
+ : "statsbomb:open-data"
185
+ : source.id;
186
+ console.error(`Syncing "${competition}" from ${modeLabel}…`);
132
187
  const result = await source.syncCompetition(competition);
133
188
  const cache = await writeSyncToStore(storeMode, dbPath, result);
134
- printSyncSummary(source.id, result, cache);
189
+ let paidExtraProbes;
190
+ if (paidExtrasRequested(paidExtras) &&
191
+ source instanceof StatsBombSource &&
192
+ result.competitions[0] &&
193
+ result.seasons[0]) {
194
+ const compNative = Number(result.competitions[0].sources.find((s) => s.source === "statsbomb")?.id);
195
+ const seasonNative = Number(result.seasons[0].sources
196
+ .find((s) => s.source === "statsbomb")
197
+ ?.id.split(":")
198
+ .at(-1));
199
+ const matchNative = result.matches[0]?.sources.find((s) => s.source === "statsbomb")?.id;
200
+ if (Number.isFinite(compNative) && Number.isFinite(seasonNative)) {
201
+ console.error("Probing optional paid endpoints (not yet mapped to schema)…");
202
+ paidExtraProbes = await source.probePaidExtras({
203
+ competitionId: compNative,
204
+ seasonId: seasonNative,
205
+ matchId: matchNative,
206
+ });
207
+ }
208
+ }
209
+ printSyncSummary(modeLabel, result, cache, paidExtraProbes);
210
+ }
211
+ async function cmdCredentials(args) {
212
+ const creds = await resolveCliCredentials(args);
213
+ const described = describeCredentials(creds);
214
+ console.log(JSON.stringify({
215
+ ...described,
216
+ hint: creds.statsbombPaidReady
217
+ ? "StatsBomb paid login detected — sync uses data.statsbombservices.com"
218
+ : "No StatsBomb login — sync uses free Open Data. FBref stays free HTML only.",
219
+ }, null, 2));
220
+ }
221
+ async function cmdEndpoints() {
222
+ console.log(JSON.stringify({
223
+ endpoints: listStatsBombEndpoints().map((e) => ({
224
+ id: e.id,
225
+ name: e.name,
226
+ wiredInCampus: e.wiredInCampus,
227
+ requiresPaidLicense: e.requiresPaidLicense,
228
+ availability: e.availability,
229
+ paidPath: e.paidPath ?? null,
230
+ openDataPath: e.openDataPath ?? null,
231
+ notes: e.notes,
232
+ })),
233
+ }, null, 2));
135
234
  }
136
- function printSyncSummary(sourceId, result, cache) {
235
+ function printSyncSummary(sourceId, result, cache, paidExtraProbes) {
137
236
  console.log(JSON.stringify({
138
237
  source: sourceId,
139
238
  cache: storeMode === "json" ? cachePath() : dbPath,
@@ -143,6 +242,7 @@ function printSyncSummary(sourceId, result, cache) {
143
242
  matches: result.matches.length,
144
243
  players: result.players?.length ?? 0,
145
244
  playerMatchStats: result.playerMatchStats?.length ?? 0,
245
+ paidExtraProbes,
146
246
  totals: {
147
247
  competitions: cache.competitions.length,
148
248
  seasons: cache.seasons.length,
@@ -159,6 +259,14 @@ async function cmdUpdate(args) {
159
259
  const playerStatsLimit = limitRaw
160
260
  ? Number(limitRaw)
161
261
  : FANTASY_PLAYER_STATS_LIMIT;
262
+ const creds = await resolveCliCredentials(args);
263
+ const sbCredentials = creds.statsbombPaidReady
264
+ ? {
265
+ username: creds.statsbomb.username,
266
+ password: creds.statsbomb.password,
267
+ apiBaseUrl: creds.statsbomb.apiBaseUrl,
268
+ }
269
+ : undefined;
162
270
  const before = await readStore(storeMode, dbPath);
163
271
  console.error(before.competitions.length === 0
164
272
  ? "Cache empty — running full fantasy sync…"
@@ -167,18 +275,19 @@ async function cmdUpdate(args) {
167
275
  includePlayerStats: withPlayers,
168
276
  playerStatsLimit,
169
277
  latestSeasonPlayersOnly: true,
278
+ credentials: sbCredentials,
170
279
  onProgress: (msg) => console.error(msg),
171
280
  });
172
281
  const cache = await writeSyncToStore(storeMode, dbPath, result);
173
- printSyncSummary("statsbomb", result, cache);
282
+ printSyncSummary(`statsbomb:${sbCredentials ? "paid" : "open-data"}`, result, cache);
174
283
  }
175
284
  async function cmdPull(args) {
176
- const url = getFlag(args, "--url") ?? dataBundleUrl(GITLAB_PROJECT_ID);
285
+ const url = getFlag(args, "--url") ?? dataBundleUrl();
177
286
  console.error(`Pulling data bundle from ${url}…`);
178
287
  const res = await fetch(url);
179
288
  if (!res.ok) {
180
289
  throw new Error(`Failed to pull data bundle (${res.status}). ` +
181
- `Ensure the GitLab CI cron has published campus-data/latest (see docs/cron.md).`);
290
+ `Ensure the GitHub Actions cron has published the data-latest release (see docs/cron.md).`);
182
291
  }
183
292
  const remote = (await res.json());
184
293
  const incoming = {
@@ -191,7 +300,6 @@ async function cmdPull(args) {
191
300
  players: remote.players ?? [],
192
301
  playerMatchStats: remote.playerMatchStats ?? [],
193
302
  lineups: remote.lineups ?? [],
194
- injuries: remote.injuries ?? [],
195
303
  };
196
304
  const local = await readStore(storeMode, dbPath);
197
305
  const merged = mergeSyncResult(local, {
@@ -202,7 +310,6 @@ async function cmdPull(args) {
202
310
  players: incoming.players,
203
311
  playerMatchStats: incoming.playerMatchStats,
204
312
  lineups: incoming.lineups,
205
- injuries: incoming.injuries,
206
313
  });
207
314
  // Preserve local identities; prefer remote entity payloads via mergeById.
208
315
  merged.identities = local.identities.length
@@ -463,14 +570,6 @@ async function cmdFantasyPoints(args) {
463
570
  breakdown: r.breakdown,
464
571
  })), null, 2));
465
572
  }
466
- async function cmdInjuries() {
467
- const client = CampusClient.fromCache(await readStore(storeMode, dbPath));
468
- console.log(JSON.stringify({
469
- available: client.injuries().available,
470
- message: INJURIES_STATUS_MESSAGE,
471
- records: client.injuries().records,
472
- }, null, 2));
473
- }
474
573
  async function main() {
475
574
  const [, , command, ...args] = process.argv;
476
575
  if (!command || command === "--help" || args.includes("--help"))
@@ -491,6 +590,13 @@ async function main() {
491
590
  case "available":
492
591
  await cmdAvailable();
493
592
  break;
593
+ case "credentials":
594
+ case "auth":
595
+ await cmdCredentials(args);
596
+ break;
597
+ case "endpoints":
598
+ await cmdEndpoints();
599
+ break;
494
600
  case "competitions":
495
601
  await cmdCompetitions();
496
602
  break;
@@ -518,9 +624,6 @@ async function main() {
518
624
  case "fantasy-points":
519
625
  await cmdFantasyPoints(args);
520
626
  break;
521
- case "injuries":
522
- await cmdInjuries();
523
- break;
524
627
  case "identities":
525
628
  await cmdIdentities(args);
526
629
  break;
package/dist/client.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { type FantasySyncOptions } from "./fantasy.js";
7
7
  import { type FantasyPointRow, type FantasyScoringRules } from "./scoring.js";
8
- import type { CampusCache, Competition, InjuryRecord, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "./types.js";
8
+ import type { CampusCache, Competition, LineupEntry, Match, Player, PlayerMatchStats, Season, Team } from "./types.js";
9
9
  export interface SquadMember {
10
10
  playerId: string;
11
11
  playerName: string;
@@ -80,9 +80,4 @@ export declare class CampusClient {
80
80
  team?: string;
81
81
  rules?: FantasyScoringRules;
82
82
  }): FantasyPointRow[];
83
- injuries(): {
84
- available: boolean;
85
- message: string;
86
- records: InjuryRecord[];
87
- };
88
83
  }
package/dist/client.js CHANGED
@@ -4,9 +4,8 @@
4
4
  * helpers against an in-memory cache loaded via `fromBundle` / `fromCache`.
5
5
  */
6
6
  import { emptyCache, loadCache, mergeSyncResult, saveCache, cachePath, } from "./cache.js";
7
- import { dataBundleUrl, GITLAB_PROJECT_ID } from "./data-bundle.js";
7
+ import { dataBundleUrl } from "./data-bundle.js";
8
8
  import { syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
9
- import { listInjuries, INJURIES_AVAILABLE, INJURIES_STATUS_MESSAGE } from "./injuries.js";
10
9
  import { DEFAULT_FANTASY_RULES, scoreFantasyPoints, } from "./scoring.js";
11
10
  function includesCI(haystack, needle) {
12
11
  return haystack.toLowerCase().includes(needle.toLowerCase());
@@ -28,11 +27,11 @@ export class CampusClient {
28
27
  }
29
28
  /** Download the cron-published bundle and optionally merge with local cache. */
30
29
  static async fromBundle(options = {}) {
31
- const url = options.url ?? dataBundleUrl(GITLAB_PROJECT_ID);
30
+ const url = options.url ?? dataBundleUrl();
32
31
  const res = await fetch(url);
33
32
  if (!res.ok) {
34
33
  throw new Error(`Failed to load data bundle (${res.status}) from ${url}. ` +
35
- "Publish via GitLab CI schedule (docs/cron.md) or call syncFantasy() once.");
34
+ "Publish via GitHub Actions schedule (docs/cron.md) or call syncFantasy() once.");
36
35
  }
37
36
  const remote = (await res.json());
38
37
  const incoming = {
@@ -45,7 +44,6 @@ export class CampusClient {
45
44
  players: remote.players ?? [],
46
45
  playerMatchStats: remote.playerMatchStats ?? [],
47
46
  lineups: remote.lineups ?? [],
48
- injuries: remote.injuries ?? [],
49
47
  };
50
48
  const base = options.mergeWith ?? emptyCache();
51
49
  const merged = mergeSyncResult(base, {
@@ -56,7 +54,6 @@ export class CampusClient {
56
54
  players: incoming.players,
57
55
  playerMatchStats: incoming.playerMatchStats,
58
56
  lineups: incoming.lineups,
59
- injuries: incoming.injuries,
60
57
  });
61
58
  merged.identities =
62
59
  base.identities.length > 0 ? base.identities : incoming.identities;
@@ -263,11 +260,4 @@ export class CampusClient {
263
260
  rules: filter?.rules ?? DEFAULT_FANTASY_RULES,
264
261
  });
265
262
  }
266
- injuries() {
267
- return {
268
- available: INJURIES_AVAILABLE,
269
- message: INJURIES_STATUS_MESSAGE,
270
- records: listInjuries(this.cache),
271
- };
272
- }
273
263
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Optional credentials so Campus can use free open feeds by default, or a
3
+ * customer's own StatsBomb login for the paid API.
4
+ *
5
+ * Resolution order (highest wins):
6
+ * 1. Explicit overrides (CLI flags / library options)
7
+ * 2. Environment variables
8
+ * 3. Local config file `.campus/config.json` (gitignored under `.campus/`)
9
+ *
10
+ * StatsBomb paid API uses HTTP Basic Auth (same as statsbombpy):
11
+ * SB_USERNAME / SB_PASSWORD
12
+ *
13
+ * FBref / Sports Reference does **not** sell a public API key — free mode is
14
+ * polite HTML only. See docs/byok.md.
15
+ *
16
+ * Never log raw secrets; use `maskSecret` / `describeCredentials`.
17
+ */
18
+ /** StatsBomb customer login (paid API). Matches statsbombpy env names. */
19
+ export declare const SB_USERNAME_ENV = "SB_USERNAME";
20
+ export declare const SB_PASSWORD_ENV = "SB_PASSWORD";
21
+ export declare const CAMPUS_STATSBOMB_USERNAME_ENV = "CAMPUS_STATSBOMB_USERNAME";
22
+ export declare const CAMPUS_STATSBOMB_PASSWORD_ENV = "CAMPUS_STATSBOMB_PASSWORD";
23
+ /** Optional override for the paid StatsBomb API host. */
24
+ export declare const CAMPUS_STATSBOMB_API_BASE_URL_ENV = "CAMPUS_STATSBOMB_API_BASE_URL";
25
+ export declare const DEFAULT_STATSBOMB_API_BASE = "https://data.statsbombservices.com";
26
+ export type CredentialSource = "override" | "env" | "config" | "none";
27
+ export interface StatsBombCredentials {
28
+ username?: string;
29
+ password?: string;
30
+ /** Paid API host (default data.statsbombservices.com). */
31
+ apiBaseUrl?: string;
32
+ }
33
+ export interface CampusConfigFile {
34
+ statsbomb?: {
35
+ username?: string;
36
+ password?: string;
37
+ apiBaseUrl?: string;
38
+ };
39
+ /** @deprecated Prefer statsbomb.username / statsbomb.password. */
40
+ apiKey?: string;
41
+ apiBaseUrl?: string;
42
+ }
43
+ export interface ResolvedCredentials {
44
+ statsbomb: StatsBombCredentials & {
45
+ usernameSource: CredentialSource;
46
+ passwordSource: CredentialSource;
47
+ apiBaseUrlSource: CredentialSource;
48
+ };
49
+ /** True when both username and password are present. */
50
+ statsbombPaidReady: boolean;
51
+ fbref: {
52
+ /** Always false — FBref has no official API-key product. */
53
+ paidApiAvailable: false;
54
+ note: string;
55
+ };
56
+ }
57
+ export interface ResolveCredentialsOptions {
58
+ statsbomb?: StatsBombCredentials;
59
+ configPath?: string;
60
+ configFile?: CampusConfigFile | null;
61
+ env?: NodeJS.ProcessEnv;
62
+ }
63
+ export declare function defaultConfigPath(cwd?: string): string;
64
+ export declare function loadConfigFile(filePath?: string): Promise<CampusConfigFile | null>;
65
+ /**
66
+ * Resolve optional paid credentials. Missing StatsBomb login is fine — free
67
+ * open-data / FBref HTML still work.
68
+ */
69
+ export declare function resolveCredentials(options?: ResolveCredentialsOptions): Promise<ResolvedCredentials>;
70
+ export declare function requireStatsBombPaidLogin(creds: StatsBombCredentials): {
71
+ username: string;
72
+ password: string;
73
+ };
74
+ /** Mask a secret for CLI / logs. */
75
+ export declare function maskSecret(secret: string, visibleTail?: number): string;
76
+ export declare function describeCredentials(creds: ResolvedCredentials): {
77
+ statsbomb: {
78
+ mode: "paid" | "open-data";
79
+ username: string | null;
80
+ usernameSource: CredentialSource;
81
+ passwordConfigured: boolean;
82
+ passwordSource: CredentialSource;
83
+ apiBaseUrl: string;
84
+ apiBaseUrlSource: CredentialSource;
85
+ };
86
+ fbref: ResolvedCredentials["fbref"];
87
+ };
88
+ /** @deprecated Use requireStatsBombPaidLogin / free open-data instead. */
89
+ export declare function requireApiKey(_creds: {
90
+ apiKey?: string;
91
+ }, _purpose?: string): string;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Optional credentials so Campus can use free open feeds by default, or a
3
+ * customer's own StatsBomb login for the paid API.
4
+ *
5
+ * Resolution order (highest wins):
6
+ * 1. Explicit overrides (CLI flags / library options)
7
+ * 2. Environment variables
8
+ * 3. Local config file `.campus/config.json` (gitignored under `.campus/`)
9
+ *
10
+ * StatsBomb paid API uses HTTP Basic Auth (same as statsbombpy):
11
+ * SB_USERNAME / SB_PASSWORD
12
+ *
13
+ * FBref / Sports Reference does **not** sell a public API key — free mode is
14
+ * polite HTML only. See docs/byok.md.
15
+ *
16
+ * Never log raw secrets; use `maskSecret` / `describeCredentials`.
17
+ */
18
+ import { readFile } from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { DEFAULT_CACHE_DIR } from "./cache.js";
21
+ /** StatsBomb customer login (paid API). Matches statsbombpy env names. */
22
+ export const SB_USERNAME_ENV = "SB_USERNAME";
23
+ export const SB_PASSWORD_ENV = "SB_PASSWORD";
24
+ export const CAMPUS_STATSBOMB_USERNAME_ENV = "CAMPUS_STATSBOMB_USERNAME";
25
+ export const CAMPUS_STATSBOMB_PASSWORD_ENV = "CAMPUS_STATSBOMB_PASSWORD";
26
+ /** Optional override for the paid StatsBomb API host. */
27
+ export const CAMPUS_STATSBOMB_API_BASE_URL_ENV = "CAMPUS_STATSBOMB_API_BASE_URL";
28
+ export const DEFAULT_STATSBOMB_API_BASE = "https://data.statsbombservices.com";
29
+ function firstEnv(env, names) {
30
+ for (const name of names) {
31
+ const value = env[name]?.trim();
32
+ if (value)
33
+ return value;
34
+ }
35
+ return undefined;
36
+ }
37
+ function pickField(override, envValue, configValue) {
38
+ if (override?.trim())
39
+ return { value: override.trim(), source: "override" };
40
+ if (envValue)
41
+ return { value: envValue, source: "env" };
42
+ if (configValue?.trim()) {
43
+ return { value: configValue.trim(), source: "config" };
44
+ }
45
+ return { value: undefined, source: "none" };
46
+ }
47
+ export function defaultConfigPath(cwd = process.cwd()) {
48
+ return path.join(cwd, DEFAULT_CACHE_DIR, "config.json");
49
+ }
50
+ export async function loadConfigFile(filePath = defaultConfigPath()) {
51
+ try {
52
+ const raw = await readFile(filePath, "utf8");
53
+ const parsed = JSON.parse(raw);
54
+ if (parsed == null || typeof parsed !== "object")
55
+ return null;
56
+ return parsed;
57
+ }
58
+ catch (err) {
59
+ const code = err.code;
60
+ if (code === "ENOENT")
61
+ return null;
62
+ throw err;
63
+ }
64
+ }
65
+ /**
66
+ * Resolve optional paid credentials. Missing StatsBomb login is fine — free
67
+ * open-data / FBref HTML still work.
68
+ */
69
+ export async function resolveCredentials(options = {}) {
70
+ const env = options.env ?? process.env;
71
+ const config = options.configFile !== undefined
72
+ ? options.configFile
73
+ : await loadConfigFile(options.configPath ?? defaultConfigPath());
74
+ const sb = config?.statsbomb;
75
+ const username = pickField(options.statsbomb?.username, firstEnv(env, [SB_USERNAME_ENV, CAMPUS_STATSBOMB_USERNAME_ENV]), sb?.username);
76
+ const password = pickField(options.statsbomb?.password, firstEnv(env, [SB_PASSWORD_ENV, CAMPUS_STATSBOMB_PASSWORD_ENV]), sb?.password);
77
+ const apiBaseUrl = pickField(options.statsbomb?.apiBaseUrl, firstEnv(env, [CAMPUS_STATSBOMB_API_BASE_URL_ENV]), sb?.apiBaseUrl ?? config?.apiBaseUrl);
78
+ const statsbomb = {
79
+ username: username.value,
80
+ password: password.value,
81
+ apiBaseUrl: apiBaseUrl.value ?? DEFAULT_STATSBOMB_API_BASE,
82
+ usernameSource: username.source,
83
+ passwordSource: password.source,
84
+ apiBaseUrlSource: apiBaseUrl.source === "none" && apiBaseUrl.value === undefined
85
+ ? "none"
86
+ : apiBaseUrl.value
87
+ ? apiBaseUrl.source === "none"
88
+ ? "none"
89
+ : apiBaseUrl.source
90
+ : "none",
91
+ };
92
+ // Default host counts as configured only when user overrode it.
93
+ if (!apiBaseUrl.value) {
94
+ statsbomb.apiBaseUrl = DEFAULT_STATSBOMB_API_BASE;
95
+ statsbomb.apiBaseUrlSource = "none";
96
+ }
97
+ return {
98
+ statsbomb,
99
+ statsbombPaidReady: Boolean(username.value && password.value),
100
+ fbref: {
101
+ paidApiAvailable: false,
102
+ note: "FBref/Sports Reference does not sell a public API key. Campus uses free HTML (rate-limited). For bulk licensing contact Sports Reference directly.",
103
+ },
104
+ };
105
+ }
106
+ export function requireStatsBombPaidLogin(creds) {
107
+ const username = creds.username?.trim();
108
+ const password = creds.password?.trim();
109
+ if (username && password)
110
+ return { username, password };
111
+ throw new Error("StatsBomb paid API needs SB_USERNAME and SB_PASSWORD " +
112
+ "(or CAMPUS_STATSBOMB_USERNAME / CAMPUS_STATSBOMB_PASSWORD, " +
113
+ "or .campus/config.json statsbomb.username/password). " +
114
+ "Without them Campus uses free Open Data.");
115
+ }
116
+ /** Mask a secret for CLI / logs. */
117
+ export function maskSecret(secret, visibleTail = 4) {
118
+ const trimmed = secret.trim();
119
+ if (trimmed.length <= visibleTail)
120
+ return "•".repeat(trimmed.length);
121
+ const head = trimmed.slice(0, Math.min(8, trimmed.length - visibleTail));
122
+ const tail = trimmed.slice(-visibleTail);
123
+ return `${head}${"•".repeat(Math.max(4, trimmed.length - head.length - visibleTail))}${tail}`;
124
+ }
125
+ export function describeCredentials(creds) {
126
+ return {
127
+ statsbomb: {
128
+ mode: creds.statsbombPaidReady ? "paid" : "open-data",
129
+ username: creds.statsbomb.username
130
+ ? maskSecret(creds.statsbomb.username)
131
+ : null,
132
+ usernameSource: creds.statsbomb.usernameSource,
133
+ passwordConfigured: Boolean(creds.statsbomb.password),
134
+ passwordSource: creds.statsbomb.passwordSource,
135
+ apiBaseUrl: creds.statsbomb.apiBaseUrl ?? DEFAULT_STATSBOMB_API_BASE,
136
+ apiBaseUrlSource: creds.statsbomb.apiBaseUrlSource,
137
+ },
138
+ fbref: creds.fbref,
139
+ };
140
+ }
141
+ /** @deprecated Use requireStatsBombPaidLogin / free open-data instead. */
142
+ export function requireApiKey(_creds, _purpose = "paid sources") {
143
+ throw new Error("Generic API keys were replaced by StatsBomb login (SB_USERNAME / SB_PASSWORD). " +
144
+ "See docs/byok.md.");
145
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Published women's fantasy data bundle (refreshed by GitLab CI cron).
3
- * Generic Package Registry paths under project 86296665.
2
+ * Published women's fantasy data bundle (refreshed by GitHub Actions cron).
3
+ * Served as assets on the rolling Release tag `data-latest`.
4
4
  */
5
- export declare const GITLAB_PROJECT_ID = "86296665";
6
- export declare const DATA_BUNDLE_PACKAGE = "campus-data";
7
- export declare const DATA_BUNDLE_VERSION = "latest";
5
+ export declare const DATA_BUNDLE_OWNER = "Minacava";
6
+ export declare const DATA_BUNDLE_REPO = "campus-stats";
7
+ export declare const DATA_BUNDLE_RELEASE_TAG = "data-latest";
8
8
  export declare const DATA_BUNDLE_FILE = "cache.json";
9
+ export declare const DATA_BUNDLE_META_FILE = "meta.json";
9
10
  /** Absolute URL of the periodically refreshed cache snapshot. */
10
- export declare function dataBundleUrl(projectId?: string, version?: string): string;
11
- export declare function dataBundleMetaUrl(projectId?: string, version?: string): string;
11
+ export declare function dataBundleUrl(tag?: string): string;
12
+ export declare function dataBundleMetaUrl(tag?: string): string;