@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
JavaScript
;
/**
* 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]);
}
}
}