This commit is contained in:
Rögl-Brunner Michel
2025-09-09 14:24:36 +02:00
parent cc47399e0c
commit 6ee5c5c017
17 changed files with 1588 additions and 25 deletions

View File

@@ -1,14 +1,15 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to # Database
# build a new ".env" file when you clone the repo. Keep this file up-to-date DATABASE_URL="postgresql://username:password@localhost:5432/pve_scripts_local"
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any # Repository Configuration
# secrets in it. If you are cloning this repo, create a copy of this file named REPO_URL="https://github.com/your-org/pve-scripts.git"
# ".env" and populate it with your secrets. 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" # Security
# should be updated accordingly. MAX_SCRIPT_EXECUTION_TIME="300000"
ALLOWED_SCRIPT_PATHS="scripts/"
# Prisma # WebSocket Configuration
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env WEBSOCKET_PORT="3001"
DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local"

2
.gitignore vendored
View File

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

67
package-lock.json generated
View File

@@ -15,11 +15,14 @@
"@trpc/client": "^11.0.0", "@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0", "@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"@types/ws": "^8.18.1",
"next": "^15.2.3", "next": "^15.2.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"simple-git": "^3.28.0",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"ws": "^8.18.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
@@ -759,6 +762,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1506,7 +1524,6 @@
"version": "20.19.13", "version": "20.19.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz",
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1532,6 +1549,15 @@
"@types/react": "^19.0.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.43.0", "version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
@@ -2745,7 +2771,6 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -5012,7 +5037,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -6128,6 +6152,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/simple-swizzle": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -6655,7 +6694,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
@@ -6818,6 +6856,27 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -10,14 +10,15 @@
"db:migrate": "prisma migrate deploy", "db:migrate": "prisma migrate deploy",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:studio": "prisma studio", "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:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "node server.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -27,11 +28,14 @@
"@trpc/client": "^11.0.0", "@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0", "@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"@types/ws": "^8.18.1",
"next": "^15.2.3", "next": "^15.2.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"simple-git": "^3.28.0",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"ws": "^8.18.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "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 ( return (
<main className="min-h-screen bg-gray-100">
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-400 text-white"> <div className="container mx-auto px-4 py-8">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16"> {/* Header */}
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">PVE Scripts local</h1> <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> </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 NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .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: { runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV, 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, // 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"; 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. * All routers added in /api/routers should be manually added here.
*/ */
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
post: postRouter, scripts: scriptsRouter,
}); });
// export type definition of API // 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();