Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot]
cb5056508d chore: add VERSION v0.4.3 2025-10-17 07:20:03 +00:00
Michel Roegl-Brunner
b793c57000 refactor: migrate from better-sqlite3 to Prisma (#170)
* refactor: migrate from better-sqlite3 to Prisma

- Install Prisma dependencies and initialize with SQLite
- Create Prisma schema matching existing database structure
- Replace database.js with Prisma-based database service
- Update all API routes, tRPC routers, and WebSocket handler
- Convert TypeScript types to match Prisma schema
- Update build process to include Prisma migrations
- Remove better-sqlite3 dependency

All database operations now use Prisma while maintaining SQLite backend.

* fix: flatten server data in installed scripts API responses

- Transform Prisma nested server objects to flattened fields expected by frontend
- Update getAllInstalledScripts, getInstalledScriptsByServer, and getInstalledScriptById
- Server names should now display correctly in the installed scripts table
- Use nullish coalescing operators for better null handling

* fix: ensure DATABASE_URL is set in .env for Prisma during updates

- Add ensure_database_url() function to update.sh
- Function checks if .env exists and creates from .env.example if needed
- Automatically adds DATABASE_URL if not present
- Call function after restore_backup_files() in update flow
- Fixes Prisma client generation error during updates
2025-10-17 09:17:20 +02:00
dependabot[bot]
6b45c41334 build(deps-dev): Bump @types/node from 24.7.2 to 24.8.0 (#167) 2025-10-16 22:30:27 +02:00
dependabot[bot]
a8eb41e087 build(deps): Bump lucide-react from 0.545.0 to 0.546.0 (#168) 2025-10-16 22:29:52 +02:00
Michel Roegl-Brunner
52adbd9f5c Merge pull request #169 from community-scripts/dependabot/npm_and_yarn/tanstack/react-query-5.90.5 2025-10-16 22:29:28 +02:00
dependabot[bot]
73d3aeec99 build(deps): Bump @tanstack/react-query from 5.90.3 to 5.90.5
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.3 to 5.90.5.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-16 19:56:41 +00:00
Michel Roegl-Brunner
1635bb17da Add Breaking Changes category to release drafter 2025-10-16 15:57:56 +02:00
github-actions[bot]
b4b8da5725 chore: add VERSION v0.4.2 (#165)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-16 13:53:51 +00:00
23 changed files with 1204 additions and 981 deletions

View File

@@ -7,6 +7,9 @@ exclude-labels:
- automated - automated
categories: categories:
- title: "Breaking Changes"
labels:
- breaking
- title: "🚀 Features" - title: "🚀 Features"
labels: labels:
- feature - feature

3
.gitignore vendored
View File

@@ -49,4 +49,5 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
/generated/prisma

View File

@@ -1 +1 @@
0.4.1 0.4.3

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,12 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.1",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.3", "@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
@@ -36,11 +37,10 @@
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0", "lucide-react": "^0.546.0",
"next": "^15.5.5", "next": "^15.5.5",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -65,7 +65,7 @@
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.2", "@types/node": "^24.8.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
@@ -77,6 +77,7 @@
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.0", "prettier-plugin-tailwindcss": "^0.7.0",
"prisma": "^6.17.1",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.46.1", "typescript-eslint": "^8.46.1",

45
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,45 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model InstalledScript {
id Int @id @default(autoincrement())
script_name String
script_path String
container_id String?
server_id Int?
execution_mode String
installation_date DateTime? @default(now())
status String
output_log String?
web_ui_ip String?
web_ui_port Int?
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
@@map("installed_scripts")
}
model Server {
id Int @id @default(autoincrement())
name String @unique
ip String
user String
password String?
auth_type String? @default("password")
ssh_key String?
ssh_key_passphrase String?
ssh_port Int? @default(22)
color String?
created_at DateTime? @default(now())
updated_at DateTime? @updatedAt
ssh_key_path String?
key_generated Boolean? @default(false)
installed_scripts InstalledScript[]
@@map("servers")
}

View File

@@ -7,7 +7,7 @@ import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database.js'; import { getDatabase } from './src/server/database-prisma.js';
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production';
const hostname = '0.0.0.0'; const hostname = '0.0.0.0';
@@ -186,11 +186,11 @@ class ScriptExecutionHandler {
* @param {string} scriptPath - Path to the script * @param {string} scriptPath - Path to the script
* @param {string} executionMode - 'local' or 'ssh' * @param {string} executionMode - 'local' or 'ssh'
* @param {number|null} serverId - Server ID for SSH executions * @param {number|null} serverId - Server ID for SSH executions
* @returns {number|null} - Installation record ID * @returns {Promise<number|null>} - Installation record ID
*/ */
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) { async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
try { try {
const result = this.db.createInstalledScript({ const result = await this.db.createInstalledScript({
script_name: scriptName, script_name: scriptName,
script_path: scriptPath, script_path: scriptPath,
container_id: undefined, container_id: undefined,
@@ -199,7 +199,7 @@ class ScriptExecutionHandler {
status: 'in_progress', status: 'in_progress',
output_log: '' output_log: ''
}); });
return Number(result.lastInsertRowid); return Number(result.id);
} catch (error) { } catch (error) {
console.error('Error creating installation record:', error); console.error('Error creating installation record:', error);
return null; return null;
@@ -211,9 +211,9 @@ class ScriptExecutionHandler {
* @param {number} installationId - Installation record ID * @param {number} installationId - Installation record ID
* @param {Object} updateData - Data to update * @param {Object} updateData - Data to update
*/ */
updateInstallationRecord(installationId, updateData) { async updateInstallationRecord(installationId, updateData) {
try { try {
this.db.updateInstalledScript(installationId, updateData); await this.db.updateInstalledScript(installationId, updateData);
} catch (error) { } catch (error) {
console.error('Error updating installation record:', error); console.error('Error updating installation record:', error);
} }
@@ -327,7 +327,7 @@ class ScriptExecutionHandler {
// Create installation record // Create installation record
const serverId = server ? (server.id ?? null) : null; const serverId = server ? (server.id ?? null) : null;
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId); installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
if (!installationId) { if (!installationId) {
console.error('Failed to create installation record'); console.error('Failed to create installation record');
@@ -356,7 +356,7 @@ class ScriptExecutionHandler {
// Update installation record with failure // Update installation record with failure
if (installationId) { if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
return; return;
} }
@@ -394,7 +394,7 @@ class ScriptExecutionHandler {
}); });
// Handle pty data (both stdout and stderr combined) // Handle pty data (both stdout and stderr combined)
childProcess.onData((data) => { childProcess.onData(async (data) => {
const output = data.toString(); const output = data.toString();
// Store output in buffer for logging // Store output in buffer for logging
@@ -410,7 +410,7 @@ class ScriptExecutionHandler {
// Parse for Container ID // Parse for Container ID
const containerId = this.parseContainerId(output); const containerId = this.parseContainerId(output);
if (containerId && installationId) { if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId }); await this.updateInstallationRecord(installationId, { container_id: containerId });
} }
// Parse for Web UI URL // Parse for Web UI URL
@@ -418,7 +418,7 @@ class ScriptExecutionHandler {
if (webUIUrl && installationId) { if (webUIUrl && installationId) {
const { ip, port } = webUIUrl; const { ip, port } = webUIUrl;
if (ip && port) { if (ip && port) {
this.updateInstallationRecord(installationId, { await this.updateInstallationRecord(installationId, {
web_ui_ip: ip, web_ui_ip: ip,
web_ui_port: port web_ui_port: port
}); });
@@ -464,7 +464,7 @@ class ScriptExecutionHandler {
// Update installation record with failure // Update installation record with failure
if (installationId) { if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
} }
} }
@@ -491,7 +491,7 @@ class ScriptExecutionHandler {
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript( const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
server, server,
scriptPath, scriptPath,
/** @param {string} data */ (data) => { /** @param {string} data */ async (data) => {
// Store output in buffer for logging // Store output in buffer for logging
const exec = this.activeExecutions.get(executionId); const exec = this.activeExecutions.get(executionId);
if (exec) { if (exec) {
@@ -505,7 +505,7 @@ class ScriptExecutionHandler {
// Parse for Container ID // Parse for Container ID
const containerId = this.parseContainerId(data); const containerId = this.parseContainerId(data);
if (containerId && installationId) { if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId }); await this.updateInstallationRecord(installationId, { container_id: containerId });
} }
// Parse for Web UI URL // Parse for Web UI URL
@@ -513,7 +513,7 @@ class ScriptExecutionHandler {
if (webUIUrl && installationId) { if (webUIUrl && installationId) {
const { ip, port } = webUIUrl; const { ip, port } = webUIUrl;
if (ip && port) { if (ip && port) {
this.updateInstallationRecord(installationId, { await this.updateInstallationRecord(installationId, {
web_ui_ip: ip, web_ui_ip: ip,
web_ui_port: port web_ui_port: port
}); });
@@ -545,13 +545,13 @@ class ScriptExecutionHandler {
timestamp: Date.now() timestamp: Date.now()
}); });
}, },
/** @param {number} code */ (code) => { /** @param {number} code */ async (code) => {
const exec = this.activeExecutions.get(executionId); const exec = this.activeExecutions.get(executionId);
const isSuccess = code === 0; const isSuccess = code === 0;
// Update installation record with final status and output // Update installation record with final status and output
if (installationId && exec) { if (installationId && exec) {
this.updateInstallationRecord(installationId, { await this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed', status: isSuccess ? 'success' : 'failed',
output_log: exec.outputBuffer output_log: exec.outputBuffer
}); });
@@ -586,7 +586,7 @@ class ScriptExecutionHandler {
// Update installation record with failure // Update installation record with failure
if (installationId) { if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' }); await this.updateInstallationRecord(installationId, { status: 'failed' });
} }
} }
} }

View File

@@ -159,7 +159,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
...prev, ...prev,
ssh_key: data.privateKey ?? '', ssh_key: data.privateKey ?? '',
ssh_key_path: keyPath, ssh_key_path: keyPath,
key_generated: 1 key_generated: true
})); }));
setGeneratedPublicKey(data.publicKey ?? ''); setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedServerId(serverId); setGeneratedServerId(serverId);

View File

@@ -192,8 +192,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</span> </span>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
Created: {new Date(server.created_at).toLocaleDateString()} Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
{server.updated_at !== server.created_at && ( {server.updated_at && server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span> <span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
)} )}
</div> </div>
@@ -253,7 +253,7 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</Button> </Button>
<div className="flex space-x-2"> <div className="flex space-x-2">
{/* View Public Key button - only show for generated keys */} {/* View Public Key button - only show for generated keys */}
{server.key_generated === 1 && ( {server.key_generated === true && (
<Button <Button
onClick={() => handleViewPublicKey(server)} onClick={() => handleViewPublicKey(server)}
variant="outline" variant="outline"

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database'; import { getDatabase } from '../../../../../server/database-prisma.js';
import { getSSHService } from '../../../../../server/ssh-service'; import { getSSHService } from '../../../../../server/ssh-service';
export async function GET( export async function GET(
@@ -18,7 +18,7 @@ export async function GET(
} }
const db = getDatabase(); const db = getDatabase();
const server = db.getServerById(id); const server = await db.getServerById(id);
if (!server) { if (!server) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../server/database'; import { getDatabase } from '../../../../server/database-prisma.js';
import type { CreateServerData } from '../../../../types/server'; import type { CreateServerData } from '../../../../types/server';
export async function GET( export async function GET(
@@ -18,7 +18,7 @@ export async function GET(
} }
const db = getDatabase(); const db = getDatabase();
const server = db.getServerById(id); const server = await db.getServerById(id);
if (!server) { if (!server) {
return NextResponse.json( return NextResponse.json(
@@ -95,7 +95,7 @@ export async function PUT(
const db = getDatabase(); const db = getDatabase();
// Check if server exists // Check if server exists
const existingServer = db.getServerById(id); const existingServer = await db.getServerById(id);
if (!existingServer) { if (!existingServer) {
return NextResponse.json( return NextResponse.json(
{ error: 'Server not found' }, { error: 'Server not found' },
@@ -103,7 +103,7 @@ export async function PUT(
); );
} }
const result = db.updateServer(id, { await db.updateServer(id, {
name, name,
ip, ip,
user, user,
@@ -113,14 +113,14 @@ export async function PUT(
ssh_key_passphrase, ssh_key_passphrase,
ssh_port: ssh_port ?? 22, ssh_port: ssh_port ?? 22,
color, color,
key_generated: key_generated ?? 0, key_generated: key_generated ?? false,
ssh_key_path ssh_key_path
}); });
return NextResponse.json( return NextResponse.json(
{ {
message: 'Server updated successfully', message: 'Server updated successfully',
changes: result.changes changes: 1
} }
); );
} catch (error) { } catch (error) {
@@ -158,7 +158,7 @@ export async function DELETE(
const db = getDatabase(); const db = getDatabase();
// Check if server exists // Check if server exists
const existingServer = db.getServerById(id); const existingServer = await db.getServerById(id);
if (!existingServer) { if (!existingServer) {
return NextResponse.json( return NextResponse.json(
{ error: 'Server not found' }, { error: 'Server not found' },
@@ -167,14 +167,14 @@ export async function DELETE(
} }
// Delete all installed scripts associated with this server // Delete all installed scripts associated with this server
db.deleteInstalledScriptsByServer(id); await db.deleteInstalledScriptsByServer(id);
const result = db.deleteServer(id); await db.deleteServer(id);
return NextResponse.json( return NextResponse.json(
{ {
message: 'Server deleted successfully', message: 'Server deleted successfully',
changes: result.changes changes: 1
} }
); );
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database'; import { getDatabase } from '../../../../../server/database-prisma.js';
import { getSSHService } from '../../../../../server/ssh-service'; import { getSSHService } from '../../../../../server/ssh-service';
import type { Server } from '../../../../../types/server'; import type { Server } from '../../../../../types/server';
@@ -19,7 +19,7 @@ export async function POST(
} }
const db = getDatabase(); const db = getDatabase();
const server = db.getServerById(id) as Server; const server = await db.getServerById(id) as Server;
if (!server) { if (!server) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getSSHService } from '../../../../server/ssh-service'; import { getSSHService } from '../../../../server/ssh-service';
import { getDatabase } from '../../../../server/database'; import { getDatabase } from '../../../../server/database-prisma.js';
export async function POST(_request: NextRequest) { export async function POST(_request: NextRequest) {
try { try {
@@ -9,7 +9,7 @@ export async function POST(_request: NextRequest) {
const db = getDatabase(); const db = getDatabase();
// Get the next available server ID for key file naming // Get the next available server ID for key file naming
const serverId = db.getNextServerId(); const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId); const keyPair = await sshService.generateKeyPair(serverId);

View File

@@ -1,12 +1,12 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../server/database'; import { getDatabase } from '../../../server/database-prisma.js';
import type { CreateServerData } from '../../../types/server'; import type { CreateServerData } from '../../../types/server';
export async function GET() { export async function GET() {
try { try {
const db = getDatabase(); const db = getDatabase();
const servers = db.getAllServers(); const servers = await db.getAllServers();
return NextResponse.json(servers); return NextResponse.json(servers);
} catch (error) { } catch (error) {
console.error('Error fetching servers:', error); console.error('Error fetching servers:', error);
@@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
const db = getDatabase(); const db = getDatabase();
const result = db.createServer({ const result = await db.createServer({
name, name,
ip, ip,
user, user,
@@ -71,14 +71,14 @@ export async function POST(request: NextRequest) {
ssh_key_passphrase, ssh_key_passphrase,
ssh_port: ssh_port ?? 22, ssh_port: ssh_port ?? 22,
color, color,
key_generated: key_generated ?? 0, key_generated: key_generated ?? false,
ssh_key_path ssh_key_path
}); });
return NextResponse.json( return NextResponse.json(
{ {
message: 'Server created successfully', message: 'Server created successfully',
id: result.lastInsertRowid id: result.id
}, },
{ status: 201 } { status: 201 }
); );

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database"; import { getDatabase } from "~/server/database-prisma.js";
// Removed unused imports // Removed unused imports
@@ -10,10 +10,26 @@ export const installedScriptsRouter = createTRPCRouter({
.query(async () => { .query(async () => {
try { try {
const db = getDatabase(); const db = getDatabase();
const scripts = db.getAllInstalledScripts(); const scripts = await db.getAllInstalledScripts();
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map(script => ({
...script,
server_name: script.server?.name ?? null,
server_ip: script.server?.ip ?? null,
server_user: script.server?.user ?? null,
server_password: script.server?.password ?? null,
server_auth_type: script.server?.auth_type ?? null,
server_ssh_key: script.server?.ssh_key ?? null,
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null,
server: undefined // Remove nested server object
}));
return { return {
success: true, success: true,
scripts scripts: transformedScripts
}; };
} catch (error) { } catch (error) {
console.error('Error in getAllInstalledScripts:', error); console.error('Error in getAllInstalledScripts:', error);
@@ -31,10 +47,26 @@ export const installedScriptsRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const scripts = db.getInstalledScriptsByServer(input.serverId); const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map(script => ({
...script,
server_name: script.server?.name ?? null,
server_ip: script.server?.ip ?? null,
server_user: script.server?.user ?? null,
server_password: script.server?.password ?? null,
server_auth_type: script.server?.auth_type ?? null,
server_ssh_key: script.server?.ssh_key ?? null,
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null,
server: undefined // Remove nested server object
}));
return { return {
success: true, success: true,
scripts scripts: transformedScripts
}; };
} catch (error) { } catch (error) {
console.error('Error in getInstalledScriptsByServer:', error); console.error('Error in getInstalledScriptsByServer:', error);
@@ -52,7 +84,7 @@ export const installedScriptsRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const script = db.getInstalledScriptById(input.id); const script = await db.getInstalledScriptById(input.id);
if (!script) { if (!script) {
return { return {
success: false, success: false,
@@ -60,9 +92,24 @@ export const installedScriptsRouter = createTRPCRouter({
script: null script: null
}; };
} }
// Transform script to flatten server data for frontend compatibility
const transformedScript = {
...script,
server_name: script.server?.name ?? null,
server_ip: script.server?.ip ?? null,
server_user: script.server?.user ?? null,
server_password: script.server?.password ?? null,
server_auth_type: script.server?.auth_type ?? null,
server_ssh_key: script.server?.ssh_key ?? null,
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null,
server: undefined // Remove nested server object
};
return { return {
success: true, success: true,
script script: transformedScript
}; };
} catch (error) { } catch (error) {
console.error('Error in getInstalledScriptById:', error); console.error('Error in getInstalledScriptById:', error);
@@ -90,10 +137,10 @@ export const installedScriptsRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const result = db.createInstalledScript(input); const result = await db.createInstalledScript(input);
return { return {
success: true, success: true,
id: result.lastInsertRowid, id: result.id,
message: 'Installed script record created successfully' message: 'Installed script record created successfully'
}; };
} catch (error) { } catch (error) {
@@ -120,9 +167,9 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
const { id, ...updateData } = input; const { id, ...updateData } = input;
const db = getDatabase(); const db = getDatabase();
const result = db.updateInstalledScript(id, updateData); const result = await db.updateInstalledScript(id, updateData);
if (result.changes === 0) { if (!result) {
return { return {
success: false, success: false,
error: 'No changes made or script not found' error: 'No changes made or script not found'
@@ -148,9 +195,9 @@ export const installedScriptsRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const result = db.deleteInstalledScript(input.id); const result = await db.deleteInstalledScript(input.id);
if (result.changes === 0) { if (!result) {
return { return {
success: false, success: false,
error: 'Script not found or already deleted' error: 'Script not found or already deleted'
@@ -175,7 +222,7 @@ export const installedScriptsRouter = createTRPCRouter({
.query(async () => { .query(async () => {
try { try {
const db = getDatabase(); const db = getDatabase();
const allScripts = db.getAllInstalledScripts(); const allScripts = await db.getAllInstalledScripts();
const stats = { const stats = {
total: allScripts.length, total: allScripts.length,
@@ -219,7 +266,7 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
const db = getDatabase(); const db = getDatabase();
const server = db.getServerById(input.serverId); const server = await db.getServerById(input.serverId);
if (!server) { if (!server) {
console.error('Server not found for ID:', input.serverId); console.error('Server not found for ID:', input.serverId);
@@ -350,7 +397,7 @@ export const installedScriptsRouter = createTRPCRouter({
// Get existing scripts to check for duplicates // Get existing scripts to check for duplicates
const existingScripts = db.getAllInstalledScripts(); const existingScripts = await db.getAllInstalledScripts();
// Create installed script records for detected containers (skip duplicates) // Create installed script records for detected containers (skip duplicates)
const createdScripts = []; const createdScripts = [];
@@ -373,7 +420,7 @@ export const installedScriptsRouter = createTRPCRouter({
continue; continue;
} }
const result = db.createInstalledScript({ const result = await db.createInstalledScript({
script_name: container.hostname, script_name: container.hostname,
script_path: `detected/${container.hostname}`, script_path: `detected/${container.hostname}`,
container_id: container.containerId, container_id: container.containerId,
@@ -384,7 +431,7 @@ export const installedScriptsRouter = createTRPCRouter({
}); });
createdScripts.push({ createdScripts.push({
id: result.lastInsertRowid, id: result.id,
containerId: container.containerId, containerId: container.containerId,
hostname: container.hostname, hostname: container.hostname,
serverName: container.serverName serverName: container.serverName
@@ -420,8 +467,8 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
const db = getDatabase(); const db = getDatabase();
const allScripts = db.getAllInstalledScripts(); const allScripts = await db.getAllInstalledScripts();
const allServers = db.getAllServers(); const allServers = await db.getAllServers();
if (allScripts.length === 0) { if (allScripts.length === 0) {
@@ -452,7 +499,7 @@ export const installedScriptsRouter = createTRPCRouter({
const scriptData = script as any; const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id); const server = allServers.find((s: any) => s.id === scriptData.server_id);
if (!server) { if (!server) {
db.deleteInstalledScript(Number(scriptData.id)); await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name)); deletedScripts.push(String(scriptData.script_name));
continue; continue;
} }
@@ -488,7 +535,7 @@ export const installedScriptsRouter = createTRPCRouter({
}); });
if (!containerExists) { if (!containerExists) {
db.deleteInstalledScript(Number(scriptData.id)); await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name)); deletedScripts.push(String(scriptData.script_name));
} else { } else {
} }
@@ -525,7 +572,7 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
const db = getDatabase(); const db = getDatabase();
const allServers = db.getAllServers(); const allServers = await db.getAllServers();
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {}; const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
// Import SSH services // Import SSH services
@@ -630,7 +677,7 @@ export const installedScriptsRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const script = db.getInstalledScriptById(input.id); const script = await db.getInstalledScriptById(input.id);
if (!script) { if (!script) {
return { return {
@@ -652,7 +699,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Get server info // Get server info
const server = db.getServerById(Number(scriptData.server_id)); const server = await db.getServerById(Number(scriptData.server_id));
if (!server) { if (!server) {
return { return {
success: false, success: false,
@@ -732,7 +779,7 @@ export const installedScriptsRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const script = db.getInstalledScriptById(input.id); const script = await db.getInstalledScriptById(input.id);
if (!script) { if (!script) {
return { return {
@@ -752,7 +799,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Get server info // Get server info
const server = db.getServerById(Number(scriptData.server_id)); const server = await db.getServerById(Number(scriptData.server_id));
if (!server) { if (!server) {
return { return {
success: false, success: false,
@@ -823,7 +870,7 @@ export const installedScriptsRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const script = db.getInstalledScriptById(input.id); const script = await db.getInstalledScriptById(input.id);
if (!script) { if (!script) {
return { return {
@@ -843,7 +890,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Get server info // Get server info
const server = db.getServerById(Number(scriptData.server_id)); const server = await db.getServerById(Number(scriptData.server_id));
if (!server) { if (!server) {
return { return {
success: false, success: false,
@@ -950,9 +997,9 @@ export const installedScriptsRouter = createTRPCRouter({
}); });
// If destroy was successful, delete the database record // If destroy was successful, delete the database record
const deleteResult = db.deleteInstalledScript(input.id); const deleteResult = await db.deleteInstalledScript(input.id);
if (deleteResult.changes === 0) { if (!deleteResult) {
return { return {
success: false, success: false,
error: 'Container destroyed but failed to delete database record' error: 'Container destroyed but failed to delete database record'
@@ -985,7 +1032,7 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
console.log('🔍 Auto-detect WebUI called with id:', input.id); console.log('🔍 Auto-detect WebUI called with id:', input.id);
const db = getDatabase(); const db = getDatabase();
const script = db.getInstalledScriptById(input.id); const script = await db.getInstalledScriptById(input.id);
if (!script) { if (!script) {
console.log('❌ Script not found for id:', input.id); console.log('❌ Script not found for id:', input.id);
@@ -1013,7 +1060,7 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Get server info // Get server info
const server = db.getServerById(Number(scriptData.server_id)); const server = await db.getServerById(Number(scriptData.server_id));
if (!server) { if (!server) {
console.log('❌ Server not found for id:', scriptData.server_id); console.log('❌ Server not found for id:', scriptData.server_id);
return { return {
@@ -1121,12 +1168,12 @@ export const installedScriptsRouter = createTRPCRouter({
// Update the database with detected IP and port // Update the database with detected IP and port
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort); console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
const updateResult = db.updateInstalledScript(input.id, { const updateResult = await db.updateInstalledScript(input.id, {
web_ui_ip: detectedIp, web_ui_ip: detectedIp,
web_ui_port: detectedPort web_ui_port: detectedPort
}); });
if (updateResult.changes === 0) { if (!updateResult) {
console.log('❌ Database update failed - no changes made'); console.log('❌ Database update failed - no changes made');
return { return {
success: false, success: false,

View File

@@ -1,13 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database"; import { getDatabase } from "~/server/database-prisma.js";
export const serversRouter = createTRPCRouter({ export const serversRouter = createTRPCRouter({
getAllServers: publicProcedure getAllServers: publicProcedure
.query(async () => { .query(async () => {
try { try {
const db = getDatabase(); const db = getDatabase();
const servers = db.getAllServers(); const servers = await db.getAllServers();
return { success: true, servers }; return { success: true, servers };
} catch (error) { } catch (error) {
console.error('Error fetching servers:', error); console.error('Error fetching servers:', error);
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const db = getDatabase(); const db = getDatabase();
const server = db.getServerById(input.id); const server = await db.getServerById(input.id);
if (!server) { if (!server) {
return { success: false, error: 'Server not found', server: null }; return { success: false, error: 'Server not found', server: null };
} }

View File

@@ -0,0 +1,254 @@
import { prisma } from './db.js';
import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
class DatabaseServicePrisma {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = await this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: Boolean(key_generated),
color,
}
});
}
async getAllServers() {
return await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
}
async getServerById(id) {
return await prisma.server.findUnique({
where: { id }
});
}
async updateServer(id, serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
return await prisma.server.update({
where: { id },
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
color,
}
});
}
async deleteServer(id) {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
// Delete SSH key files if they exist
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
unlinkSync(server.ssh_key_path);
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
return await prisma.server.delete({
where: { id }
});
}
// Installed Scripts CRUD operations
async createInstalledScript(scriptData) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
return await prisma.installedScript.create({
data: {
script_name,
script_path,
container_id: container_id ?? null,
server_id: server_id ?? null,
execution_mode,
status,
output_log: output_log ?? null,
web_ui_ip: web_ui_ip ?? null,
web_ui_port: web_ui_port ?? null,
}
});
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async getInstalledScriptById(id) {
return await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
}
async getInstalledScriptsByServer(server_id) {
return await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async updateInstalledScript(id, updateData) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
if (output_log !== undefined) updateFields.output_log = output_log;
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
if (Object.keys(updateFields).length === 0) {
return { changes: 0 };
}
return await prisma.installedScript.update({
where: { id },
data: updateFields
});
}
async deleteInstalledScript(id) {
return await prisma.installedScript.delete({
where: { id }
});
}
async deleteInstalledScriptsByServer(server_id) {
return await prisma.installedScript.deleteMany({
where: { server_id }
});
}
async getNextServerId() {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
}
createSSHKeyFile(serverId, sshKey) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
async close() {
await prisma.$disconnect();
}
}
// Singleton instance
let dbInstance = null;
export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}
export default DatabaseServicePrisma;

View File

@@ -0,0 +1,279 @@
import { prisma } from './db';
import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
import type { CreateServerData } from '../types/server';
class DatabaseServicePrisma {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = await this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: Boolean(key_generated),
color,
}
});
}
async getAllServers() {
return await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
}
async getServerById(id: number) {
return await prisma.server.findUnique({
where: { id }
});
}
async updateServer(id: number, serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
return await prisma.server.update({
where: { id },
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
color,
}
});
}
async deleteServer(id: number) {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
// Delete SSH key files if they exist
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
unlinkSync(server.ssh_key_path);
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
return await prisma.server.delete({
where: { id }
});
}
// Installed Scripts CRUD operations
async createInstalledScript(scriptData: {
script_name: string;
script_path: string;
container_id?: string;
server_id?: number;
execution_mode: string;
status: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
return await prisma.installedScript.create({
data: {
script_name,
script_path,
container_id: container_id ?? null,
server_id: server_id ?? null,
execution_mode,
status,
output_log: output_log ?? null,
web_ui_ip: web_ui_ip ?? null,
web_ui_port: web_ui_port ?? null,
}
});
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async getInstalledScriptById(id: number) {
return await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
}
async getInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async updateInstalledScript(id: number, updateData: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
} = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
if (output_log !== undefined) updateFields.output_log = output_log;
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
if (Object.keys(updateFields).length === 0) {
return { changes: 0 };
}
return await prisma.installedScript.update({
where: { id },
data: updateFields
});
}
async deleteInstalledScript(id: number) {
return await prisma.installedScript.delete({
where: { id }
});
}
async deleteInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.deleteMany({
where: { server_id }
});
}
async getNextServerId() {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
}
createSSHKeyFile(serverId: number, sshKey: string) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
async close() {
await prisma.$disconnect();
}
}
// Singleton instance
let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}
export default DatabaseServicePrisma;

View File

@@ -1,476 +0,0 @@
import Database from 'better-sqlite3';
import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
class DatabaseService {
constructor() {
const dbPath = join(process.cwd(), 'data', 'settings.db');
this.db = new Database(dbPath);
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
// Create servers table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
user TEXT NOT NULL,
password TEXT,
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key')),
ssh_key TEXT,
ssh_key_passphrase TEXT,
ssh_port INTEGER DEFAULT 22,
ssh_key_path TEXT,
key_generated INTEGER DEFAULT 0,
color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Migration: Add new columns to existing servers table
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key'))
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN color TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key_path TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN key_generated INTEGER DEFAULT 0
`);
} catch (e) {
// Column already exists, ignore error
}
// Update existing servers to have auth_type='password' if not set
this.db.exec(`
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
`);
// Update existing servers to have ssh_port=22 if not set
this.db.exec(`
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
`);
// Migration: Convert 'both' auth_type to 'key'
this.db.exec(`
UPDATE servers SET auth_type = 'key' WHERE auth_type = 'both'
`);
// Update existing servers to have key_generated=0 if not set
this.db.exec(`
UPDATE servers SET key_generated = 0 WHERE key_generated IS NULL
`);
// Migration: Add web_ui_ip column to existing installed_scripts table
try {
this.db.exec(`
ALTER TABLE installed_scripts ADD COLUMN web_ui_ip TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
// Migration: Add web_ui_port column to existing installed_scripts table
try {
this.db.exec(`
ALTER TABLE installed_scripts ADD COLUMN web_ui_port INTEGER
`);
} catch (e) {
// Column already exists, ignore error
}
// Create installed_scripts table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL,
script_path TEXT NOT NULL,
container_id TEXT,
server_id INTEGER,
execution_mode TEXT NOT NULL CHECK(execution_mode IN ('local', 'ssh')),
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
output_log TEXT,
web_ui_ip TEXT,
web_ui_port INTEGER,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
)
`);
// Create trigger to update updated_at on row update
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS update_servers_timestamp
AFTER UPDATE ON servers
BEGIN
UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
}
// Server CRUD operations
/**
* @param {import('../types/server').CreateServerData} serverData
*/
createServer(serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, ssh_key_path, key_generated, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated || 0, color);
}
getAllServers() {
const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC');
return stmt.all();
}
/**
* @param {number} id
*/
getServerById(id) {
const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?');
return stmt.get(id);
}
/**
* @param {number} id
* @param {import('../types/server').CreateServerData} serverData
*/
updateServer(id, serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = this.getServerById(id);
// @ts-ignore - Database migration adds this column
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
// @ts-ignore - Database migration adds this column
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
// @ts-ignore - Database migration adds this column
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
// @ts-ignore - Database migration adds this column
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(existingServer.ssh_key_path);
// @ts-ignore - Database migration adds this column
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
const stmt = this.db.prepare(`
UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, ssh_key_path = ?, key_generated = ?, color = ?
WHERE id = ?
`);
// @ts-ignore - Database migration adds this column
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated !== undefined ? key_generated : (existingServer?.key_generated || 0), color, id);
}
/**
* @param {number} id
*/
deleteServer(id) {
// Get server info before deletion to clean up key files
const server = this.getServerById(id);
// Delete SSH key files if they exist
// @ts-ignore - Database migration adds this column
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
// @ts-ignore - Database migration adds this column
unlinkSync(server.ssh_key_path);
// @ts-ignore - Database migration adds this column
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
return stmt.run(id);
}
// Installed Scripts CRUD operations
/**
* @param {Object} scriptData
* @param {string} scriptData.script_name
* @param {string} scriptData.script_path
* @param {string} [scriptData.container_id]
* @param {number} [scriptData.server_id]
* @param {string} scriptData.execution_mode
* @param {string} scriptData.status
* @param {string} [scriptData.output_log]
* @param {string} [scriptData.web_ui_ip]
* @param {number} [scriptData.web_ui_port]
*/
createInstalledScript(scriptData) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
const stmt = this.db.prepare(`
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null, web_ui_ip || null, web_ui_port || null);
}
getAllInstalledScripts() {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip,
s.user as server_user,
s.password as server_password,
s.auth_type as server_auth_type,
s.ssh_key as server_ssh_key,
s.ssh_key_passphrase as server_ssh_key_passphrase,
s.ssh_port as server_ssh_port,
s.color as server_color
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
ORDER BY inst.installation_date DESC
`);
return stmt.all();
}
/**
* @param {number} id
*/
getInstalledScriptById(id) {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
WHERE inst.id = ?
`);
return stmt.get(id);
}
/**
* @param {number} server_id
*/
getInstalledScriptsByServer(server_id) {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
WHERE inst.server_id = ?
ORDER BY inst.installation_date DESC
`);
return stmt.all(server_id);
}
/**
* @param {number} id
* @param {Object} updateData
* @param {string} [updateData.script_name]
* @param {string} [updateData.container_id]
* @param {string} [updateData.status]
* @param {string} [updateData.output_log]
* @param {string} [updateData.web_ui_ip]
* @param {number} [updateData.web_ui_port]
*/
updateInstalledScript(id, updateData) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updates = [];
const values = [];
if (script_name !== undefined) {
updates.push('script_name = ?');
values.push(script_name);
}
if (container_id !== undefined) {
updates.push('container_id = ?');
values.push(container_id);
}
if (status !== undefined) {
updates.push('status = ?');
values.push(status);
}
if (output_log !== undefined) {
updates.push('output_log = ?');
values.push(output_log);
}
if (web_ui_ip !== undefined) {
updates.push('web_ui_ip = ?');
values.push(web_ui_ip);
}
if (web_ui_port !== undefined) {
updates.push('web_ui_port = ?');
values.push(web_ui_port);
}
if (updates.length === 0) {
return { changes: 0 };
}
values.push(id);
const stmt = this.db.prepare(`
UPDATE installed_scripts
SET ${updates.join(', ')}
WHERE id = ?
`);
return stmt.run(...values);
}
/**
* @param {number} id
*/
deleteInstalledScript(id) {
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE id = ?');
return stmt.run(id);
}
/**
* @param {number} server_id
*/
deleteInstalledScriptsByServer(server_id) {
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE server_id = ?');
return stmt.run(server_id);
}
/**
* Get the next available server ID for key file naming
* @returns {number}
*/
getNextServerId() {
const stmt = this.db.prepare('SELECT MAX(id) as maxId FROM servers');
const result = stmt.get();
// @ts-ignore - SQL query result type
return (result?.maxId || 0) + 1;
}
/**
* Create SSH key file and return the path
* @param {number} serverId
* @param {string} sshKey
* @returns {string}
*/
createSSHKeyFile(serverId, sshKey) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
close() {
this.db.close();
}
}
// Singleton instance
/** @type {DatabaseService | null} */
let dbInstance = null;
export function getDatabase() {
if (!dbInstance) {
dbInstance = new DatabaseService();
}
return dbInstance;
}
export default DatabaseService;

7
src/server/db.js Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis;
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

9
src/server/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -8,11 +8,11 @@ export interface Server {
ssh_key?: string; ssh_key?: string;
ssh_key_passphrase?: string; ssh_key_passphrase?: string;
ssh_key_path?: string; ssh_key_path?: string;
key_generated?: number; key_generated?: boolean;
ssh_port?: number; ssh_port?: number;
color?: string; color?: string;
created_at: string; created_at: Date | null;
updated_at: string; updated_at: Date | null;
} }
export interface CreateServerData { export interface CreateServerData {
@@ -24,7 +24,7 @@ export interface CreateServerData {
ssh_key?: string; ssh_key?: string;
ssh_key_passphrase?: string; ssh_key_passphrase?: string;
ssh_key_path?: string; ssh_key_path?: string;
key_generated?: number; key_generated?: boolean;
ssh_port?: number; ssh_port?: number;
color?: string; color?: string;
} }

View File

@@ -412,6 +412,36 @@ restore_backup_files() {
fi fi
} }
# Ensure DATABASE_URL is set in .env file for Prisma
ensure_database_url() {
log "Ensuring DATABASE_URL is set in .env file..."
# Check if .env file exists
if [ ! -f ".env" ]; then
log_warning ".env file not found, creating from .env.example..."
if [ -f ".env.example" ]; then
cp ".env.example" ".env"
else
log_error ".env.example not found, cannot create .env file"
return 1
fi
fi
# Check if DATABASE_URL is already set
if grep -q "^DATABASE_URL=" .env; then
log "DATABASE_URL already exists in .env file"
return 0
fi
# Add DATABASE_URL to .env file
log "Adding DATABASE_URL to .env file..."
echo "" >> .env
echo "# Database" >> .env
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
log_success "DATABASE_URL added to .env file"
}
# Check if systemd service exists # Check if systemd service exists
check_service() { check_service() {
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.) # systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
@@ -607,6 +637,32 @@ install_and_build() {
log_success "Dependencies installed successfully" log_success "Dependencies installed successfully"
rm -f "$npm_log" rm -f "$npm_log"
# Generate Prisma client
log "Generating Prisma client..."
if ! npx prisma generate > "$npm_log" 2>&1; then
log_error "Failed to generate Prisma client"
log_error "Prisma generate output:"
cat "$npm_log" | while read -r line; do
log_error "PRISMA: $line"
done
rm -f "$npm_log"
return 1
fi
log_success "Prisma client generated successfully"
# Run Prisma migrations
log "Running Prisma migrations..."
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
log_warning "Prisma migrations failed or no migrations to run"
log "Prisma migrate output:"
cat "$npm_log" | while read -r line; do
log "PRISMA: $line"
done
else
log_success "Prisma migrations completed successfully"
fi
rm -f "$npm_log"
log "Building application..." log "Building application..."
# Set NODE_ENV to production for build # Set NODE_ENV to production for build
export NODE_ENV=production export NODE_ENV=production
@@ -838,6 +894,9 @@ main() {
# Restore .env and data directory before building # Restore .env and data directory before building
restore_backup_files restore_backup_files
# Ensure DATABASE_URL is set for Prisma
ensure_database_url
# Install dependencies and build # Install dependencies and build
if ! install_and_build; then if ! install_and_build; then
log_error "Install and build failed, rolling back..." log_error "Install and build failed, rolling back..."