import { getForgeDefinition } from "./forge-manifest.js"; const GITHUB_HOSTS = new Set(getForgeDefinition("github")?.cloudHosts ?? []); const TRANSPORT_BY_PROTOCOL: Record = { "https:": "https", "http:": "http", "ssh:": "ssh", }; export interface GitRemoteLocation { transport: "scp" | "ssh" | "http" | "https"; host: string; /** * Explicit non-default port from the remote (e.g. a self-hosted forge on * `:60443`), or undefined for a default-port or scp-form remote. Kept separate * from `host` so host-identity matching (forge detection, cloud-host checks) * stays port-agnostic; only consumers that reconstruct a URL (web links) use it. */ port?: string; path: string; } export interface GitHubRemoteIdentity { owner: string; name: string; repo: string; } export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null { const location = parseGitRemoteLocation(remoteUrl); if (!location || !isGitHubHost(location.host)) return null; return parseGitHubRemoteIdentity(location.path); } /** * Whether `repo` is already a complete git remote (a URL or scp-like address) * rather than `owner/repo` shorthand that still needs a clone protocol picked. * * Clients (app + CLI) and the daemon must agree on this classification: the * daemon treats `parseGitRemoteLocation(repo) !== null` as "complete remote" * and everything else as shorthand, so reuse the same parser here instead of a * separate regex that would drift (e.g. accepting `git://` the parser rejects). */ export function isCompleteGitRemote(repo: string): boolean { return parseGitRemoteLocation(repo) !== null; } export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { const trimmed = remoteUrl.trim(); if (!trimmed) return null; const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u); if (scpLike) { const host = normalizeHost(scpLike[1] ?? ""); const path = normalizeRemotePath(scpLike[2] ?? ""); if (!isValidRemoteHost(host) || !path) return null; return { transport: "scp", host, path }; } let parsed: URL; try { parsed = new URL(trimmed); } catch { return null; } const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()]; if (!transport) return null; const host = normalizeHost(parsed.hostname); let path: string; try { path = decodeURIComponent(parsed.pathname); } catch { return null; } const normalizedPath = normalizeRemotePath(path); if (!isValidRemoteHost(host) || !normalizedPath) return null; return { transport, host, port: parsed.port || undefined, path: normalizedPath }; } export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { const segments = path.split("/").filter(Boolean); if (segments.length !== 2) return null; const [owner, name] = segments; if (!owner || !name) return null; return { owner, name, repo: `${owner}/${name}` }; } export function isGitHubHost(host: string): boolean { return GITHUB_HOSTS.has(host); } export function normalizeHost(host: string): string { return host.trim().replace(/\.+$/u, "").toLowerCase(); } function normalizeRemotePath(path: string): string | null { let normalized = path.trim().replace(/^\/+|\/+$/gu, ""); if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4); return normalized || null; } function isValidRemoteHost(host: string): boolean { return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host); }