// src/plugin/loader/plugin-forge.ts import type { PluginManifest } from '../types' /** * Represents a release from a plugin forge (GitHub, GitLab, Codeberg, etc.) */ export interface PluginRelease { /** Release version (e.g., "1.5.10") */ version: string /** Release tag name (e.g., "1.5.10" or "v1.5.10") */ tag: string /** Whether this is a prerelease */ prerelease: boolean /** Direct download URLs for assets */ assets: { manifestUrl: string mainJsUrl: string } } /** * Abstract interface for plugin forges (GitHub, GitLab, Codeberg, etc.) * * A forge is a Git hosting service that provides releases with downloadable assets. * Each forge implementation knows how to: * 1. Parse repository URLs for its platform * 2. Fetch release information via API * 3. Construct download URLs for plugin assets */ export interface PluginForge { /** Human-readable name for this forge (e.g., "GitHub") */ readonly name: string /** * Check if this forge can handle the given URL. * @param url URL to check * @returns true if this forge handles URLs from this domain */ canHandle(url: string): boolean /** * Parse a repository URL into owner/repo components. * @param url Repository URL (e.g., https://github.com/liamcain/obsidian-calendar-plugin) * @returns Parsed repo info, or null if URL is invalid */ parseRepoUrl(url: string): { owner: string; repo: string } | null /** * Fetch the latest non-prerelease release for a repository. * @param owner Repository owner * @param repo Repository name * @returns Release info with asset download URLs * @throws If no suitable release is found or API request fails */ fetchLatestRelease(owner: string, repo: string): Promise /** * Download and parse the manifest.json from a release. * @param release Release to download from * @returns Parsed manifest * @throws If download or parsing fails */ downloadManifest(release: PluginRelease): Promise /** * Download the main.js source code from a release. * @param release Release to download from * @returns JavaScript source code * @throws If download fails */ downloadMainJs(release: PluginRelease): Promise } /** * GitHub implementation of PluginForge. * * Uses the GitHub Releases API to find releases and downloads assets directly. * No authentication required for public repositories. */ export class GitHubForge implements PluginForge { readonly name = 'GitHub' canHandle(url: string): boolean { try { const parsed = new URL(url) return parsed.hostname === 'github.com' || parsed.hostname === 'www.github.com' } catch { return false } } parseRepoUrl(url: string): { owner: string; repo: string } | null { try { const parsed = new URL(url) if (parsed.hostname !== 'github.com' && parsed.hostname !== 'www.github.com') { return null } // Path should be /owner/repo or /owner/repo/... const pathParts = parsed.pathname.split('/').filter(Boolean) if (pathParts.length < 2) { return null } return { owner: pathParts[0], repo: pathParts[1], } } catch { return null } } async fetchLatestRelease(owner: string, repo: string): Promise { // Fetch all releases (GitHub API returns newest first) const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`) if (!response.ok) { if (response.status === 404) { throw new Error(`Repository not found: ${owner}/${repo}`) } throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) } interface GitHubRelease { tag_name: string prerelease: boolean draft: boolean assets: Array<{ name: string browser_download_url: string }> } const releases: GitHubRelease[] = await response.json() // Find the latest non-prerelease, non-draft release with the required assets for (const release of releases) { if (release.prerelease || release.draft) { continue } const manifestAsset = release.assets.find(a => a.name === 'manifest.json') const mainJsAsset = release.assets.find(a => a.name === 'main.js') if (manifestAsset && mainJsAsset) { // Extract version from tag (remove leading 'v' if present) const version = release.tag_name.replace(/^v/, '') return { version, tag: release.tag_name, prerelease: release.prerelease, assets: { manifestUrl: manifestAsset.browser_download_url, mainJsUrl: mainJsAsset.browser_download_url, }, } } } throw new Error( `No suitable release found for ${owner}/${repo}. ` + `Releases must include manifest.json and main.js assets.` ) } async downloadManifest(release: PluginRelease): Promise { const response = await fetch(release.assets.manifestUrl) if (!response.ok) { throw new Error(`Failed to download manifest: ${response.status}`) } const manifest: PluginManifest = await response.json() return manifest } async downloadMainJs(release: PluginRelease): Promise { const response = await fetch(release.assets.mainJsUrl) if (!response.ok) { throw new Error(`Failed to download main.js: ${response.status}`) } return response.text() } } /** * Registry of available plugin forges. * * To add support for a new forge (e.g., GitLab, Codeberg): * 1. Implement PluginForge interface * 2. Add instance to FORGES array */ const FORGES: PluginForge[] = [ new GitHubForge(), ] /** * Find a forge that can handle the given URL. * @param url Repository or plugin URL * @returns Matching forge, or null if no forge handles this URL */ export function findForge(url: string): PluginForge | null { return FORGES.find(forge => forge.canHandle(url)) ?? null } /** * Check if a URL looks like a forge repository URL (as opposed to a direct plugin URL). * Forge URLs point to the repository root, like https://github.com/owner/repo * Direct URLs point to plugin files, like https://example.com/my-plugin/ */ export function isForgeUrl(url: string): boolean { return findForge(url) !== null }