23
.env.example
23
.env.example
@@ -1,14 +1,15 @@
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
# Database
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/pve_scripts_local"
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
# Repository Configuration
|
||||
REPO_URL="https://github.com/your-org/pve-scripts.git"
|
||||
REPO_BRANCH="main"
|
||||
SCRIPTS_DIRECTORY="scripts"
|
||||
ALLOWED_SCRIPT_EXTENSIONS=".sh,.py,.js,.ts,.bash"
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
# Security
|
||||
MAX_SCRIPT_EXECUTION_TIME="300000"
|
||||
ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local"
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3001"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.cursorignore
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
29
README.md
29
README.md
@@ -1,29 +0,0 @@
|
||||
# Create T3 App
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Drizzle](https://orm.drizzle.team)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
## How do I deploy this?
|
||||
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
|
||||
67
package-lock.json
generated
67
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
46
scripts/demo.sh
Normal 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
244
server.js
Normal 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`);
|
||||
});
|
||||
});
|
||||
117
src/app/_components/RepoStatus.tsx
Normal file
117
src/app/_components/RepoStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/app/_components/ScriptsList.tsx
Normal file
138
src/app/_components/ScriptsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/app/_components/Terminal.tsx
Normal file
206
src/app/_components/Terminal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/api/trpc/[trpc]/route.ts
Normal file
24
src/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/env.js
20
src/env.js
@@ -11,6 +11,16 @@ export const env = createEnv({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
// Repository Configuration
|
||||
REPO_URL: z.string().url().optional(),
|
||||
REPO_BRANCH: z.string().default("main"),
|
||||
SCRIPTS_DIRECTORY: z.string().default("scripts"),
|
||||
ALLOWED_SCRIPT_EXTENSIONS: z.string().default(".sh,.py,.js,.ts,.bash"),
|
||||
// Security
|
||||
MAX_SCRIPT_EXECUTION_TIME: z.string().default("300000"), // 5 minutes in ms
|
||||
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: z.string().default("3001"),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -29,6 +39,16 @@ export const env = createEnv({
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// Repository Configuration
|
||||
REPO_URL: process.env.REPO_URL,
|
||||
REPO_BRANCH: process.env.REPO_BRANCH,
|
||||
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
|
||||
ALLOWED_SCRIPT_EXTENSIONS: process.env.ALLOWED_SCRIPT_EXTENSIONS,
|
||||
// Security
|
||||
MAX_SCRIPT_EXECUTION_TIME: process.env.MAX_SCRIPT_EXECUTION_TIME,
|
||||
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
59
src/server/api/routers/scripts.ts
Normal file
59
src/server/api/routers/scripts.ts
Normal 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();
|
||||
})
|
||||
});
|
||||
215
src/server/api/websocket/handler.ts
Normal file
215
src/server/api/websocket/handler.ts
Normal 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
177
src/server/lib/git.ts
Normal 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
212
src/server/lib/scripts.ts
Normal 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();
|
||||
Reference in New Issue
Block a user