feat(coding-agent): add SSH URL support for git packages

Use hosted-git-info library for robust parsing of SSH URLs (git@host:path
and ssh://) in addition to HTTPS. SSH and HTTPS URLs for the same repo
are now properly deduplicated.
This commit is contained in:
Markus Ekholm 2026-02-05 15:13:05 +01:00
parent 0404a93e33
commit fba9a8aece
No known key found for this signature in database
GPG key ID: D0889DEBE04FA19B
9 changed files with 576 additions and 48 deletions

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { parseGitUrl } from "../src/utils/git.js";
describe("SSH Git URL Parsing", () => {
describe("ssh:// protocol", () => {
it("should parse basic ssh:// URL", () => {
const result = parseGitUrl("ssh://git@github.com/user/repo");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "ssh://git@github.com/user/repo",
});
expect(result?.ref).toBeUndefined();
});
it("should parse ssh:// URL with port", () => {
const result = parseGitUrl("ssh://git@github.com:22/user/repo");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "ssh://git@github.com:22/user/repo",
});
});
it("should parse ssh:// URL with .git suffix", () => {
const result = parseGitUrl("ssh://git@github.com/user/repo.git");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "ssh://git@github.com/user/repo.git",
});
});
it("should parse ssh:// URL with ref", () => {
const result = parseGitUrl("ssh://git@github.com/user/repo@v1.0.0");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
ref: "v1.0.0",
repo: "ssh://git@github.com/user/repo",
});
});
});
describe("git@host:path pattern", () => {
it("should parse basic git@host:path", () => {
const result = parseGitUrl("git@github.com:user/repo");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "git@github.com:user/repo",
});
expect(result?.ref).toBeUndefined();
});
it("should parse git@host:path with .git", () => {
const result = parseGitUrl("git@github.com:user/repo.git");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "git@github.com:user/repo.git",
});
});
it("should parse git@host:path with ref", () => {
const result = parseGitUrl("git@github.com:user/repo@v1.0.0");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
ref: "v1.0.0",
repo: "git@github.com:user/repo",
});
});
it("should parse git@host:path with ref and .git", () => {
const result = parseGitUrl("git@github.com:user/repo.git@main");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
ref: "main",
repo: "git@github.com:user/repo.git",
});
});
});
describe("HTTPS URLs", () => {
it("should parse HTTPS URL", () => {
const result = parseGitUrl("https://github.com/user/repo");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
});
});
it("should parse shorthand URL", () => {
const result = parseGitUrl("github.com/user/repo");
expect(result).toMatchObject({
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
});
});
});
describe("edge cases", () => {
it("should return null for invalid URLs", () => {
expect(parseGitUrl("git@github.com")).toBeNull();
expect(parseGitUrl("not-a-url")).toBeNull();
});
it("should handle different hosts", () => {
expect(parseGitUrl("git@gitlab.com:user/repo")?.host).toBe("gitlab.com");
expect(parseGitUrl("git@bitbucket.org:user/repo")?.host).toBe("bitbucket.org");
});
});
});

View file

