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 (
+
+ );
+ }
+
+ 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 */}
+
+
+
+
+
+
+ {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();