feat: Add multi-repository support for script synchronization
- Add Repository model to Prisma schema with migration - Create repositoryService for managing repositories - Add repositories API router with CRUD operations - Update GitHubJsonService to support multiple repositories - Update ScriptDownloaderService to use repository URL from scripts - Add repository_url field to Script and ScriptCard types - Add repository management UI tab to GeneralSettingsModal - Display repository source on script cards and detail modal - Implement repository deletion with JSON file cleanup - Initialize default repositories (main and dev) on server startup
This commit is contained in:
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
44
scripts/ct/debian.sh
Normal file
44
scripts/ct/debian.sh
Normal file
@@ -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}"
|
||||
24
scripts/install/debian-install.sh
Normal file
24
scripts/install/debian-install.sh
Normal file
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string>('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
@@ -54,6 +55,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(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<number | null>(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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('repositories')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'repositories'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Repositories
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1245,6 +1272,191 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'repositories' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Repository Management</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Manage GitHub repositories for script synchronization. The main repository has priority when enabled.
|
||||
</p>
|
||||
|
||||
{/* Add New Repository */}
|
||||
<div className="p-4 border border-border rounded-lg mb-4">
|
||||
<h4 className="font-medium text-foreground mb-3">Add New Repository</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="new-repo-url" className="block text-sm font-medium text-foreground mb-1">
|
||||
Repository URL
|
||||
</label>
|
||||
<Input
|
||||
id="new-repo-url"
|
||||
type="url"
|
||||
placeholder="https://github.com/owner/repo"
|
||||
value={newRepoUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewRepoUrl(e.target.value)}
|
||||
disabled={isAddingRepo}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Enter a GitHub repository URL (e.g., https://github.com/owner/repo)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enable after adding</p>
|
||||
<p className="text-xs text-muted-foreground">Repository will be enabled by default</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={newRepoEnabled}
|
||||
onCheckedChange={setNewRepoEnabled}
|
||||
disabled={isAddingRepo}
|
||||
label="Enable repository"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newRepoUrl.trim()) {
|
||||
setMessage({ type: 'error', text: 'Please enter a repository URL' });
|
||||
return;
|
||||
}
|
||||
setIsAddingRepo(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await createRepoMutation.mutateAsync({
|
||||
url: newRepoUrl.trim(),
|
||||
enabled: newRepoEnabled
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: 'Repository added successfully!' });
|
||||
setNewRepoUrl('');
|
||||
setNewRepoEnabled(true);
|
||||
await refetchRepositories();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error ?? 'Failed to add repository' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to add repository' });
|
||||
} finally {
|
||||
setIsAddingRepo(false);
|
||||
}
|
||||
}}
|
||||
disabled={isAddingRepo || !newRepoUrl.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isAddingRepo ? 'Adding...' : 'Add Repository'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository List */}
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-3">Repositories</h4>
|
||||
{repositoriesData?.success && repositoriesData.repositories.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{repositoriesData.repositories.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="p-3 border border-border rounded-lg flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{repo.url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
{repo.is_default && (
|
||||
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{repo.priority === 1 ? 'Main' : 'Dev'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Priority: {repo.priority} {repo.enabled ? '• Enabled' : '• Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={repo.enabled}
|
||||
onCheckedChange={async (enabled) => {
|
||||
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'}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!repo.is_removable) {
|
||||
setMessage({ type: 'error', text: 'Default repositories cannot be deleted' });
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Are you sure you want to delete this repository? All scripts from this repository will be removed.`)) {
|
||||
return;
|
||||
}
|
||||
setDeletingRepoId(repo.id);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await deleteRepoMutation.mutateAsync({ id: repo.id });
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: 'Repository deleted successfully!' });
|
||||
await refetchRepositories();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error ?? 'Failed to delete repository' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to delete repository' });
|
||||
} finally {
|
||||
setDeletingRepoId(null);
|
||||
}
|
||||
}}
|
||||
disabled={!repo.is_removable || deletingRepoId === repo.id || deleteRepoMutation.isPending}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-error hover:text-error/80 hover:bg-error/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No repositories configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Display */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-md text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-success/10 text-success-foreground border border-success/20'
|
||||
: 'bg-error/10 text-error-foreground border border-error/20'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||
@@ -81,6 +90,11 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.repository_url && (
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
||||
{getRepoName(script.repository_url)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Status */}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
||||
@@ -102,6 +111,11 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.repository_url && (
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
||||
{getRepoName(script.repository_url)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
|
||||
@@ -234,6 +234,18 @@ export function ScriptDetailModal({
|
||||
<TypeBadge type={script.type} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.privileged && <PrivilegedBadge />}
|
||||
{script.repository_url && (
|
||||
<a
|
||||
href={script.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`Source: ${script.repository_url}`}
|
||||
>
|
||||
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
114
src/server/api/routers/repositories.ts
Normal file
114
src/server/api/routers/repositories.ts
Normal file
@@ -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'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<T>(endpoint: string): Promise<T> {
|
||||
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<T>(repoUrl: string, endpoint: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
private async downloadJsonFile(filePath: string): Promise<Script> {
|
||||
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
||||
this.initializeConfig();
|
||||
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`;
|
||||
const repoPath = this.extractRepoPath(repoUrl);
|
||||
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch!}/${filePath}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
};
|
||||
|
||||
// Add GitHub token authentication if available (for raw files, use token in URL or header)
|
||||
// Add GitHub token authentication if available
|
||||
if (env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||
}
|
||||
@@ -90,64 +92,56 @@ export class GitHubJsonService {
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
return JSON.parse(content) as Script;
|
||||
const script = JSON.parse(content) as Script;
|
||||
// Add repository_url to script
|
||||
script.repository_url = repoUrl;
|
||||
return script;
|
||||
}
|
||||
|
||||
private extractRepoPath(): string {
|
||||
async getJsonFiles(repoUrl: string): Promise<GitHubFile[]> {
|
||||
this.initializeConfig();
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl!);
|
||||
if (!match) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
async getJsonFiles(): Promise<GitHubFile[]> {
|
||||
this.initializeConfig();
|
||||
if (!this.repoUrl) {
|
||||
throw new Error('REPO_URL environment variable is not set. Cannot fetch from GitHub.');
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await this.fetchFromGitHub<GitHubFile[]>(
|
||||
repoUrl,
|
||||
`/contents/${this.jsonFolder!}?ref=${this.branch!}`
|
||||
);
|
||||
|
||||
// Filter for JSON files only
|
||||
return files.filter(file => file.name.endsWith('.json'));
|
||||
} catch (error) {
|
||||
console.error('Error fetching JSON files from GitHub:', error);
|
||||
throw new Error('Failed to fetch script files from repository');
|
||||
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
|
||||
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllScripts(): Promise<Script[]> {
|
||||
async getAllScripts(repoUrl: string): Promise<Script[]> {
|
||||
try {
|
||||
// First, get the list of JSON files (1 API call)
|
||||
const jsonFiles = await this.getJsonFiles();
|
||||
const jsonFiles = await this.getJsonFiles(repoUrl);
|
||||
const scripts: Script[] = [];
|
||||
|
||||
// Then download each JSON file using raw URLs (no rate limit)
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const script = await this.downloadJsonFile(file.path);
|
||||
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||
scripts.push(script);
|
||||
} catch (error) {
|
||||
console.error(`Failed to download script ${file.name}:`, error);
|
||||
console.error(`Failed to download script ${file.name} from ${repoUrl}:`, error);
|
||||
// Continue with other files even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all scripts:', error);
|
||||
throw new Error('Failed to fetch scripts from repository');
|
||||
console.error(`Error fetching all scripts from ${repoUrl}:`, error);
|
||||
throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptCards(): Promise<ScriptCard[]> {
|
||||
async getScriptCards(repoUrl: string): Promise<ScriptCard[]> {
|
||||
try {
|
||||
const scripts = await this.getAllScripts();
|
||||
const scripts = await this.getAllScripts(repoUrl);
|
||||
|
||||
return scripts.map(script => ({
|
||||
name: script.name,
|
||||
@@ -157,29 +151,50 @@ export class GitHubJsonService {
|
||||
type: script.type,
|
||||
updateable: script.updateable,
|
||||
website: script.website,
|
||||
repository_url: script.repository_url,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error creating script cards:', error);
|
||||
throw new Error('Failed to create script cards');
|
||||
console.error(`Error creating script cards from ${repoUrl}:`, error);
|
||||
throw new Error(`Failed to create script cards from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptBySlug(slug: string): Promise<Script | null> {
|
||||
async getScriptBySlug(slug: string, repoUrl?: string): Promise<Script | null> {
|
||||
try {
|
||||
// Try to get from local cache first
|
||||
const localScript = await this.getScriptFromLocal(slug);
|
||||
if (localScript) {
|
||||
// If repoUrl is specified and doesn't match, return null
|
||||
if (repoUrl && localScript.repository_url !== repoUrl) {
|
||||
return null;
|
||||
}
|
||||
return localScript;
|
||||
}
|
||||
|
||||
// If not found locally, try to download just this specific script
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const script = await this.downloadJsonFile(`${this.jsonFolder!}/${slug}.json`);
|
||||
return script;
|
||||
} catch {
|
||||
return null;
|
||||
// If not found locally and repoUrl is provided, try to download from that repo
|
||||
if (repoUrl) {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder!}/${slug}.json`);
|
||||
return script;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If no repoUrl specified, try all enabled repos
|
||||
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||
for (const repo of enabledRepos) {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const script = await this.downloadJsonFile(repo.url, `${this.jsonFolder!}/${slug}.json`);
|
||||
return script;
|
||||
} catch {
|
||||
// Continue to next repo
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching script by slug:', error);
|
||||
throw new Error(`Failed to fetch script: ${slug}`);
|
||||
@@ -193,14 +208,16 @@ export class GitHubJsonService {
|
||||
return this.scriptCache.get(slug)!;
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
|
||||
this.initializeConfig();
|
||||
const filePath = join(this.localJsonDirectory!, `${slug}.json`);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content) as Script;
|
||||
|
||||
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
||||
if (!script.repository_url) {
|
||||
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
|
||||
// Cache the script
|
||||
this.scriptCache.set(slug, script);
|
||||
|
||||
@@ -210,44 +227,119 @@ export class GitHubJsonService {
|
||||
}
|
||||
}
|
||||
|
||||
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
|
||||
/**
|
||||
* Sync JSON files from a specific repository
|
||||
*/
|
||||
async syncJsonFilesForRepo(repoUrl: string): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
|
||||
try {
|
||||
console.log('Starting fast incremental JSON sync...');
|
||||
console.log(`Starting JSON sync from repository: ${repoUrl}`);
|
||||
|
||||
// Get file list from GitHub
|
||||
console.log('Fetching file list from GitHub...');
|
||||
const githubFiles = await this.getJsonFiles();
|
||||
console.log(`Found ${githubFiles.length} JSON files in repository`);
|
||||
console.log(`Fetching file list from GitHub (${repoUrl})...`);
|
||||
const githubFiles = await this.getJsonFiles(repoUrl);
|
||||
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
|
||||
|
||||
// Get local files
|
||||
const localFiles = await this.getLocalJsonFiles();
|
||||
console.log(`Found ${localFiles.length} files in local directory`);
|
||||
console.log(`Found ${localFiles.filter(f => f.endsWith('.json')).length} local JSON files`);
|
||||
console.log(`Found ${localFiles.length} local JSON files`);
|
||||
|
||||
// Compare and find files that need syncing
|
||||
const filesToSync = this.findFilesToSync(githubFiles, localFiles);
|
||||
console.log(`Found ${filesToSync.length} files that need syncing`);
|
||||
// For multi-repo support, we need to check if file exists AND if it's from this repo
|
||||
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
||||
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
||||
|
||||
if (filesToSync.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'All JSON files are up to date',
|
||||
message: `All JSON files are up to date for repository: ${repoUrl}`,
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Download and save only the files that need syncing
|
||||
const syncedFiles = await this.syncSpecificFiles(filesToSync);
|
||||
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`,
|
||||
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
|
||||
count: syncedFiles.length,
|
||||
syncedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('JSON sync failed:', error);
|
||||
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync JSON files from all enabled repositories (main repo has priority)
|
||||
*/
|
||||
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
|
||||
try {
|
||||
console.log('Starting multi-repository JSON sync...');
|
||||
|
||||
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||
|
||||
if (enabledRepos.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No enabled repositories found',
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
||||
|
||||
const allSyncedFiles: string[] = [];
|
||||
const processedSlugs = new Set<string>(); // Track slugs we've already processed
|
||||
let totalSynced = 0;
|
||||
|
||||
// Process repos in priority order (lower priority number = higher priority)
|
||||
for (const repo of enabledRepos) {
|
||||
try {
|
||||
console.log(`Syncing from repository: ${repo.url} (priority: ${repo.priority})`);
|
||||
|
||||
const result = await this.syncJsonFilesForRepo(repo.url);
|
||||
|
||||
if (result.success) {
|
||||
// Only count files that weren't already processed from a higher priority repo
|
||||
const newFiles = result.syncedFiles.filter(file => {
|
||||
const slug = file.replace('.json', '');
|
||||
if (processedSlugs.has(slug)) {
|
||||
return false; // Already processed from higher priority repo
|
||||
}
|
||||
processedSlugs.add(slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
allSyncedFiles.push(...newFiles);
|
||||
totalSynced += newFiles.length;
|
||||
} else {
|
||||
console.error(`Failed to sync from ${repo.url}: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error syncing from ${repo.url}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Also update existing files that don't have repository_url set (backward compatibility)
|
||||
await this.updateExistingFilesWithRepositoryUrl();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
|
||||
count: totalSynced,
|
||||
syncedFiles: allSyncedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Multi-repository JSON sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
@@ -257,6 +349,36 @@ export class GitHubJsonService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing JSON files that don't have repository_url (backward compatibility)
|
||||
*/
|
||||
private async updateExistingFilesWithRepositoryUrl(): Promise<void> {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const files = await this.getLocalJsonFiles();
|
||||
const mainRepoUrl = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(this.localJsonDirectory!, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content) as Script;
|
||||
|
||||
if (!script.repository_url) {
|
||||
script.repository_url = mainRepoUrl;
|
||||
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||
console.log(`Updated ${file} with repository_url: ${mainRepoUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be read or parsed
|
||||
console.error(`Error updating ${file}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating existing files with repository_url:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLocalJsonFiles(): Promise<string[]> {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
@@ -267,13 +389,47 @@ export class GitHubJsonService {
|
||||
}
|
||||
}
|
||||
|
||||
private findFilesToSync(githubFiles: GitHubFile[], localFiles: string[]): GitHubFile[] {
|
||||
const localFileSet = new Set(localFiles);
|
||||
// Return only files that don't exist locally
|
||||
return githubFiles.filter(ghFile => !localFileSet.has(ghFile.name));
|
||||
/**
|
||||
* Find files that need syncing for a specific repository
|
||||
* This checks if file exists locally AND if it's from the same repository
|
||||
*/
|
||||
private async findFilesToSyncForRepo(repoUrl: string, githubFiles: GitHubFile[], localFiles: string[]): Promise<GitHubFile[]> {
|
||||
const filesToSync: GitHubFile[] = [];
|
||||
|
||||
for (const ghFile of githubFiles) {
|
||||
const slug = ghFile.name.replace('.json', '');
|
||||
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
|
||||
|
||||
let needsSync = false;
|
||||
|
||||
// Check if file exists locally
|
||||
if (!localFiles.includes(ghFile.name)) {
|
||||
needsSync = true;
|
||||
} else {
|
||||
// File exists, check if it's from the same repository
|
||||
try {
|
||||
const content = await readFile(localFilePath, 'utf-8');
|
||||
const script = JSON.parse(content) as Script;
|
||||
|
||||
// If repository_url doesn't match or doesn't exist, we need to sync
|
||||
if (!script.repository_url || script.repository_url !== repoUrl) {
|
||||
needsSync = true;
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the file, sync it
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSync) {
|
||||
filesToSync.push(ghFile);
|
||||
}
|
||||
}
|
||||
|
||||
return filesToSync;
|
||||
}
|
||||
|
||||
private async syncSpecificFiles(filesToSync: GitHubFile[]): Promise<string[]> {
|
||||
private async syncSpecificFiles(repoUrl: string, filesToSync: GitHubFile[]): Promise<string[]> {
|
||||
this.initializeConfig();
|
||||
const syncedFiles: string[] = [];
|
||||
|
||||
@@ -281,19 +437,25 @@ export class GitHubJsonService {
|
||||
|
||||
for (const file of filesToSync) {
|
||||
try {
|
||||
const script = await this.downloadJsonFile(file.path);
|
||||
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||
const filename = `${script.slug}.json`;
|
||||
const filePath = join(this.localJsonDirectory!, filename);
|
||||
|
||||
// Ensure repository_url is set
|
||||
script.repository_url = repoUrl;
|
||||
|
||||
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||
syncedFiles.push(filename);
|
||||
|
||||
// Clear cache for this script
|
||||
this.scriptCache.delete(script.slug);
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${file.name}:`, error);
|
||||
console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -64,6 +64,7 @@ export class LocalScriptsService {
|
||||
type: script.type,
|
||||
updateable: script.updateable,
|
||||
website: script.website,
|
||||
repository_url: script.repository_url,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error creating script cards:', error);
|
||||
|
||||
224
src/server/services/repositoryService.ts
Normal file
224
src/server/services/repositoryService.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { prisma } from '../db.ts';
|
||||
|
||||
export class RepositoryService {
|
||||
/**
|
||||
* Initialize default repositories if they don't exist
|
||||
*/
|
||||
async initializeDefaultRepositories(): Promise<void> {
|
||||
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
|
||||
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
|
||||
|
||||
// Check if repositories already exist
|
||||
const existingRepos = await prisma.repository.findMany({
|
||||
where: {
|
||||
url: {
|
||||
in: [mainRepoUrl, devRepoUrl]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const existingUrls = new Set(existingRepos.map(r => r.url));
|
||||
|
||||
// Create main repo if it doesn't exist
|
||||
if (!existingUrls.has(mainRepoUrl)) {
|
||||
await prisma.repository.create({
|
||||
data: {
|
||||
url: mainRepoUrl,
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
is_removable: false,
|
||||
priority: 1
|
||||
}
|
||||
});
|
||||
console.log('Initialized main repository:', mainRepoUrl);
|
||||
}
|
||||
|
||||
// Create dev repo if it doesn't exist
|
||||
if (!existingUrls.has(devRepoUrl)) {
|
||||
await prisma.repository.create({
|
||||
data: {
|
||||
url: devRepoUrl,
|
||||
enabled: false,
|
||||
is_default: true,
|
||||
is_removable: false,
|
||||
priority: 2
|
||||
}
|
||||
});
|
||||
console.log('Initialized dev repository:', devRepoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories, sorted by priority
|
||||
*/
|
||||
async getAllRepositories() {
|
||||
return await prisma.repository.findMany({
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
{ created_at: 'asc' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled repositories, sorted by priority
|
||||
*/
|
||||
async getEnabledRepositories() {
|
||||
return await prisma.repository.findMany({
|
||||
where: {
|
||||
enabled: true
|
||||
},
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
{ created_at: 'asc' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository by URL
|
||||
*/
|
||||
async getRepositoryByUrl(url: string) {
|
||||
return await prisma.repository.findUnique({
|
||||
where: { url }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new repository
|
||||
*/
|
||||
async createRepository(data: {
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
}) {
|
||||
// Validate GitHub URL
|
||||
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const existing = await this.getRepositoryByUrl(data.url);
|
||||
if (existing) {
|
||||
throw new Error('Repository already exists');
|
||||
}
|
||||
|
||||
// Get max priority for user-added repos
|
||||
const maxPriority = await prisma.repository.aggregate({
|
||||
_max: {
|
||||
priority: true
|
||||
}
|
||||
});
|
||||
|
||||
return await prisma.repository.create({
|
||||
data: {
|
||||
url: data.url,
|
||||
enabled: data.enabled ?? true,
|
||||
is_default: false,
|
||||
is_removable: true,
|
||||
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update repository
|
||||
*/
|
||||
async updateRepository(id: number, data: {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
priority?: number;
|
||||
}) {
|
||||
// If updating URL, validate it
|
||||
if (data.url) {
|
||||
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||
}
|
||||
|
||||
// Check for duplicates (excluding current repo)
|
||||
const existing = await prisma.repository.findFirst({
|
||||
where: {
|
||||
url: data.url,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('Repository URL already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.repository.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete repository and associated JSON files
|
||||
*/
|
||||
async deleteRepository(id: number) {
|
||||
const repo = await prisma.repository.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
throw new Error('Repository not found');
|
||||
}
|
||||
|
||||
if (!repo.is_removable) {
|
||||
throw new Error('Cannot delete default repository');
|
||||
}
|
||||
|
||||
// Delete associated JSON files
|
||||
await this.deleteRepositoryJsonFiles(repo.url);
|
||||
|
||||
// Delete repository
|
||||
await prisma.repository.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all JSON files associated with a repository
|
||||
*/
|
||||
private async deleteRepositoryJsonFiles(repoUrl: string): Promise<void> {
|
||||
const { readdir, unlink, readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
|
||||
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||
|
||||
try {
|
||||
const files = await readdir(jsonDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = join(jsonDirectory, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
// If script has repository_url matching the repo, delete it
|
||||
if (script.repository_url === repoUrl) {
|
||||
await unlink(filePath);
|
||||
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be read or parsed
|
||||
console.error(`Error processing file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist, which is fine
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Error deleting repository JSON files:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const repositoryService = new RepositoryService();
|
||||
|
||||
@@ -14,7 +14,7 @@ export class ScriptDownloaderService {
|
||||
private initializeConfig() {
|
||||
if (this.scriptsDirectory === null) {
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
this.repoUrl = env.REPO_URL ?? '';
|
||||
this.repoUrl = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,29 +26,48 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFileFromGitHub(filePath: string): Promise<string> {
|
||||
private extractRepoPath(repoUrl: string): string {
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
private async downloadFileFromGitHub(repoUrl: string, filePath: string, branch: string = 'main'): Promise<string> {
|
||||
this.initializeConfig();
|
||||
if (!this.repoUrl) {
|
||||
throw new Error('REPO_URL environment variable is not set');
|
||||
if (!repoUrl) {
|
||||
throw new Error('Repository URL is not set');
|
||||
}
|
||||
|
||||
const url = `https://raw.githubusercontent.com/${this.extractRepoPath()}/main/${filePath}`;
|
||||
const repoPath = this.extractRepoPath(repoUrl);
|
||||
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
};
|
||||
|
||||
// Add GitHub token authentication if available
|
||||
const { env } = await import('~/env.js');
|
||||
if (env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Failed to download ${filePath} from ${repoUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private extractRepoPath(): string {
|
||||
this.initializeConfig();
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl!);
|
||||
if (!match) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
private getRepoUrlForScript(script: Script): string {
|
||||
// Use repository_url from script if available, otherwise fallback to env or default
|
||||
if (script.repository_url) {
|
||||
return script.repository_url;
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
this.initializeConfig();
|
||||
return this.repoUrl!;
|
||||
}
|
||||
|
||||
private modifyScriptContent(content: string): string {
|
||||
@@ -64,6 +83,9 @@ export class ScriptDownloaderService {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
const files: string[] = [];
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const { env } = await import('~/env.js');
|
||||
const branch = env.REPO_BRANCH ?? 'main';
|
||||
|
||||
// Ensure directories exist
|
||||
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'ct'));
|
||||
@@ -78,8 +100,8 @@ export class ScriptDownloaderService {
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
if (fileName) {
|
||||
// Download from GitHub
|
||||
const content = await this.downloadFileFromGitHub(scriptPath);
|
||||
// Download from GitHub using the script's repository URL
|
||||
const content = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
|
||||
|
||||
// Determine target directory based on script path
|
||||
let targetDir: string;
|
||||
@@ -143,7 +165,7 @@ export class ScriptDownloaderService {
|
||||
if (hasCtScript) {
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
try {
|
||||
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
|
||||
const installContent = await this.downloadFileFromGitHub(repoUrl, `install/${installScriptName}`, branch);
|
||||
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
|
||||
await writeFile(localInstallPath, installContent, 'utf-8');
|
||||
files.push(`install/${installScriptName}`);
|
||||
@@ -303,6 +325,10 @@ export class ScriptDownloaderService {
|
||||
private async scriptNeedsUpdate(script: Script): Promise<boolean> {
|
||||
if (!script.install_methods?.length) return false;
|
||||
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const { env } = await import('~/env.js');
|
||||
const branch = env.REPO_BRANCH ?? 'main';
|
||||
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script) {
|
||||
const scriptPath = method.script;
|
||||
@@ -346,8 +372,8 @@ export class ScriptDownloaderService {
|
||||
// Read local content
|
||||
const localContent = await readFile(filePath, 'utf8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(scriptPath);
|
||||
// Download remote content from the script's repository
|
||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
|
||||
|
||||
// Compare content (simple string comparison for now)
|
||||
// In a more sophisticated implementation, you might want to compare
|
||||
@@ -566,7 +592,7 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
|
||||
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
@@ -588,7 +614,7 @@ export class ScriptDownloaderService {
|
||||
const installScriptPath = `install/${installScriptName}`;
|
||||
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(installScriptPath, installScriptPath)
|
||||
this.compareSingleFile(script, installScriptPath, installScriptPath)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
@@ -611,15 +637,18 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
private async compareSingleFile(remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
|
||||
private async compareSingleFile(script: Script, remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
|
||||
try {
|
||||
const localPath = join(this.scriptsDirectory!, filePath);
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const { env } = await import('~/env.js');
|
||||
const branch = env.REPO_BRANCH ?? 'main';
|
||||
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(remotePath);
|
||||
// Download remote content from the script's repository
|
||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
||||
|
||||
// Apply modification only for CT scripts, not for other script types
|
||||
let modifiedRemoteContent: string;
|
||||
@@ -642,6 +671,9 @@ export class ScriptDownloaderService {
|
||||
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const { env } = await import('~/env.js');
|
||||
const branch = env.REPO_BRANCH ?? 'main';
|
||||
let localContent: string | null = null;
|
||||
let remoteContent: string | null = null;
|
||||
|
||||
@@ -660,7 +692,7 @@ export class ScriptDownloaderService {
|
||||
// Find the corresponding script path in install_methods
|
||||
const method = script.install_methods?.find(m => m.script === filePath);
|
||||
if (method?.script) {
|
||||
const downloadedContent = await this.downloadFileFromGitHub(method.script);
|
||||
const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch);
|
||||
remoteContent = this.modifyScriptContent(downloadedContent);
|
||||
}
|
||||
} catch {
|
||||
@@ -677,7 +709,7 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
|
||||
try {
|
||||
remoteContent = await this.downloadFileFromGitHub(filePath);
|
||||
remoteContent = await this.downloadFileFromGitHub(repoUrl, filePath, branch);
|
||||
} catch {
|
||||
// Error downloading remote install script
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Script {
|
||||
install_methods: ScriptInstallMethod[];
|
||||
default_credentials: ScriptCredentials;
|
||||
notes: (ScriptNote | string)[];
|
||||
repository_url?: string;
|
||||
}
|
||||
|
||||
export interface ScriptCard {
|
||||
@@ -62,6 +63,7 @@ export interface ScriptCard {
|
||||
interface_port?: number | null;
|
||||
// Optional: basenames of install scripts (without extension)
|
||||
install_basenames?: string[];
|
||||
repository_url?: string;
|
||||
}
|
||||
|
||||
export interface GitHubFile {
|
||||
|
||||
Reference in New Issue
Block a user