From 25399576392c0bedee8a2df0633735ba4dfbf40a Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Wed, 10 Sep 2025 14:58:37 +0200 Subject: [PATCH] feat: Add Load Script functionality to script detail modal - Create ScriptDownloaderService to download and modify script files from GitHub - Add tRPC routes for loading scripts and checking file existence - Add Load Script button to ScriptDetailModal with loading states - Implement sed replacement for build.func source line in CT scripts - Download CT scripts to scripts/ct/ and install scripts to scripts/install/ - Add visual indicators for script file availability - Show success/error messages for script loading operations --- src/app/_components/ScriptDetailModal.tsx | 106 +++++++++++++-- src/server/api/routers/scripts.ts | 62 +++++++++ src/server/services/scriptDownloader.ts | 158 ++++++++++++++++++++++ 3 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 src/server/services/scriptDownloader.ts diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 2596f28..716eb79 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { api } from '~/trpc/react'; import type { Script } from '~/types/script'; interface ScriptDetailModalProps { @@ -11,6 +12,33 @@ interface ScriptDetailModalProps { export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModalProps) { const [imageError, setImageError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadMessage, setLoadMessage] = useState(null); + + // Check if script files exist locally + const { data: scriptFilesData } = api.scripts.checkScriptFiles.useQuery( + { slug: script?.slug ?? '' }, + { enabled: !!script && isOpen } + ); + + // Load script mutation + const loadScriptMutation = api.scripts.loadScript.useMutation({ + onSuccess: (data) => { + setIsLoading(false); + if (data.success) { + setLoadMessage(`✅ ${data.message}`); + } else { + setLoadMessage(`❌ ${data.error}`); + } + // Clear message after 5 seconds + setTimeout(() => setLoadMessage(null), 5000); + }, + onError: (error) => { + setIsLoading(false); + setLoadMessage(`❌ Error: ${error.message}`); + setTimeout(() => setLoadMessage(null), 5000); + }, + }); if (!isOpen || !script) return null; @@ -24,6 +52,14 @@ export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModal } }; + const handleLoadScript = async () => { + if (!script) return; + + setIsLoading(true); + setLoadMessage(null); + loadScriptMutation.mutate({ slug: script.slug }); + }; + return (
- +
+ {/* Load Script Button */} + + +
+ {/* Load Message */} + {loadMessage && ( +
+ {loadMessage} +
+ )} + + {/* Script Files Status */} + {scriptFilesData?.success && ( +
+
+
+
+ CT Script: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'} +
+
+
+ Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'} +
+
+ {scriptFilesData.files.length > 0 && ( +
+ Files: {scriptFilesData.files.join(', ')} +
+ )} +
+ )} + {/* Content */}
{/* Description */} diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts index a8a0462..3c29d07 100644 --- a/src/server/api/routers/scripts.ts +++ b/src/server/api/routers/scripts.ts @@ -4,6 +4,7 @@ import { scriptManager } from "~/server/lib/scripts"; import { gitManager } from "~/server/lib/git"; import { githubService } from "~/server/services/github"; import { localScriptsService } from "~/server/services/localScripts"; +import { scriptDownloaderService } from "~/server/services/scriptDownloader"; export const scriptsRouter = createTRPCRouter({ // Get all available scripts @@ -137,5 +138,66 @@ export const scriptsRouter = createTRPCRouter({ count: 0 }; } + }), + + // Load script files from GitHub + loadScript: publicProcedure + .input(z.object({ slug: z.string() })) + .mutation(async ({ input }) => { + try { + // Get the script details + const script = await localScriptsService.getScriptBySlug(input.slug); + if (!script) { + return { + success: false, + error: 'Script not found', + files: [] + }; + } + + // Load the script files + const result = await scriptDownloaderService.loadScript(script); + return result; + } catch (error) { + console.error('Error in loadScript:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load script', + files: [] + }; + } + }), + + // Check if script files exist locally + checkScriptFiles: publicProcedure + .input(z.object({ slug: z.string() })) + .query(async ({ input }) => { + try { + const script = await localScriptsService.getScriptBySlug(input.slug); + if (!script) { + return { + success: false, + error: 'Script not found', + ctExists: false, + installExists: false, + files: [] + }; + } + + const result = await scriptDownloaderService.checkScriptExists(script); + return { + success: true, + ...result + }; + } catch (error) { + console.error('Error in checkScriptFiles:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check script files', + ctExists: false, + installExists: false, + files: [] + }; + } }) }); diff --git a/src/server/services/scriptDownloader.ts b/src/server/services/scriptDownloader.ts new file mode 100644 index 0000000..c631cb9 --- /dev/null +++ b/src/server/services/scriptDownloader.ts @@ -0,0 +1,158 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { env } from '~/env.js'; +import type { Script } from '~/types/script'; + +export class ScriptDownloaderService { + private scriptsDirectory: string; + private repoUrl: string; + + constructor() { + this.scriptsDirectory = join(process.cwd(), 'scripts'); + this.repoUrl = env.REPO_URL || ''; + } + + private async ensureDirectoryExists(dirPath: string): Promise { + try { + await mkdir(dirPath, { recursive: true }); + } catch (error) { + // Directory might already exist, ignore error + } + } + + private async downloadFileFromGitHub(filePath: string): Promise { + if (!this.repoUrl) { + throw new Error('REPO_URL environment variable is not set'); + } + + const url = `https://raw.githubusercontent.com/${this.extractRepoPath()}/main/${filePath}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`); + } + + return response.text(); + } + + private extractRepoPath(): string { + const match = this.repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/); + if (!match) { + throw new Error('Invalid GitHub repository URL'); + } + return `${match[1]}/${match[2]}`; + } + + private modifyScriptContent(content: string): string { + // Replace the build.func source line + const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g; + const newPattern = 'this SCRIPT_DIR="$(dirname "$0")" source "$SCRIPT_DIR/../core/build.func"'; + + return content.replace(oldPattern, newPattern); + } + + async loadScript(script: Script): Promise<{ success: boolean; message: string; files: string[] }> { + try { + const files: string[] = []; + + // Ensure directories exist + await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct')); + await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install')); + + // Download and save CT script + if (script.install_methods && script.install_methods.length > 0) { + for (const method of script.install_methods) { + if (method.script && method.script.startsWith('ct/')) { + const scriptPath = method.script; + const fileName = scriptPath.split('/').pop(); + + if (fileName) { + // Download from GitHub + const content = await this.downloadFileFromGitHub(scriptPath); + + // Modify the content + const modifiedContent = this.modifyScriptContent(content); + + // Save to local directory + const localPath = join(this.scriptsDirectory, 'ct', fileName); + await writeFile(localPath, modifiedContent, 'utf-8'); + + files.push(`ct/${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 (error) { + // Install script might not exist, that's okay + console.log(`Install script not found for ${script.slug}: ${error}`); + } + + return { + success: true, + message: `Successfully loaded ${files.length} script(s) for ${script.name}`, + files + }; + } catch (error) { + console.error('Error loading script:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to load script', + files: [] + }; + } + } + + async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> { + const files: string[] = []; + let ctExists = false; + let installExists = false; + + try { + // Check CT script + if (script.install_methods && script.install_methods.length > 0) { + for (const method of script.install_methods) { + if (method.script && method.script.startsWith('ct/')) { + const fileName = method.script.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 + } + } + } + } + } + + // Check install script + const installScriptName = `${script.slug}-install.sh`; + const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName); + try { + await readFile(localInstallPath, 'utf-8'); + installExists = true; + files.push(`install/${installScriptName}`); + } catch { + // File doesn't exist + } + + return { ctExists, installExists, files }; + } catch (error) { + console.error('Error checking script existence:', error); + return { ctExists: false, installExists: false, files: [] }; + } + } +} + +// Singleton instance +export const scriptDownloaderService = new ScriptDownloaderService();