Merge pull request #1 from michelroegl-brunner/PoC

Proof Of Concept
This commit is contained in:
Michel Roegl-Brunner
2025-09-09 14:25:52 +02:00
committed by GitHub
18 changed files with 1588 additions and 54 deletions

View File

@@ -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"

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.cursorignore
# dependencies
/node_modules
/.pnp

View File

@@ -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.

67
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

46
scripts/demo.sh Normal file
View File

@@ -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."

244
server.js Normal file
View File

@@ -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`);
});
});

View File

@@ -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 (
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
<div className="flex items-center justify-center">
<div className="text-gray-600">Loading repository status...</div>
</div>
</div>
);
}
if (!repoStatus) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
<div className="text-red-600">Failed to load repository status</div>
</div>
);
}
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 (
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">{getStatusIcon()}</span>
<div>
<h3 className="text-lg font-semibold text-gray-800">Repository Status</h3>
<div className={`text-sm font-medium ${getStatusColor()}`}>
{getStatusText()}
</div>
{repoStatus.isRepo && (
<div className="text-xs text-gray-500 mt-1">
<p>Branch: {repoStatus.branch || 'Unknown'}</p>
{repoStatus.lastCommit && (
<p>Last commit: {repoStatus.lastCommit.substring(0, 8)}</p>
)}
</div>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => refetchStatus()}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
>
🔄 Refresh
</button>
<button
onClick={handleUpdate}
disabled={isUpdating || !repoStatus.isRepo}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
isUpdating || !repoStatus.isRepo
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isUpdating ? '⏳ Updating...' : '⬇️ Update Repo'}
</button>
</div>
</div>
{updateRepoMutation.isSuccess && (
<div className="mt-3 p-2 bg-green-50 border border-green-200 rounded text-sm text-green-700">
{updateRepoMutation.data?.message}
</div>
)}
{updateRepoMutation.isError && (
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{updateRepoMutation.error?.message || 'Failed to update repository'}
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-lg text-gray-600">Loading scripts...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-lg text-red-600">
Error loading scripts: {error.message}
</div>
</div>
);
}
if (!data?.scripts || data.scripts.length === 0) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-lg text-gray-600">No scripts found in the scripts directory</div>
</div>
);
}
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 (
<div className="w-full">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-2">Available Scripts</h2>
<p className="text-gray-600">
Found {data.scripts.length} script(s) in {data.directoryInfo.path}
</p>
<div className="mt-2 text-sm text-gray-500">
<p>Allowed extensions: {data.directoryInfo.allowedExtensions.join(', ')}</p>
<p>Max execution time: {Math.round(data.directoryInfo.maxExecutionTime / 1000)}s</p>
</div>
</div>
<div className="grid gap-4">
{data.scripts.map((script) => (
<div
key={script.path}
className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">{getFileIcon(script.extension)}</span>
<div>
<h3 className="text-lg font-semibold text-gray-800">{script.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
<p>Size: {formatFileSize(script.size)}</p>
<p>Modified: {formatDate(script.lastModified)}</p>
<p>Extension: {script.extension}</p>
<p className={`font-medium ${script.executable ? 'text-green-600' : 'text-red-600'}`}>
{script.executable ? '✅ Executable' : '❌ Not executable'}
</p>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => setSelectedScript(script.path)}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
>
View
</button>
<button
onClick={() => onRunScript(script.path, script.name)}
disabled={!script.executable}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
script.executable
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
{script.executable ? '▶️ Run' : '🚫 Cannot Run'}
</button>
</div>
</div>
</div>
))}
</div>
{selectedScript && (
<div className="mt-6">
<Terminal
scriptPath={selectedScript}
onClose={() => setSelectedScript(null)}
/>
</div>
)}
</div>
);
}

View File

@@ -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<string[]>([]);
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const wsRef = useRef<WebSocket | null>(null);
const outputRef = useRef<HTMLDivElement>(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 (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
{/* Terminal Header */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-gray-300 font-mono text-sm ml-2">
{scriptName}
</span>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-400 text-xs">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
{/* Terminal Output */}
<div
ref={outputRef}
className="h-96 overflow-y-auto p-4 font-mono text-sm"
style={{ backgroundColor: '#000000', color: '#00ff00' }}
>
{output.length === 0 ? (
<div className="text-gray-500">
<p>Terminal ready. Click "Start Script" to begin execution.</p>
<p className="mt-2">Script: {scriptPath}</p>
</div>
) : (
output.map((line, index) => (
<div key={index} className="whitespace-pre-wrap">
{line}
</div>
))
)}
</div>
{/* Terminal Controls */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700">
<div className="flex space-x-2">
<button
onClick={startScript}
disabled={!isConnected || isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
isConnected && !isRunning
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
Start
</button>
<button
onClick={stopScript}
disabled={!isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
isRunning
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
Stop
</button>
<button
onClick={clearOutput}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
🗑 Clear
</button>
</div>
<button
onClick={onClose}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Close
</button>
</div>
</div>
);
}

View File

@@ -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 ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export { handler as GET, handler as POST };

View File

@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-400 text-white">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">PVE Scripts local</h1>
<main className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
🚀 PVE Scripts Local Management
</h1>
<p className="text-gray-600">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
</div>
</main>
{/* Repository Status */}
<div className="mb-8">
<RepoStatus />
</div>
{/* Running Script Terminal */}
{runningScript && (
<div className="mb-8">
<Terminal
scriptPath={runningScript.path}
onClose={handleCloseTerminal}
/>
</div>
)}
{/* Scripts List */}
<ScriptsList onRunScript={handleRunScript} />
</div>
</main>
);
}

View File

@@ -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,
},
/**

View File

@@ -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

View File

@@ -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();
})
});

View File

@@ -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<string, { process: any; ws: WebSocket }> = 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);
}

177
src/server/lib/git.ts Normal file
View File

@@ -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<boolean> {
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<void> {
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);

212
src/server/lib/scripts.ts Normal file
View File

@@ -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<ScriptInfo[]> {
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<boolean> {
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<ChildProcess> {
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<string> {
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();