diff --git a/prisma/migrations/20251113125441_add_repositories/migration.sql b/prisma/migrations/20251113125441_add_repositories/migration.sql new file mode 100644 index 0000000..e671856 --- /dev/null +++ b/prisma/migrations/20251113125441_add_repositories/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "repositories" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "url" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "is_default" BOOLEAN NOT NULL DEFAULT false, + "is_removable" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 0, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "repositories_url_key" ON "repositories"("url"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 065ec72..e985ad9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,3 +95,16 @@ model LXCConfig { @@map("lxc_configs") } + +model Repository { + id Int @id @default(autoincrement()) + url String @unique + enabled Boolean @default(true) + is_default Boolean @default(false) + is_removable Boolean @default(true) + priority Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("repositories") +} diff --git a/scripts/ct/debian.sh b/scripts/ct/debian.sh new file mode 100644 index 0000000..c82b107 --- /dev/null +++ b/scripts/ct/debian.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +APP="Debian" +var_tags="${var_tags:-os}" +var_cpu="${var_cpu:-1}" +var_ram="${var_ram:-512}" +var_disk="${var_disk:-2}" +var_os="${var_os:-debian}" +var_version="${var_version:-13}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + if [[ ! -d /var ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + msg_info "Updating $APP LXC" + $STD apt update + $STD apt -y upgrade + msg_ok "Updated $APP LXC" + msg_ok "Updated successfully!" + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" diff --git a/scripts/install/debian-install.sh b/scripts/install/debian-install.sh new file mode 100644 index 0000000..7b00eca --- /dev/null +++ b/scripts/install/debian-install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt -y autoremove +$STD apt -y autoclean +$STD apt -y clean +msg_ok "Cleaned" + diff --git a/server.js b/server.js index 0ca86bf..caf8e9f 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,7 @@ import stripAnsi from 'strip-ansi'; import { spawn as ptySpawn } from 'node-pty'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getDatabase } from './src/server/database-prisma.js'; -import { initializeAutoSync, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; +import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; import dotenv from 'dotenv'; // Load environment variables from .env file @@ -978,10 +978,13 @@ app.prepare().then(() => { console.error(err); process.exit(1); }) - .listen(port, hostname, () => { + .listen(port, hostname, async () => { console.log(`> Ready on http://${hostname}:${port}`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); + // Initialize default repositories + await initializeRepositories(); + // Initialize auto-sync service initializeAutoSync(); diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index 2dedf0a..5e8110c 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -9,6 +9,7 @@ import { useTheme } from './ThemeProvider'; import { useRegisterModal } from './modal/ModalStackProvider'; import { api } from '~/trpc/react'; import { useAuth } from './AuthProvider'; +import { Trash2, ExternalLink } from 'lucide-react'; interface GeneralSettingsModalProps { isOpen: boolean; @@ -19,7 +20,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); const { theme, setTheme } = useTheme(); const { isAuthenticated, expirationTime, checkAuth } = useAuth(); - const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync' | 'repositories'>('general'); const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState(''); const [githubToken, setGithubToken] = useState(''); const [saveFilter, setSaveFilter] = useState(false); @@ -54,6 +55,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState(null); const [cronValidationError, setCronValidationError] = useState(''); + // Repository management state + const [newRepoUrl, setNewRepoUrl] = useState(''); + const [newRepoEnabled, setNewRepoEnabled] = useState(true); + const [isAddingRepo, setIsAddingRepo] = useState(false); + const [deletingRepoId, setDeletingRepoId] = useState(null); + + // Repository queries and mutations + const { data: repositoriesData, refetch: refetchRepositories } = api.repositories.getAll.useQuery(undefined, { + enabled: isOpen && activeTab === 'repositories' + }); + const createRepoMutation = api.repositories.create.useMutation(); + const updateRepoMutation = api.repositories.update.useMutation(); + const deleteRepoMutation = api.repositories.delete.useMutation(); + // Load existing settings when modal opens useEffect(() => { if (isOpen) { @@ -601,6 +616,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr > Auto-Sync + @@ -1245,6 +1272,191 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr )} + + {activeTab === 'repositories' && ( +
+
+

Repository Management

+

+ Manage GitHub repositories for script synchronization. The main repository has priority when enabled. +

+ + {/* Add New Repository */} +
+

Add New Repository

+
+
+ + ) => setNewRepoUrl(e.target.value)} + disabled={isAddingRepo} + className="w-full" + /> +