@ -0,0 +1,150 @@
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultPackageManager } from "../src/core/package-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
describe("Package Manager SSH URL Support", () => {
let tempDir: string;
let agentDir: string;
let settingsManager: SettingsManager;
let packageManager: DefaultPackageManager;
beforeEach(() => {
tempDir = join(tmpdir(), `pm-ssh-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
agentDir = join(tempDir, "agent");
mkdirSync(agentDir, { recursive: true });
settingsManager = SettingsManager.inMemory();
packageManager = new DefaultPackageManager({
cwd: tempDir,
agentDir,
settingsManager,
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe("parseSource with SSH URLs", () => {
it("should parse git@host:path format", () => {
const parsed = (packageManager as any).parseSource("git@github.com:user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.repo).toBe("git@github.com:user/repo");
expect(parsed.pinned).toBe(false);
});
it("should parse git@host:path with ref", () => {
const parsed = (packageManager as any).parseSource("git@github.com:user/repo@v1.0.0");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.ref).toBe("v1.0.0");
expect(parsed.repo).toBe("git@github.com:user/repo");
expect(parsed.pinned).toBe(true);
});
it("should parse ssh:// protocol format", () => {
const parsed = (packageManager as any).parseSource("ssh://git@github.com/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.repo).toBe("ssh://git@github.com/user/repo");
});
it("should parse ssh:// with port", () => {
const parsed = (packageManager as any).parseSource("ssh://git@github.com:22/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.repo).toBe("ssh://git@github.com:22/user/repo");
});
it("should parse git: prefix with SSH URL", () => {
const parsed = (packageManager as any).parseSource("git:git@github.com:user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.repo).toBe("git@github.com:user/repo");
});
it("should parse .git suffix", () => {
const parsed = (packageManager as any).parseSource("git@github.com:user/repo.git");
expect(parsed.type).toBe("git");
expect(parsed.path).toBe("user/repo");
expect(parsed.repo).toBe("git@github.com:user/repo.git");
});
});
describe("getPackageIdentity with SSH URLs", () => {
it("should normalize SSH URL to same identity as HTTPS", () => {
const sshIdentity = (packageManager as any).getPackageIdentity("git@github.com:user/repo");
const httpsIdentity = (packageManager as any).getPackageIdentity("https://github.com/user/repo");
expect(sshIdentity).toBe(httpsIdentity);
expect(sshIdentity).toBe("git:github.com/user/repo");
});
it("should normalize ssh:// to same identity as git@", () => {
const sshProtocol = (packageManager as any).getPackageIdentity("ssh://git@github.com/user/repo");
const gitAt = (packageManager as any).getPackageIdentity("git@github.com:user/repo");
expect(sshProtocol).toBe(gitAt);
});
it("should ignore ref in identity", () => {
const withRef = (packageManager as any).getPackageIdentity("git@github.com:user/repo@v1.0.0");
const withoutRef = (packageManager as any).getPackageIdentity("git@github.com:user/repo");
expect(withRef).toBe(withoutRef);
});
});
describe("SSH URL install behavior", () => {
it("should emit start event for SSH URL install", async () => {
const events: any[] = [];
packageManager.setProgressCallback((event) => events.push(event));
try {
await packageManager.install("git@github.com:nonexistent/repo");
} catch {
// Expected to fail - repo doesn't exist or no SSH access
}
expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true);
});
it("should emit start event for ssh:// URL install", async () => {
const events: any[] = [];
packageManager.setProgressCallback((event) => events.push(event));
try {
await packageManager.install("ssh://git@github.com/nonexistent/repo");
} catch {
// Expected to fail
}
expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true);
});
});
describe("different git hosts", () => {
it("should parse GitLab SSH URL", () => {
const parsed = (packageManager as any).parseSource("git@gitlab.com:group/project");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("gitlab.com");
expect(parsed.path).toBe("group/project");
expect(parsed.repo).toBe("git@gitlab.com:group/project");
});
it("should parse Bitbucket SSH URL", () => {
const parsed = (packageManager as any).parseSource("git@bitbucket.org:team/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("bitbucket.org");
expect(parsed.path).toBe("team/repo");
expect(parsed.repo).toBe("git@bitbucket.org:team/repo");
});
});
});

View file

@ -248,6 +248,113 @@ Content`,
});
});
describe("HTTPS git URL parsing (old behavior)", () => {
it("should parse HTTPS GitHub URLs correctly", async () => {
const parsed = (packageManager as any).parseSource("https://github.com/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.pinned).toBe(false);
});
it("should parse HTTPS URLs with git: prefix", async () => {
const parsed = (packageManager as any).parseSource("git:https://github.com/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
});
it("should parse HTTPS URLs with ref", async () => {
const parsed = (packageManager as any).parseSource("https://github.com/user/repo@v1.2.3");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
expect(parsed.ref).toBe("v1.2.3");
expect(parsed.pinned).toBe(true);
});
it("should parse HTTPS URLs without protocol", async () => {
const parsed = (packageManager as any).parseSource("github.com/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
});
it("should parse HTTPS URLs with .git suffix", async () => {
const parsed = (packageManager as any).parseSource("https://github.com/user/repo.git");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("github.com");
expect(parsed.path).toBe("user/repo");
});
it("should parse GitLab HTTPS URLs", async () => {
const parsed = (packageManager as any).parseSource("https://gitlab.com/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("gitlab.com");
expect(parsed.path).toBe("user/repo");
});
it("should parse Bitbucket HTTPS URLs", async () => {
const parsed = (packageManager as any).parseSource("https://bitbucket.org/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("bitbucket.org");
expect(parsed.path).toBe("user/repo");
});
it("should parse Codeberg HTTPS URLs", async () => {
const parsed = (packageManager as any).parseSource("https://codeberg.org/user/repo");
expect(parsed.type).toBe("git");
expect(parsed.host).toBe("codeberg.org");
expect(parsed.path).toBe("user/repo");
});
it("should generate correct package identity for HTTPS URLs", async () => {
const identity1 = (packageManager as any).getPackageIdentity("https://github.com/user/repo");
const identity2 = (packageManager as any).getPackageIdentity("https://github.com/user/repo@v1.0.0");
const identity3 = (packageManager as any).getPackageIdentity("github.com/user/repo");
const identity4 = (packageManager as any).getPackageIdentity("https://github.com/user/repo.git");
// All should have the same identity (normalized)
expect(identity1).toBe("git:github.com/user/repo");
expect(identity2).toBe("git:github.com/user/repo");
expect(identity3).toBe("git:github.com/user/repo");
expect(identity4).toBe("git:github.com/user/repo");
});
it("should deduplicate HTTPS URLs with different formats", async () => {
const pkgDir = join(tempDir, "https-dedup-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });
writeFileSync(join(pkgDir, "extensions", "test.ts"), "export default function() {}");
// Mock the package as if it were cloned from different URL formats
// In reality, these would all point to the same local dir after install
settingsManager.setPackages([
"https://github.com/user/repo",
"github.com/user/repo",
"https://github.com/user/repo.git",
]);
// Since these URLs don't actually exist and we can't clone them,
// we verify they produce the same identity
const id1 = (packageManager as any).getPackageIdentity("https://github.com/user/repo");
const id2 = (packageManager as any).getPackageIdentity("github.com/user/repo");
const id3 = (packageManager as any).getPackageIdentity("https://github.com/user/repo.git");
expect(id1).toBe(id2);
expect(id2).toBe(id3);
});
it("should handle HTTPS URLs with refs in resolve", async () => {
// This tests that the ref is properly extracted and stored
const parsed = (packageManager as any).parseSource("https://github.com/user/repo@main");
expect(parsed.ref).toBe("main");
expect(parsed.pinned).toBe(true);
const parsed2 = (packageManager as any).parseSource("https://github.com/user/repo@feature/branch");
expect(parsed2.ref).toBe("feature/branch");
});
});
describe("pattern filtering in top-level arrays", () => {
it("should exclude extensions with ! pattern", async () => {
const extDir = join(agentDir, "extensions");
@ -709,6 +816,78 @@ Content`,
expect(result.extensions.some((r) => r.path.includes("pkg1"))).toBe(true);
expect(result.extensions.some((r) => r.path.includes("pkg2"))).toBe(true);
});
it("should dedupe SSH and HTTPS URLs for same repo", async () => {
// Same repository, different URL formats
const httpsUrl = "https://github.com/user/repo";
const sshUrl = "git@github.com:user/repo";
const httpsIdentity = (packageManager as any).getPackageIdentity(httpsUrl);
const sshIdentity = (packageManager as any).getPackageIdentity(sshUrl);
// Both should resolve to the same identity
expect(httpsIdentity).toBe("git:github.com/user/repo");
expect(sshIdentity).toBe("git:github.com/user/repo");
expect(httpsIdentity).toBe(sshIdentity);
});
it("should dedupe SSH and HTTPS with refs", async () => {
const httpsUrl = "https://github.com/user/repo@v1.0.0";
const sshUrl = "git@github.com:user/repo@v1.0.0";
const httpsIdentity = (packageManager as any).getPackageIdentity(httpsUrl);
const sshIdentity = (packageManager as any).getPackageIdentity(sshUrl);
// Identity should ignore ref (version)
expect(httpsIdentity).toBe("git:github.com/user/repo");
expect(sshIdentity).toBe("git:github.com/user/repo");
expect(httpsIdentity).toBe(sshIdentity);
});
it("should dedupe SSH URL with ssh:// protocol and git@ format", async () => {
const sshProtocol = "ssh://git@github.com/user/repo";
const gitAt = "git@github.com:user/repo";
const sshProtocolIdentity = (packageManager as any).getPackageIdentity(sshProtocol);
const gitAtIdentity = (packageManager as any).getPackageIdentity(gitAt);
// Both SSH formats should resolve to same identity
expect(sshProtocolIdentity).toBe("git:github.com/user/repo");
expect(gitAtIdentity).toBe("git:github.com/user/repo");
expect(sshProtocolIdentity).toBe(gitAtIdentity);
});
it("should dedupe all URL formats for same repo (HTTPS, SSH, git@)", async () => {
const urls = [
"https://github.com/user/repo",
"github.com/user/repo",
"https://github.com/user/repo.git",
"git@github.com:user/repo",
"git@github.com:user/repo.git",
"ssh://git@github.com/user/repo",
"git:https://github.com/user/repo",
];
const identities = urls.map((url) => (packageManager as any).getPackageIdentity(url));
// All should produce the same identity
const uniqueIdentities = [...new Set(identities)];
expect(uniqueIdentities.length).toBe(1);
expect(uniqueIdentities[0]).toBe("git:github.com/user/repo");
});
it("should keep different repos separate (HTTPS vs SSH)", async () => {
const repo1Https = "https://github.com/user/repo1";
const repo2Ssh = "git@github.com:user/repo2";
const id1 = (packageManager as any).getPackageIdentity(repo1Https);
const id2 = (packageManager as any).getPackageIdentity(repo2Ssh);
// Different repos should have different identities
expect(id1).toBe("git:github.com/user/repo1");
expect(id2).toBe("git:github.com/user/repo2");
expect(id1).not.toBe(id2);
});
});
describe("multi-file extension discovery (issue #1102)", () => {