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
This commit is contained in:
Michel Roegl-Brunner
2025-09-10 14:58:37 +02:00
parent 97753f7647
commit 2539957639
3 changed files with 318 additions and 8 deletions

View File

@@ -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<string | null>(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 (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
@@ -70,16 +106,70 @@ export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModal
</div>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center space-x-4">
{/* Load Script Button */}
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
isLoading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Loading...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>Load Script</span>
</>
)}
</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Load Message */}
{loadMessage && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-blue-800 text-sm">
{loadMessage}
</div>
)}
{/* Script Files Status */}
{scriptFilesData?.success && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${scriptFilesData.ctExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span>CT Script: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'}</span>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
</div>
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-gray-600">
Files: {scriptFilesData.files.join(', ')}
</div>
)}
</div>
)}
{/* Content */}
<div className="p-6 space-y-6">
{/* Description */}

View File

@@ -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: []
};
}
})
});

View File

@@ -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<void> {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
// Directory might already exist, ignore error
}
}
private async downloadFileFromGitHub(filePath: string): Promise<string> {
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();