feat: Add repository status and update functionality

- Add ORIGINAL_REPO_URL environment variable for repository updates
- Create RepoStatusButton component with status display and update functionality
- Enhance GitManager with fullUpdate() method (git pull + npm install + build)
- Add fullUpdateRepo API endpoint for complete repository updates
- Display repository status with visual indicators (up-to-date, updates available, etc.)
- Show real-time progress during update process
- Add manual refresh capability for repository status
- Integrate repository status component into main page
This commit is contained in:
Michel Roegl-Brunner
2025-09-15 15:31:51 +02:00
parent 067a7d6e79
commit cb724f245b
14 changed files with 1334 additions and 92 deletions

View File

@@ -41,6 +41,13 @@ export const scriptsRouter = createTRPCRouter({
return result;
}),
// Full update repository (git pull, npm install, build)
fullUpdateRepo: publicProcedure
.mutation(async () => {
const result = await gitManager.fullUpdate();
return result;
}),
// Get script content for viewing
getScriptContent: publicProcedure
.input(z.object({ path: z.string() }))

View File

@@ -1,6 +1,10 @@
import { simpleGit, type SimpleGit } from 'simple-git';
import { env } from '~/env.js';
import { join } from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export class GitManager {
private git: SimpleGit;
@@ -18,7 +22,7 @@ export class GitManager {
*/
async isBehindRemote(): Promise<boolean> {
try {
if (!env.REPO_URL) {
if (!env.ORIGINAL_REPO_URL) {
return false; // No remote configured
}
@@ -41,7 +45,7 @@ export class GitManager {
*/
async pullUpdates(): Promise<{ success: boolean; message: string }> {
try {
if (!env.REPO_URL) {
if (!env.ORIGINAL_REPO_URL) {
return { success: false, message: 'No remote repository configured' };
}
@@ -68,18 +72,102 @@ export class GitManager {
}
}
/**
* Full update process: git pull, npm install, and restart server
*/
async fullUpdate(): Promise<{ success: boolean; message: string; steps: string[] }> {
const steps: string[] = [];
try {
if (!env.ORIGINAL_REPO_URL) {
return {
success: false,
message: 'No remote repository configured',
steps: ['❌ No remote repository configured']
};
}
// Step 1: Git pull
steps.push('🔄 Pulling latest changes from repository...');
const pullResult = await this.pullUpdates();
if (!pullResult.success) {
return {
success: false,
message: pullResult.message,
steps: [...steps, `${pullResult.message}`]
};
}
steps.push(`${pullResult.message}`);
// Step 2: npm install
steps.push('📦 Installing/updating dependencies...');
try {
const { stdout, stderr } = await execAsync('npm install', { cwd: this.repoPath });
if (stderr && !stderr.includes('npm WARN')) {
console.warn('npm install warnings:', stderr);
}
steps.push('✅ Dependencies updated successfully');
} catch (error) {
const errorMsg = `Failed to install dependencies: ${error instanceof Error ? error.message : 'Unknown error'}`;
steps.push(`${errorMsg}`);
return {
success: false,
message: errorMsg,
steps
};
}
// Step 3: Build the application
steps.push('🔨 Building application...');
try {
const { stdout, stderr } = await execAsync('npm run build', { cwd: this.repoPath });
if (stderr && !stderr.includes('npm WARN')) {
console.warn('npm build warnings:', stderr);
}
steps.push('✅ Application built successfully');
} catch (error) {
const errorMsg = `Failed to build application: ${error instanceof Error ? error.message : 'Unknown error'}`;
steps.push(`${errorMsg}`);
return {
success: false,
message: errorMsg,
steps
};
}
// Step 4: Restart server (this will be handled by the process manager)
steps.push('🔄 Server restart required - please restart manually or use a process manager');
steps.push('✅ Update process completed successfully');
return {
success: true,
message: 'Repository updated successfully. Please restart the server to apply changes.',
steps
};
} catch (error) {
const errorMsg = `Update failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
steps.push(`${errorMsg}`);
return {
success: false,
message: errorMsg,
steps
};
}
}
/**
* Clone the repository if it doesn't exist
*/
private async cloneRepository(): Promise<{ success: boolean; message: string }> {
try {
if (!env.REPO_URL) {
if (!env.ORIGINAL_REPO_URL) {
return { success: false, message: 'No repository URL configured' };
}
// Clone the repository
await this.git.clone(env.REPO_URL, this.repoPath, [
await this.git.clone(env.ORIGINAL_REPO_URL, this.repoPath, [
'--branch', env.REPO_BRANCH,
'--single-branch',
'--depth', '1'
@@ -87,7 +175,7 @@ export class GitManager {
return {
success: true,
message: `Successfully cloned repository from ${env.REPO_URL}`
message: `Successfully cloned repository from ${env.ORIGINAL_REPO_URL}`
};
} catch (error) {
console.error('Error cloning repository:', error);
@@ -103,7 +191,7 @@ export class GitManager {
*/
async initializeRepository(): Promise<void> {
try {
if (!env.REPO_URL) {
if (!env.ORIGINAL_REPO_URL) {
return;
}

View File

@@ -9,6 +9,7 @@ export class GitHubJsonService {
private branch: string;
private jsonFolder: string;
private localJsonDirectory: string;
private scriptCache: Map<string, Script> = new Map();
constructor() {
this.repoUrl = env.REPO_URL ?? "";
@@ -130,14 +131,49 @@ export class GitHubJsonService {
async getScriptBySlug(slug: string): Promise<Script | null> {
try {
const scripts = await this.getAllScripts();
return scripts.find(script => script.slug === slug) ?? null;
// Try to get from local cache first
const localScript = await this.getScriptFromLocal(slug);
if (localScript) {
return localScript;
}
// If not found locally, try to download just this specific script
try {
const script = await this.downloadJsonFile(`${this.jsonFolder}/${slug}.json`);
return script;
} catch (error) {
console.log(`Script ${slug} not found in repository`);
return null;
}
} catch (error) {
console.error('Error fetching script by slug:', error);
throw new Error(`Failed to fetch script: ${slug}`);
}
}
private async getScriptFromLocal(slug: string): Promise<Script | null> {
try {
// Check cache first
if (this.scriptCache.has(slug)) {
return this.scriptCache.get(slug)!;
}
const { readFile } = await import('fs/promises');
const { join } = await import('path');
const filePath = join(this.localJsonDirectory, `${slug}.json`);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content) as Script;
// Cache the script
this.scriptCache.set(slug, script);
return script;
} catch {
return null;
}
}
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
try {
// Get all scripts from GitHub (1 API call + raw downloads)

View File

@@ -73,8 +73,17 @@ export class LocalScriptsService {
async getScriptBySlug(slug: string): Promise<Script | null> {
try {
const scripts = await this.getAllScripts();
return scripts.find(script => script.slug === slug) ?? null;
// Try to read the specific script file directly instead of loading all scripts
const filename = `${slug}.json`;
const filePath = join(this.scriptsDirectory, filename);
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as Script;
} catch (fileError) {
// If file doesn't exist, return null instead of throwing
return null;
}
} catch (error) {
console.error('Error fetching script by slug:', error);
throw new Error(`Failed to fetch script: ${slug}`);

View File

@@ -59,11 +59,12 @@ export class ScriptDownloaderService {
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm'));
// Download and save CT script
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script?.startsWith('ct/')) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
@@ -71,28 +72,57 @@ export class ScriptDownloaderService {
// Download from GitHub
const content = await this.downloadFileFromGitHub(scriptPath);
// Modify the content
const modifiedContent = this.modifyScriptContent(content);
// Determine target directory based on script path
let targetDir: string;
let filePath: string;
// Save to local directory
const localPath = join(this.scriptsDirectory, 'ct', fileName);
await writeFile(localPath, modifiedContent, 'utf-8');
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Don't modify content for tools scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Don't modify content for VM scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Don't modify content for VW scripts
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
files.push(`ct/${fileName}`);
files.push(`${targetDir}/${fileName}`);
}
}
}
}
// Download and save install script
const installScriptName = `${script.slug}-install.sh`;
try {
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
} catch {
// Install script might not exist, that's okay
// Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
} catch {
// Install script might not exist, that's okay
}
}
return {
@@ -116,27 +146,67 @@ export class ScriptDownloaderService {
let installExists = false;
try {
// Check CT script
// Check scripts based on their install methods
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script?.startsWith('ct/')) {
const fileName = method.script.split('/').pop();
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory, 'ct', fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true;
files.push(`ct/${fileName}`);
} catch {
// File doesn't exist
let targetDir: string;
let localPath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
localPath = join(this.scriptsDirectory, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true;
files.push(`${targetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
localPath = join(this.scriptsDirectory, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for tools scripts too for UI consistency
files.push(`${targetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
localPath = join(this.scriptsDirectory, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VM scripts too for UI consistency
files.push(`${targetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
localPath = join(this.scriptsDirectory, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VW scripts too for UI consistency
files.push(`${targetDir}/${fileName}`);
} catch {
// File doesn't exist
}
}
}
}
}
}
// Check install script
const installScriptName = `${script.slug}-install.sh`;
// Only check install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
await readFile(localInstallPath, 'utf-8');
@@ -145,6 +215,7 @@ export class ScriptDownloaderService {
} catch {
// File doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
@@ -165,31 +236,44 @@ export class ScriptDownloaderService {
return { hasDifferences: false, differences: [] };
}
// Compare CT script only if it exists locally
// If we have local files, proceed with comparison
// Use Promise.all to run comparisons in parallel
const comparisonPromises: Promise<void>[] = [];
// Compare scripts only if they exist locally
if (localFilesExist.ctExists && script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script?.startsWith('ct/')) {
const fileName = method.script.split('/').pop();
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory, 'ct', fileName);
try {
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(method.script);
// Apply the same modification that would be applied during load
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
// Compare content
if (localContent !== modifiedRemoteContent) {
hasDifferences = true;
differences.push(`ct/${fileName}`);
}
} catch {
// Don't add to differences if there's an error reading files
}
let targetDir: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
} else {
continue; // Skip unknown script types
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${targetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
}
}
@@ -198,27 +282,25 @@ export class ScriptDownloaderService {
// Compare install script only if it exists locally
if (localFilesExist.installExists) {
const installScriptName = `${script.slug}-install.sh`;
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
// Read local content
const localContent = await readFile(localInstallPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
// Apply the same modification that would be applied during load
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
// Compare content
if (localContent !== modifiedRemoteContent) {
hasDifferences = true;
differences.push(`install/${installScriptName}`);
}
} catch {
// Don't add to differences if there's an error reading files
}
const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
@@ -226,6 +308,34 @@ export class ScriptDownloaderService {
}
}
private async compareSingleFile(remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
try {
const localPath = join(this.scriptsDirectory, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(remotePath);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent: string;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools, vm, or vw scripts
}
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
}
}
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
try {
let localContent: string | null = null;