Merge pull request #1287 from markusn/feature/ssh-git-packages

feat(coding-agent): add SSH URL support for git packages
This commit is contained in:
Mario Zechner 2026-02-05 17:09:28 +01:00 committed by GitHub
commit 6c6d937b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 576 additions and 48 deletions

30
package-lock.json generated
View file

@ -3875,6 +3875,13 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/hosted-git-info": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hosted-git-info/-/hosted-git-info-3.0.5.tgz",
"integrity": "sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
@ -5636,6 +5643,18 @@
"node": "*"
}
},
"node_modules/hosted-git-info": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz",
"integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==",
"license": "ISC",
"dependencies": {
"lru-cache": "^11.1.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/html-parse-string": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/html-parse-string/-/html-parse-string-0.0.9.tgz",
@ -6244,7 +6263,6 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
@ -7571,7 +7589,6 @@
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
@ -7600,8 +7617,7 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@ -7719,7 +7735,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7816,7 +7831,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@ -7905,7 +7919,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -8020,7 +8033,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -8394,6 +8406,7 @@
"diff": "^8.0.2",
"file-type": "^21.1.1",
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.1.1",
@ -8405,6 +8418,7 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/hosted-git-info": "^3.0.5",
"@types/ms": "^2.1.0",
"@types/node": "^24.3.0",
"@types/proper-lockfile": "^4.1.4",

View file

@ -60,13 +60,32 @@ npm:pkg
```
git:github.com/user/repo@v1
https://github.com/user/repo@v1
git@github.com:user/repo@v1
ssh://git@github.com/user/repo@v1
```
- HTTPS and SSH URLs are both supported.
- SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).
- Raw `https://` URLs work without the `git:` prefix.
- Refs pin the package and skip `pi update`.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Runs `npm install` after clone or pull if `package.json` exists.
**SSH examples:**
```bash
# Standard git@host:path format
pi install git@github.com:user/repo
# With git: prefix
pi install git:git@github.com:user/repo
# ssh:// protocol format
pi install ssh://git@github.com/user/repo
# With version ref
pi install git@github.com:user/repo@v1.0.0
```
### Local Paths
```

View file

@ -49,6 +49,7 @@
"diff": "^8.0.2",
"file-type": "^21.1.1",
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.1.1",
@ -66,6 +67,7 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/hosted-git-info": "^3.0.5",
"@types/ms": "^2.1.0",
"@types/node": "^24.3.0",
"@types/proper-lockfile": "^4.1.4",

View file

@ -6,7 +6,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { looksLikeGitUrl } from "../utils/git.js";
import { type GitSource, parseGitUrl } from "../utils/git.js";
import type { PackageSource, SettingsManager } from "./settings-manager.js";
export interface PathMetadata {
@ -68,15 +68,6 @@ type NpmSource = {
pinned: boolean;
};
type GitSource = {
type: "git";
repo: string;
host: string;
path: string;
ref?: string;
pinned: boolean;
};
type LocalSource = {
type: "local";
path: string;
@ -894,21 +885,10 @@ export class DefaultPackageManager implements PackageManager {
};
}
if (source.startsWith("git:") || looksLikeGitUrl(source)) {
const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source;
const [repo, ref] = repoSpec.split("@");
const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, "");
const parts = normalized.split("/");
const host = parts.shift() ?? "";
const repoPath = parts.join("/");
return {
type: "git",
repo: normalized,
host,
path: repoPath,
ref,
pinned: Boolean(ref),
};
// Try parsing as git URL
const gitParsed = parseGitUrl(source);
if (gitParsed) {
return gitParsed;
}
return { type: "local", path: source };
@ -961,6 +941,8 @@ export class DefaultPackageManager implements PackageManager {
/**
* Get a unique identity for a package, ignoring version/ref.
* Used to detect when the same package is in both global and project settings.
* For git packages, uses normalized host/path to ensure SSH and HTTPS URLs
* for the same repository are treated as identical.
*/
private getPackageIdentity(source: string, scope?: SourceScope): string {
const parsed = this.parseSource(source);
@ -968,7 +950,8 @@ export class DefaultPackageManager implements PackageManager {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.repo}`;
// Use host/path for identity to normalize SSH and HTTPS
return `git:${parsed.host}/${parsed.path}`;
}
if (scope) {
const baseDir = this.getBaseDirForScope(scope);
@ -1046,8 +1029,8 @@ export class DefaultPackageManager implements PackageManager {
this.ensureGitIgnore(gitRoot);
}
mkdirSync(dirname(targetDir), { recursive: true });
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
await this.runCommand("git", ["clone", source.repo, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}

View file

@ -33,6 +33,7 @@ import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { parseGitUrl } from "./utils/git.js";
/**
* Read all content from piped stdin.
@ -122,15 +123,13 @@ function normalizeExtensionSource(source: string): { type: "npm" | "git" | "loca
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/);
return { type: "npm", key: match?.[1] ?? spec };
}
if (source.startsWith("git:")) {
const repo = source.slice("git:".length).trim().split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "").replace(/\.git$/, "") };
}
// Raw git URLs
if (source.startsWith("https://") || source.startsWith("http://")) {
const repo = source.split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "").replace(/\.git$/, "") };
// Try parsing as git URL
const parsed = parseGitUrl(source);
if (parsed) {
return { type: "git", key: `${parsed.host}/${parsed.path}` };
}
return { type: "local", key: source };
}

View file

@ -1,6 +1,71 @@
const GIT_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
import hostedGitInfo from "hosted-git-info";
export function looksLikeGitUrl(source: string): boolean {
const normalized = source.replace(/^https?:\/\//, "");
return GIT_HOSTS.some((host) => normalized.startsWith(`${host}/`));
/**
* Parsed git URL information.
*/
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Clone URL (always valid for git clone, without ref suffix) */
repo: string;
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
/** Git ref (branch, tag, commit) if specified */
ref?: string;
/** True if ref was specified (package won't be auto-updated) */
pinned: boolean;
};
/**
* Parse any git URL (SSH or HTTPS) into a GitSource.
*/
export function parseGitUrl(source: string): GitSource | null {
let url = source.startsWith("git:") ? source.slice(4).trim() : source;
// Try hosted-git-info, converting @ref to #ref if needed
let info = hostedGitInfo.fromUrl(url);
const lastAt = url.lastIndexOf("@");
if ((info?.project?.includes("@") || !info) && lastAt > 0) {
info = hostedGitInfo.fromUrl(`${url.slice(0, lastAt)}#${url.slice(lastAt + 1)}`) ?? info;
url = url.slice(0, lastAt); // strip ref from url for repo field
}
// Try with https:// prefix for shorthand URLs
if (!info) {
info = hostedGitInfo.fromUrl(`https://${url}`);
if (info) url = `https://${url}`; // make repo a valid clone URL
}
if (info) {
return {
type: "git",
repo: url,
host: info.domain || "",
path: `${info.user}/${info.project}`,
ref: info.committish || undefined,
pinned: Boolean(info.committish),
};
}
// Fallback for codeberg (not in hosted-git-info)
const normalized = url.replace(/^https?:\/\//, "").replace(/@[^/]*$/, "");
const codebergHost = "codeberg.org";
if (normalized.startsWith(`${codebergHost}/`)) {
const ref = url.match(/@([^/]+)$/)?.[1];
const repoUrl = ref ? url.slice(0, url.lastIndexOf("@")) : url;
// Ensure repo is a valid clone URL
const cloneableRepo = repoUrl.startsWith("http") ? repoUrl : `https://${repoUrl}`;
return {
type: "git",
repo: cloneableRepo,
host: codebergHost,
path: normalized.slice(codebergHost.length + 1).replace(/\.git$/, ""),
ref,
pinned: Boolean(ref),
};
}
return null;
}

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)", () => {