Merge pull request #2449 from statxc/feat/github-enterprise-url-support

feat: GitHub enterprise url support
This commit is contained in:
Dotta 2026-04-02 06:07:44 -05:00 committed by GitHub
commit 2c1883fc77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 121 additions and 68 deletions

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
isGithubShorthand, isGithubShorthand,
isGithubUrl, looksLikeRepoUrl,
isHttpUrl, isHttpUrl,
normalizeGithubImportSource, normalizeGithubImportSource,
} from "../commands/client/company.js"; } from "../commands/client/company.js";
@ -21,17 +21,17 @@ describe("isHttpUrl", () => {
}); });
}); });
describe("isGithubUrl", () => { describe("looksLikeRepoUrl", () => {
it("matches GitHub URLs", () => { it("matches GitHub URLs", () => {
expect(isGithubUrl("https://github.com/org/repo")).toBe(true); expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
}); });
it("rejects non-GitHub HTTP URLs", () => { it("rejects URLs without owner/repo path", () => {
expect(isGithubUrl("https://example.com/foo")).toBe(false); expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
}); });
it("rejects local paths", () => { it("rejects local paths", () => {
expect(isGithubUrl("/tmp/my-company")).toBe(false); expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
}); });
}); });

View file

@ -765,8 +765,15 @@ export function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input.trim()); return /^https?:\/\//i.test(input.trim());
} }
export function isGithubUrl(input: string): boolean { export function looksLikeRepoUrl(input: string): boolean {
return /^https?:\/\/github\.com\//i.test(input.trim()); try {
const url = new URL(input.trim());
if (url.protocol !== "https:") return false;
const segments = url.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
} }
function isGithubSegment(input: string): boolean { function isGithubSegment(input: string): boolean {
@ -797,13 +804,15 @@ function normalizeGithubImportPath(input: string | null | undefined): string | n
} }
function buildGithubImportUrl(input: { function buildGithubImportUrl(input: {
hostname?: string;
owner: string; owner: string;
repo: string; repo: string;
ref?: string | null; ref?: string | null;
path?: string | null; path?: string | null;
companyPath?: string | null; companyPath?: string | null;
}): string { }): string {
const url = new URL(`https://github.com/${input.owner}/${input.repo.replace(/\.git$/i, "")}`); const host = input.hostname || "github.com";
const url = new URL(`https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`);
const ref = input.ref?.trim(); const ref = input.ref?.trim();
if (ref) { if (ref) {
url.searchParams.set("ref", ref); url.searchParams.set("ref", ref);
@ -834,14 +843,15 @@ export function normalizeGithubImportSource(input: string, refOverride?: string)
}); });
} }
if (!isGithubUrl(trimmed)) { if (!looksLikeRepoUrl(trimmed)) {
throw new Error("GitHub source must be a github.com URL or owner/repo[/path] shorthand."); throw new Error("GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand.");
} }
if (!ref) { if (!ref) {
return trimmed; return trimmed;
} }
const url = new URL(trimmed); const url = new URL(trimmed);
const hostname = url.hostname;
const parts = url.pathname.split("/").filter(Boolean); const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) { if (parts.length < 2) {
throw new Error("Invalid GitHub URL."); throw new Error("Invalid GitHub URL.");
@ -852,18 +862,18 @@ export function normalizeGithubImportSource(input: string, refOverride?: string)
const existingPath = normalizeGithubImportPath(url.searchParams.get("path")); const existingPath = normalizeGithubImportPath(url.searchParams.get("path"));
const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath")); const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath"));
if (existingCompanyPath) { if (existingCompanyPath) {
return buildGithubImportUrl({ owner, repo, ref, companyPath: existingCompanyPath }); return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: existingCompanyPath });
} }
if (existingPath) { if (existingPath) {
return buildGithubImportUrl({ owner, repo, ref, path: existingPath }); return buildGithubImportUrl({ hostname, owner, repo, ref, path: existingPath });
} }
if (parts[2] === "tree") { if (parts[2] === "tree") {
return buildGithubImportUrl({ owner, repo, ref, path: parts.slice(4).join("/") }); return buildGithubImportUrl({ hostname, owner, repo, ref, path: parts.slice(4).join("/") });
} }
if (parts[2] === "blob") { if (parts[2] === "blob") {
return buildGithubImportUrl({ owner, repo, ref, companyPath: parts.slice(4).join("/") }); return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: parts.slice(4).join("/") });
} }
return buildGithubImportUrl({ owner, repo, ref }); return buildGithubImportUrl({ hostname, owner, repo, ref });
} }
async function pathExists(inputPath: string): Promise<boolean> { async function pathExists(inputPath: string): Promise<boolean> {
@ -1208,13 +1218,13 @@ export function registerCompanyCommands(program: Command): void {
| { type: "github"; url: string }; | { type: "github"; url: string };
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from); const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath); const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
if (isHttpUrl(from) || isGithubSource) { if (isHttpUrl(from) || isGithubSource) {
if (!isGithubUrl(from) && !isGithubShorthand(from)) { if (!looksLikeRepoUrl(from) && !isGithubShorthand(from)) {
throw new Error( throw new Error(
"Only GitHub URLs and local paths are supported for import. " + "Only GitHub URLs and local paths are supported for import. " +
"Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.", "Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.",
); );
} }
sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) }; sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) };

