|
import { json } from '@remix-run/node'; |
|
import type { ActionFunction } from '@remix-run/node'; |
|
import { exec } from 'child_process'; |
|
import { promisify } from 'util'; |
|
|
|
const execAsync = promisify(exec); |
|
|
|
interface UpdateRequestBody { |
|
branch: string; |
|
autoUpdate?: boolean; |
|
} |
|
|
|
interface UpdateProgress { |
|
stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete'; |
|
message: string; |
|
progress?: number; |
|
error?: string; |
|
details?: { |
|
changedFiles?: string[]; |
|
additions?: number; |
|
deletions?: number; |
|
commitMessages?: string[]; |
|
totalSize?: string; |
|
currentCommit?: string; |
|
remoteCommit?: string; |
|
updateReady?: boolean; |
|
changelog?: string; |
|
compareUrl?: string; |
|
}; |
|
} |
|
|
|
export const action: ActionFunction = async ({ request }) => { |
|
if (request.method !== 'POST') { |
|
return json({ error: 'Method not allowed' }, { status: 405 }); |
|
} |
|
|
|
try { |
|
const body = await request.json(); |
|
|
|
if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') { |
|
return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 }); |
|
} |
|
|
|
const { branch, autoUpdate = false } = body as UpdateRequestBody; |
|
|
|
|
|
const stream = new ReadableStream({ |
|
async start(controller) { |
|
const encoder = new TextEncoder(); |
|
const sendProgress = (update: UpdateProgress) => { |
|
controller.enqueue(encoder.encode(JSON.stringify(update) + '\n')); |
|
}; |
|
|
|
try { |
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Checking repository status...', |
|
progress: 0, |
|
}); |
|
|
|
|
|
let defaultBranch = branch || 'main'; |
|
|
|
try { |
|
await execAsync('git remote get-url upstream'); |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Repository remote verified', |
|
progress: 10, |
|
}); |
|
} catch { |
|
throw new Error( |
|
'No upstream repository found. Please set up the upstream repository first by running:\ngit remote add upstream https://github.com/stackblitz-labs/bolt.diy.git', |
|
); |
|
} |
|
|
|
|
|
if (!branch) { |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Detecting default branch...', |
|
progress: 20, |
|
}); |
|
|
|
try { |
|
const { stdout } = await execAsync('git remote show upstream | grep "HEAD branch" | cut -d" " -f5'); |
|
defaultBranch = stdout.trim() || 'main'; |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: `Using branch: ${defaultBranch}`, |
|
progress: 30, |
|
}); |
|
} catch { |
|
defaultBranch = 'main'; |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Using default branch: main', |
|
progress: 30, |
|
}); |
|
} |
|
} |
|
|
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Fetching latest changes...', |
|
progress: 40, |
|
}); |
|
|
|
|
|
await execAsync('git fetch --all'); |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Remote changes fetched', |
|
progress: 50, |
|
}); |
|
|
|
|
|
try { |
|
await execAsync(`git rev-parse --verify upstream/${defaultBranch}`); |
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Remote branch verified', |
|
progress: 60, |
|
}); |
|
} catch { |
|
throw new Error( |
|
`Remote branch 'upstream/${defaultBranch}' not found. Please ensure the upstream repository is properly configured.`, |
|
); |
|
} |
|
|
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Comparing versions...', |
|
progress: 70, |
|
}); |
|
|
|
const { stdout: currentCommit } = await execAsync('git rev-parse HEAD'); |
|
const { stdout: remoteCommit } = await execAsync(`git rev-parse upstream/${defaultBranch}`); |
|
|
|
|
|
if (currentCommit.trim() === remoteCommit.trim()) { |
|
sendProgress({ |
|
stage: 'complete', |
|
message: 'No updates available. You are on the latest version.', |
|
progress: 100, |
|
details: { |
|
currentCommit: currentCommit.trim().substring(0, 7), |
|
remoteCommit: remoteCommit.trim().substring(0, 7), |
|
}, |
|
}); |
|
return; |
|
} |
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Analyzing changes...', |
|
progress: 80, |
|
}); |
|
|
|
|
|
let changedFiles: string[] = []; |
|
let commitMessages: string[] = []; |
|
let stats: RegExpMatchArray | null = null; |
|
let totalSizeInBytes = 0; |
|
|
|
|
|
const formatSize = (bytes: number) => { |
|
if (bytes === 0) { |
|
return '0 B'; |
|
} |
|
|
|
const k = 1024; |
|
const sizes = ['B', 'KB', 'MB', 'GB']; |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; |
|
}; |
|
|
|
|
|
try { |
|
const { stdout: diffOutput } = await execAsync( |
|
`git diff --name-status ${currentCommit.trim()}..${remoteCommit.trim()}`, |
|
); |
|
const files = diffOutput.split('\n').filter(Boolean); |
|
|
|
if (files.length === 0) { |
|
sendProgress({ |
|
stage: 'complete', |
|
message: `No file changes detected between your version and upstream/${defaultBranch}. You might be on a different branch.`, |
|
progress: 100, |
|
details: { |
|
currentCommit: currentCommit.trim().substring(0, 7), |
|
remoteCommit: remoteCommit.trim().substring(0, 7), |
|
}, |
|
}); |
|
return; |
|
} |
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: `Found ${files.length} changed files, calculating sizes...`, |
|
progress: 90, |
|
}); |
|
|
|
|
|
for (const line of files) { |
|
const [status, file] = line.split('\t'); |
|
|
|
if (status !== 'D') { |
|
|
|
try { |
|
const { stdout: sizeOutput } = await execAsync(`git cat-file -s ${remoteCommit.trim()}:${file}`); |
|
const size = parseInt(sizeOutput) || 0; |
|
totalSizeInBytes += size; |
|
} catch { |
|
console.debug(`Could not get size for file: ${file}`); |
|
} |
|
} |
|
} |
|
|
|
changedFiles = files.map((line) => { |
|
const [status, file] = line.split('\t'); |
|
return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`; |
|
}); |
|
} catch (err) { |
|
console.debug('Failed to get changed files:', err); |
|
throw new Error(`Failed to compare changes with upstream/${defaultBranch}. Are you on the correct branch?`); |
|
} |
|
|
|
|
|
try { |
|
const { stdout: logOutput } = await execAsync( |
|
`git log --pretty=format:"%h|%s|%aI" ${currentCommit.trim()}..${remoteCommit.trim()}`, |
|
); |
|
|
|
|
|
const commits = logOutput |
|
.split('\n') |
|
.filter(Boolean) |
|
.map((line) => { |
|
const [hash, subject, timestamp] = line.split('|'); |
|
let type = 'other'; |
|
let message = subject; |
|
|
|
if (subject.startsWith('feat:') || subject.startsWith('feature:')) { |
|
type = 'feature'; |
|
message = subject.replace(/^feat(?:ure)?:/, '').trim(); |
|
} else if (subject.startsWith('fix:')) { |
|
type = 'fix'; |
|
message = subject.replace(/^fix:/, '').trim(); |
|
} else if (subject.startsWith('docs:')) { |
|
type = 'docs'; |
|
message = subject.replace(/^docs:/, '').trim(); |
|
} else if (subject.startsWith('style:')) { |
|
type = 'style'; |
|
message = subject.replace(/^style:/, '').trim(); |
|
} else if (subject.startsWith('refactor:')) { |
|
type = 'refactor'; |
|
message = subject.replace(/^refactor:/, '').trim(); |
|
} else if (subject.startsWith('perf:')) { |
|
type = 'perf'; |
|
message = subject.replace(/^perf:/, '').trim(); |
|
} else if (subject.startsWith('test:')) { |
|
type = 'test'; |
|
message = subject.replace(/^test:/, '').trim(); |
|
} else if (subject.startsWith('build:')) { |
|
type = 'build'; |
|
message = subject.replace(/^build:/, '').trim(); |
|
} else if (subject.startsWith('ci:')) { |
|
type = 'ci'; |
|
message = subject.replace(/^ci:/, '').trim(); |
|
} |
|
|
|
return { |
|
hash, |
|
type, |
|
message, |
|
timestamp: new Date(timestamp), |
|
}; |
|
}); |
|
|
|
|
|
const groupedCommits = commits.reduce( |
|
(acc, commit) => { |
|
if (!acc[commit.type]) { |
|
acc[commit.type] = []; |
|
} |
|
|
|
acc[commit.type].push(commit); |
|
|
|
return acc; |
|
}, |
|
{} as Record<string, typeof commits>, |
|
); |
|
|
|
|
|
const formattedMessages = Object.entries(groupedCommits).map(([type, commits]) => { |
|
const emoji = { |
|
feature: 'β¨', |
|
fix: 'π', |
|
docs: 'π', |
|
style: 'π', |
|
refactor: 'β»οΈ', |
|
perf: 'β‘', |
|
test: 'π§ͺ', |
|
build: 'π οΈ', |
|
ci: 'βοΈ', |
|
other: 'π', |
|
}[type]; |
|
|
|
const title = { |
|
feature: 'Features', |
|
fix: 'Bug Fixes', |
|
docs: 'Documentation', |
|
style: 'Styles', |
|
refactor: 'Code Refactoring', |
|
perf: 'Performance', |
|
test: 'Tests', |
|
build: 'Build', |
|
ci: 'CI', |
|
other: 'Other Changes', |
|
}[type]; |
|
|
|
return `### ${emoji} ${title}\n\n${commits |
|
.map((c) => `* ${c.message} (${c.hash.substring(0, 7)}) - ${c.timestamp.toLocaleString()}`) |
|
.join('\n')}`; |
|
}); |
|
|
|
commitMessages = formattedMessages; |
|
} catch { |
|
|
|
} |
|
|
|
|
|
try { |
|
const { stdout: diffStats } = await execAsync( |
|
`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`, |
|
); |
|
stats = diffStats.match( |
|
/(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/, |
|
); |
|
} catch { |
|
|
|
} |
|
|
|
|
|
if (!stats && changedFiles.length === 0) { |
|
sendProgress({ |
|
stage: 'complete', |
|
message: `No changes detected between your version and upstream/${defaultBranch}. This might be unexpected - please check your git status.`, |
|
progress: 100, |
|
}); |
|
return; |
|
} |
|
|
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: 'Fetching changelog...', |
|
progress: 95, |
|
}); |
|
|
|
const changelog = await fetchChangelog(currentCommit.trim(), remoteCommit.trim()); |
|
|
|
|
|
sendProgress({ |
|
stage: 'fetch', |
|
message: `Changes detected on upstream/${defaultBranch}`, |
|
progress: 100, |
|
details: { |
|
changedFiles, |
|
additions: stats?.[2] ? parseInt(stats[2]) : 0, |
|
deletions: stats?.[3] ? parseInt(stats[3]) : 0, |
|
commitMessages, |
|
totalSize: formatSize(totalSizeInBytes), |
|
currentCommit: currentCommit.trim().substring(0, 7), |
|
remoteCommit: remoteCommit.trim().substring(0, 7), |
|
updateReady: true, |
|
changelog, |
|
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`, |
|
}, |
|
}); |
|
|
|
|
|
if (!autoUpdate) { |
|
sendProgress({ |
|
stage: 'complete', |
|
message: 'Update is ready to be applied. Click "Update Now" to proceed.', |
|
progress: 100, |
|
details: { |
|
changedFiles, |
|
additions: stats?.[2] ? parseInt(stats[2]) : 0, |
|
deletions: stats?.[3] ? parseInt(stats[3]) : 0, |
|
commitMessages, |
|
totalSize: formatSize(totalSizeInBytes), |
|
currentCommit: currentCommit.trim().substring(0, 7), |
|
remoteCommit: remoteCommit.trim().substring(0, 7), |
|
updateReady: true, |
|
changelog, |
|
compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`, |
|
}, |
|
}); |
|
return; |
|
} |
|
|
|
|
|
sendProgress({ |
|
stage: 'pull', |
|
message: `Pulling changes from upstream/${defaultBranch}...`, |
|
progress: 0, |
|
}); |
|
|
|
await execAsync(`git pull upstream ${defaultBranch}`); |
|
|
|
sendProgress({ |
|
stage: 'pull', |
|
message: 'Changes pulled successfully', |
|
progress: 100, |
|
}); |
|
|
|
|
|
sendProgress({ |
|
stage: 'install', |
|
message: 'Installing dependencies...', |
|
progress: 0, |
|
}); |
|
|
|
await execAsync('pnpm install'); |
|
|
|
sendProgress({ |
|
stage: 'install', |
|
message: 'Dependencies installed successfully', |
|
progress: 100, |
|
}); |
|
|
|
|
|
sendProgress({ |
|
stage: 'build', |
|
message: 'Building application...', |
|
progress: 0, |
|
}); |
|
|
|
await execAsync('pnpm build'); |
|
|
|
sendProgress({ |
|
stage: 'build', |
|
message: 'Build completed successfully', |
|
progress: 100, |
|
}); |
|
|
|
|
|
sendProgress({ |
|
stage: 'complete', |
|
message: 'Update completed successfully! Click Restart to apply changes.', |
|
progress: 100, |
|
}); |
|
} catch (err) { |
|
sendProgress({ |
|
stage: 'complete', |
|
message: 'Update failed', |
|
error: err instanceof Error ? err.message : 'Unknown error occurred', |
|
}); |
|
} finally { |
|
controller.close(); |
|
} |
|
}, |
|
}); |
|
|
|
return new Response(stream, { |
|
headers: { |
|
'Content-Type': 'text/event-stream', |
|
'Cache-Control': 'no-cache', |
|
Connection: 'keep-alive', |
|
}, |
|
}); |
|
} catch (err) { |
|
console.error('Update preparation failed:', err); |
|
return json( |
|
{ |
|
success: false, |
|
error: err instanceof Error ? err.message : 'Unknown error occurred while preparing update', |
|
}, |
|
{ status: 500 }, |
|
); |
|
} |
|
}; |
|
|
|
|
|
async function fetchChangelog(currentCommit: string, remoteCommit: string): Promise<string> { |
|
try { |
|
|
|
const { stdout: changelogContent } = await execAsync('git show upstream/main:changelog.md'); |
|
|
|
|
|
if (changelogContent) { |
|
return changelogContent; |
|
} |
|
|
|
|
|
let changelog = '# Changes in this Update\n\n'; |
|
|
|
|
|
const { stdout: commitLog } = await execAsync( |
|
`git log --pretty=format:"%h|%s|%b" ${currentCommit.trim()}..${remoteCommit.trim()}`, |
|
); |
|
|
|
const commits = commitLog.split('\n').filter(Boolean); |
|
const categorizedCommits: Record<string, string[]> = { |
|
'β¨ Features': [], |
|
'π Bug Fixes': [], |
|
'π Documentation': [], |
|
'π Styles': [], |
|
'β»οΈ Code Refactoring': [], |
|
'β‘ Performance': [], |
|
'π§ͺ Tests': [], |
|
'π οΈ Build': [], |
|
'βοΈ CI': [], |
|
'π Other Changes': [], |
|
}; |
|
|
|
|
|
for (const commit of commits) { |
|
const [hash, subject] = commit.split('|'); |
|
let category = 'π Other Changes'; |
|
|
|
if (subject.startsWith('feat:') || subject.startsWith('feature:')) { |
|
category = 'β¨ Features'; |
|
} else if (subject.startsWith('fix:')) { |
|
category = 'π Bug Fixes'; |
|
} else if (subject.startsWith('docs:')) { |
|
category = 'π Documentation'; |
|
} else if (subject.startsWith('style:')) { |
|
category = 'π Styles'; |
|
} else if (subject.startsWith('refactor:')) { |
|
category = 'β»οΈ Code Refactoring'; |
|
} else if (subject.startsWith('perf:')) { |
|
category = 'β‘ Performance'; |
|
} else if (subject.startsWith('test:')) { |
|
category = 'π§ͺ Tests'; |
|
} else if (subject.startsWith('build:')) { |
|
category = 'π οΈ Build'; |
|
} else if (subject.startsWith('ci:')) { |
|
category = 'βοΈ CI'; |
|
} |
|
|
|
const message = subject.includes(':') ? subject.split(':')[1].trim() : subject.trim(); |
|
categorizedCommits[category].push(`* ${message} (${hash.substring(0, 7)})`); |
|
} |
|
|
|
|
|
for (const [category, commits] of Object.entries(categorizedCommits)) { |
|
if (commits.length > 0) { |
|
changelog += `\n## ${category}\n\n${commits.join('\n')}\n`; |
|
} |
|
} |
|
|
|
|
|
const { stdout: stats } = await execAsync(`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`); |
|
|
|
if (stats) { |
|
changelog += '\n## π Stats\n\n'; |
|
changelog += `${stats.trim()}\n`; |
|
} |
|
|
|
return changelog; |
|
} catch (error) { |
|
console.error('Error fetching changelog:', error); |
|
return 'Unable to fetch changelog'; |
|
} |
|
} |
|
|