UNPKG

gh2ide

Version:

Native messaging host for GitHub to IDE Chrome extension - opens GitHub repos and files directly in your IDE

506 lines (457 loc) 16.7 kB
#!/usr/bin/env node // Chrome Native Messaging host: read length-prefixed JSON on stdin, write length-prefixed JSON on stdout import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const LOG_PREFIX = '[native]'; const LOG_FILE = (() => { const envPath = process.env.GITHUB_VSCODE_LOG_FILE; if (envPath === '') return null; return envPath || path.join(os.homedir(), '.github-vscode-interceptor', 'native-host.log'); })(); let logDirEnsured = false; const resolvedEditorCache = new Map(); function normalizePathValue(p) { if (typeof p !== 'string') return ''; return p.replace(/[\\/]+$/, ''); } function formatLogArg(arg) { if (typeof arg === 'string') return arg; try { return JSON.stringify(arg); } catch (_) { return String(arg); } } function log(...args) { const line = `${new Date().toISOString()} ${LOG_PREFIX} ${args.map(formatLogArg).join(' ')}`; console.error(line); if (!LOG_FILE) return; try { if (!logDirEnsured) { fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); logDirEnsured = true; } fs.appendFileSync(LOG_FILE, line + '\n'); } catch (err) { console.error(`${LOG_PREFIX} failed to write log file`, err?.message || err); } } const DEFAULT_EDITORS = [ { id: 'code', name: 'VS Code', command: 'code', alternates: [ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', '/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code', 'code.cmd', ], args: { fileWithLine: ['--reuse-window', '-g', '{path}:{line}'], file: ['--reuse-window', '-g', '{path}'], fileInRepo: ['--reuse-window', '-g', '{path}:{line}'], folder: ['-n', '{path}'], }, }, { id: 'rider', name: 'JetBrains Rider', command: 'open', alternates: [ '/Applications/Rider.app/Contents/MacOS/rider', '/Applications/JetBrains Toolbox/Rider.app/Contents/MacOS/rider', 'C:/Program Files/JetBrains/Rider/bin/rider64.exe', ], args: { fileWithLine: ['-na', 'Rider.app', '--args', '--line', '{line}', '{path}'], file: ['-na', 'Rider.app', '--args', '{path}'], fileInRepo: ['-a', 'Rider.app', '--args', '--line', '{line}', '{path}'], folder: ['-na', 'Rider.app', '--args', '{path}'], }, }, { id: 'cursor', name: 'Cursor', command: 'cursor', alternates: [ '/Applications/Cursor.app/Contents/MacOS/Cursor', 'C:/Users/%USERNAME%/AppData/Local/Programs/cursor-app/cursor.exe', ], args: { fileWithLine: ['{path}:{line}'], file: ['{path}'], fileInRepo: ['{path}:{line}'], folder: ['{path}'], }, }, ]; const DEFAULT_CONFIG = { cloneRoot: path.join(os.homedir(), 'Dev'), defaultRemote: 'origin', groupByOwner: false, openMode: 'repo', defaultEditor: 'code', editors: DEFAULT_EDITORS, }; function sanitizeConfig(raw) { const candidate = raw && typeof raw === 'object' ? raw : {}; const cloneRoot = normalizePathValue(typeof candidate.cloneRoot === 'string' ? candidate.cloneRoot : DEFAULT_CONFIG.cloneRoot) || DEFAULT_CONFIG.cloneRoot; const defaultRemote = typeof candidate.defaultRemote === 'string' && candidate.defaultRemote.trim() ? candidate.defaultRemote.trim() : DEFAULT_CONFIG.defaultRemote; const groupByOwner = candidate.groupByOwner === true; const openMode = candidate.openMode === 'file' ? 'file' : 'repo'; const editors = sanitizeEditors(candidate.editors); const defaultEditor = editors.some((ed) => ed.id === candidate.defaultEditor) ? candidate.defaultEditor : (editors[0]?.id || DEFAULT_CONFIG.defaultEditor); return { cloneRoot, defaultRemote, groupByOwner, openMode, defaultEditor, editors, }; } function sanitizeEditors(raw) { const list = Array.isArray(raw) ? raw : []; const seen = new Set(); const cleaned = list .map((entry) => sanitizeEditor(entry)) .filter((editor) => { if (!editor) return false; if (seen.has(editor.id)) return false; seen.add(editor.id); return true; }); if (cleaned.length) return cleaned; return structuredClone(DEFAULT_EDITORS); } function sanitizeEditor(entry) { if (!entry || typeof entry !== 'object') return null; const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null; if (!id) return null; const name = typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : id; const command = typeof entry.command === 'string' && entry.command.trim() ? entry.command.trim() : null; const alternates = Array.isArray(entry.alternates) ? entry.alternates.filter((alt) => typeof alt === 'string' && alt.trim()).map((alt) => alt.trim()) : []; if (!command && alternates.length === 0) return null; const args = {}; ['fileWithLine', 'file', 'fileInRepo', 'folder'].forEach((key) => { if (Array.isArray(entry.args?.[key])) { args[key] = entry.args[key].map((part) => String(part)); } }); return { id, name, command, alternates, args }; } function structuredClone(data) { return JSON.parse(JSON.stringify(data)); } function writeMessage(obj) { log('writeMessage', obj); const json = JSON.stringify(obj); const len = Buffer.byteLength(json); const header = Buffer.alloc(4); header.writeUInt32LE(len, 0); process.stdout.write(header); process.stdout.write(json); } function readMessage() { const header = Buffer.alloc(4); if (fs.readSync(0, header, 0, 4, null) !== 4) return null; const len = header.readUInt32LE(0); const buf = Buffer.alloc(len); if (fs.readSync(0, buf, 0, len, null) !== len) return null; const msg = JSON.parse(buf.toString('utf8')); log('readMessage', msg); return msg; } function repoLocalPath(cloneRoot, owner, repo, groupByOwner) { const base = expandTilde(cloneRoot); if (groupByOwner && owner) return path.join(base, owner, repo); return path.join(base, repo); } function expandTilde(p) { return p?.startsWith('~') ? p.replace('~', os.homedir()) : p; } function findEditor(cfg, editorId) { const fallback = cfg.editors[0]; if (!editorId) return fallback; const match = cfg.editors.find((editor) => editor.id === editorId); return match || fallback; } function editorCandidates(editor) { const candidates = []; if (editor.command) candidates.push(editor.command); if (Array.isArray(editor.alternates)) candidates.push(...editor.alternates); return candidates.map((candidate) => expandTilde(candidate)); } function renderArgs(template, replacements) { if (!Array.isArray(template)) return []; return template .map((part) => part .replace(/{path}/g, replacements.path ?? '') .replace(/{line}/g, replacements.line ?? '') .replace(/{folder}/g, replacements.folder ?? replacements.path ?? '') ) .filter((part) => part !== ''); } function launchEditor(editor, { targetPath, line, cwd, targetType }) { if (!editor) return { ok: false, message: 'Editor not configured' }; const candidates = editorCandidates(editor); if (!candidates.length) return { ok: false, message: 'No executable configured for editor' }; const replacements = { path: targetPath, folder: targetPath, line: line != null ? String(line) : '', }; let template; switch (targetType) { case 'folder': template = editor.args?.folder; break; case 'fileInRepo': if (line != null) { template = editor.args?.fileInRepo || editor.args?.fileWithLine || editor.args?.file; } else { template = editor.args?.file || editor.args?.fileInRepo || editor.args?.fileWithLine; } break; case 'file': default: if (line != null && editor.args?.fileWithLine) { template = editor.args.fileWithLine; } else { template = editor.args?.file; } break; } if (!template) { if (targetType === 'folder') template = ['{path}']; else if (line != null) template = ['-g', '{path}:{line}']; else template = ['{path}']; } const args = renderArgs(template, replacements); let lastError = 'Editor command failed'; for (const candidate of candidates) { const resolved = resolveCommand(candidate, editor.id); if (!resolved) continue; const res = spawnSync(resolved, args, { stdio: 'ignore', cwd }); if (res.error) { lastError = res.error.message || String(res.error); log('launchEditor spawn error', { editor: editor.id, candidate: resolved, error: lastError }); continue; } if (res.status === 0) { log('launchEditor success', { editor: editor.id, command: resolved, args }); resolvedEditorCache.set(editor.id, resolved); return { ok: true }; } lastError = res.stderr?.trim() || `Exit code ${res.status}`; log('launchEditor non-zero status', { editor: editor.id, candidate: resolved, status: res.status, stderr: res.stderr?.trim() }); } return { ok: false, message: lastError }; } function resolveCommand(candidate, editorId) { if (!candidate) return null; if (resolvedEditorCache.has(`${editorId}:${candidate}`)) { return resolvedEditorCache.get(`${editorId}:${candidate}`); } if (candidate.includes(path.sep) || candidate.startsWith('.')) { const expanded = path.isAbsolute(candidate) ? candidate : path.resolve(candidate); resolvedEditorCache.set(`${editorId}:${candidate}`, expanded); return expanded; } if (process.platform === 'win32') { const where = spawnSync('where', [candidate], { encoding: 'utf8' }); if (where.status === 0) { const located = where.stdout.split(/\r?\n/).find((line) => line.trim()); if (located) { const trimmed = located.trim(); resolvedEditorCache.set(`${editorId}:${candidate}`, trimmed); return trimmed; } } } else { const which = spawnSync('which', [candidate], { encoding: 'utf8' }); if (which.status === 0) { const located = which.stdout.trim(); if (located) { resolvedEditorCache.set(`${editorId}:${candidate}`, located); return located; } } } resolvedEditorCache.set(`${editorId}:${candidate}`, candidate); return candidate; } function ensureCloned(remote, localPath) { if (fs.existsSync(localPath)) return { ok: true, existed: true }; fs.mkdirSync(path.dirname(localPath), { recursive: true }); const res = spawnSync('git', ['clone', remote, localPath], { encoding: 'utf8', stdio: 'pipe' }); const ok = res.status === 0; return { ok, existed: false, stderr: res.stderr }; } function currentBranch(localPath) { const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: localPath, encoding: 'utf8', stdio: 'pipe' }); return res.status === 0 ? res.stdout.trim() : null; } function switchBranch(localPath, branch) { const fetch = spawnSync('git', ['fetch', '--all', '--prune'], { cwd: localPath, encoding: 'utf8', stdio: 'pipe' }); if (fetch.status !== 0) return { ok: false, message: fetch.stderr }; const res = spawnSync('git', ['switch', branch], { cwd: localPath, encoding: 'utf8', stdio: 'pipe' }); return { ok: res.status === 0, message: res.stderr }; } function fileFromRepo(localPath, filepath) { return filepath ? path.join(localPath, filepath) : null; } function handleResolve(msg) { const cfg = sanitizeConfig(msg.config); const localPath = repoLocalPath(cfg.cloneRoot, msg.owner, msg.repo, cfg.groupByOwner); const remote = `https://github.com/${msg.owner}/${msg.repo}.git`; const editor = findEditor(cfg, msg.editorId || cfg.defaultEditor); const editorId = editor.id; const openMode = msg.openMode === 'file' || msg.openMode === 'repo' ? msg.openMode : cfg.openMode; if (!fs.existsSync(localPath)) { return { status: 'NEEDS_CLONE', remote, localPath, openPayload: { action: 'open', localPath, filepath: msg.filepath, line: msg.line, editorId, openMode, config: cfg }, }; } if (msg.branch) { const cur = currentBranch(localPath); if (cur && cur !== msg.branch) { return { status: 'WRONG_BRANCH', currentBranch: cur, expectedBranch: msg.branch, localPath, openPayload: { action: 'open', localPath, filepath: msg.filepath, line: msg.line, editorId, openMode, config: cfg }, }; } } const openRes = openPayload({ action: 'open', localPath, filepath: msg.filepath, line: msg.line, editorId, openMode, config: cfg }); return openRes.ok ? { status: 'OPENED' } : { status: 'ERROR', message: openRes.message || 'Failed to open editor' }; } function openPayload(payload) { const cfg = sanitizeConfig(payload.config); const { localPath, filepath, line, editorId, openMode } = payload; if (!fs.existsSync(localPath)) return { ok: false, message: `Local repo path not found: ${localPath}` }; const editor = findEditor(cfg, editorId || cfg.defaultEditor); const effectiveMode = openMode === 'file' || openMode === 'repo' ? openMode : cfg.openMode; if (effectiveMode === 'repo') { const folderResult = launchEditor(editor, { targetPath: localPath, cwd: localPath, targetType: 'folder', }); if (!folderResult.ok) return folderResult; if (filepath) { const abs = fileFromRepo(localPath, filepath); if (abs && fs.existsSync(abs)) { const fileResult = launchEditor(editor, { targetPath: abs, line: line != null ? line : null, cwd: localPath, targetType: line != null ? 'fileInRepo' : 'file', }); if (!fileResult.ok) return fileResult; } } return folderResult; } if (filepath) { const abs = fileFromRepo(localPath, filepath); if (fs.existsSync(abs)) { const fileAttempt = launchEditor(editor, { targetPath: abs, line: line != null ? line : null, cwd: localPath, targetType: 'file', }); if (fileAttempt.ok) return fileAttempt; return launchEditor(editor, { targetPath: localPath, cwd: localPath, targetType: 'folder', }); } } return launchEditor(editor, { targetPath: localPath, cwd: localPath, targetType: 'folder', }); } function handleClone(msg) { const { remote, localPath } = msg; const out = ensureCloned(remote, localPath); if (!out.ok) return { status: 'ERROR', message: out.stderr || 'clone failed' }; return { status: 'CLONED' }; } function handleSwitchBranch(msg) { const { localPath, branch } = msg; const res = switchBranch(localPath, branch); return res.ok ? { status: 'SWITCHED' } : { status: 'ERROR', message: res.message }; } function handleChooseCloneRoot() { if (process.platform === 'darwin') { const script = 'set theFolder to choose folder with prompt "Select clone root for GitHub to IDE"\nPOSIX path of theFolder'; const res = spawnSync('osascript', ['-e', script], { encoding: 'utf8' }); if (res.error) { return { status: 'ERROR', message: res.error.message }; } if (res.status === 0) { const chosen = normalizePathValue(res.stdout.trim()); return { status: 'CHOSEN', path: chosen }; } const stderr = res.stderr?.trim(); if (stderr?.toLowerCase().includes('user canceled')) return { status: 'CANCELLED' }; return { status: 'ERROR', message: stderr || 'Folder selection failed' }; } if (process.platform === 'win32') { return { status: 'ERROR', message: 'Directory picker not supported on Windows yet.' }; } return { status: 'ERROR', message: 'Directory picker not supported on this platform yet.' }; } function main() { while (true) { const msg = readMessage(); if (!msg) break; try { switch (msg.action) { case 'ping': writeMessage({ status: 'PONG' }); break; case 'resolve': writeMessage(handleResolve(msg)); break; case 'clone': writeMessage(handleClone(msg)); break; case 'switchBranch': writeMessage(handleSwitchBranch(msg)); break; case 'chooseCloneRoot': writeMessage(handleChooseCloneRoot()); break; case 'open': writeMessage(openPayload(msg).ok ? { status: 'OPENED' } : { status: 'ERROR', message: 'open failed' }); break; default: writeMessage({ status: 'ERROR', message: `unknown action: ${msg.action}` }); break; } } catch (e) { writeMessage({ status: 'ERROR', message: String(e?.message || e) }); } } } main();