diff --git a/.env.example b/.env.example index f1963f7..5de04b7 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,12 @@ ALLOWED_SCRIPT_PATHS="scripts/" WEBSOCKET_PORT="3001" # User settings +# Optional tokens for private repos: GITHUB_TOKEN (GitHub), GITLAB_TOKEN (GitLab), +# BITBUCKET_APP_PASSWORD or BITBUCKET_TOKEN (Bitbucket). REPO_URL and added repos +# can be GitHub, GitLab, Bitbucket, or custom Git servers. GITHUB_TOKEN= +GITLAB_TOKEN= +BITBUCKET_APP_PASSWORD= SAVE_FILTER=false FILTERS= AUTH_USERNAME= diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index fbeb1a7..a9146de 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -1617,7 +1617,7 @@ export function GeneralSettingsModal({ ) => setNewRepoUrl(e.target.value) @@ -1626,8 +1626,9 @@ export function GeneralSettingsModal({ className="w-full" />

- Enter a GitHub repository URL (e.g., - https://github.com/owner/repo) + Supported: GitHub, GitLab, Bitbucket, or custom Git + servers (e.g. https://github.com/owner/repo, + https://gitlab.com/owner/repo)

diff --git a/src/env.js b/src/env.js index cb4aae6..6b6972f 100644 --- a/src/env.js +++ b/src/env.js @@ -23,8 +23,11 @@ export const env = createEnv({ ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"), // WebSocket Configuration WEBSOCKET_PORT: z.string().default("3001"), - // GitHub Configuration + // Git provider tokens (optional, for private repos) GITHUB_TOKEN: z.string().optional(), + GITLAB_TOKEN: z.string().optional(), + BITBUCKET_APP_PASSWORD: z.string().optional(), + BITBUCKET_TOKEN: z.string().optional(), // Authentication Configuration AUTH_USERNAME: z.string().optional(), AUTH_PASSWORD_HASH: z.string().optional(), @@ -62,8 +65,10 @@ export const env = createEnv({ ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS, // WebSocket Configuration WEBSOCKET_PORT: process.env.WEBSOCKET_PORT, - // GitHub Configuration GITHUB_TOKEN: process.env.GITHUB_TOKEN, + GITLAB_TOKEN: process.env.GITLAB_TOKEN, + BITBUCKET_APP_PASSWORD: process.env.BITBUCKET_APP_PASSWORD, + BITBUCKET_TOKEN: process.env.BITBUCKET_TOKEN, // Authentication Configuration AUTH_USERNAME: process.env.AUTH_USERNAME, AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH, diff --git a/src/server/lib/gitProvider/bitbucket.ts b/src/server/lib/gitProvider/bitbucket.ts new file mode 100644 index 0000000..4205c70 --- /dev/null +++ b/src/server/lib/gitProvider/bitbucket.ts @@ -0,0 +1,55 @@ +import type { DirEntry, GitProvider } from './types'; +import { parseRepoUrl } from '../repositoryUrlValidation'; + +export class BitbucketProvider implements GitProvider { + async listDirectory(repoUrl: string, path: string, branch: string): Promise { + const { owner, repo } = parseRepoUrl(repoUrl); + const listUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${path}`; + const headers: Record = { + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN; + if (token) { + const auth = Buffer.from(`:${token}`).toString('base64'); + headers.Authorization = `Basic ${auth}`; + } + + const response = await fetch(listUrl, { headers }); + if (!response.ok) { + throw new Error(`Bitbucket API error: ${response.status} ${response.statusText}`); + } + + const body = (await response.json()) as { values?: { path: string; type: string }[] }; + const data = body.values ?? (Array.isArray(body) ? body : []); + if (!Array.isArray(data)) { + throw new Error('Bitbucket API returned unexpected response'); + } + return data.map((item: { path: string; type: string }) => { + const name = item.path.split('/').pop() ?? item.path; + return { + name, + path: item.path, + type: item.type === 'commit_directory' ? ('dir' as const) : ('file' as const), + }; + }); + } + + async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise { + const { owner, repo } = parseRepoUrl(repoUrl); + const rawUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${filePath}`; + const headers: Record = { + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN; + if (token) { + const auth = Buffer.from(`:${token}`).toString('base64'); + headers.Authorization = `Basic ${auth}`; + } + + const response = await fetch(rawUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`); + } + return response.text(); + } +} diff --git a/src/server/lib/gitProvider/custom.ts b/src/server/lib/gitProvider/custom.ts new file mode 100644 index 0000000..a4db2a0 --- /dev/null +++ b/src/server/lib/gitProvider/custom.ts @@ -0,0 +1,44 @@ +import type { DirEntry, GitProvider } from "./types"; +import { parseRepoUrl } from "../repositoryUrlValidation"; + +export class CustomProvider implements GitProvider { + async listDirectory(repoUrl: string, path: string, branch: string): Promise { + const { origin, owner, repo } = parseRepoUrl(repoUrl); + const apiUrl = `${origin}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`; + const headers: Record = { "User-Agent": "PVEScripts-Local/1.0" }; + const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN; + if (token) headers.Authorization = `token ${token}`; + + const response = await fetch(apiUrl, { headers }); + if (!response.ok) { + throw new Error(`Custom Git server: list directory failed (${response.status}).`); + } + const data = (await response.json()) as { type: string; name: string; path: string }[]; + if (!Array.isArray(data)) { + const single = data as unknown as { type?: string; name?: string; path?: string }; + if (single?.name) { + return [{ name: single.name, path: single.path ?? path, type: single.type === "dir" ? "dir" : "file" }]; + } + throw new Error("Custom Git server returned unexpected response"); + } + return data.map((item) => ({ + name: item.name, + path: item.path, + type: item.type === "dir" ? ("dir" as const) : ("file" as const), + })); + } + + async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise { + const { origin, owner, repo } = parseRepoUrl(repoUrl); + const rawUrl = `${origin}/${owner}/${repo}/raw/${encodeURIComponent(branch)}/${filePath}`; + const headers: Record = { "User-Agent": "PVEScripts-Local/1.0" }; + const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN; + if (token) headers.Authorization = `token ${token}`; + + const response = await fetch(rawUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to download ${filePath} from custom Git server (${response.status}).`); + } + return response.text(); + } +} diff --git a/src/server/lib/gitProvider/github.ts b/src/server/lib/gitProvider/github.ts new file mode 100644 index 0000000..5a08aaa --- /dev/null +++ b/src/server/lib/gitProvider/github.ts @@ -0,0 +1,60 @@ +import type { DirEntry, GitProvider } from './types'; +import { parseRepoUrl } from '../repositoryUrlValidation'; + +export class GitHubProvider implements GitProvider { + async listDirectory(repoUrl: string, path: string, branch: string): Promise { + const { owner, repo } = parseRepoUrl(repoUrl); + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`; + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.GITHUB_TOKEN; + if (token) headers.Authorization = `token ${token}`; + + const response = await fetch(apiUrl, { headers }); + if (!response.ok) { + if (response.status === 403) { + const err = new Error( + `GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN. Status: ${response.status} ${response.statusText}` + ); + (err as Error & { name: string }).name = 'RateLimitError'; + throw err; + } + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { type: string; name: string; path: string }[]; + if (!Array.isArray(data)) { + throw new Error('GitHub API returned unexpected response'); + } + return data.map((item) => ({ + name: item.name, + path: item.path, + type: item.type === 'dir' ? ('dir' as const) : ('file' as const), + })); + } + + async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise { + const { owner, repo } = parseRepoUrl(repoUrl); + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${filePath}`; + const headers: Record = { + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.GITHUB_TOKEN; + if (token) headers.Authorization = `token ${token}`; + + const response = await fetch(rawUrl, { headers }); + if (!response.ok) { + if (response.status === 403) { + const err = new Error( + `GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN.` + ); + (err as Error & { name: string }).name = 'RateLimitError'; + throw err; + } + throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`); + } + return response.text(); + } +} diff --git a/src/server/lib/gitProvider/gitlab.ts b/src/server/lib/gitProvider/gitlab.ts new file mode 100644 index 0000000..089b3c5 --- /dev/null +++ b/src/server/lib/gitProvider/gitlab.ts @@ -0,0 +1,58 @@ +import type { DirEntry, GitProvider } from './types'; +import { parseRepoUrl } from '../repositoryUrlValidation'; + +export class GitLabProvider implements GitProvider { + private getBaseUrl(repoUrl: string): string { + const { origin } = parseRepoUrl(repoUrl); + return origin; + } + + private getProjectId(repoUrl: string): string { + const { owner, repo } = parseRepoUrl(repoUrl); + return encodeURIComponent(`${owner}/${repo}`); + } + + async listDirectory(repoUrl: string, path: string, branch: string): Promise { + const baseUrl = this.getBaseUrl(repoUrl); + const projectId = this.getProjectId(repoUrl); + const apiUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/tree?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(branch)}&per_page=100`; + const headers: Record = { + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.GITLAB_TOKEN; + if (token) headers['PRIVATE-TOKEN'] = token; + + const response = await fetch(apiUrl, { headers }); + if (!response.ok) { + throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { type: string; name: string; path: string }[]; + if (!Array.isArray(data)) { + throw new Error('GitLab API returned unexpected response'); + } + return data.map((item) => ({ + name: item.name, + path: item.path, + type: item.type === 'tree' ? ('dir' as const) : ('file' as const), + })); + } + + async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise { + const baseUrl = this.getBaseUrl(repoUrl); + const projectId = this.getProjectId(repoUrl); + const encodedPath = encodeURIComponent(filePath); + const rawUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(branch)}`; + const headers: Record = { + 'User-Agent': 'PVEScripts-Local/1.0', + }; + const token = process.env.GITLAB_TOKEN; + if (token) headers['PRIVATE-TOKEN'] = token; + + const response = await fetch(rawUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`); + } + return response.text(); + } +} diff --git a/src/server/lib/gitProvider/index.js b/src/server/lib/gitProvider/index.js new file mode 100644 index 0000000..24c6c60 --- /dev/null +++ b/src/server/lib/gitProvider/index.js @@ -0,0 +1 @@ +export { listDirectory, downloadRawFile, getRepoProvider } from "./index.ts"; diff --git a/src/server/lib/gitProvider/index.ts b/src/server/lib/gitProvider/index.ts new file mode 100644 index 0000000..493e70b --- /dev/null +++ b/src/server/lib/gitProvider/index.ts @@ -0,0 +1,28 @@ +import type { DirEntry, GitProvider } from "./types"; +import { getRepoProvider } from "../repositoryUrlValidation"; +import { GitHubProvider } from "./github"; +import { GitLabProvider } from "./gitlab"; +import { BitbucketProvider } from "./bitbucket"; +import { CustomProvider } from "./custom"; + +const providers: Record = { + github: new GitHubProvider(), + gitlab: new GitLabProvider(), + bitbucket: new BitbucketProvider(), + custom: new CustomProvider(), +}; + +export type { DirEntry, GitProvider }; +export { getRepoProvider }; + +export function getGitProvider(repoUrl: string): GitProvider { + return providers[getRepoProvider(repoUrl)]!; +} + +export async function listDirectory(repoUrl: string, path: string, branch: string): Promise { + return getGitProvider(repoUrl).listDirectory(repoUrl, path, branch); +} + +export async function downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise { + return getGitProvider(repoUrl).downloadRawFile(repoUrl, filePath, branch); +} diff --git a/src/server/lib/gitProvider/types.ts b/src/server/lib/gitProvider/types.ts new file mode 100644 index 0000000..7d16df5 --- /dev/null +++ b/src/server/lib/gitProvider/types.ts @@ -0,0 +1,14 @@ +/** + * Git provider interface for listing and downloading repository files. + */ + +export type DirEntry = { + name: string; + path: string; + type: 'file' | 'dir'; +}; + +export interface GitProvider { + listDirectory(repoUrl: string, path: string, branch: string): Promise; + downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise; +} diff --git a/src/server/lib/repositoryUrlValidation.js b/src/server/lib/repositoryUrlValidation.js new file mode 100644 index 0000000..f145fa6 --- /dev/null +++ b/src/server/lib/repositoryUrlValidation.js @@ -0,0 +1,37 @@ +/** + * Repository URL validation (JS mirror for server.js). + */ +const VALID_REPO_URL = + /^(https?:\/\/)(github\.com|gitlab\.com|bitbucket\.org|[^/]+)\/[^/]+\/[^/]+$/; + +export const REPO_URL_ERROR_MESSAGE = + 'Invalid repository URL. Supported: GitHub, GitLab, Bitbucket, and custom Git servers (e.g. https://host/owner/repo).'; + +export function isValidRepositoryUrl(url) { + if (typeof url !== 'string' || !url.trim()) return false; + return VALID_REPO_URL.test(url.trim()); +} + +export function getRepoProvider(url) { + if (!isValidRepositoryUrl(url)) throw new Error(REPO_URL_ERROR_MESSAGE); + const normalized = url.trim().toLowerCase(); + if (normalized.includes('github.com')) return 'github'; + if (normalized.includes('gitlab.com')) return 'gitlab'; + if (normalized.includes('bitbucket.org')) return 'bitbucket'; + return 'custom'; +} + +export function parseRepoUrl(url) { + if (!isValidRepositoryUrl(url)) throw new Error(REPO_URL_ERROR_MESSAGE); + try { + const u = new URL(url.trim()); + const pathParts = u.pathname.replace(/^\/+/, '').replace(/\.git\/?$/, '').split('/'); + return { + origin: u.origin, + owner: pathParts[0] ?? '', + repo: pathParts[1] ?? '', + }; + } catch { + throw new Error(REPO_URL_ERROR_MESSAGE); + } +} diff --git a/src/server/lib/repositoryUrlValidation.ts b/src/server/lib/repositoryUrlValidation.ts new file mode 100644 index 0000000..36479b5 --- /dev/null +++ b/src/server/lib/repositoryUrlValidation.ts @@ -0,0 +1,57 @@ +/** + * Repository URL validation and provider detection. + * Supports GitHub, GitLab, Bitbucket, and custom Git servers. + */ + +const VALID_REPO_URL = + /^(https?:\/\/)(github\.com|gitlab\.com|bitbucket\.org|[^/]+)\/[^/]+\/[^/]+$/; + +export const REPO_URL_ERROR_MESSAGE = + 'Invalid repository URL. Supported: GitHub, GitLab, Bitbucket, and custom Git servers (e.g. https://host/owner/repo).'; + +export type RepoProvider = 'github' | 'gitlab' | 'bitbucket' | 'custom'; + +/** + * Check if a string is a valid repository URL (format only). + */ +export function isValidRepositoryUrl(url: string): boolean { + if (typeof url !== 'string' || !url.trim()) return false; + return VALID_REPO_URL.test(url.trim()); +} + +/** + * Detect the Git provider from a repository URL. + */ +export function getRepoProvider(url: string): RepoProvider { + if (!isValidRepositoryUrl(url)) { + throw new Error(REPO_URL_ERROR_MESSAGE); + } + const normalized = url.trim().toLowerCase(); + if (normalized.includes('github.com')) return 'github'; + if (normalized.includes('gitlab.com')) return 'gitlab'; + if (normalized.includes('bitbucket.org')) return 'bitbucket'; + return 'custom'; +} + +/** + * Parse owner and repo from a repository URL (path segments). + * Works for GitHub, GitLab, Bitbucket, and custom (host/owner/repo). + */ +export function parseRepoUrl(url: string): { origin: string; owner: string; repo: string } { + if (!isValidRepositoryUrl(url)) { + throw new Error(REPO_URL_ERROR_MESSAGE); + } + try { + const u = new URL(url.trim()); + const pathParts = u.pathname.replace(/^\/+/, '').replace(/\.git\/?$/, '').split('/'); + const owner = pathParts[0] ?? ''; + const repo = pathParts[1] ?? ''; + return { + origin: u.origin, + owner, + repo, + }; + } catch { + throw new Error(REPO_URL_ERROR_MESSAGE); + } +} diff --git a/src/server/services/githubJsonService.js b/src/server/services/githubJsonService.js index da5f53b..519d526 100644 --- a/src/server/services/githubJsonService.js +++ b/src/server/services/githubJsonService.js @@ -2,6 +2,7 @@ import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { join } from 'path'; import { repositoryService } from './repositoryService.js'; +import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js'; // Get environment variables const getEnv = () => ({ @@ -28,76 +29,9 @@ class GitHubJsonService { } } - getBaseUrl(repoUrl) { - const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); - if (!urlMatch) { - throw new Error(`Invalid GitHub repository URL: ${repoUrl}`); - } - - const [, owner, repo] = urlMatch; - return `https://api.github.com/repos/${owner}/${repo}`; - } - - extractRepoPath(repoUrl) { - const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); - if (!match) { - throw new Error('Invalid GitHub repository URL'); - } - return `${match[1]}/${match[2]}`; - } - - async fetchFromGitHub(repoUrl, endpoint) { - const baseUrl = this.getBaseUrl(repoUrl); - const env = getEnv(); - - const headers = { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'PVEScripts-Local/1.0', - }; - - if (env.GITHUB_TOKEN) { - headers.Authorization = `token ${env.GITHUB_TOKEN}`; - } - - const response = await fetch(`${baseUrl}${endpoint}`, { headers }); - - if (!response.ok) { - if (response.status === 403) { - const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`); - error.name = 'RateLimitError'; - throw error; - } - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } - - return response.json(); - } - async downloadJsonFile(repoUrl, filePath) { this.initializeConfig(); - const repoPath = this.extractRepoPath(repoUrl); - const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch}/${filePath}`; - const env = getEnv(); - - const headers = { - 'User-Agent': 'PVEScripts-Local/1.0', - }; - - if (env.GITHUB_TOKEN) { - headers.Authorization = `token ${env.GITHUB_TOKEN}`; - } - - const response = await fetch(rawUrl, { headers }); - if (!response.ok) { - if (response.status === 403) { - const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits.`); - error.name = 'RateLimitError'; - throw error; - } - throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`); - } - - const content = await response.text(); + const content = await downloadRawFile(repoUrl, filePath, this.branch); const script = JSON.parse(content); script.repository_url = repoUrl; return script; @@ -105,16 +39,13 @@ class GitHubJsonService { async getJsonFiles(repoUrl) { this.initializeConfig(); - try { - const files = await this.fetchFromGitHub( - repoUrl, - `/contents/${this.jsonFolder}?ref=${this.branch}` - ); - - return files.filter(file => file.name.endsWith('.json')); + const entries = await listDirectory(repoUrl, this.jsonFolder, this.branch); + return entries + .filter((e) => e.type === 'file' && e.name.endsWith('.json')) + .map((e) => ({ name: e.name, path: e.path })); } catch (error) { - console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error); + console.error(`Error fetching JSON files from repository (${repoUrl}):`, error); throw new Error(`Failed to fetch script files from repository: ${repoUrl}`); } } diff --git a/src/server/services/githubJsonService.ts b/src/server/services/githubJsonService.ts index b70b3d6..64da8a7 100644 --- a/src/server/services/githubJsonService.ts +++ b/src/server/services/githubJsonService.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { env } from '../../env.js'; import type { Script, ScriptCard, GitHubFile } from '../../types/script'; import { repositoryService } from './repositoryService'; +import { listDirectory, downloadRawFile } from '~/server/lib/gitProvider'; export class GitHubJsonService { private branch: string | null = null; @@ -22,96 +23,24 @@ export class GitHubJsonService { } } - private getBaseUrl(repoUrl: string): string { - const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); - if (!urlMatch) { - throw new Error(`Invalid GitHub repository URL: ${repoUrl}`); - } - - const [, owner, repo] = urlMatch; - return `https://api.github.com/repos/${owner}/${repo}`; - } - - private extractRepoPath(repoUrl: string): string { - const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); - if (!match) { - throw new Error('Invalid GitHub repository URL'); - } - return `${match[1]}/${match[2]}`; - } - - private async fetchFromGitHub(repoUrl: string, endpoint: string): Promise { - const baseUrl = this.getBaseUrl(repoUrl); - - const headers: HeadersInit = { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'PVEScripts-Local/1.0', - }; - - // Add GitHub token authentication if available - if (env.GITHUB_TOKEN) { - headers.Authorization = `token ${env.GITHUB_TOKEN}`; - } - - const response = await fetch(`${baseUrl}${endpoint}`, { headers }); - - if (!response.ok) { - if (response.status === 403) { - const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`); - error.name = 'RateLimitError'; - throw error; - } - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - return data as T; - } - private async downloadJsonFile(repoUrl: string, filePath: string): Promise