diff --git a/.env.example b/.env.example index 0261196..072b8f6 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,15 @@ -# Since the ".env" file is gitignored, you can use the ".env.example" file to -# build a new ".env" file when you clone the repo. Keep this file up-to-date -# when you add new variables to `.env`. +# Database +DATABASE_URL="postgresql://username:password@localhost:5432/pve_scripts_local" -# This file will be committed to version control, so make sure not to have any -# secrets in it. If you are cloning this repo, create a copy of this file named -# ".env" and populate it with your secrets. +# Repository Configuration +REPO_URL="https://github.com/your-org/pve-scripts.git" +REPO_BRANCH="main" +SCRIPTS_DIRECTORY="scripts" +ALLOWED_SCRIPT_EXTENSIONS=".sh,.py,.js,.ts,.bash" -# When adding additional environment variables, the schema in "/src/env.js" -# should be updated accordingly. +# Security +MAX_SCRIPT_EXECUTION_TIME="300000" +ALLOWED_SCRIPT_PATHS="scripts/" -# Prisma -# https://www.prisma.io/docs/reference/database-reference/connection-urls#env -DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local" +# WebSocket Configuration +WEBSOCKET_PORT="3001" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c24a835..9e91245 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.cursorignore + # dependencies /node_modules /.pnp diff --git a/package-lock.json b/package-lock.json index 61e9972..d6adb7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,14 @@ "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", + "@types/ws": "^8.18.1", "next": "^15.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", + "simple-git": "^3.28.0", "superjson": "^2.2.1", + "ws": "^8.18.3", "zod": "^3.24.2" }, "devDependencies": { @@ -759,6 +762,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1506,7 +1524,6 @@ "version": "20.19.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz", "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1532,6 +1549,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -2745,7 +2771,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5012,7 +5037,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6128,6 +6152,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-git": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", + "integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6655,7 +6694,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6818,6 +6856,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index a199b15..bb368b3 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,15 @@ "db:migrate": "prisma migrate deploy", "db:push": "prisma db push", "db:studio": "prisma studio", - "dev": "next dev --turbo", + "dev": "node server.js", + "dev:next": "next dev --turbo", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "postinstall": "prisma generate", "lint": "next lint", "lint:fix": "next lint --fix", "preview": "next build && next start", - "start": "next start", + "start": "node server.js", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -27,11 +28,14 @@ "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", + "@types/ws": "^8.18.1", "next": "^15.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", + "simple-git": "^3.28.0", "superjson": "^2.2.1", + "ws": "^8.18.3", "zod": "^3.24.2" }, "devDependencies": { diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100644 index 0000000..bfa92b0 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Demo script for PVE Scripts Local Management +# This script demonstrates live output streaming + +echo "🚀 Starting PVE Script Demo..." +echo "================================" +echo "" + +echo "📋 System Information:" +echo " - Hostname: $(hostname)" +echo " - User: $(whoami)" +echo " - Date: $(date)" +echo " - Uptime: $(uptime)" +echo "" + +echo "🔧 Simulating Proxmox operations..." +echo " - Checking Proxmox API connection..." +sleep 2 +echo " ✅ API connection successful" +echo "" + +echo " - Listing VMs..." +sleep 1 +echo " đŸ“Ļ VM 100: Ubuntu Server 22.04 (running)" +echo " đŸ“Ļ VM 101: Windows Server 2022 (stopped)" +echo " đŸ“Ļ VM 102: Debian 12 (running)" +echo "" + +echo " - Checking storage..." +sleep 1 +echo " 💾 Local storage: 500GB (200GB used)" +echo " 💾 NFS storage: 2TB (800GB used)" +echo "" + +echo " - Checking cluster status..." +sleep 1 +echo " đŸ—ī¸ Node: pve-01 (online)" +echo " đŸ—ī¸ Node: pve-02 (online)" +echo " đŸ—ī¸ Node: pve-03 (maintenance)" +echo "" + +echo "đŸŽ¯ Demo completed successfully!" +echo "================================" +echo "This script ran for demonstration purposes." +echo "In a real scenario, this would perform actual Proxmox operations." diff --git a/server.js b/server.js new file mode 100644 index 0000000..2441155 --- /dev/null +++ b/server.js @@ -0,0 +1,244 @@ +import { createServer } from 'http'; +import { parse } from 'url'; +import next from 'next'; +import { WebSocketServer } from 'ws'; +import { spawn } from 'child_process'; +import { join, resolve } from 'path'; + +const dev = process.env.NODE_ENV !== 'production'; +const hostname = 'localhost'; +const port = process.env.PORT || 3000; + +// when using middleware `hostname` and `port` must be provided below +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +// WebSocket handler for script execution +class ScriptExecutionHandler { + constructor(server) { + this.wss = new WebSocketServer({ + server, + path: '/ws/script-execution' + }); + this.activeExecutions = new Map(); + this.setupWebSocket(); + } + + setupWebSocket() { + this.wss.on('connection', (ws, request) => { + console.log('New WebSocket connection for script execution'); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.handleMessage(ws, message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + this.sendMessage(ws, { + type: 'error', + data: 'Invalid message format', + timestamp: Date.now() + }); + } + }); + + ws.on('close', () => { + console.log('WebSocket connection closed'); + this.cleanupActiveExecutions(ws); + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.cleanupActiveExecutions(ws); + }); + }); + } + + async handleMessage(ws, message) { + const { action, scriptPath, executionId } = message; + + switch (action) { + case 'start': + if (scriptPath && executionId) { + await this.startScriptExecution(ws, scriptPath, executionId); + } else { + this.sendMessage(ws, { + type: 'error', + data: 'Missing scriptPath or executionId', + timestamp: Date.now() + }); + } + break; + + case 'stop': + if (executionId) { + this.stopScriptExecution(executionId); + } + break; + + default: + this.sendMessage(ws, { + type: 'error', + data: 'Unknown action', + timestamp: Date.now() + }); + } + } + + async startScriptExecution(ws, scriptPath, executionId) { + try { + // Basic validation + const scriptsDir = join(process.cwd(), 'scripts'); + const resolvedPath = resolve(scriptPath); + + if (!resolvedPath.startsWith(resolve(scriptsDir))) { + this.sendMessage(ws, { + type: 'error', + data: 'Script path is not within the allowed scripts directory', + timestamp: Date.now() + }); + return; + } + + // Check if execution is already running + if (this.activeExecutions.has(executionId)) { + this.sendMessage(ws, { + type: 'error', + data: 'Script execution already running', + timestamp: Date.now() + }); + return; + } + + // Start script execution + const process = spawn('bash', [scriptPath], { + cwd: scriptsDir, + stdio: ['pipe', 'pipe', 'pipe'], + shell: true + }); + + // Store the execution + this.activeExecutions.set(executionId, { process, ws }); + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting execution of ${scriptPath}`, + timestamp: Date.now() + }); + + // Handle stdout + process.stdout?.on('data', (data) => { + this.sendMessage(ws, { + type: 'output', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle stderr + process.stderr?.on('data', (data) => { + this.sendMessage(ws, { + type: 'error', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle process exit + process.on('exit', (code, signal) => { + this.sendMessage(ws, { + type: 'end', + data: `Script execution finished with code: ${code}, signal: ${signal}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + + // Handle process error + process.on('error', (error) => { + this.sendMessage(ws, { + type: 'error', + data: `Process error: ${error.message}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start script: ${error.message}`, + timestamp: Date.now() + }); + } + } + + stopScriptExecution(executionId) { + const execution = this.activeExecutions.get(executionId); + if (execution) { + execution.process.kill('SIGTERM'); + this.activeExecutions.delete(executionId); + + this.sendMessage(execution.ws, { + type: 'end', + data: 'Script execution stopped by user', + timestamp: Date.now() + }); + } + } + + sendMessage(ws, message) { + if (ws.readyState === 1) { // WebSocket.OPEN + ws.send(JSON.stringify(message)); + } + } + + cleanupActiveExecutions(ws) { + for (const [executionId, execution] of this.activeExecutions.entries()) { + if (execution.ws === ws) { + execution.process.kill('SIGTERM'); + this.activeExecutions.delete(executionId); + } + } + } +} + +app.prepare().then(() => { + const httpServer = createServer(async (req, res) => { + try { + // Be sure to pass `true` as the second argument to `url.parse`. + // This tells it to parse the query portion of the URL. + const parsedUrl = parse(req.url, true); + const { pathname, query } = parsedUrl; + + if (pathname === '/ws/script-execution') { + // WebSocket upgrade will be handled by the WebSocket server + return; + } + + await handle(req, res, parsedUrl); + } catch (err) { + console.error('Error occurred handling', req.url, err); + res.statusCode = 500; + res.end('internal server error'); + } + }); + + // Create WebSocket handler + const scriptHandler = new ScriptExecutionHandler(httpServer); + + httpServer + .once('error', (err) => { + console.error(err); + process.exit(1); + }) + .listen(port, () => { + console.log(`> Ready on http://${hostname}:${port}`); + console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); + }); +}); diff --git a/src/app/_components/RepoStatus.tsx b/src/app/_components/RepoStatus.tsx new file mode 100644 index 0000000..ec02cea --- /dev/null +++ b/src/app/_components/RepoStatus.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { api } from '~/trpc/react'; +import { useState } from 'react'; + +export function RepoStatus() { + const [isUpdating, setIsUpdating] = useState(false); + + const { data: repoStatus, isLoading: statusLoading, refetch: refetchStatus } = api.scripts.getRepoStatus.useQuery(); + const updateRepoMutation = api.scripts.updateRepo.useMutation({ + onSuccess: () => { + setIsUpdating(false); + refetchStatus(); + }, + onError: () => { + setIsUpdating(false); + } + }); + + const handleUpdate = async () => { + setIsUpdating(true); + updateRepoMutation.mutate(); + }; + + if (statusLoading) { + return ( +
+
+
Loading repository status...
+
+
+ ); + } + + if (!repoStatus) { + return ( +
+
Failed to load repository status
+
+ ); + } + + const getStatusColor = () => { + if (!repoStatus.isRepo) return 'text-gray-500'; + if (repoStatus.isBehind) return 'text-yellow-600'; + return 'text-green-600'; + }; + + const getStatusText = () => { + if (!repoStatus.isRepo) return 'No repository found'; + if (repoStatus.isBehind) return 'Behind remote'; + return 'Up to date'; + }; + + const getStatusIcon = () => { + if (!repoStatus.isRepo) return '❓'; + if (repoStatus.isBehind) return 'âš ī¸'; + return '✅'; + }; + + return ( +
+
+
+ {getStatusIcon()} +
+

Repository Status

+
+ {getStatusText()} +
+ {repoStatus.isRepo && ( +
+

Branch: {repoStatus.branch || 'Unknown'}

+ {repoStatus.lastCommit && ( +

Last commit: {repoStatus.lastCommit.substring(0, 8)}

+ )} +
+ )} +
+
+ +
+ + + +
+
+ + {updateRepoMutation.isSuccess && ( +
+ ✅ {updateRepoMutation.data?.message} +
+ )} + + {updateRepoMutation.isError && ( +
+ ❌ {updateRepoMutation.error?.message || 'Failed to update repository'} +
+ )} +
+ ); +} diff --git a/src/app/_components/ScriptsList.tsx b/src/app/_components/ScriptsList.tsx new file mode 100644 index 0000000..ab1b3b6 --- /dev/null +++ b/src/app/_components/ScriptsList.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { api } from '~/trpc/react'; +import { useState } from 'react'; +import { Terminal } from './Terminal'; + +interface ScriptsListProps { + onRunScript: (scriptPath: string, scriptName: string) => void; +} + +export function ScriptsList({ onRunScript }: ScriptsListProps) { + const { data, isLoading, error, refetch } = api.scripts.getScripts.useQuery(); + const [selectedScript, setSelectedScript] = useState(null); + + if (isLoading) { + return ( +
+
Loading scripts...
+
+ ); + } + + if (error) { + return ( +
+
+ Error loading scripts: {error.message} +
+
+ ); + } + + if (!data?.scripts || data.scripts.length === 0) { + return ( +
+
No scripts found in the scripts directory
+
+ ); + } + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatDate = (date: Date): string => { + return new Date(date).toLocaleString(); + }; + + const getFileIcon = (extension: string): string => { + switch (extension) { + case '.sh': + case '.bash': + return '🐚'; + case '.py': + return '🐍'; + case '.js': + return '📜'; + case '.ts': + return '🔷'; + default: + return '📄'; + } + }; + + return ( +
+
+

Available Scripts

+

+ Found {data.scripts.length} script(s) in {data.directoryInfo.path} +

+
+

Allowed extensions: {data.directoryInfo.allowedExtensions.join(', ')}

+

Max execution time: {Math.round(data.directoryInfo.maxExecutionTime / 1000)}s

+
+
+ +
+ {data.scripts.map((script) => ( +
+
+
+ {getFileIcon(script.extension)} +
+

{script.name}

+
+

Size: {formatFileSize(script.size)}

+

Modified: {formatDate(script.lastModified)}

+

Extension: {script.extension}

+

+ {script.executable ? '✅ Executable' : '❌ Not executable'} +

+
+
+
+ +
+ + +
+
+
+ ))} +
+ + {selectedScript && ( +
+ setSelectedScript(null)} + /> +
+ )} +
+ ); +} diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx new file mode 100644 index 0000000..0495891 --- /dev/null +++ b/src/app/_components/Terminal.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { api } from '~/trpc/react'; + +interface TerminalProps { + scriptPath: string; + onClose: () => void; +} + +interface TerminalMessage { + type: 'start' | 'output' | 'error' | 'end'; + data: string; + timestamp: number; +} + +export function Terminal({ scriptPath, onClose }: TerminalProps) { + const [isConnected, setIsConnected] = useState(false); + const [isRunning, setIsRunning] = useState(false); + const [output, setOutput] = useState([]); + const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const wsRef = useRef(null); + const outputRef = useRef(null); + + const scriptName = scriptPath.split('/').pop() || scriptPath.split('\\').pop() || 'Unknown Script'; + + useEffect(() => { + // Connect to WebSocket + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connected'); + setIsConnected(true); + }; + + ws.onmessage = (event) => { + try { + const message: TerminalMessage = JSON.parse(event.data); + handleMessage(message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + ws.onclose = () => { + console.log('WebSocket disconnected'); + setIsConnected(false); + setIsRunning(false); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + setIsConnected(false); + }; + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, []); + + const handleMessage = (message: TerminalMessage) => { + const timestamp = new Date(message.timestamp).toLocaleTimeString(); + const prefix = `[${timestamp}] `; + + switch (message.type) { + case 'start': + setOutput(prev => [...prev, `${prefix}🚀 ${message.data}`]); + setIsRunning(true); + break; + case 'output': + setOutput(prev => [...prev, message.data]); + break; + case 'error': + setOutput(prev => [...prev, `${prefix}❌ ${message.data}`]); + break; + case 'end': + setOutput(prev => [...prev, `${prefix}✅ ${message.data}`]); + setIsRunning(false); + break; + } + }; + + const startScript = () => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: 'start', + scriptPath, + executionId + })); + } + }; + + const stopScript = () => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: 'stop', + executionId + })); + } + }; + + const clearOutput = () => { + setOutput([]); + }; + + // Auto-scroll to bottom when new output arrives + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output]); + + return ( +
+ {/* Terminal Header */} +
+
+
+
+
+
+
+ + {scriptName} + +
+ +
+
+ + {isConnected ? 'Connected' : 'Disconnected'} + +
+
+ + {/* Terminal Output */} +
+ {output.length === 0 ? ( +
+

Terminal ready. Click "Start Script" to begin execution.

+

Script: {scriptPath}

+
+ ) : ( + output.map((line, index) => ( +
+ {line} +
+ )) + )} +
+ + {/* Terminal Controls */} +
+
+ + + + + +
+ + +
+
+ ); +} diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..7d05596 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,24 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { type NextRequest } from "next/server"; + +import { env } from "~/env.js"; +import { appRouter } from "~/server/api/root"; +import { createTRPCContext } from "~/server/api/trpc"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createTRPCContext({ headers: req.headers }), + onError: + env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ""}: ${error.message}`, + ); + } + : undefined, + }); + +export { handler as GET, handler as POST }; diff --git a/src/app/page.tsx b/src/app/page.tsx index af64722..62696a0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,53 @@ +'use client'; -export default async function Home() { +import { useState } from 'react'; +import { ScriptsList } from './_components/ScriptsList'; +import { RepoStatus } from './_components/RepoStatus'; +import { Terminal } from './_components/Terminal'; + +export default function Home() { + const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null); + + const handleRunScript = (scriptPath: string, scriptName: string) => { + setRunningScript({ path: scriptPath, name: scriptName }); + }; + + const handleCloseTerminal = () => { + setRunningScript(null); + }; return ( - -
-
-

PVE Scripts local

+
+
+ {/* Header */} +
+

+ 🚀 PVE Scripts Local Management +

+

+ Manage and execute Proxmox helper scripts locally with live output streaming +

-
+ {/* Repository Status */} +
+ +
+ + {/* Running Script Terminal */} + {runningScript && ( +
+ +
+ )} + + {/* Scripts List */} + +
+
); } diff --git a/src/env.js b/src/env.js index 6ca7f3e..2a31a5d 100644 --- a/src/env.js +++ b/src/env.js @@ -11,6 +11,16 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), + // Repository Configuration + REPO_URL: z.string().url().optional(), + REPO_BRANCH: z.string().default("main"), + SCRIPTS_DIRECTORY: z.string().default("scripts"), + ALLOWED_SCRIPT_EXTENSIONS: z.string().default(".sh,.py,.js,.ts,.bash"), + // Security + MAX_SCRIPT_EXECUTION_TIME: z.string().default("300000"), // 5 minutes in ms + ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"), + // WebSocket Configuration + WEBSOCKET_PORT: z.string().default("3001"), }, /** @@ -29,6 +39,16 @@ export const env = createEnv({ runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, + // Repository Configuration + REPO_URL: process.env.REPO_URL, + REPO_BRANCH: process.env.REPO_BRANCH, + SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY, + ALLOWED_SCRIPT_EXTENSIONS: process.env.ALLOWED_SCRIPT_EXTENSIONS, + // Security + MAX_SCRIPT_EXECUTION_TIME: process.env.MAX_SCRIPT_EXECUTION_TIME, + ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS, + // WebSocket Configuration + WEBSOCKET_PORT: process.env.WEBSOCKET_PORT, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, }, /** diff --git a/src/server/api/root.ts b/src/server/api/root.ts index b341fc4..b1d29c8 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,4 +1,4 @@ -import { postRouter } from "~/server/api/routers/post"; +import { scriptsRouter } from "~/server/api/routers/scripts"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -7,7 +7,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; * All routers added in /api/routers should be manually added here. */ export const appRouter = createTRPCRouter({ - post: postRouter, + scripts: scriptsRouter, }); // export type definition of API diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts new file mode 100644 index 0000000..3716f2f --- /dev/null +++ b/src/server/api/routers/scripts.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { scriptManager } from "~/server/lib/scripts"; +import { gitManager } from "~/server/lib/git"; + +export const scriptsRouter = createTRPCRouter({ + // Get all available scripts + getScripts: publicProcedure + .query(async () => { + const scripts = await scriptManager.getScripts(); + return { + scripts, + directoryInfo: scriptManager.getScriptsDirectoryInfo() + }; + }), + + // Get repository status + getRepoStatus: publicProcedure + .query(async () => { + const status = await gitManager.getStatus(); + return status; + }), + + // Update repository + updateRepo: publicProcedure + .mutation(async () => { + const result = await gitManager.pullUpdates(); + return result; + }), + + // Get script content + getScriptContent: publicProcedure + .input(z.object({ scriptPath: z.string() })) + .query(async ({ input }) => { + try { + const content = await scriptManager.getScriptContent(input.scriptPath); + return { success: true, content }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + }), + + // 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(); + }) +}); diff --git a/src/server/api/websocket/handler.ts b/src/server/api/websocket/handler.ts new file mode 100644 index 0000000..b899f60 --- /dev/null +++ b/src/server/api/websocket/handler.ts @@ -0,0 +1,215 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { IncomingMessage } from 'http'; +import { scriptManager } from '~/server/lib/scripts'; +import { env } from '~/env.js'; + +interface ScriptExecutionMessage { + type: 'start' | 'output' | 'error' | 'end'; + data: string; + timestamp: number; +} + +export class ScriptExecutionHandler { + private wss: WebSocketServer; + private activeExecutions: Map = new Map(); + + constructor(server: any) { + this.wss = new WebSocketServer({ + server, + path: '/ws/script-execution' + }); + + this.wss.on('connection', this.handleConnection.bind(this)); + } + + private handleConnection(ws: WebSocket, request: IncomingMessage) { + console.log('New WebSocket connection for script execution'); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.handleMessage(ws, message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + this.sendMessage(ws, { + type: 'error', + data: 'Invalid message format', + timestamp: Date.now() + }); + } + }); + + ws.on('close', () => { + console.log('WebSocket connection closed'); + // Clean up any active executions for this connection + this.cleanupActiveExecutions(ws); + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.cleanupActiveExecutions(ws); + }); + } + + private async handleMessage(ws: WebSocket, message: any) { + const { action, scriptPath, executionId } = message; + + switch (action) { + case 'start': + if (scriptPath && executionId) { + await this.startScriptExecution(ws, scriptPath, executionId); + } else { + this.sendMessage(ws, { + type: 'error', + data: 'Missing scriptPath or executionId', + timestamp: Date.now() + }); + } + break; + + case 'stop': + if (executionId) { + this.stopScriptExecution(executionId); + } + break; + + default: + this.sendMessage(ws, { + type: 'error', + data: 'Unknown action', + timestamp: Date.now() + }); + } + } + + private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string) { + try { + // Validate script path + const validation = scriptManager.validateScriptPath(scriptPath); + if (!validation.valid) { + this.sendMessage(ws, { + type: 'error', + data: validation.message || 'Invalid script path', + timestamp: Date.now() + }); + return; + } + + // Check if execution is already running + if (this.activeExecutions.has(executionId)) { + this.sendMessage(ws, { + type: 'error', + data: 'Script execution already running', + timestamp: Date.now() + }); + return; + } + + // Start script execution + const process = await scriptManager.executeScript(scriptPath); + + // Store the execution + this.activeExecutions.set(executionId, { process, ws }); + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting execution of ${scriptPath}`, + timestamp: Date.now() + }); + + // Handle stdout + process.stdout?.on('data', (data: Buffer) => { + this.sendMessage(ws, { + type: 'output', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle stderr + process.stderr?.on('data', (data: Buffer) => { + this.sendMessage(ws, { + type: 'error', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle process exit + process.on('exit', (code: number | null, signal: string | null) => { + this.sendMessage(ws, { + type: 'end', + data: `Script execution finished with code: ${code}, signal: ${signal}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + + // Handle process error + process.on('error', (error: Error) => { + this.sendMessage(ws, { + type: 'error', + data: `Process error: ${error.message}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start script: ${error instanceof Error ? error.message : 'Unknown error'}`, + timestamp: Date.now() + }); + } + } + + private stopScriptExecution(executionId: string) { + const execution = this.activeExecutions.get(executionId); + if (execution) { + execution.process.kill('SIGTERM'); + this.activeExecutions.delete(executionId); + + this.sendMessage(execution.ws, { + type: 'end', + data: 'Script execution stopped by user', + timestamp: Date.now() + }); + } + } + + private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } + } + + private cleanupActiveExecutions(ws: WebSocket) { + for (const [executionId, execution] of this.activeExecutions.entries()) { + if (execution.ws === ws) { + execution.process.kill('SIGTERM'); + this.activeExecutions.delete(executionId); + } + } + } + + // Get active executions count + getActiveExecutionsCount(): number { + return this.activeExecutions.size; + } + + // Get active executions info + getActiveExecutions(): string[] { + return Array.from(this.activeExecutions.keys()); + } +} + +// Export function to create handler +export function createScriptExecutionHandler(server: any): ScriptExecutionHandler { + return new ScriptExecutionHandler(server); +} diff --git a/src/server/lib/git.ts b/src/server/lib/git.ts new file mode 100644 index 0000000..4df9cdb --- /dev/null +++ b/src/server/lib/git.ts @@ -0,0 +1,177 @@ +import { simpleGit, SimpleGit } from 'simple-git'; +import { env } from '~/env.js'; +import { join } from 'path'; + +export class GitManager { + private git: SimpleGit; + private repoPath: string; + private scriptsDir: string; + + constructor() { + this.repoPath = process.cwd(); + this.scriptsDir = join(this.repoPath, env.SCRIPTS_DIRECTORY); + this.git = simpleGit(this.repoPath); + } + + /** + * Check if the repository is behind the remote + */ + async isBehindRemote(): Promise { + try { + if (!env.REPO_URL) { + return false; // No remote configured + } + + // Fetch latest changes without merging + await this.git.fetch(); + + // Check if local branch is behind remote + const status = await this.git.status(); + const behind = status.behind > 0; + + return behind; + } catch (error) { + console.error('Error checking repo status:', error); + return false; + } + } + + /** + * Pull updates from remote repository + */ + async pullUpdates(): Promise<{ success: boolean; message: string }> { + try { + if (!env.REPO_URL) { + return { success: false, message: 'No remote repository configured' }; + } + + // Check if we're in a git repository + const isRepo = await this.git.checkIsRepo(); + if (!isRepo) { + // Clone the repository if it doesn't exist + return await this.cloneRepository(); + } + + // Pull latest changes + const result = await this.git.pull(env.REPO_BRANCH); + + return { + success: true, + message: `Successfully pulled updates. ${result.summary.changes} changes, ${result.summary.insertions} insertions, ${result.summary.deletions} deletions` + }; + } catch (error) { + console.error('Error pulling updates:', error); + return { + success: false, + message: `Failed to pull updates: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Clone the repository if it doesn't exist + */ + private async cloneRepository(): Promise<{ success: boolean; message: string }> { + try { + if (!env.REPO_URL) { + return { success: false, message: 'No repository URL configured' }; + } + + console.log(`Cloning repository from ${env.REPO_URL}...`); + + // Clone the repository + await this.git.clone(env.REPO_URL, this.repoPath, { + '--branch': env.REPO_BRANCH, + '--single-branch': true, + '--depth': 1 + }); + + return { + success: true, + message: `Successfully cloned repository from ${env.REPO_URL}` + }; + } catch (error) { + console.error('Error cloning repository:', error); + return { + success: false, + message: `Failed to clone repository: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Initialize repository on startup if needed + */ + async initializeRepository(): Promise { + try { + if (!env.REPO_URL) { + console.log('No remote repository configured, skipping initialization'); + return; + } + + const isRepo = await this.git.checkIsRepo(); + if (!isRepo) { + console.log('Repository not found, cloning...'); + const result = await this.cloneRepository(); + if (result.success) { + console.log('Repository initialized successfully'); + } else { + console.error('Failed to initialize repository:', result.message); + } + } else { + console.log('Repository already exists, checking for updates...'); + const behind = await this.isBehindRemote(); + if (behind) { + console.log('Repository is behind remote, pulling updates...'); + const result = await this.pullUpdates(); + if (result.success) { + console.log('Repository updated successfully'); + } else { + console.error('Failed to update repository:', result.message); + } + } else { + console.log('Repository is up to date'); + } + } + } catch (error) { + console.error('Error initializing repository:', error); + } + } + + /** + * Get repository status information + */ + async getStatus(): Promise<{ + isRepo: boolean; + isBehind: boolean; + lastCommit?: string; + branch?: string; + }> { + try { + const isRepo = await this.git.checkIsRepo(); + if (!isRepo) { + return { isRepo: false, isBehind: false }; + } + + const isBehind = await this.isBehindRemote(); + const log = await this.git.log({ maxCount: 1 }); + const status = await this.git.status(); + + return { + isRepo: true, + isBehind, + lastCommit: log.latest?.hash, + branch: status.current + }; + } catch (error) { + console.error('Error getting repository status:', error); + return { isRepo: false, isBehind: false }; + } + } +} + +// Export singleton instance +export const gitManager = new GitManager(); + +// Initialize repository on module load +gitManager.initializeRepository().catch(console.error); diff --git a/src/server/lib/scripts.ts b/src/server/lib/scripts.ts new file mode 100644 index 0000000..dbe0733 --- /dev/null +++ b/src/server/lib/scripts.ts @@ -0,0 +1,212 @@ +import { readdir, stat, access } from 'fs/promises'; +import { join, resolve, extname } from 'path'; +import { env } from '~/env.js'; +import { spawn, ChildProcess } from 'child_process'; + +export interface ScriptInfo { + name: string; + path: string; + extension: string; + size: number; + lastModified: Date; + executable: boolean; +} + +export class ScriptManager { + private scriptsDir: string; + private allowedExtensions: string[]; + private allowedPaths: string[]; + private maxExecutionTime: number; + + constructor() { + this.scriptsDir = join(process.cwd(), env.SCRIPTS_DIRECTORY); + this.allowedExtensions = env.ALLOWED_SCRIPT_EXTENSIONS.split(',').map(ext => ext.trim()); + this.allowedPaths = env.ALLOWED_SCRIPT_PATHS.split(',').map(path => path.trim()); + this.maxExecutionTime = parseInt(env.MAX_SCRIPT_EXECUTION_TIME, 10); + } + + /** + * Get all available scripts in the scripts directory + */ + async getScripts(): Promise { + try { + const files = await readdir(this.scriptsDir); + const scripts: ScriptInfo[] = []; + + for (const file of files) { + const filePath = join(this.scriptsDir, file); + const stats = await stat(filePath); + + if (stats.isFile()) { + const extension = extname(file); + + // Check if file extension is allowed + if (this.allowedExtensions.includes(extension)) { + // Check if file is executable + const executable = await this.isExecutable(filePath); + + scripts.push({ + name: file, + path: filePath, + extension, + size: stats.size, + lastModified: stats.mtime, + executable + }); + } + } + } + + return scripts.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error) { + console.error('Error reading scripts directory:', error); + return []; + } + } + + /** + * Check if a file is executable + */ + private async isExecutable(filePath: string): Promise { + try { + await access(filePath, 0o111); // Check execute permission + return true; + } catch { + return false; + } + } + + /** + * Validate if a script path is allowed to be executed + */ + validateScriptPath(scriptPath: string): { valid: boolean; message?: string } { + const resolvedPath = resolve(scriptPath); + const scriptsDirResolved = resolve(this.scriptsDir); + + // Check if the script is within the allowed directory + if (!resolvedPath.startsWith(scriptsDirResolved)) { + return { + valid: false, + message: 'Script path is not within the allowed scripts directory' + }; + } + + // Check if the script path matches any allowed path pattern + const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/'); + const isAllowed = this.allowedPaths.some(allowedPath => + relativePath.startsWith(allowedPath.replace(/\\/g, '/')) + ); + + if (!isAllowed) { + return { + valid: false, + message: 'Script path is not in the allowed paths list' + }; + } + + // Check file extension + const extension = extname(scriptPath); + if (!this.allowedExtensions.includes(extension)) { + return { + valid: false, + message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}` + }; + } + + return { valid: true }; + } + + /** + * Execute a script and return a child process + */ + async executeScript(scriptPath: string): Promise { + const validation = this.validateScriptPath(scriptPath); + if (!validation.valid) { + throw new Error(validation.message); + } + + // Determine the command to run based on file extension + const extension = extname(scriptPath); + let command: string; + let args: string[] = []; + + switch (extension) { + case '.sh': + case '.bash': + command = 'bash'; + args = [scriptPath]; + break; + case '.py': + command = 'python'; + args = [scriptPath]; + break; + case '.js': + command = 'node'; + args = [scriptPath]; + break; + case '.ts': + command = 'npx'; + args = ['ts-node', scriptPath]; + break; + default: + // Try to execute directly (for files with shebang) + command = scriptPath; + args = []; + } + + // Spawn the process + const childProcess = spawn(command, args, { + cwd: this.scriptsDir, + stdio: ['pipe', 'pipe', 'pipe'], + shell: true + }); + + // Set up timeout + const timeout = setTimeout(() => { + if (!childProcess.killed) { + childProcess.kill('SIGTERM'); + console.log(`Script execution timed out after ${this.maxExecutionTime}ms`); + } + }, this.maxExecutionTime); + + // Clean up timeout when process exits + childProcess.on('exit', () => { + clearTimeout(timeout); + }); + + return childProcess; + } + + /** + * Get script content for display + */ + async getScriptContent(scriptPath: string): Promise { + const validation = this.validateScriptPath(scriptPath); + if (!validation.valid) { + throw new Error(validation.message); + } + + const { readFile } = await import('fs/promises'); + return await readFile(scriptPath, 'utf-8'); + } + + /** + * Get scripts directory information + */ + getScriptsDirectoryInfo(): { + path: string; + allowedExtensions: string[]; + allowedPaths: string[]; + maxExecutionTime: number; + } { + return { + path: this.scriptsDir, + allowedExtensions: this.allowedExtensions, + allowedPaths: this.allowedPaths, + maxExecutionTime: this.maxExecutionTime + }; + } +} + +// Export singleton instance +export const scriptManager = new ScriptManager();