+ Enter a GitHub repository URL (e.g., https://github.com/owner/repo) +

+
+
+
+

Enable after adding

+

Repository will be enabled by default

+
+ +
+ +
+
+ + {/* Repository List */} +
+

Repositories

+ {repositoriesData?.success && repositoriesData.repositories.length > 0 ? ( +
+ {repositoriesData.repositories.map((repo) => ( +
+
+
+ + {repo.url} + + + {repo.is_default && ( + + {repo.priority === 1 ? 'Main' : 'Dev'} + + )} +
+

+ Priority: {repo.priority} {repo.enabled ? '• Enabled' : '• Disabled'} +

+
+
+ { + setMessage(null); + try { + const result = await updateRepoMutation.mutateAsync({ + id: repo.id, + enabled + }); + if (result.success) { + setMessage({ type: 'success', text: `Repository ${enabled ? 'enabled' : 'disabled'} successfully!` }); + await refetchRepositories(); + } else { + setMessage({ type: 'error', text: result.error ?? 'Failed to update repository' }); + } + } catch (error) { + setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to update repository' }); + } + }} + disabled={updateRepoMutation.isPending} + label={repo.enabled ? 'Disable' : 'Enable'} + /> + +
+
+ ))} +
+ ) : ( +

No repositories configured

+ )} +
+ + {/* Message Display */} + {message && ( +
+ {message.text} +
+ )} +
+
+ )} diff --git a/src/app/_components/ScriptCard.tsx b/src/app/_components/ScriptCard.tsx index bbc2068..eb0fcc0 100644 --- a/src/app/_components/ScriptCard.tsx +++ b/src/app/_components/ScriptCard.tsx @@ -26,6 +26,15 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect } }; + const getRepoName = (url?: string): string => { + if (!url) return ''; + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); + if (match) { + return `${match[1]}/${match[2]}`; + } + return url; + }; + return (
{script.updateable && } + {script.repository_url && ( + + {getRepoName(script.repository_url)} + + )}
{/* Download Status */} diff --git a/src/app/_components/ScriptCardList.tsx b/src/app/_components/ScriptCardList.tsx index f253ea1..3446928 100644 --- a/src/app/_components/ScriptCardList.tsx +++ b/src/app/_components/ScriptCardList.tsx @@ -44,6 +44,15 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe return script.categoryNames.join(', '); }; + const getRepoName = (url?: string): string => { + if (!url) return ''; + const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); + if (match) { + return `${match[1]}/${match[2]}`; + } + return url; + }; + return (
{script.updateable && } + {script.repository_url && ( + + {getRepoName(script.repository_url)} + + )} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2b3a3fc..6194547 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -2,6 +2,7 @@ import { scriptsRouter } from "~/server/api/routers/scripts"; import { installedScriptsRouter } from "~/server/api/routers/installedScripts"; import { serversRouter } from "~/server/api/routers/servers"; import { versionRouter } from "~/server/api/routers/version"; +import { repositoriesRouter } from "~/server/api/routers/repositories"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ installedScripts: installedScriptsRouter, servers: serversRouter, version: versionRouter, + repositories: repositoriesRouter, }); // export type definition of API diff --git a/src/server/api/routers/repositories.ts b/src/server/api/routers/repositories.ts new file mode 100644 index 0000000..62b0fba --- /dev/null +++ b/src/server/api/routers/repositories.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { repositoryService } from "~/server/services/repositoryService"; + +export const repositoriesRouter = createTRPCRouter({ + // Get all repositories + getAll: publicProcedure.query(async () => { + try { + const repositories = await repositoryService.getAllRepositories(); + return { + success: true, + repositories + }; + } catch (error) { + console.error('Error fetching repositories:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch repositories', + repositories: [] + }; + } + }), + + // Get enabled repositories + getEnabled: publicProcedure.query(async () => { + try { + const repositories = await repositoryService.getEnabledRepositories(); + return { + success: true, + repositories + }; + } catch (error) { + console.error('Error fetching enabled repositories:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch enabled repositories', + repositories: [] + }; + } + }), + + // Create a new repository + create: publicProcedure + .input(z.object({ + url: z.string().url(), + enabled: z.boolean().optional().default(true), + priority: z.number().optional() + })) + .mutation(async ({ input }) => { + try { + const repository = await repositoryService.createRepository({ + url: input.url, + enabled: input.enabled, + priority: input.priority + }); + return { + success: true, + repository + }; + } catch (error) { + console.error('Error creating repository:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create repository' + }; + } + }), + + // Update a repository + update: publicProcedure + .input(z.object({ + id: z.number(), + enabled: z.boolean().optional(), + url: z.string().url().optional(), + priority: z.number().optional() + })) + .mutation(async ({ input }) => { + try { + const { id, ...data } = input; + const repository = await repositoryService.updateRepository(id, data); + return { + success: true, + repository + }; + } catch (error) { + console.error('Error updating repository:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update repository' + }; + } + }), + + // Delete a repository + delete: publicProcedure + .input(z.object({ + id: z.number() + })) + .mutation(async ({ input }) => { + try { + await repositoryService.deleteRepository(input.id); + return { + success: true + }; + } catch (error) { + console.error('Error deleting repository:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete repository' + }; + } + }) +}); + diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts index 5ee0fbd..2a53e92 100644 --- a/src/server/api/routers/scripts.ts +++ b/src/server/api/routers/scripts.ts @@ -213,6 +213,8 @@ export const scriptsRouter = createTRPCRouter({ // Add interface port interface_port: script?.interface_port, install_basenames, + // Add repository_url from script + repository_url: script?.repository_url ?? card.repository_url, } as ScriptCard; }); diff --git a/src/server/lib/autoSyncInit.js b/src/server/lib/autoSyncInit.js index 7c9a587..b6147cb 100644 --- a/src/server/lib/autoSyncInit.js +++ b/src/server/lib/autoSyncInit.js @@ -1,8 +1,23 @@ import { AutoSyncService } from '../services/autoSyncService.js'; +import { repositoryService } from '../services/repositoryService.ts'; let autoSyncService = null; let isInitialized = false; +/** + * Initialize default repositories + */ +export async function initializeRepositories() { + try { + console.log('Initializing default repositories...'); + await repositoryService.initializeDefaultRepositories(); + console.log('Default repositories initialized successfully'); + } catch (error) { + console.error('Failed to initialize repositories:', error); + console.error('Error stack:', error.stack); + } +} + /** * Initialize auto-sync service and schedule cron job if enabled */ diff --git a/src/server/services/githubJsonService.ts b/src/server/services/githubJsonService.ts index f9cd99d..e9569b7 100644 --- a/src/server/services/githubJsonService.ts +++ b/src/server/services/githubJsonService.ts @@ -1,11 +1,10 @@ -import { writeFile, mkdir, readdir } from 'fs/promises'; +import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { join } from 'path'; import { env } from '../../env.js'; import type { Script, ScriptCard, GitHubFile } from '../../types/script'; +import { repositoryService } from './repositoryService.js'; export class GitHubJsonService { - private baseUrl: string | null = null; - private repoUrl: string | null = null; private branch: string | null = null; private jsonFolder: string | null = null; private localJsonDirectory: string | null = null; @@ -16,31 +15,33 @@ export class GitHubJsonService { } private initializeConfig() { - if (this.repoUrl === null) { - this.repoUrl = env.REPO_URL ?? ""; + if (this.branch === null) { this.branch = env.REPO_BRANCH; this.jsonFolder = env.JSON_FOLDER; this.localJsonDirectory = join(process.cwd(), 'scripts', 'json'); - - // Only validate GitHub URL if it's provided - if (this.repoUrl) { - // Extract owner and repo from the URL - const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl); - if (!urlMatch) { - throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`); - } - - const [, owner, repo] = urlMatch; - this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`; - } else { - // Set a dummy base URL if no REPO_URL is provided - this.baseUrl = ""; - } } } - private async fetchFromGitHub(endpoint: string): Promise { - this.initializeConfig(); + private getBaseUrl(repoUrl: string): string { + const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); + if (!urlMatch) { + throw new Error(`Invalid GitHub repository URL: ${repoUrl}`); + } + + const [, owner, repo] = urlMatch; + return `https://api.github.com/repos/${owner}/${repo}`; + } + + private extractRepoPath(repoUrl: string): string { + const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); + if (!match) { + throw new Error('Invalid GitHub repository URL'); + } + return `${match[1]}/${match[2]}`; + } + + private async fetchFromGitHub(repoUrl: string, endpoint: string): Promise { + const baseUrl = this.getBaseUrl(repoUrl); const headers: HeadersInit = { 'Accept': 'application/vnd.github.v3+json', @@ -52,7 +53,7 @@ export class GitHubJsonService { headers.Authorization = `token ${env.GITHUB_TOKEN}`; } - const response = await fetch(`${this.baseUrl!}${endpoint}`, { headers }); + const response = await fetch(`${baseUrl}${endpoint}`, { headers }); if (!response.ok) { if (response.status === 403) { @@ -66,15 +67,16 @@ export class GitHubJsonService { return response.json() as Promise; } - private async downloadJsonFile(filePath: string): Promise