Files
dockhand/lib/server/git.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

1136 lines
36 KiB
TypeScript

import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
getGitRepository,
getGitCredential,
updateGitRepository,
getGitStack,
updateGitStack,
upsertStackSource,
type GitRepository,
type GitCredential,
type GitStackWithRepo
} from './db';
import { deployStack } from './stacks';
// Directory for storing cloned repositories
const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos';
// Ensure git repos directory exists
if (!existsSync(GIT_REPOS_DIR)) {
mkdirSync(GIT_REPOS_DIR, { recursive: true });
}
/**
* Mask sensitive values in environment variables for safe logging.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
}
return masked;
}
function getRepoPath(repoId: number): string {
return join(GIT_REPOS_DIR, `repo-${repoId}`);
}
interface GitEnv {
[key: string]: string;
}
async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
const env: GitEnv = {
...process.env as GitEnv,
GIT_TERMINAL_PROMPT: '0',
// Prevent SSH agent from providing keys automatically
SSH_AUTH_SOCK: ''
};
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Create a temporary SSH key file (use absolute path so SSH can find it)
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
await Bun.write(sshKeyPath, credential.sshPrivateKey);
// Ensure SSH key has correct permissions (0600 = owner read/write only)
// Bun.write's mode option doesn't always work reliably, so use chmodSync
chmodSync(sshKeyPath, 0o600);
// Configure SSH to use ONLY this key (no agent, no default keys)
const sshCommand = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`;
env.GIT_SSH_COMMAND = sshCommand;
} else {
// No SSH credential - prevent using any keys (IdentitiesOnly=yes with no -i means no keys)
env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o PasswordAuthentication=no -o PubkeyAuthentication=no';
}
return env;
}
function cleanupSshKey(credential: GitCredential | null): void {
if (credential?.authType === 'ssh') {
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
try {
if (existsSync(sshKeyPath)) {
rmSync(sshKeyPath);
}
} catch {
// Ignore cleanup errors
}
}
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
// For SSH URLs or no auth, return as-is
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
return url;
}
// For HTTPS with password auth, embed credentials
try {
const parsed = new URL(url);
if (credential.username) {
parsed.username = credential.username;
}
if (credential.password) {
parsed.password = credential.password;
}
return parsed.toString();
} catch {
return url;
}
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
try {
const proc = Bun.spawn(['git', ...args], {
cwd,
env,
stdout: 'pipe',
stderr: 'pipe'
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text()
]);
const code = await proc.exited;
return { stdout: stdout.trim(), stderr: stderr.trim(), code };
} catch (err: any) {
return { stdout: '', stderr: err.message, code: 1 };
}
}
export interface SyncResult {
success: boolean;
commit?: string;
composeContent?: string;
envFileVars?: Record<string, string>; // Variables from .env file in repo
error?: string;
updated?: boolean;
}
export interface TestResult {
success: boolean;
branch?: string;
lastCommit?: string;
composeFileExists?: boolean;
error?: string;
}
/**
* Clean up git/SSH error messages for user display
*/
function cleanGitError(stderr: string): string {
// Remove SSH warnings and noise
const lines = stderr.split('\n').filter(line => {
const l = line.trim().toLowerCase();
// Skip SSH warnings
if (l.startsWith('warning:')) return false;
if (l.includes('added') && l.includes('to the list of known hosts')) return false;
// Skip empty lines
if (!l) return false;
return true;
});
// Find the most relevant error
const fatalLine = lines.find(l => l.toLowerCase().includes('fatal:'));
const permissionLine = lines.find(l => l.toLowerCase().includes('permission denied'));
const errorLine = lines.find(l => l.toLowerCase().includes('error:'));
// Return cleaner message
if (permissionLine) {
return 'Permission denied. Check your SSH credentials.';
}
if (fatalLine) {
// Clean up common fatal messages
const msg = fatalLine.replace(/^fatal:\s*/i, '').trim();
if (msg.includes('Could not read from remote repository')) {
return 'Could not access repository. Check URL and credentials.';
}
return msg;
}
if (errorLine) {
return errorLine.replace(/^error:\s*/i, '').trim();
}
// Fallback to original (joined and trimmed)
return lines.join(' ').trim() || 'Failed to connect to repository';
}
/**
* Core function to test a git repository connection.
* Tests the URL, branch, and credentials passed directly (not from DB).
*/
async function testRepositoryConnection(options: {
url: string;
branch: string;
credential: GitCredential | null;
}): Promise<TestResult> {
const { url, branch, credential } = options;
const env = await buildGitEnv(credential);
const repoUrl = buildRepoUrl(url, credential);
try {
// Use git ls-remote to test connection and verify branch
const result = await execGit(
['ls-remote', '--heads', '--refs', repoUrl, branch || 'HEAD'],
process.cwd(),
env
);
cleanupSshKey(credential);
if (result.code !== 0) {
console.error('[Git] Connection test failed:', result.stderr);
return { success: false, error: cleanGitError(result.stderr) };
}
// Parse the output to get commit hash
const lines = result.stdout.split('\n').filter(l => l.trim());
if (lines.length === 0) {
// Branch not found, but connection worked - check if repo has any branches
const allBranchesResult = await execGit(
['ls-remote', '--heads', '--refs', repoUrl],
process.cwd(),
env
);
cleanupSshKey(credential);
if (allBranchesResult.code !== 0) {
return { success: false, error: cleanGitError(allBranchesResult.stderr) };
}
const allBranches = allBranchesResult.stdout.split('\n')
.filter(l => l.trim())
.map(l => {
const m = l.match(/refs\/heads\/(.+)$/);
return m ? m[1] : null;
})
.filter(Boolean);
if (allBranches.length === 0) {
return { success: true, branch: '(empty repository)' };
}
return {
success: false,
error: `Branch '${branch}' not found. Available branches: ${allBranches.slice(0, 5).join(', ')}${allBranches.length > 5 ? '...' : ''}`
};
}
const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/);
const lastCommit = match ? match[1].substring(0, 7) : undefined;
const foundBranch = match ? match[2] : branch;
return {
success: true,
branch: foundBranch,
lastCommit
};
} catch (error: any) {
cleanupSshKey(credential);
return { success: false, error: error.message };
}
}
/**
* Test a saved repository from the database (used by grid test button).
*/
export async function testRepository(repoId: number): Promise<TestResult> {
const repo = await getGitRepository(repoId);
if (!repo) {
return { success: false, error: 'Repository not found' };
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
return testRepositoryConnection({
url: repo.url,
branch: repo.branch,
credential
});
}
/**
* Test a repository configuration before saving (used by modal test button).
* Uses credentialId to fetch stored credentials from the database.
*/
export async function testRepositoryConfig(options: {
url: string;
branch: string;
credentialId?: number | null;
}): Promise<TestResult> {
const { url, branch, credentialId } = options;
if (!url) {
return { success: false, error: 'Repository URL is required' };
}
// Fetch credential from database if credentialId is provided
const credential = credentialId ? await getGitCredential(credentialId) : null;
if (credentialId && !credential) {
return { success: false, error: 'Credential not found' };
}
return testRepositoryConnection({
url,
branch: branch || 'main',
credential
});
}
export async function syncRepository(repoId: number): Promise<SyncResult> {
const repo = await getGitRepository(repoId);
if (!repo) {
return { success: false, error: 'Repository not found' };
}
// Check if sync is already in progress
if (repo.syncStatus === 'syncing') {
return { success: false, error: 'Sync already in progress' };
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getRepoPath(repoId);
const env = await buildGitEnv(credential);
try {
// Update sync status
await updateGitRepository(repoId, { syncStatus: 'syncing', syncError: null });
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
// Clone the repository (shallow clone)
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
// Get current commit before pull
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
// Pull latest changes
const result = await execGit(['pull', 'origin', repo.branch], repoPath, env);
if (result.code !== 0) {
throw new Error(`Git pull failed: ${result.stderr}`);
}
// Get commit after pull
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
updated = beforeCommit !== afterCommit;
}
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
// Read the compose file
const composePath = join(repoPath, repo.composePath);
if (!existsSync(composePath)) {
throw new Error(`Compose file not found: ${repo.composePath}`);
}
const composeContent = await Bun.file(composePath).text();
// Update repository status
await updateGitRepository(repoId, {
syncStatus: 'synced',
lastSync: new Date().toISOString(),
lastCommit: currentCommit,
syncError: null
});
cleanupSshKey(credential);
return {
success: true,
commit: currentCommit,
composeContent,
updated
};
} catch (error: any) {
cleanupSshKey(credential);
await updateGitRepository(repoId, {
syncStatus: 'error',
syncError: error.message
});
return { success: false, error: error.message };
}
}
export async function deployFromRepository(repoId: number): Promise<{ success: boolean; output?: string; error?: string }> {
const repo = await getGitRepository(repoId);
if (!repo) {
return { success: false, error: 'Repository not found' };
}
// Sync first
const syncResult = await syncRepository(repoId);
if (!syncResult.success) {
return { success: false, error: syncResult.error };
}
const stackName = repo.name;
// Deploy using unified function - handles both new and existing stacks
const result = await deployStack({
name: stackName,
compose: syncResult.composeContent!,
envId: repo.environmentId
});
if (result.success) {
// Record the stack source
await upsertStackSource({
stackName: stackName,
environmentId: repo.environmentId,
sourceType: 'git',
gitRepositoryId: repoId
});
}
return result;
}
export async function checkForUpdates(repoId: number): Promise<{ hasUpdates: boolean; currentCommit?: string; latestCommit?: string; error?: string }> {
const repo = await getGitRepository(repoId);
if (!repo) {
return { hasUpdates: false, error: 'Repository not found' };
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getRepoPath(repoId);
const env = await buildGitEnv(credential);
try {
if (!existsSync(repoPath)) {
return { hasUpdates: true, currentCommit: 'none', latestCommit: 'unknown' };
}
// Get current commit
const currentResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const currentCommit = currentResult.stdout.substring(0, 7);
// Fetch latest without merging
await execGit(['fetch', 'origin', repo.branch], repoPath, env);
// Get remote commit
const latestResult = await execGit(['rev-parse', `origin/${repo.branch}`], repoPath, env);
const latestCommit = latestResult.stdout.substring(0, 7);
cleanupSshKey(credential);
return {
hasUpdates: currentCommit !== latestCommit,
currentCommit,
latestCommit
};
} catch (error: any) {
cleanupSshKey(credential);
return { hasUpdates: false, error: error.message };
}
}
export function deleteRepositoryFiles(repoId: number): void {
const repoPath = getRepoPath(repoId);
try {
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
} catch (error) {
console.error('Failed to delete repository files:', error);
}
}
// === Git Stack Functions ===
function getStackRepoPath(stackId: number): string {
return join(GIT_REPOS_DIR, `stack-${stackId}`);
}
export async function syncGitStack(stackId: number): Promise<SyncResult> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
return { success: false, error: 'Git stack not found' };
}
const logPrefix = `[Stack:${gitStack.stackName}]`;
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} SYNC GIT STACK START`);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} Stack ID:`, stackId);
console.log(`${logPrefix} Stack name:`, gitStack.stackName);
console.log(`${logPrefix} Repository ID:`, gitStack.repositoryId);
console.log(`${logPrefix} Compose path:`, gitStack.composePath);
console.log(`${logPrefix} Env file path:`, gitStack.envFilePath || '(none)');
console.log(`${logPrefix} Environment ID:`, gitStack.environmentId);
// Check if sync is already in progress
if (gitStack.syncStatus === 'syncing') {
console.log(`${logPrefix} ERROR: Sync already in progress`);
return { success: false, error: 'Sync already in progress' };
}
const repo = await getGitRepository(gitStack.repositoryId);
if (!repo) {
console.log(`${logPrefix} ERROR: Repository not found`);
return { success: false, error: 'Repository not found' };
}
console.log(`${logPrefix} Repository URL:`, repo.url);
console.log(`${logPrefix} Repository branch:`, repo.branch);
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getStackRepoPath(stackId);
const env = await buildGitEnv(credential);
console.log(`${logPrefix} Local repo path:`, repoPath);
console.log(`${logPrefix} Has credential:`, !!credential);
try {
// Update sync status
await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null });
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
console.log(`${logPrefix} Repo doesn't exist locally, cloning...`);
// Clone the repository (shallow clone)
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
console.log(`${logPrefix} Clone exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
console.log(`${logPrefix} Repo exists, pulling latest...`);
// Get current commit before pull
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
console.log(`${logPrefix} Commit before pull:`, beforeCommit.substring(0, 7));
// Pull latest changes
const result = await execGit(['pull', 'origin', repo.branch], repoPath, env);
console.log(`${logPrefix} Pull exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Pull stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Pull stderr:`, result.stderr);
if (result.code !== 0) {
throw new Error(`Git pull failed: ${result.stderr}`);
}
// Get commit after pull
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
console.log(`${logPrefix} Commit after pull:`, afterCommit.substring(0, 7));
updated = beforeCommit !== afterCommit;
console.log(`${logPrefix} Repo updated:`, updated);
}
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
console.log(`${logPrefix} Current commit:`, currentCommit);
// Read the compose file
const composePath = join(repoPath, gitStack.composePath);
console.log(`${logPrefix} Reading compose file from:`, composePath);
if (!existsSync(composePath)) {
console.log(`${logPrefix} ERROR: Compose file not found at:`, composePath);
throw new Error(`Compose file not found: ${gitStack.composePath}`);
}
const composeContent = await Bun.file(composePath).text();
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
if (existsSync(envFilePath)) {
try {
console.log(`${logPrefix} Reading env file...`);
const envContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
} catch (err) {
// Log but don't fail - env file is optional
console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err);
}
} else {
console.warn(`${logPrefix} Configured env file not found:`, gitStack.envFilePath);
}
} else {
console.log(`${logPrefix} No env file path configured`);
}
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
lastSync: new Date().toISOString(),
lastCommit: currentCommit,
syncError: null
});
cleanupSshKey(credential);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} SYNC GIT STACK COMPLETE`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Success: true`);
console.log(`${logPrefix} Updated:`, updated);
console.log(`${logPrefix} Commit:`, currentCommit);
console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0);
return {
success: true,
commit: currentCommit,
composeContent,
envFileVars,
updated
};
} catch (error: any) {
cleanupSshKey(credential);
await updateGitStack(stackId, {
syncStatus: 'error',
syncError: error.message
});
console.log(`${logPrefix} SYNC ERROR:`, error.message);
return { success: false, error: error.message };
}
}
export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> {
const force = options?.force ?? true; // Default to force for backward compatibility
const gitStack = await getGitStack(stackId);
if (!gitStack) {
return { success: false, error: 'Git stack not found' };
}
const logPrefix = `[Stack:${gitStack.stackName}]`;
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} DEPLOY GIT STACK START`);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} Stack ID:`, stackId);
console.log(`${logPrefix} Force deploy:`, force);
// Sync first
console.log(`${logPrefix} Syncing git repository...`);
const syncResult = await syncGitStack(stackId);
if (!syncResult.success) {
console.log(`${logPrefix} Sync failed:`, syncResult.error);
return { success: false, error: syncResult.error };
}
console.log(`${logPrefix} Sync successful`);
console.log(`${logPrefix} Sync result - updated:`, syncResult.updated);
console.log(`${logPrefix} Sync result - commit:`, syncResult.commit);
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
}
// Check if there are changes - skip redeploy if no changes and not forced
// Note: For new stacks (first deploy), syncResult.updated will be true
if (!force && !syncResult.updated) {
console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`);
return {
success: true,
output: 'No changes detected, skipping redeploy',
skipped: true
};
}
const forceRecreate = syncResult.updated && !!gitStack.envFilePath;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, hasEnvFile=${!!gitStack.envFilePath})`);
// Deploy using unified function - handles both new and existing stacks
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
// Force recreate when git detected changes AND stack has .env file configured
// This ensures containers pick up new env var values even if compose file didn't change
// Note: Without this, docker compose only detects compose file changes, not env var changes
console.log(`${logPrefix} Calling deployStack...`);
const result = await deployStack({
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
forceRecreate
});
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} DEPLOY GIT STACK RESULT`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Success:`, result.success);
if (result.output) console.log(`${logPrefix} Output:`, result.output);
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Record the stack source
await upsertStackSource({
stackName: gitStack.stackName,
environmentId: gitStack.environmentId,
sourceType: 'git',
gitRepositoryId: gitStack.repositoryId,
gitStackId: stackId
});
}
return result;
}
export async function testGitStack(stackId: number): Promise<TestResult> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
return { success: false, error: 'Git stack not found' };
}
const repo = await getGitRepository(gitStack.repositoryId);
if (!repo) {
return { success: false, error: 'Repository not found' };
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const env = await buildGitEnv(credential);
const repoUrl = buildRepoUrl(repo.url, credential);
try {
// Use git ls-remote to test connection and get branch info
const result = await execGit(
['ls-remote', '--heads', '--refs', repoUrl, repo.branch],
process.cwd(),
env
);
cleanupSshKey(credential);
if (result.code !== 0) {
return { success: false, error: result.stderr || 'Failed to connect to repository' };
}
// Parse the output to get commit hash
const lines = result.stdout.split('\n').filter(l => l.trim());
if (lines.length === 0) {
return { success: false, error: `Branch '${repo.branch}' not found in repository` };
}
const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/);
const lastCommit = match ? match[1].substring(0, 7) : undefined;
const branch = match ? match[2] : repo.branch;
cleanupSshKey(credential);
return {
success: true,
branch,
lastCommit
};
} catch (error: any) {
cleanupSshKey(credential);
return { success: false, error: error.message };
}
}
export function deleteGitStackFiles(stackId: number): void {
const repoPath = getStackRepoPath(stackId);
try {
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
} catch (error) {
console.error('Failed to delete git stack files:', error);
}
}
// Progress callback type
type ProgressCallback = (data: {
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
message?: string;
step?: number;
totalSteps?: number;
error?: string;
}) => void;
export async function deployGitStackWithProgress(
stackId: number,
onProgress: ProgressCallback
): Promise<{ success: boolean; output?: string; error?: string }> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
onProgress({ status: 'error', error: 'Git stack not found' });
return { success: false, error: 'Git stack not found' };
}
// Check if sync is already in progress
if (gitStack.syncStatus === 'syncing') {
onProgress({ status: 'error', error: 'Sync already in progress' });
return { success: false, error: 'Sync already in progress' };
}
const repo = await getGitRepository(gitStack.repositoryId);
if (!repo) {
onProgress({ status: 'error', error: 'Repository not found' });
return { success: false, error: 'Repository not found' };
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getStackRepoPath(stackId);
const env = await buildGitEnv(credential);
const totalSteps = 5;
try {
// Step 1: Connecting
onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null });
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
// Step 2-3: Fetching and resetting to latest (works with shallow clones)
onProgress({ status: 'fetching', message: 'Fetching latest changes...', step: 2, totalSteps });
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
// Fetch the latest from origin (shallow fetch)
const fetchResult = await execGit(['fetch', '--depth=1', 'origin', repo.branch], repoPath, env);
if (fetchResult.code !== 0) {
throw new Error(`Git fetch failed: ${fetchResult.stderr}`);
}
// Reset to the fetched commit (this works reliably with shallow clones)
onProgress({ status: 'fetching', message: 'Updating to latest...', step: 3, totalSteps });
const resetResult = await execGit(['reset', '--hard', `origin/${repo.branch}`], repoPath, env);
if (resetResult.code !== 0) {
throw new Error(`Git reset failed: ${resetResult.stderr}`);
}
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
updated = beforeCommit !== afterCommit;
}
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
// Step 4: Reading compose file
onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps });
const composePath = join(repoPath, gitStack.composePath);
if (!existsSync(composePath)) {
throw new Error(`Compose file not found: ${gitStack.composePath}`);
}
const composeContent = await Bun.file(composePath).text();
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
if (existsSync(envFilePath)) {
try {
const envContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
} catch (err) {
// Log but don't fail - env file is optional
console.warn(`Failed to read env file ${gitStack.envFilePath}:`, err);
}
} else {
console.warn(`Configured env file not found: ${gitStack.envFilePath}`);
}
}
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
lastSync: new Date().toISOString(),
lastCommit: currentCommit,
syncError: null
});
cleanupSshKey(credential);
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
const result = await deployStack({
name: gitStack.stackName,
compose: composeContent,
envId: gitStack.environmentId,
envFileVars
});
if (result.success) {
// Record the stack source
await upsertStackSource({
stackName: gitStack.stackName,
environmentId: gitStack.environmentId,
sourceType: 'git',
gitRepositoryId: gitStack.repositoryId,
gitStackId: stackId
});
onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` });
} else {
throw new Error(result.error || 'Failed to deploy stack');
}
return result;
} catch (error: any) {
cleanupSshKey(credential);
await updateGitStack(stackId, {
syncStatus: 'error',
syncError: error.message
});
onProgress({ status: 'error', error: error.message });
return { success: false, error: error.message };
}
}
// =============================================================================
// ENV FILE OPERATIONS
// =============================================================================
/**
* List all .env* files in a git stack's repository.
* Returns relative paths from the repository root.
*/
export async function listGitStackEnvFiles(stackId: number): Promise<{ files: string[]; error?: string }> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
return { files: [], error: 'Git stack not found' };
}
const repoPath = getStackRepoPath(stackId);
if (!existsSync(repoPath)) {
return { files: [], error: 'Repository not synced - deploy the stack first' };
}
try {
// Find all .env* files recursively (but not too deep)
const maxDepth = 3;
// Use find to locate all .env* files
const proc = Bun.spawn(['find', repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
stdout: 'pipe',
stderr: 'pipe'
});
const output = await new Response(proc.stdout).text();
await proc.exited;
const files = output.trim().split('\n').filter(f => f);
const envFiles: string[] = [];
for (const file of files) {
// Convert absolute path to relative from repo root
const relativePath = file.replace(repoPath + '/', '');
// Skip files in node_modules or .git directories
if (!relativePath.includes('node_modules/') && !relativePath.includes('.git/')) {
envFiles.push(relativePath);
}
}
return { files: envFiles.sort() };
} catch (error: any) {
return { files: [], error: error.message };
}
}
/**
* Parse a .env file content into key-value pairs.
* Handles comments, empty lines, and quoted values.
*/
export function parseEnvFileContent(content: string, stackName?: string): Record<string, string> {
const logPrefix = stackName ? `[Stack:${stackName}]` : '[Git]';
const result: Record<string, string> = {};
const skippedLines: string[] = [];
const invalidKeys: string[] = [];
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} PARSE ENV FILE CONTENT`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Raw content length:`, content.length, 'chars');
console.log(`${logPrefix} Raw content:`);
console.log(content);
const lines = content.split('\n');
console.log(`${logPrefix} Total lines:`, lines.length);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
if (trimmed) skippedLines.push(`Line ${i + 1}: ${trimmed.substring(0, 50)}...`);
continue;
}
// Find the first = sign
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) {
skippedLines.push(`Line ${i + 1} (no =): ${trimmed.substring(0, 50)}`);
continue;
}
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Handle quoted values
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Only add if key is valid env var name
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result[key] = value;
} else {
invalidKeys.push(`Line ${i + 1}: "${key}" (invalid key format)`);
}
}
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
if (skippedLines.length > 0) {
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
}
if (invalidKeys.length > 0) {
console.log(`${logPrefix} Invalid keys (${invalidKeys.length}):`, invalidKeys.join('; '));
}
return result;
}
/**
* Read and parse a .env file from a git stack's repository.
*/
export async function readGitStackEnvFile(
stackId: number,
envFilePath: string
): Promise<{ vars: Record<string, string>; error?: string }> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
return { vars: {}, error: 'Git stack not found' };
}
const repoPath = getStackRepoPath(stackId);
if (!existsSync(repoPath)) {
return { vars: {}, error: 'Repository not synced - deploy the stack first' };
}
// Security check: ensure the path doesn't escape the repo
const normalizedPath = envFilePath.replace(/\.\./g, '').replace(/^\//, '');
const fullPath = join(repoPath, normalizedPath);
if (!fullPath.startsWith(repoPath)) {
return { vars: {}, error: 'Invalid file path' };
}
if (!existsSync(fullPath)) {
return { vars: {}, error: `File not found: ${envFilePath}` };
}
try {
const content = await Bun.file(fullPath).text();
const vars = parseEnvFileContent(content);
return { vars };
} catch (error: any) {
return { vars: {}, error: error.message };
}
}