diff --git a/openspec/changes/rhess-enterprise-skills-server/tasks.md b/openspec/changes/rhess-enterprise-skills-server/tasks.md index 843083f..bcd6078 100644 --- a/openspec/changes/rhess-enterprise-skills-server/tasks.md +++ b/openspec/changes/rhess-enterprise-skills-server/tasks.md @@ -27,10 +27,10 @@ ## 4. Skill Ingestion Engine -- [ ] 4.1 Implement `clone(url: string, dest: string): Promise` using `simple-git` with `--depth 1` -- [ ] 4.2 Implement `discoverSkills(repoPath: string): SkillCandidate[]` walking all Agent Skills spec discovery paths -- [ ] 4.3 Implement YAML frontmatter parser: validates `name` and `description` are present; returns structured metadata -- [ ] 4.4 Implement archive bundler: tar.gz multi-file skills, compute SHA256 digest; single-file skills served as-is with digest +- [x] 4.1 Implement `clone(url: string, dest: string): Promise` using `simple-git` with `--depth 1` +- [x] 4.2 Implement `discoverSkills(repoPath: string): SkillCandidate[]` walking all Agent Skills spec discovery paths +- [x] 4.3 Implement YAML frontmatter parser: validates `name` and `description` are present; returns structured metadata +- [x] 4.4 Implement archive bundler: tar.gz multi-file skills, compute SHA256 digest; single-file skills served as-is with digest - [ ] 4.5 Implement `ingestSource(sourceId, url): SyncReport` — clone → discover → parse → classify → stage - [ ] 4.6 Implement atomic swap: single SQLite transaction deletes old source skills and inserts new ones - [ ] 4.7 Implement bundled example skills loader: seeds catalog on first boot if no sources registered diff --git a/src/server/ingestion/bundle.ts b/src/server/ingestion/bundle.ts new file mode 100644 index 0000000..1c08f41 --- /dev/null +++ b/src/server/ingestion/bundle.ts @@ -0,0 +1,67 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { create } from "tar"; +import type { SkillCandidate } from "./discover.js"; + +export interface BundleResult { + artifactType: "skill-md" | "archive"; + /** SHA-256 hex digest of the artifact (content for skill-md, tar.gz bytes for archive) */ + digest: string; + /** For skill-md: raw SKILL.md content. For archive: base64-encoded tar.gz */ + artifact: string; +} + +export async function bundleSkill(candidate: SkillCandidate): Promise { + if (candidate.supportingFiles.length === 0) { + return bundleSkillMd(candidate); + } + return bundleArchive(candidate); +} + +async function bundleSkillMd(candidate: SkillCandidate): Promise { + const rawContent = fs.readFileSync(candidate.skillMdPath, "utf-8"); + const digest = crypto.createHash("sha256").update(rawContent, "utf-8").digest("hex"); + return { + artifactType: "skill-md", + digest, + artifact: rawContent, + }; +} + +async function bundleArchive(candidate: SkillCandidate): Promise { + const chunks: Buffer[] = []; + + await new Promise((resolve, reject) => { + const pack = create( + { + gzip: true, + cwd: candidate.skillDir, + portable: true, + }, + getAllRelativeFiles(candidate), + ); + + pack.on("data", (chunk: Buffer) => chunks.push(chunk)); + pack.on("end", resolve); + pack.on("error", reject); + }); + + const buffer = Buffer.concat(chunks); + const digest = crypto.createHash("sha256").update(buffer).digest("hex"); + + return { + artifactType: "archive", + digest, + artifact: buffer.toString("base64"), + }; +} + +function getAllRelativeFiles(candidate: SkillCandidate): string[] { + // SKILL.md first, then supporting files sorted lexicographically. + // Sorting is done here (not in discover.ts) so digest determinism is + // enforced at the archiving boundary regardless of how the candidate was built. + const skillMdRel = path.relative(candidate.skillDir, candidate.skillMdPath); + const sorted = [...candidate.supportingFiles].sort(); + return [skillMdRel, ...sorted]; +} diff --git a/src/server/ingestion/clone.ts b/src/server/ingestion/clone.ts new file mode 100644 index 0000000..25c6ba8 --- /dev/null +++ b/src/server/ingestion/clone.ts @@ -0,0 +1,20 @@ +import { simpleGit } from "simple-git"; + +// Accepted forms: https://, http://, ssh://, or SCP-style git@host:path +const VALID_GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)/; + +export async function clone(url: string, dest: string): Promise { + if (!VALID_GIT_URL.test(url)) { + throw new Error( + `CLONE_FAILED: invalid URL — only HTTPS and SSH Git URLs are accepted (got: ${url})` + ); + } + + const git = simpleGit(); + try { + await git.clone(url, dest, ["--depth", "1"]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`CLONE_FAILED: ${message}`); + } +} diff --git a/src/server/ingestion/discover.ts b/src/server/ingestion/discover.ts new file mode 100644 index 0000000..5403e93 --- /dev/null +++ b/src/server/ingestion/discover.ts @@ -0,0 +1,141 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface SkillCandidate { + slug: string; + skillMdPath: string; + skillDir: string; + discoveryPath: string; + supportingFiles: string[]; +} + +const DISCOVERY_DIRS = [ + "skills", + ".claude/skills", + ".cursor/skills", + ".github/copilot/skills", + ".windsurf/skills", + ".gemini/skills", +]; + +function isSkillMd(name: string): boolean { + return name.toLowerCase() === "skill.md"; +} + +/** + * Recursively walk a directory, collecting all SKILL.md files. + * Returns absolute paths to each SKILL.md found. + */ +function walkForSkillMd(dir: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkForSkillMd(fullPath)); + } else if (entry.isFile() && isSkillMd(entry.name)) { + results.push(fullPath); + } + } + return results; +} + +export function discoverSkills(repoPath: string): SkillCandidate[] { + const candidates: SkillCandidate[] = []; + + for (const discoveryRelPath of DISCOVERY_DIRS) { + const discoveryAbsPath = path.join(repoPath, discoveryRelPath); + + if (!fs.existsSync(discoveryAbsPath)) { + continue; + } + + const skillMdPaths = walkForSkillMd(discoveryAbsPath); + + for (const skillMdPath of skillMdPaths) { + const skillDir = path.dirname(skillMdPath); + + // Determine slug: if SKILL.md is directly in the discovery dir, use the + // discovery dir's basename; otherwise use the immediate parent dir name. + const slug = + skillDir === discoveryAbsPath + ? path.basename(discoveryAbsPath) + : path.basename(skillDir); + + // Collect supporting files (non-SKILL.md files in skillDir). + // Subdirectories that contain their own SKILL.md are separate skill roots + // and must be excluded entirely — their contents are not supporting files + // of this skill. + const supportingFiles: string[] = []; + try { + const entries = fs.readdirSync(skillDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && !isSkillMd(entry.name)) { + supportingFiles.push(entry.name); + } else if (entry.isDirectory()) { + const subDir = path.join(skillDir, entry.name); + if (directoryContainsSkillMd(subDir)) { + // Separate skill root — skip entirely + continue; + } + const sub = walkAllFiles(subDir); + for (const f of sub) { + // Defensively exclude any SKILL.md encountered deeper in the tree + if (!isSkillMd(path.basename(f))) { + supportingFiles.push(path.relative(skillDir, f)); + } + } + } + } + } catch { + // ignore read errors for supporting files + } + + candidates.push({ + slug, + skillMdPath, + skillDir, + discoveryPath: discoveryRelPath, + supportingFiles, + }); + } + } + + return candidates; +} + +/** Returns true if the directory directly contains a SKILL.md (case-insensitive). */ +function directoryContainsSkillMd(dir: string): boolean { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .some((e) => e.isFile() && isSkillMd(e.name)); + } catch { + return false; + } +} + +function walkAllFiles(dir: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkAllFiles(fullPath)); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + return results; +} diff --git a/src/server/ingestion/frontmatter.ts b/src/server/ingestion/frontmatter.ts new file mode 100644 index 0000000..9c92086 --- /dev/null +++ b/src/server/ingestion/frontmatter.ts @@ -0,0 +1,61 @@ +import yaml from "js-yaml"; + +export interface ParsedFrontmatter { + name: string; + description: string; + allowedTools: string[]; + rawContent: string; +} + +export type FrontmatterResult = + | { ok: true; data: ParsedFrontmatter } + | { ok: false; reason: string }; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +export function parseFrontmatter(content: string): FrontmatterResult { + const match = FRONTMATTER_RE.exec(content); + if (!match) { + return { ok: false, reason: "No YAML frontmatter delimiters found" }; + } + + const yamlBlock = match[1] ?? ""; + + let parsed: unknown; + try { + parsed = yaml.load(yamlBlock); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, reason: `Malformed YAML frontmatter: ${message}` }; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, reason: "Frontmatter must be a YAML mapping" }; + } + + const fm = parsed as Record; + + if (typeof fm["name"] !== "string" || fm["name"].trim() === "") { + return { ok: false, reason: "Missing or empty 'name' field in frontmatter" }; + } + + if (typeof fm["description"] !== "string" || fm["description"].trim() === "") { + return { ok: false, reason: "Missing or empty 'description' field in frontmatter" }; + } + + const rawAllowedTools = fm["allowed-tools"]; + let allowedTools: string[] = []; + if (Array.isArray(rawAllowedTools)) { + allowedTools = rawAllowedTools.filter((t): t is string => typeof t === "string"); + } + + return { + ok: true, + data: { + name: fm["name"], + description: fm["description"], + allowedTools, + rawContent: content, + }, + }; +} diff --git a/src/server/ingestion/index.ts b/src/server/ingestion/index.ts new file mode 100644 index 0000000..d912770 --- /dev/null +++ b/src/server/ingestion/index.ts @@ -0,0 +1,7 @@ +export { clone } from "./clone.js"; +export { discoverSkills } from "./discover.js"; +export type { SkillCandidate } from "./discover.js"; +export { parseFrontmatter } from "./frontmatter.js"; +export type { ParsedFrontmatter, FrontmatterResult } from "./frontmatter.js"; +export { bundleSkill } from "./bundle.js"; +export type { BundleResult } from "./bundle.js"; diff --git a/test/server/ingestion/primitives.test.ts b/test/server/ingestion/primitives.test.ts new file mode 100644 index 0000000..5c3a70d --- /dev/null +++ b/test/server/ingestion/primitives.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import crypto from "node:crypto"; +import { discoverSkills } from "../../../src/server/ingestion/discover.js"; +import { parseFrontmatter } from "../../../src/server/ingestion/frontmatter.js"; +import { bundleSkill } from "../../../src/server/ingestion/bundle.js"; +import { clone } from "../../../src/server/ingestion/clone.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "rhess-test-")); +} + +function writeSkillMd(dir: string, content: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "SKILL.md"), content); +} + +const VALID_FRONTMATTER = `--- +name: My Skill +description: Does something useful +--- +Body content here. +`; + +// --------------------------------------------------------------------------- +// discoverSkills +// --------------------------------------------------------------------------- + +describe("discoverSkills", () => { + let repoDir: string; + + beforeEach(() => { + repoDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it("finds SKILL.md under each of the 6 spec discovery paths", () => { + const paths = [ + "skills/my-skill", + ".claude/skills/my-skill", + ".cursor/skills/my-skill", + ".github/copilot/skills/my-skill", + ".windsurf/skills/my-skill", + ".gemini/skills/my-skill", + ]; + for (const p of paths) { + writeSkillMd(path.join(repoDir, p), VALID_FRONTMATTER); + } + + const candidates = discoverSkills(repoDir); + expect(candidates).toHaveLength(6); + const discoveryPaths = candidates.map((c) => c.discoveryPath); + expect(discoveryPaths).toContain("skills"); + expect(discoveryPaths).toContain(".claude/skills"); + expect(discoveryPaths).toContain(".cursor/skills"); + expect(discoveryPaths).toContain(".github/copilot/skills"); + expect(discoveryPaths).toContain(".windsurf/skills"); + expect(discoveryPaths).toContain(".gemini/skills"); + }); + + it("does NOT index a SKILL.md at the repo root", () => { + fs.writeFileSync(path.join(repoDir, "SKILL.md"), VALID_FRONTMATTER); + const candidates = discoverSkills(repoDir); + expect(candidates).toHaveLength(0); + }); + + it("does NOT index a SKILL.md outside discovery paths", () => { + writeSkillMd(path.join(repoDir, "docs/my-skill"), VALID_FRONTMATTER); + const candidates = discoverSkills(repoDir); + expect(candidates).toHaveLength(0); + }); + + it("matches SKILL.md case-insensitively", () => { + const skillDir = path.join(repoDir, "skills/my-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "skill.md"), VALID_FRONTMATTER); + const candidates = discoverSkills(repoDir); + expect(candidates).toHaveLength(1); + }); + + it("sets slug to the skill directory name", () => { + writeSkillMd(path.join(repoDir, "skills/react-best-practices"), VALID_FRONTMATTER); + const candidates = discoverSkills(repoDir); + expect(candidates[0]?.slug).toBe("react-best-practices"); + }); + + it("collects supporting files relative to the skill dir", () => { + const skillDir = path.join(repoDir, "skills/my-skill"); + writeSkillMd(skillDir, VALID_FRONTMATTER); + fs.writeFileSync(path.join(skillDir, "helper.sh"), "#!/bin/sh"); + fs.mkdirSync(path.join(skillDir, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(skillDir, "scripts", "util.py"), ""); + + const candidates = discoverSkills(repoDir); + expect(candidates).toHaveLength(1); + expect(candidates[0]?.supportingFiles).toContain("helper.sh"); + expect(candidates[0]?.supportingFiles).toContain("scripts/util.py"); + }); + + // --- Qodo bug 1 fix --- + it("does not include a nested SKILL.md as a supporting file", () => { + const parentDir = path.join(repoDir, "skills/parent-skill"); + writeSkillMd(parentDir, VALID_FRONTMATTER); + + // Nested skill — should be its own candidate, not a supporting file + const nestedDir = path.join(parentDir, "nested-skill"); + writeSkillMd(nestedDir, VALID_FRONTMATTER); + + const candidates = discoverSkills(repoDir); + // Both are discovered as separate candidates + expect(candidates).toHaveLength(2); + const parentCandidate = candidates.find((c) => c.slug === "parent-skill"); + expect(parentCandidate).toBeDefined(); + // The nested skill dir must not appear in parent's supportingFiles + const supportingPaths = parentCandidate?.supportingFiles ?? []; + for (const f of supportingPaths) { + expect(f.toLowerCase()).not.toContain("skill.md"); + } + expect(supportingPaths.some((f) => f.startsWith("nested-skill"))).toBe(false); + }); + + it("does not include any SKILL.md files in supportingFiles even in deeper subdirs", () => { + const skillDir = path.join(repoDir, "skills/my-skill"); + writeSkillMd(skillDir, VALID_FRONTMATTER); + // Subdirectory without its own SKILL.md — files here are supporting files + const subDir = path.join(skillDir, "assets"); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, "readme.txt"), "notes"); + // Paranoia: manually place a SKILL.md deep — should be filtered out + fs.writeFileSync(path.join(subDir, "SKILL.md"), VALID_FRONTMATTER); + + const candidates = discoverSkills(repoDir); + const candidate = candidates.find((c) => c.slug === "my-skill"); + const supportingPaths = candidate?.supportingFiles ?? []; + for (const f of supportingPaths) { + expect(f.toLowerCase()).not.toContain("skill.md"); + } + }); +}); + +// --------------------------------------------------------------------------- +// parseFrontmatter +// --------------------------------------------------------------------------- + +describe("parseFrontmatter", () => { + it("returns ok:true for valid frontmatter", () => { + const result = parseFrontmatter(VALID_FRONTMATTER); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.name).toBe("My Skill"); + expect(result.data.description).toBe("Does something useful"); + expect(result.data.rawContent).toBe(VALID_FRONTMATTER); + } + }); + + it("returns ok:false when no frontmatter delimiters", () => { + const result = parseFrontmatter("Just some plain text\nwith no frontmatter"); + expect(result.ok).toBe(false); + }); + + it("returns ok:false for malformed YAML", () => { + const result = parseFrontmatter("---\nname: [unclosed\n---\nBody"); + expect(result.ok).toBe(false); + }); + + it("returns ok:false when name is missing", () => { + const result = parseFrontmatter("---\ndescription: Something\n---\nBody"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/name/i); + }); + + it("returns ok:false when description is missing", () => { + const result = parseFrontmatter("---\nname: My Skill\n---\nBody"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/description/i); + }); + + it("returns ok:false when name is empty string", () => { + const result = parseFrontmatter("---\nname: ''\ndescription: Something\n---\nBody"); + expect(result.ok).toBe(false); + }); + + it("extracts allowed-tools as array", () => { + const content = "---\nname: Tool\ndescription: Uses tools\nallowed-tools:\n - Bash\n - Read\n---\nBody"; + const result = parseFrontmatter(content); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowedTools).toEqual(["Bash", "Read"]); + } + }); + + it("treats non-array allowed-tools as empty without failing", () => { + const content = "---\nname: Tool\ndescription: Desc\nallowed-tools: Bash\n---\nBody"; + const result = parseFrontmatter(content); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowedTools).toEqual([]); + } + }); + + it("never throws — always returns a result object", () => { + const inputs = ["", "---", "---\n---", "---\n!!invalid\n---"]; + for (const input of inputs) { + expect(() => parseFrontmatter(input)).not.toThrow(); + } + }); +}); + +// --------------------------------------------------------------------------- +// bundleSkill +// --------------------------------------------------------------------------- + +describe("bundleSkill", () => { + let skillDir: string; + + beforeEach(() => { + skillDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(skillDir, { recursive: true, force: true }); + }); + + it("returns skill-md type for a single-file skill", async () => { + fs.writeFileSync(path.join(skillDir, "SKILL.md"), VALID_FRONTMATTER); + const candidate = { + slug: "my-skill", + skillMdPath: path.join(skillDir, "SKILL.md"), + skillDir, + discoveryPath: "skills", + supportingFiles: [], + }; + const result = await bundleSkill(candidate); + expect(result.artifactType).toBe("skill-md"); + expect(result.artifact).toBe(VALID_FRONTMATTER); + expect(result.digest).toMatch(/^[0-9a-f]{64}$/); + }); + + it("digest is the SHA-256 of the raw content for skill-md", async () => { + fs.writeFileSync(path.join(skillDir, "SKILL.md"), VALID_FRONTMATTER); + const candidate = { + slug: "my-skill", + skillMdPath: path.join(skillDir, "SKILL.md"), + skillDir, + discoveryPath: "skills", + supportingFiles: [], + }; + const result = await bundleSkill(candidate); + const expected = crypto.createHash("sha256").update(VALID_FRONTMATTER, "utf-8").digest("hex"); + expect(result.digest).toBe(expected); + }); + + it("returns archive type for a multi-file skill", async () => { + fs.writeFileSync(path.join(skillDir, "SKILL.md"), VALID_FRONTMATTER); + fs.writeFileSync(path.join(skillDir, "helper.sh"), "#!/bin/sh"); + const candidate = { + slug: "my-skill", + skillMdPath: path.join(skillDir, "SKILL.md"), + skillDir, + discoveryPath: "skills", + supportingFiles: ["helper.sh"], + }; + const result = await bundleSkill(candidate); + expect(result.artifactType).toBe("archive"); + expect(result.digest).toMatch(/^[0-9a-f]{64}$/); + // artifact is base64 + expect(() => Buffer.from(result.artifact, "base64")).not.toThrow(); + }); + + // --- Qodo bug 3 fix --- + it("produces a deterministic digest regardless of supportingFiles order", async () => { + fs.writeFileSync(path.join(skillDir, "SKILL.md"), VALID_FRONTMATTER); + fs.writeFileSync(path.join(skillDir, "a.sh"), "a"); + fs.writeFileSync(path.join(skillDir, "b.sh"), "b"); + fs.writeFileSync(path.join(skillDir, "c.sh"), "c"); + + const makeCandidate = (order: string[]) => ({ + slug: "my-skill", + skillMdPath: path.join(skillDir, "SKILL.md"), + skillDir, + discoveryPath: "skills", + supportingFiles: order, + }); + + const r1 = await bundleSkill(makeCandidate(["a.sh", "b.sh", "c.sh"])); + const r2 = await bundleSkill(makeCandidate(["c.sh", "a.sh", "b.sh"])); + const r3 = await bundleSkill(makeCandidate(["b.sh", "c.sh", "a.sh"])); + + expect(r1.digest).toBe(r2.digest); + expect(r2.digest).toBe(r3.digest); + }); +}); + +// --------------------------------------------------------------------------- +// clone — URL validation (Qodo bug 2 fix) +// --------------------------------------------------------------------------- + +describe("clone — URL validation", () => { + it("throws CLONE_FAILED for a non-git URL scheme", async () => { + await expect(clone("file:///some/path", "/tmp/dest")).rejects.toThrow("CLONE_FAILED"); + }); + + it("throws CLONE_FAILED for a bare local path", async () => { + await expect(clone("/usr/local/repo", "/tmp/dest")).rejects.toThrow("CLONE_FAILED"); + }); + + it("throws CLONE_FAILED for an ftp:// URL", async () => { + await expect(clone("ftp://example.com/repo.git", "/tmp/dest")).rejects.toThrow("CLONE_FAILED"); + }); + + it("accepts https:// URLs (fails at network, not validation)", async () => { + // Will fail because the URL is unreachable, but the error must NOT be a + // validation rejection — it should be a git clone failure. + await expect( + clone("https://invalid.example.internal/repo.git", "/tmp/rhess-clone-test") + ).rejects.toThrow("CLONE_FAILED"); + // Verify it's a git error, not our validation message + try { + await clone("https://invalid.example.internal/repo.git", "/tmp/rhess-clone-test"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + expect(msg).not.toContain("invalid URL"); + } + }); + + it("accepts ssh:// URLs (fails at network, not validation)", async () => { + await expect( + clone("ssh://git@invalid.example.internal/repo.git", "/tmp/rhess-clone-test") + ).rejects.toThrow("CLONE_FAILED"); + try { + await clone("ssh://git@invalid.example.internal/repo.git", "/tmp/rhess-clone-test"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + expect(msg).not.toContain("invalid URL"); + } + }); + + it("accepts git@ SCP-style URLs (fails at network, not validation)", async () => { + await expect( + clone("git@github.com:invalid-org/invalid-repo.git", "/tmp/rhess-clone-test") + ).rejects.toThrow("CLONE_FAILED"); + try { + await clone("git@github.com:invalid-org/invalid-repo.git", "/tmp/rhess-clone-test"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + expect(msg).not.toContain("invalid URL"); + } + }); +});