UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

299 lines (298 loc) 11.5 kB
"use strict"; /** * Git Utilities * * Shared git operations for the MCP server layer. * Provides authenticated clone, pull, and push using simple-git. * * PRD #362: Git Operations for Recommend Tool * * Environment variables: * - DOT_AI_GIT_TOKEN: PAT authentication token * - GITHUB_APP_ENABLED: Enable GitHub App authentication * - GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID: GitHub App config */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.scrubCredentials = scrubCredentials; exports.getAuthenticatedUrl = getAuthenticatedUrl; exports.getAuthToken = getAuthToken; exports.getGitAuthConfigFromEnv = getGitAuthConfigFromEnv; exports.sanitizeRelativePath = sanitizeRelativePath; exports.cloneRepo = cloneRepo; exports.pullRepo = pullRepo; exports.pushRepo = pushRepo; const simple_git_1 = __importDefault(require("simple-git")); const jwt = __importStar(require("jsonwebtoken")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const FETCH_TIMEOUT_MS = 30000; const GIT_TIMEOUT_MS = 120000; // 2 minutes for git operations // ─── Auth helpers ─── function scrubCredentials(message) { return message .replace(/\/\/x-access-token:[^@]+@/g, '//***@') .replace(/\/\/[^/:][^@]*:[^@]+@/g, '//***@'); } function getAuthenticatedUrl(repoUrl, token) { const url = new URL(repoUrl); url.username = 'x-access-token'; url.password = token; return url.toString(); } async function fetchWithTimeout(url, options, timeoutMs = FETCH_TIMEOUT_MS) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } } function generateGitHubAppJWT(appId, privateKey) { const now = Math.floor(Date.now() / 1000); return jwt.sign({ iat: now - 60, exp: now + 10 * 60, iss: appId }, privateKey, { algorithm: 'RS256' }); } async function getGitHubAppInstallationToken(appId, privateKey, installationId) { const appJWT = generateGitHubAppJWT(appId, privateKey); let installId = installationId; if (!installId) { const resp = await fetchWithTimeout('https://api.github.com/app/installations', { headers: { Authorization: `Bearer ${appJWT}`, Accept: 'application/vnd.github.v3+json', }, }); if (!resp.ok) { throw new Error(`Failed to list installations: ${resp.statusText}`); } const installations = (await resp.json()); if (installations.length === 0) { throw new Error('No GitHub App installations found'); } installId = String(installations[0].id); } const tokenResp = await fetchWithTimeout(`https://api.github.com/app/installations/${installId}/access_tokens`, { method: 'POST', headers: { Authorization: `Bearer ${appJWT}`, Accept: 'application/vnd.github.v3+json', }, }); if (!tokenResp.ok) { throw new Error(`Failed to get installation token: ${tokenResp.statusText}`); } const data = (await tokenResp.json()); return { token: data.token, expiresAt: data.expires_at }; } async function getAuthToken(authConfig) { if (authConfig.pat) return authConfig.pat; if (authConfig.githubApp) { const { appId, privateKey, installationId } = authConfig.githubApp; const tokenData = await getGitHubAppInstallationToken(appId, privateKey, installationId); return tokenData.token; } throw new Error('No authentication method configured. Provide either PAT or GitHub App credentials.'); } function getGitAuthConfigFromEnv() { const pat = process.env.DOT_AI_GIT_TOKEN; const githubAppEnabled = process.env.GITHUB_APP_ENABLED === 'true'; if (pat) return { pat }; if (githubAppEnabled) { const appId = process.env.GITHUB_APP_ID; const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; const installationId = process.env.GITHUB_APP_INSTALLATION_ID; if (!appId || !privateKey) { throw new Error('GitHub App enabled but GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY not set'); } return { githubApp: { appId, privateKey: privateKey.replace(/\\n/g, '\n'), installationId, }, }; } return {}; } // ─── Git options helper ─── function gitOptions(baseDir) { return { baseDir: baseDir || process.cwd(), binary: 'git', maxConcurrentProcesses: 6, timeout: { block: GIT_TIMEOUT_MS }, }; } // ─── Path safety ─── /** * Sanitize a relative path to prevent directory traversal. * Rejects absolute paths and paths that escape the base directory. */ function sanitizeRelativePath(relativePath) { if (relativePath.startsWith('/')) { throw new Error('Relative path cannot be absolute'); } const normalized = path.posix.normalize(relativePath); if (normalized.startsWith('..') || path.posix.isAbsolute(normalized)) { throw new Error('Relative path cannot escape target directory'); } return normalized; } async function cloneRepo(repoUrl, targetDir, opts) { const authConfig = getGitAuthConfigFromEnv(); let cloneUrl; // Use authenticated URL if credentials are available, otherwise clone unauthenticated (public repos) if (authConfig.pat || authConfig.githubApp) { const token = await getAuthToken(authConfig); cloneUrl = getAuthenticatedUrl(repoUrl, token); } else { cloneUrl = repoUrl; } const git = (0, simple_git_1.default)(gitOptions()); const cloneOptions = []; if (opts?.branch) { cloneOptions.push('--branch', opts.branch); } if (opts?.depth) { cloneOptions.push('--depth', String(opts.depth)); } await git.clone(cloneUrl, targetDir, cloneOptions); const repoGit = (0, simple_git_1.default)(targetDir); const status = await repoGit.status(); const branch = status.current || opts?.branch || 'main'; return { localPath: targetDir, branch }; } // ─── Pull ─── async function pullRepo(repoPath) { const authConfig = getGitAuthConfigFromEnv(); const hasAuth = !!(authConfig.pat || authConfig.githubApp); const git = (0, simple_git_1.default)(gitOptions(repoPath)); let originalOriginUrl; if (hasAuth) { const token = await getAuthToken(authConfig); const remotes = await git.getRemotes(true); const origin = remotes.find(r => r.name === 'origin'); originalOriginUrl = origin?.refs.fetch; if (originalOriginUrl) { const authUrl = getAuthenticatedUrl(originalOriginUrl, token); await git.remote(['set-url', 'origin', authUrl]); } } try { await git.pull('origin', undefined, ['--ff-only']); const status = await git.status(); return { branch: status.current || 'main' }; } finally { // Restore original origin URL to prevent auth tokens persisting in .git/config if (hasAuth && originalOriginUrl) { await git.remote(['set-url', 'origin', originalOriginUrl]); } } } async function pushRepo(repoPath, files, commitMessage, opts) { const git = (0, simple_git_1.default)(gitOptions(repoPath)); if (opts?.branch) { const branches = await git.branchLocal(); if (!branches.all.includes(opts.branch)) { await git.checkoutLocalBranch(opts.branch); } else { await git.checkout(opts.branch); } } for (const file of files) { const repoRoot = path.resolve(repoPath); const fullPath = path.resolve(repoPath, file.path); if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) { throw new Error(`Path traversal detected: "${file.path}" attempts to write outside repository directory`); } const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(fullPath, file.content); } await git.add(files.map(f => f.path)); const gitUserName = opts?.author?.name || process.env.GIT_AUTHOR_NAME || 'dot-ai-bot'; const gitUserEmail = opts?.author?.email || process.env.GIT_AUTHOR_EMAIL || 'dot-ai@users.noreply.github.com'; await git.addConfig('user.name', gitUserName); await git.addConfig('user.email', gitUserEmail); const finalMessage = process.env.CI === 'true' ? `${commitMessage} [skip ci]` : commitMessage; const commitResult = await git.commit(finalMessage); if (!commitResult.commit) { return { commitSha: undefined, branch: (await git.status()).current || 'main', filesAdded: [], }; } const authConfig = getGitAuthConfigFromEnv(); const token = await getAuthToken(authConfig); const remotes = await git.getRemotes(true); const origin = remotes.find(r => r.name === 'origin'); let originalOriginUrl; if (origin) { originalOriginUrl = origin.refs.fetch; const authUrl = getAuthenticatedUrl(originalOriginUrl, token); await git.remote(['set-url', 'origin', authUrl]); } try { const currentBranch = (await git.status()).current || 'main'; await git.push('origin', currentBranch, ['--set-upstream']); return { commitSha: commitResult.commit, branch: currentBranch, filesAdded: files.map(f => f.path), }; } finally { if (origin && originalOriginUrl) { await git.remote(['set-url', 'origin', originalOriginUrl]); } } }