diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index fae77e5f..de039419 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -30,7 +30,15 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { findServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; -import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; +import { + fetchText, + fetchJson, + resolveGitHubDefaultBranch, + resolveGitHubCommitSha, + parseGitHubSourceUrl, + resolveGitHubPinnedRef, + resolveRawGitHubUrl, +} from "./github-skill-helpers.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; @@ -74,7 +82,6 @@ type ParsedSkillImportSource = { type SkillSourceMeta = { skillKey?: string; sourceKind?: string; - hostname?: string; owner?: string; repo?: string; ref?: string; @@ -471,88 +478,7 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise { - const response = await ghFetch(url, { - headers: { - accept: "application/vnd.github+json", - }, - }); - if (!response.ok) { - throw unprocessable(`Failed to fetch ${url}: ${response.status}`); - } - return response.json() as Promise; -} - - -async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { - const response = await fetchJson<{ default_branch?: string }>( - `${apiBase}/repos/${owner}/${repo}`, - ); - return asString(response.default_branch) ?? "main"; -} - -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) { - const response = await fetchJson<{ sha?: string }>( - `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, - ); - const sha = asString(response.sha); - if (!sha) { - throw unprocessable(`Failed to resolve GitHub ref ${ref}`); - } - return sha; -} - -function parseGitHubSourceUrl(rawUrl: string) { - const url = new URL(rawUrl); - if (url.protocol !== "https:") { - throw unprocessable("GitHub source URL must use HTTPS"); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw unprocessable("Invalid GitHub URL"); - } - const owner = parts[0]!; - const repo = parts[1]!.replace(/\.git$/i, ""); - let ref = "main"; - let basePath = ""; - let filePath: string | null = null; - let explicitRef = false; - if (parts[2] === "tree") { - ref = parts[3] ?? "main"; - basePath = parts.slice(4).join("/"); - explicitRef = true; - } else if (parts[2] === "blob") { - ref = parts[3] ?? "main"; - filePath = parts.slice(4).join("/"); - basePath = filePath ? path.posix.dirname(filePath) : ""; - explicitRef = true; - } - return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; -} - -async function resolveGitHubPinnedRef(parsed: ReturnType) { - const apiBase = gitHubApiBase(parsed.hostname); - if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { - return { - pinnedRef: parsed.ref, - trackingRef: parsed.explicitRef ? parsed.ref : null, - }; - } - - const trackingRef = parsed.explicitRef - ? parsed.ref - : await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase); - const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase); - return { pinnedRef, trackingRef }; -} +// [nexus] GitHub helpers extracted to shared module — imported below function extractCommandTokens(raw: string) { @@ -676,10 +602,9 @@ function deriveImportedSkillSource( const repoPath = asString(sourceEntry?.path); const commit = asString(sourceEntry?.commit); const trackingRef = asString(sourceEntry?.trackingRef); - const sourceHostname = asString(sourceEntry?.hostname) || "github.com"; const url = asString(sourceEntry?.url) ?? (repo - ? `https://${sourceHostname}/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` + ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null); const [owner, repoName] = (repo ?? "").split("/"); if (repo && owner && repoName) { @@ -690,7 +615,6 @@ function deriveImportedSkillSource( metadata: { ...(canonicalKey ? { skillKey: canonicalKey } : {}), sourceKind: "github", - ...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}), owner, repo: repoName, ref: commit, @@ -984,21 +908,12 @@ async function readUrlSkillImports( ): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { const url = sourceUrl.trim(); const warnings: string[] = []; - const looksLikeRepoUrl = (() => { try { - const parsed = new URL(url); - if (parsed.protocol !== "https:") return false; - const h = parsed.hostname.toLowerCase(); - if (h.endsWith(".githubusercontent.com") || h === "gist.github.com") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2 && !parsed.pathname.endsWith(".md"); - } catch { return false; } })(); - if (looksLikeRepoUrl) { + if (url.includes("github.com/")) { const parsed = parseGitHubSourceUrl(url); - const apiBase = gitHubApiBase(parsed.hostname); const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); let ref = pinnedRef; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( - `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); @@ -1025,7 +940,7 @@ async function readUrlSkillImports( const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; - const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath)); + const markdown = await fetchText(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoSkillPath)); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); @@ -1039,10 +954,9 @@ async function readUrlSkillImports( const metadata = { ...(skillKey ? { skillKey } : {}), sourceKind: "github", - ...(parsed.hostname !== "github.com" ? { hostname: parsed.hostname } : {}), owner: parsed.owner, repo: parsed.repo, - ref, + ref: ref, trackingRef, repoSkillDir: normalizeGitHubSkillDirectory( basePrefix ? `${basePrefix}${skillDir}` : skillDir, @@ -1654,9 +1568,7 @@ export function companySkillService(db: Db) { }; } - const hostname = asString(metadata.hostname) || "github.com"; - const apiBase = gitHubApiBase(hostname); - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase); + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef); return { supported: true, reason: null, @@ -1694,14 +1606,13 @@ export function companySkillService(db: Db) { const metadata = getSkillMeta(skill); const owner = asString(metadata.owner); const repo = asString(metadata.repo); - const hostname = asString(metadata.hostname) || "github.com"; const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main"; const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug); if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + content = await fetchText(resolveRawGitHubUrl(owner, repo, ref, repoPath)); } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md");