Files
hermes-agent/apps/desktop/src/lib/plugin-source-urls.ts
seref 359e09fd65 feat(desktop): one-click plugin install via hermes:// deeplinks
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.

Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
2026-08-18 14:45:23 -07:00

120 lines
3.2 KiB
TypeScript

const GITHUB_BROWSER_SEGMENTS = new Set(['tree', 'blob', 'commit'])
export interface PluginSourceLinks {
gitUrl: string
browseUrl: string | null
subdir: string | null
}
function resolvePluginGitUrl(identifier: string): { gitUrl: string; subdir: string | null } {
const trimmed = identifier.trim()
if (!trimmed) {
throw new Error('Plugin identifier is required.')
}
if (/^(https?:\/\/|git@|ssh:\/\/|file:\/\/)/.test(trimmed)) {
if (trimmed.startsWith('https://github.com/')) {
const rest = trimmed.slice('https://github.com/'.length).split(/[?#]/)[0].replace(/\/+$/, '')
const parts = rest.split('/').filter(Boolean)
if (parts.length >= 3 && parts[2] && GITHUB_BROWSER_SEGMENTS.has(parts[2])) {
const repo = parts[1].replace(/\.git$/, '')
let subdir: string | null = null
if (parts[2] === 'tree' && parts.length >= 5) {
subdir = parts.slice(4).join('/').replace(/\/+$/, '') || null
}
return { gitUrl: `https://github.com/${parts[0]}/${repo}.git`, subdir }
}
}
if (trimmed.includes('#')) {
const hashIdx = trimmed.indexOf('#')
const gitUrl = trimmed.slice(0, hashIdx)
const subdir = trimmed.slice(hashIdx + 1).replace(/^\/+|\/+$/g, '') || null
return { gitUrl, subdir }
}
const marker = '.git/'
if (trimmed.includes(marker)) {
const idx = trimmed.indexOf(marker)
const gitUrl = trimmed.slice(0, idx + marker.length - 1)
const subdir = trimmed.slice(idx + marker.length).replace(/^\/+|\/+$/g, '') || null
return { gitUrl, subdir }
}
return { gitUrl: trimmed, subdir: null }
}
const parts = trimmed.split('/').filter(Boolean)
if (parts.length >= 2) {
const [owner, repo, ...rest] = parts
const gitUrl = `https://github.com/${owner}/${repo}.git`
const subdir = rest.join('/').replace(/\/+$/, '') || null
return { gitUrl, subdir }
}
throw new Error('Invalid plugin identifier.')
}
function githubBrowseBase(gitUrl: string): string | null {
const sshMatch = gitUrl.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i)
if (sshMatch) {
return `https://github.com/${sshMatch[1]}/${sshMatch[2].replace(/\.git$/, '')}`
}
try {
const url = new URL(gitUrl)
if (url.hostname.toLowerCase() === 'github.com') {
const parts = url.pathname.replace(/\/+$/, '').split('/').filter(Boolean)
if (parts.length >= 2) {
return `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}`
}
}
} catch {
return null
}
return null
}
function browseUrlFromGitUrl(gitUrl: string, subdir: string | null): string | null {
const githubBase = githubBrowseBase(gitUrl)
if (githubBase) {
return subdir ? `${githubBase}/tree/HEAD/${subdir}` : githubBase
}
if (/^https?:\/\//.test(gitUrl)) {
const base = gitUrl.replace(/\.git$/, '')
return subdir ? `${base}/tree/HEAD/${subdir}` : base
}
return null
}
export function resolvePluginSourceLinks(identifier: string): PluginSourceLinks | null {
try {
const { gitUrl, subdir } = resolvePluginGitUrl(identifier)
return {
gitUrl,
browseUrl: browseUrlFromGitUrl(gitUrl, subdir),
subdir
}
} catch {
return null
}
}