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/README.md b/README.md
index 67943c7..e69de29 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +0,0 @@
-# Create T3 App
-
-This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
-
-## What's next? How do I make an app with this?
-
-We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
-
-If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
-
-- [Next.js](https://nextjs.org)
-- [NextAuth.js](https://next-auth.js.org)
-- [Prisma](https://prisma.io)
-- [Drizzle](https://orm.drizzle.team)
-- [Tailwind CSS](https://tailwindcss.com)
-- [tRPC](https://trpc.io)
-
-## Learn More
-
-To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
-
-- [Documentation](https://create.t3.gg/)
-- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) â Check out these awesome tutorials
-
-You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) â your feedback and contributions are welcome!
-
-## How do I deploy this?
-
-Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
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();