View file

@ -375,6 +375,7 @@ describe("company portability", () => {
expect( expect(
parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"), parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"),
).toEqual({ ).toEqual({
hostname: "github.com",
owner: "paperclipai", owner: "paperclipai",
repo: "companies", repo: "companies",
ref: "feature/demo", ref: "feature/demo",
@ -389,6 +390,7 @@ describe("company portability", () => {
"https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md", "https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md",
), ),
).toEqual({ ).toEqual({
hostname: "github.com",
owner: "paperclipai", owner: "paperclipai",
repo: "companies", repo: "companies",
ref: "abc123", ref: "abc123",

View file

@ -45,6 +45,7 @@ import {
writePaperclipSkillSyncPreference, writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js"; import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js"; import { accessService } from "./access.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
@ -2101,7 +2102,7 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
} }
async function fetchText(url: string) { async function fetchText(url: string) {
const response = await fetch(url); const response = await ghFetch(url);
if (!response.ok) { if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`); throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
} }
@ -2109,7 +2110,7 @@ async function fetchText(url: string) {
} }
async function fetchOptionalText(url: string) { async function fetchOptionalText(url: string) {
const response = await fetch(url); const response = await ghFetch(url);
if (response.status === 404) return null; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`); throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
@ -2118,7 +2119,7 @@ async function fetchOptionalText(url: string) {
} }
async function fetchBinary(url: string) { async function fetchBinary(url: string) {
const response = await fetch(url); const response = await ghFetch(url);
if (!response.ok) { if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`); throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
} }
@ -2126,7 +2127,7 @@ async function fetchBinary(url: string) {
} }
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { const response = await ghFetch(url, {
headers: { headers: {
accept: "application/vnd.github+json", accept: "application/vnd.github+json",
}, },
@ -2411,14 +2412,16 @@ function buildManifestFromPackageFiles(
const repoPath = asString(primarySource?.path); const repoPath = asString(primarySource?.path);
const commit = asString(primarySource?.commit); const commit = asString(primarySource?.commit);
const trackingRef = asString(primarySource?.trackingRef); const trackingRef = asString(primarySource?.trackingRef);
const sourceHostname = asString(primarySource?.hostname) || "github.com";
const [owner, repoName] = (repo ?? "").split("/"); const [owner, repoName] = (repo ?? "").split("/");
sourceType = "github"; sourceType = "github";
sourceLocator = asString(primarySource?.url) sourceLocator = asString(primarySource?.url)
?? (repo ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null); ?? (repo ? `https://${sourceHostname}/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null);
sourceRef = commit; sourceRef = commit;
normalizedMetadata = owner && repoName normalizedMetadata = owner && repoName
? { ? {
sourceKind: "github", sourceKind: "github",
...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}),
owner, owner,
repo: repoName, repo: repoName,
ref: commit, ref: commit,
@ -2564,9 +2567,10 @@ function normalizeGitHubSourcePath(value: string | null | undefined) {
export function parseGitHubSourceUrl(rawUrl: string) { export function parseGitHubSourceUrl(rawUrl: string) {
const url = new URL(rawUrl); const url = new URL(rawUrl);
if (url.hostname !== "github.com") { if (url.protocol !== "https:") {
throw unprocessable("GitHub source must use github.com URL"); throw unprocessable("GitHub source URL must use HTTPS");
} }
const hostname = url.hostname;
const parts = url.pathname.split("/").filter(Boolean); const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) { if (parts.length < 2) {
throw unprocessable("Invalid GitHub URL"); throw unprocessable("Invalid GitHub URL");
@ -2584,6 +2588,7 @@ export function parseGitHubSourceUrl(rawUrl: string) {
if (basePath === ".") basePath = ""; if (basePath === ".") basePath = "";
} }
return { return {
hostname,
owner, owner,
repo, repo,
ref: queryRef || "main", ref: queryRef || "main",
@ -2607,13 +2612,9 @@ export function parseGitHubSourceUrl(rawUrl: string) {
basePath = path.posix.dirname(blobPath); basePath = path.posix.dirname(blobPath);
if (basePath === ".") basePath = ""; if (basePath === ".") basePath = "";
} }
return { owner, repo, ref, basePath, companyPath }; return { hostname, owner, repo, ref, basePath, companyPath };
} }
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
const normalizedFilePath = filePath.replace(/^\/+/, "");
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
}
export function companyPortabilityService(db: Db, storage?: StorageService) { export function companyPortabilityService(db: Db, storage?: StorageService) {
const companies = companyService(db); const companies = companyService(db);
@ -2641,14 +2642,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
let companyMarkdown: string | null = null; let companyMarkdown: string | null = null;
try { try {
companyMarkdown = await fetchOptionalText( companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath), resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
); );
} catch (err) { } catch (err) {
if (ref === "main") { if (ref === "main") {
ref = "master"; ref = "master";
warnings.push("GitHub ref main not found; falling back to master."); warnings.push("GitHub ref main not found; falling back to master.");
companyMarkdown = await fetchOptionalText( companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath), resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
); );
} else { } else {
throw err; throw err;
@ -2664,8 +2665,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = { const files: Record<string, CompanyPortabilityFileEntry> = {
[companyPath]: companyMarkdown, [companyPath]: companyMarkdown,
}; };
const apiBase = gitHubApiBase(parsed.hostname);
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
).catch(() => ({ tree: [] })); ).catch(() => ({ tree: [] }));
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : ""; const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
const candidatePaths = (tree.tree ?? []) const candidatePaths = (tree.tree ?? [])
@ -2686,7 +2688,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath; const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
if (files[relativePath] !== undefined) continue; if (files[relativePath] !== undefined) continue;
files[normalizePortablePath(relativePath)] = await fetchText( files[normalizePortablePath(relativePath)] = await fetchText(
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
); );
} }
const companyDoc = parseFrontmatterMarkdown(companyMarkdown); const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
@ -2697,7 +2699,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (files[relativePath] !== undefined) continue; if (files[relativePath] !== undefined) continue;
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue; if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
files[relativePath] = await fetchText( files[relativePath] = await fetchText(
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
); );
} }
@ -2707,7 +2709,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/"); const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
try { try {
const binary = await fetchBinary( const binary = await fetchBinary(
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
); );
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath)); resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
} catch (err) { } catch (err) {

View file

@ -30,6 +30,7 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { findServerAdapter } from "../adapters/index.js"; import { findServerAdapter } from "../adapters/index.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { projectService } from "./projects.js"; import { projectService } from "./projects.js";
import { secretService } from "./secrets.js"; import { secretService } from "./secrets.js";
@ -73,6 +74,7 @@ type ParsedSkillImportSource = {
type SkillSourceMeta = { type SkillSourceMeta = {
skillKey?: string; skillKey?: string;
sourceKind?: string; sourceKind?: string;
hostname?: string;
owner?: string; owner?: string;
repo?: string; repo?: string;
ref?: string; ref?: string;
@ -470,7 +472,7 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
} }
async function fetchText(url: string) { async function fetchText(url: string) {
const response = await fetch(url); const response = await ghFetch(url);
if (!response.ok) { if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`); throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
} }
@ -478,7 +480,7 @@ async function fetchText(url: string) {
} }
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { const response = await ghFetch(url, {
headers: { headers: {
accept: "application/vnd.github+json", accept: "application/vnd.github+json",
}, },
@ -489,16 +491,17 @@ async function fetchJson<T>(url: string): Promise<T> {
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function resolveGitHubDefaultBranch(owner: string, repo: string) {
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
const response = await fetchJson<{ default_branch?: string }>( const response = await fetchJson<{ default_branch?: string }>(
`https://api.github.com/repos/${owner}/${repo}`, `${apiBase}/repos/${owner}/${repo}`,
); );
return asString(response.default_branch) ?? "main"; return asString(response.default_branch) ?? "main";
} }
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string) { async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
const response = await fetchJson<{ sha?: string }>( const response = await fetchJson<{ sha?: string }>(
`https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
); );
const sha = asString(response.sha); const sha = asString(response.sha);
if (!sha) { if (!sha) {
@ -509,8 +512,8 @@ async function resolveGitHubCommitSha(owner: string, repo: string, ref: string)
function parseGitHubSourceUrl(rawUrl: string) { function parseGitHubSourceUrl(rawUrl: string) {
const url = new URL(rawUrl); const url = new URL(rawUrl);
if (url.hostname !== "github.com") { if (url.protocol !== "https:") {
throw unprocessable("GitHub source must use github.com URL"); throw unprocessable("GitHub source URL must use HTTPS");
} }
const parts = url.pathname.split("/").filter(Boolean); const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) { if (parts.length < 2) {
@ -532,10 +535,11 @@ function parseGitHubSourceUrl(rawUrl: string) {
basePath = filePath ? path.posix.dirname(filePath) : ""; basePath = filePath ? path.posix.dirname(filePath) : "";
explicitRef = true; explicitRef = true;
} }
return { owner, repo, ref, basePath, filePath, explicitRef }; return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
} }
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) { async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
const apiBase = gitHubApiBase(parsed.hostname);
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return { return {
pinnedRef: parsed.ref, pinnedRef: parsed.ref,
@ -545,14 +549,11 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
const trackingRef = parsed.explicitRef const trackingRef = parsed.explicitRef
? parsed.ref ? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo); : await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef); const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
return { pinnedRef, trackingRef }; return { pinnedRef, trackingRef };
} }
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`;
}
function extractCommandTokens(raw: string) { function extractCommandTokens(raw: string) {
const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
@ -675,9 +676,10 @@ function deriveImportedSkillSource(
const repoPath = asString(sourceEntry?.path); const repoPath = asString(sourceEntry?.path);
const commit = asString(sourceEntry?.commit); const commit = asString(sourceEntry?.commit);
const trackingRef = asString(sourceEntry?.trackingRef); const trackingRef = asString(sourceEntry?.trackingRef);
const sourceHostname = asString(sourceEntry?.hostname) || "github.com";
const url = asString(sourceEntry?.url) const url = asString(sourceEntry?.url)
?? (repo ?? (repo
? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` ? `https://${sourceHostname}/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}`
: null); : null);
const [owner, repoName] = (repo ?? "").split("/"); const [owner, repoName] = (repo ?? "").split("/");
if (repo && owner && repoName) { if (repo && owner && repoName) {
@ -688,6 +690,7 @@ function deriveImportedSkillSource(
metadata: { metadata: {
...(canonicalKey ? { skillKey: canonicalKey } : {}), ...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "github", sourceKind: "github",
...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}),
owner, owner,
repo: repoName, repo: repoName,
ref: commit, ref: commit,
@ -981,12 +984,21 @@ async function readUrlSkillImports(
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { ): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
const url = sourceUrl.trim(); const url = sourceUrl.trim();
const warnings: string[] = []; const warnings: string[] = [];
if (url.includes("github.com/")) { 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) {
const parsed = parseGitHubSourceUrl(url); const parsed = parseGitHubSourceUrl(url);
const apiBase = gitHubApiBase(parsed.hostname);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
let ref = pinnedRef; let ref = pinnedRef;
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
).catch(() => { ).catch(() => {
throw unprocessable(`Failed to read GitHub tree for ${url}`); throw unprocessable(`Failed to read GitHub tree for ${url}`);
}); });
@ -1013,7 +1025,7 @@ async function readUrlSkillImports(
const skills: ImportedSkill[] = []; const skills: ImportedSkill[] = [];
for (const relativeSkillPath of skillPaths) { for (const relativeSkillPath of skillPaths) {
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
const markdown = await fetchText(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoSkillPath)); const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
const parsedMarkdown = parseFrontmatterMarkdown(markdown); const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath); const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
@ -1027,9 +1039,10 @@ async function readUrlSkillImports(
const metadata = { const metadata = {
...(skillKey ? { skillKey } : {}), ...(skillKey ? { skillKey } : {}),
sourceKind: "github", sourceKind: "github",
...(parsed.hostname !== "github.com" ? { hostname: parsed.hostname } : {}),
owner: parsed.owner, owner: parsed.owner,
repo: parsed.repo, repo: parsed.repo,
ref: ref, ref,
trackingRef, trackingRef,
repoSkillDir: normalizeGitHubSkillDirectory( repoSkillDir: normalizeGitHubSkillDirectory(
basePrefix ? `${basePrefix}${skillDir}` : skillDir, basePrefix ? `${basePrefix}${skillDir}` : skillDir,
@ -1641,7 +1654,9 @@ export function companySkillService(db: Db) {
}; };
} }
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef); const hostname = asString(metadata.hostname) || "github.com";
const apiBase = gitHubApiBase(hostname);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
return { return {
supported: true, supported: true,
reason: null, reason: null,
@ -1679,13 +1694,14 @@ export function companySkillService(db: Db) {
const metadata = getSkillMeta(skill); const metadata = getSkillMeta(skill);
const owner = asString(metadata.owner); const owner = asString(metadata.owner);
const repo = asString(metadata.repo); const repo = asString(metadata.repo);
const hostname = asString(metadata.hostname) || "github.com";
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main"; const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug); const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
if (!owner || !repo) { if (!owner || !repo) {
throw unprocessable("Skill source metadata is incomplete."); throw unprocessable("Skill source metadata is incomplete.");
} }
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
content = await fetchText(resolveRawGitHubUrl(owner, repo, ref, repoPath)); content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
} else if (skill.sourceType === "url") { } else if (skill.sourceType === "url") {
if (normalizedPath !== "SKILL.md") { if (normalizedPath !== "SKILL.md") {
throw notFound("This skill source only exposes SKILL.md"); throw notFound("This skill source only exposes SKILL.md");

View file

@ -0,0 +1,25 @@
import { unprocessable } from "../errors.js";
function isGitHubDotCom(hostname: string) {
const h = hostname.toLowerCase();
return h === "github.com" || h === "www.github.com";
}
export function gitHubApiBase(hostname: string) {
return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`;
}
export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) {
const p = filePath.replace(/^\/+/, "");
return isGitHubDotCom(hostname)
? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(url, init);
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
}

View file

@ -118,11 +118,10 @@ export function NewProjectDialog() {
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => { const looksLikeRepoUrl = (value: string) => {
try { try {
const parsed = new URL(value); const parsed = new URL(value);
const host = parsed.hostname.toLowerCase(); if (parsed.protocol !== "https:") return false;
if (host !== "github.com" && host !== "www.github.com") return false;
const segments = parsed.pathname.split("/").filter(Boolean); const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2; return segments.length >= 2;
} catch { } catch {
@ -156,8 +155,8 @@ export function NewProjectDialog() {
setWorkspaceError("Local folder must be a full absolute path."); setWorkspaceError("Local folder must be a full absolute path.");
return; return;
} }
if (repoUrl && !isGitHubRepoUrl(repoUrl)) { if (repoUrl && !looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub repo URL."); setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return; return;
} }

View file

@ -343,11 +343,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => { const looksLikeRepoUrl = (value: string) => {
try { try {
const parsed = new URL(value); const parsed = new URL(value);
const host = parsed.hostname.toLowerCase(); if (parsed.protocol !== "https:") return false;
if (host !== "github.com" && host !== "www.github.com") return false;
const segments = parsed.pathname.split("/").filter(Boolean); const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2; return segments.length >= 2;
} catch { } catch {
@ -432,8 +431,8 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
persistCodebase({ repoUrl: null }); persistCodebase({ repoUrl: null });
return; return;
} }
if (!isGitHubRepoUrl(repoUrl)) { if (!looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub repo URL."); setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return; return;
} }
setWorkspaceError(null); setWorkspaceError(null);