/** * Minimal GitOps helper: shallow-clone a repo and read a compose file. * Requires `git` on PATH on the peardock server host. */ import { spawn } from 'child_process' import fs from 'fs' import path from 'path' import os from 'os' import { randomBytes } from 'crypto' /** * @param {string} cmd * @param {string[]} args * @param {{ cwd?: string, timeoutMs?: number }} [opts] * @returns {Promise<{ code: number, stdout: string, stderr: string }>} */ function run(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd: opts.cwd || process.cwd(), env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }) let stdout = '' let stderr = '' const timer = setTimeout(() => { child.kill('SIGKILL') reject(new Error(`Command timed out: ${cmd} ${args.join(' ')}`)) }, opts.timeoutMs || 120_000) child.stdout?.on('data', (d) => { stdout += d.toString() }) child.stderr?.on('data', (d) => { stderr += d.toString() }) child.on('error', (err) => { clearTimeout(timer) reject(err) }) child.on('close', (code) => { clearTimeout(timer) resolve({ code: code ?? 1, stdout, stderr }) }) }) } /** * Shallow clone and read compose YAML. * @param {{ * repoUrl: string, * ref?: string, * composePath?: string, * }} opts * @returns {Promise<{ composeContent: string, commit?: string, path: string }>} */ export async function fetchComposeFromGit(opts) { const repoUrl = String(opts.repoUrl || '').trim() if (!repoUrl) throw Object.assign(new Error('repoUrl required'), { code: 'INVALID_ARGS' }) if (!/^https?:\/\//i.test(repoUrl) && !/^git@/i.test(repoUrl)) { throw Object.assign( new Error('repoUrl must be http(s) or git@ URL'), { code: 'INVALID_ARGS' } ) } const ref = String(opts.ref || 'main').trim() || 'main' let composePath = String(opts.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml' // Path traversal guard if (composePath.includes('..') || path.isAbsolute(composePath)) { throw Object.assign(new Error('composePath must be a relative path without ..'), { code: 'INVALID_ARGS', }) } const tmpRoot = path.join(os.tmpdir(), `peardock-gitops-${randomBytes(6).toString('hex')}`) fs.mkdirSync(tmpRoot, { recursive: true }) try { const clone = await run( 'git', ['clone', '--depth', '1', '--branch', ref, '--single-branch', repoUrl, tmpRoot], { timeoutMs: 180_000 } ) if (clone.code !== 0) { // Retry without branch if ref is a tag/sha that needs full history hint const clone2 = await run('git', ['clone', '--depth', '1', repoUrl, tmpRoot], { timeoutMs: 180_000, }) if (clone2.code !== 0) { throw new Error( `git clone failed: ${(clone.stderr || clone2.stderr || clone.stdout).slice(0, 400)}` ) } if (ref && ref !== 'main' && ref !== 'master') { await run('git', ['checkout', ref], { cwd: tmpRoot, timeoutMs: 60_000 }) } } const full = path.join(tmpRoot, composePath) if (!fs.existsSync(full)) { // try compose.yaml const alt = path.join(tmpRoot, 'compose.yaml') if (composePath === 'docker-compose.yml' && fs.existsSync(alt)) { composePath = 'compose.yaml' } else { throw new Error(`Compose file not found in repo: ${composePath}`) } } const filePath = path.join(tmpRoot, composePath) const composeContent = fs.readFileSync(filePath, 'utf8') if (!composeContent.trim()) throw new Error('Compose file is empty') let commit = '' try { const rev = await run('git', ['rev-parse', 'HEAD'], { cwd: tmpRoot, timeoutMs: 10_000 }) if (rev.code === 0) commit = rev.stdout.trim() } catch { // ignore } return { composeContent, commit, path: composePath } } finally { try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { // ignore } } } export default { fetchComposeFromGit }