From fba9a8aece487453c436b586fb6fecfa0c495edd Mon Sep 17 00:00:00 2001 From: Markus Ekholm Date: Thu, 5 Feb 2026 15:13:05 +0100 Subject: [PATCH] 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. --- package-lock.json | 30 ++- packages/coding-agent/docs/packages.md | 19 ++ packages/coding-agent/package.json | 2 + .../coding-agent/src/core/package-manager.ts | 39 ++-- packages/coding-agent/src/main.ts | 15 +- packages/coding-agent/src/utils/git.ts | 73 ++++++- .../coding-agent/test/git-ssh-url.test.ts | 117 ++++++++++++ .../test/package-manager-ssh.test.ts | 150 +++++++++++++++ .../coding-agent/test/package-manager.test.ts | 179 ++++++++++++++++++ 9 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 packages/coding-agent/test/git-ssh-url.test.ts create mode 100644 packages/coding-agent/test/package-manager-ssh.test.ts diff --git a/package-lock.json b/package-lock.json index d5449be8..02d554e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 28062114..f77fd199 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -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//` (global) or `.pi/git//` (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 ``` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index a00014b3..8653ab68 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 3135c16b..bfeecd0c 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 }); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 747e83fb..c4a96d71 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 }; } diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 30363c32..29ee97c3 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -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; } diff --git a/packages/coding-agent/test/git-ssh-url.test.ts b/packages/coding-agent/test/git-ssh-url.test.ts new file mode 100644 index 00000000..543fed62 --- /dev/null +++ b/packages/coding-agent/test/git-ssh-url.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/coding-agent/test/package-manager-ssh.test.ts b/packages/coding-agent/test/package-manager-ssh.test.ts new file mode 100644 index 00000000..2f09567c --- /dev/null +++ b/packages/coding-agent/test/package-manager-ssh.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 32d064a8..061f04bc 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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)", () => {