Files
ProxmoxVE-Local/src/server/api/routers/scripts.ts

834 lines
28 KiB
TypeScript

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<number, string> = {};
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<void>((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<void>((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: []
};
}
})
});