import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { scriptManager } from "~/server/lib/scripts"; import { githubJsonService } from "~/server/services/githubJsonService"; import { localScriptsService } from "~/server/services/localScripts"; import { scriptDownloaderService } from "~/server/services/scriptDownloader.js"; import { AutoSyncService } from "~/server/services/autoSyncService"; import { repositoryService } from "~/server/services/repositoryService"; import { getStorageService } from "~/server/services/storageService"; import { getDatabase } from "~/server/database-prisma"; import type { ScriptCard } from "~/types/script"; import type { Server } from "~/types/server"; export const scriptsRouter = createTRPCRouter({ // Get all available scripts getScripts: publicProcedure .query(async () => { const scripts = await scriptManager.getScripts(); return { scripts, directoryInfo: scriptManager.getScriptsDirectoryInfo() }; }), // Get CT scripts (for local scripts tab) getCtScripts: publicProcedure .query(async () => { const scripts = await scriptManager.getCtScripts(); return { scripts, directoryInfo: scriptManager.getScriptsDirectoryInfo() }; }), // Get all downloaded scripts from all directories getAllDownloadedScripts: publicProcedure .query(async () => { const scripts = await scriptManager.getAllDownloadedScripts(); return { scripts, directoryInfo: scriptManager.getScriptsDirectoryInfo() }; }), // Get script content for viewing getScriptContent: publicProcedure .input(z.object({ path: z.string() })) .query(async ({ input }) => { try { const { readFile } = await import('fs/promises'); const { join } = await import('path'); const { env } = await import('~/env'); const scriptsDir = join(process.cwd(), env.SCRIPTS_DIRECTORY); const fullPath = join(scriptsDir, input.path); // Security check: ensure the path is within the scripts directory if (!fullPath.startsWith(scriptsDir)) { throw new Error('Invalid script path'); } const content = await readFile(fullPath, 'utf-8'); return { success: true, content }; } catch (error) { console.error('Error reading script content:', error); return { success: false, error: 'Failed to read script content' }; } }), // Validate script path validateScript: publicProcedure .input(z.object({ scriptPath: z.string() })) .query(async ({ input }) => { const validation = scriptManager.validateScriptPath(input.scriptPath); return validation; }), // Get directory information getDirectoryInfo: publicProcedure .query(async () => { return scriptManager.getScriptsDirectoryInfo(); }), // Local script routes (using scripts/json directory) // Get all script cards from local directory getScriptCards: publicProcedure .query(async () => { try { const cards = await localScriptsService.getScriptCards(); return { success: true, cards }; } catch (error) { console.error('Error in getScriptCards:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch script cards', cards: [] }; } }), // Get all scripts from GitHub (1 API call + raw downloads) getAllScripts: publicProcedure .query(async () => { try { const scripts = await localScriptsService.getAllScripts(); return { success: true, scripts }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch scripts', scripts: [] }; } }), // Get script by slug from GitHub (1 API call + raw downloads) getScriptBySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ input }) => { try { console.log('getScriptBySlug called with slug:', input.slug); console.log('githubJsonService methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(githubJsonService))); console.log('githubJsonService.getScriptBySlug type:', typeof githubJsonService.getScriptBySlug); if (typeof githubJsonService.getScriptBySlug !== 'function') { return { success: false, error: 'getScriptBySlug method is not available on githubJsonService', script: null }; } const script = await githubJsonService.getScriptBySlug(input.slug); if (!script) { return { success: false, error: 'Script not found', script: null }; } return { success: true, script }; } catch (error) { console.error('Error in getScriptBySlug:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch script', script: null }; } }), // Get metadata (categories and other metadata) getMetadata: publicProcedure .query(async () => { try { const metadata = await localScriptsService.getMetadata(); return { success: true, metadata }; } catch (error) { console.error('Error in getMetadata:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch metadata', metadata: null }; } }), // Get script cards with category information getScriptCardsWithCategories: publicProcedure .query(async () => { try { const [cards, metadata, enabledRepos] = await Promise.all([ localScriptsService.getScriptCards(), localScriptsService.getMetadata(), repositoryService.getEnabledRepositories() ]); // Get all scripts to access their categories const scripts = await localScriptsService.getAllScripts(); // Create a set of enabled repository URLs for fast lookup const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url)); // Create category ID to name mapping const categoryMap: Record = {}; if (metadata?.categories) { metadata.categories.forEach((cat: any) => { categoryMap[cat.id] = cat.name; }); } // Enhance cards with category information and additional script data const cardsWithCategories = cards.map((card: ScriptCard) => { const script = scripts.find(s => s.slug === card.slug); const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? []; // Extract OS and version from first install method const firstInstallMethod = script?.install_methods?.[0]; const os = firstInstallMethod?.resources?.os; const version = firstInstallMethod?.resources?.version; // Extract install basenames for robust local matching (e.g., execute.sh -> execute) const install_basenames = (script?.install_methods ?? []) .map(m => m?.script) .filter((p): p is string => typeof p === 'string') .map(p => { const parts = p.split('/'); const file = parts[parts.length - 1] ?? ''; return file.replace(/\.(sh|bash|py|js|ts)$/i, ''); }); return { ...card, categories: script?.categories ?? [], categoryNames: categoryNames, // Add date_created from script date_created: script?.date_created, // Add OS and version from install methods os: os, version: version, // Add interface port interface_port: script?.interface_port, install_basenames, // Add repository_url from script repository_url: script?.repository_url ?? card.repository_url, } as ScriptCard; }); // Filter cards to only include scripts from enabled repositories // For backward compatibility, include scripts without repository_url const filteredCards = cardsWithCategories.filter((card: ScriptCard) => { const repoUrl = card.repository_url; // If script has no repository_url, include it for backward compatibility if (!repoUrl) { return true; } // Only include scripts from enabled repositories return enabledRepoUrls.has(repoUrl); }); return { success: true, cards: filteredCards, metadata }; } catch (error) { console.error('Error in getScriptCardsWithCategories:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch script cards with categories', cards: [], metadata: null }; } }), // Resync scripts from GitHub (1 API call + raw downloads) resyncScripts: publicProcedure .mutation(async () => { try { // Sync JSON files using 1 API call + raw downloads const result = await githubJsonService.syncJsonFiles(); return { success: result.success, message: result.message, count: result.count }; } catch (error) { console.error('Error in resyncScripts:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to resync scripts. Make sure REPO_URL is set.', 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: [] }; } }), // Load multiple scripts from GitHub loadMultipleScripts: publicProcedure .input(z.object({ slugs: z.array(z.string()) })) .mutation(async ({ input }) => { try { const successful = []; const failed = []; for (const slug of input.slugs) { try { // Get the script details const script = await localScriptsService.getScriptBySlug(slug); if (!script) { failed.push({ slug, error: 'Script not found' }); continue; } // Load the script files const result = await scriptDownloaderService.loadScript(script); if (result.success) { successful.push({ slug, files: result.files }); } else { const error = 'error' in result ? result.error : 'Failed to load script'; failed.push({ slug, error }); } } catch (error) { failed.push({ slug, error: error instanceof Error ? error.message : 'Failed to load script' }); } } return { success: true, message: `Downloaded ${successful.length} scripts successfully, ${failed.length} failed`, successful, failed, total: input.slugs.length }; } catch (error) { console.error('Error in loadMultipleScripts:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to load multiple scripts', successful: [], failed: [], total: 0 }; } }), // 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: [] }; } }), // Delete script files deleteScript: 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', deletedFiles: [] }; } // Delete the script files const result = await scriptDownloaderService.deleteScript(script); return result; } catch (error) { console.error('Error in deleteScript:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to delete script', deletedFiles: [] }; } }), // Compare local and remote script content compareScriptContent: 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', hasDifferences: false, differences: [] }; } const result = await scriptDownloaderService.compareScriptContent(script); return { success: true, ...result }; } catch (error) { console.error('Error in compareScriptContent:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to compare script content', hasDifferences: false, differences: [] }; } }), // Get diff content for a specific script file getScriptDiff: publicProcedure .input(z.object({ slug: z.string(), filePath: z.string() })) .query(async ({ input }) => { try { const script = await localScriptsService.getScriptBySlug(input.slug); if (!script) { return { success: false, error: 'Script not found', diff: null }; } const result = await scriptDownloaderService.getScriptDiff(script, input.filePath); return { success: true, ...result }; } catch (error) { console.error('Error in getScriptDiff:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to get script diff', diff: null }; } }), // Check if running on Proxmox VE host checkProxmoxVE: publicProcedure .query(async () => { try { const { spawn } = await import('child_process'); return new Promise((resolve) => { const child = spawn('command', ['-v', 'pveversion'], { stdio: ['pipe', 'pipe', 'pipe'], shell: true }); child.on('close', (code) => { // If command exits with code 0, pveversion command exists if (code === 0) { resolve({ success: true, isProxmoxVE: true, message: 'Running on Proxmox VE host' }); } else { resolve({ success: true, isProxmoxVE: false, message: 'Not running on Proxmox VE host' }); } }); child.on('error', (error) => { resolve({ success: false, isProxmoxVE: false, error: error.message, message: 'Failed to check Proxmox VE status' }); }); }); } catch (error) { console.error('Error in checkProxmoxVE:', error); return { success: false, isProxmoxVE: false, error: error instanceof Error ? error.message : 'Failed to check Proxmox VE status', message: 'Failed to check Proxmox VE status' }; } }), // Auto-sync settings and operations getAutoSyncSettings: publicProcedure .query(async () => { try { const autoSyncService = new AutoSyncService(); const settings = autoSyncService.loadSettings(); return { success: true, settings }; } catch (error) { console.error('Error getting auto-sync settings:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to get auto-sync settings', settings: null }; } }), saveAutoSyncSettings: publicProcedure .input(z.object({ autoSyncEnabled: z.boolean(), syncIntervalType: z.enum(['predefined', 'custom']), syncIntervalPredefined: z.string().optional(), syncIntervalCron: z.string().optional(), autoDownloadNew: z.boolean(), autoUpdateExisting: z.boolean(), notificationEnabled: z.boolean(), appriseUrls: z.array(z.string()).optional() })) .mutation(async ({ input }) => { try { // Use the global auto-sync service instance const { getAutoSyncService, setAutoSyncService } = await import('~/server/lib/autoSyncInit'); let autoSyncService = getAutoSyncService(); // If no global instance exists, create one if (!autoSyncService) { const { AutoSyncService } = await import('~/server/services/autoSyncService'); autoSyncService = new AutoSyncService(); setAutoSyncService(autoSyncService); } // Save settings to both .env file and service instance autoSyncService.saveSettings(input); // Reschedule auto-sync if enabled if (input.autoSyncEnabled) { autoSyncService.scheduleAutoSync(); console.log('Auto-sync rescheduled with new settings'); } else { autoSyncService.stopAutoSync(); // Ensure the service is completely stopped and won't restart autoSyncService.isRunning = false; console.log('Auto-sync stopped'); } return { success: true, message: 'Auto-sync settings saved successfully' }; } catch (error) { console.error('Error saving auto-sync settings:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to save auto-sync settings' }; } }), testNotification: publicProcedure .mutation(async () => { try { const autoSyncService = new AutoSyncService(); const result = await autoSyncService.testNotification(); return result; } catch (error) { console.error('Error testing notification:', error); return { success: false, message: error instanceof Error ? error.message : 'Failed to test notification' }; } }), triggerManualAutoSync: publicProcedure .mutation(async () => { try { const autoSyncService = new AutoSyncService(); const result = await autoSyncService.executeAutoSync(); return { success: true, message: 'Manual auto-sync completed successfully', result }; } catch (error) { console.error('Error in manual auto-sync:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to execute manual auto-sync', result: null }; } }), getAutoSyncStatus: publicProcedure .query(async () => { try { const autoSyncService = new AutoSyncService(); const status = autoSyncService.getStatus(); return { success: true, status }; } catch (error) { console.error('Error getting auto-sync status:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to get auto-sync status', status: null }; } }), // Get rootfs storages for a server (for container creation) getRootfsStorages: publicProcedure .input(z.object({ serverId: z.number(), forceRefresh: z.boolean().optional().default(false) })) .query(async ({ input }) => { try { const db = getDatabase(); const server = await db.getServerById(input.serverId); if (!server) { return { success: false, error: 'Server not found', storages: [] }; } // Get server hostname to filter storages by node assignment const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); const sshExecutionService = getSSHExecutionService(); let serverHostname = ''; try { await new Promise((resolve, reject) => { void sshExecutionService.executeCommand( server as Server, 'hostname', (data: string) => { serverHostname += data; }, (error: string) => { reject(new Error(`Failed to get hostname: ${error}`)); }, (exitCode: number) => { if (exitCode === 0) { resolve(); } else { reject(new Error(`hostname command failed with exit code ${exitCode}`)); } } ); }); } catch (error) { console.error('Error getting server hostname:', error); // Continue without filtering if hostname can't be retrieved } const normalizedHostname = serverHostname.trim().toLowerCase(); const storageService = getStorageService(); const allStorages = await storageService.getStorages(server as Server, input.forceRefresh); // Filter storages by node hostname matching and content type (rootdir for containers) const rootfsStorages = allStorages.filter(storage => { // Check content type - must have rootdir for containers const hasRootdir = storage.content.includes('rootdir'); if (!hasRootdir) { return false; } // If storage has no nodes specified, it's available on all nodes if (!storage.nodes || storage.nodes.length === 0) { return true; } // If we couldn't get hostname, include all storages (fallback) if (!normalizedHostname) { return true; } // Check if server hostname is in the nodes array (case-insensitive, trimmed) const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase()); return normalizedNodes.includes(normalizedHostname); }); return { success: true, storages: rootfsStorages.map(s => ({ name: s.name, type: s.type, content: s.content })) }; } catch (error) { console.error('Error fetching rootfs storages:', error); // Return empty array on error (as per plan requirement) return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch storages', storages: [] }; } }), // Get template storages for a server (for template storage selection) getTemplateStorages: publicProcedure .input(z.object({ serverId: z.number(), forceRefresh: z.boolean().optional().default(false) })) .query(async ({ input }) => { try { const db = getDatabase(); const server = await db.getServerById(input.serverId); if (!server) { return { success: false, error: 'Server not found', storages: [] }; } // Get server hostname to filter storages by node assignment const { getSSHExecutionService } = await import('~/server/ssh-execution-service'); const sshExecutionService = getSSHExecutionService(); let serverHostname = ''; try { await new Promise((resolve, reject) => { void sshExecutionService.executeCommand( server as Server, 'hostname', (data: string) => { serverHostname += data; }, (error: string) => { reject(new Error(`Failed to get hostname: ${error}`)); }, (exitCode: number) => { if (exitCode === 0) { resolve(); } else { reject(new Error(`hostname command failed with exit code ${exitCode}`)); } } ); }); } catch (error) { console.error('Error getting server hostname:', error); // Continue without filtering if hostname can't be retrieved } const normalizedHostname = serverHostname.trim().toLowerCase(); const storageService = getStorageService(); const allStorages = await storageService.getStorages(server as Server, input.forceRefresh); // Filter storages by node hostname matching and content type (vztmpl for templates) const templateStorages = allStorages.filter(storage => { // Check content type - must have vztmpl for templates const hasVztmpl = storage.content.includes('vztmpl'); if (!hasVztmpl) { return false; } // If storage has no nodes specified, it's available on all nodes if (!storage.nodes || storage.nodes.length === 0) { return true; } // If we couldn't get hostname, include all storages (fallback) if (!normalizedHostname) { return true; } // Check if server hostname is in the nodes array (case-insensitive, trimmed) const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase()); return normalizedNodes.includes(normalizedHostname); }); return { success: true, storages: templateStorages.map(s => ({ name: s.name, type: s.type, content: s.content })) }; } catch (error) { console.error('Error fetching template storages:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch storages', storages: [] }; } }) });