Merge pull request #318 from community-scripts/feat/add_repo

feat: Add multi-repository support with repository filtering
This commit is contained in:
Michel Roegl-Brunner
2025-11-13 15:42:18 +01:00
committed by GitHub
433 changed files with 2729 additions and 2936 deletions

View File

@@ -29,6 +29,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
@@ -289,6 +290,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) {
return false;
}
// Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl);
});
}
// Apply sorting
scripts.sort((a, b) => {
if (!a || !b) return 0;

View File

@@ -3,12 +3,14 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
import { api } from "~/trpc/react";
export interface FilterState {
searchQuery: string;
showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable
selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve'
selectedRepositories: string[]; // Array of selected repository URLs
sortBy: "name" | "created"; // Sort criteria (removed 'updated')
sortOrder: "asc" | "desc"; // Sort direction
}
@@ -43,6 +45,23 @@ export function FilterBar({
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
// Fetch enabled repositories
const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
const enabledRepos = enabledReposData?.repositories ?? [];
// Helper function to extract repository name from URL
const getRepoName = (url: string): string => {
try {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
return `${match[1]}/${match[2]}`;
}
return url;
} catch {
return url;
}
};
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
};
@@ -52,6 +71,7 @@ export function FilterBar({
searchQuery: "",
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: "name",
sortOrder: "asc",
});
@@ -61,6 +81,7 @@ export function FilterBar({
filters.searchQuery ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.selectedRepositories.length > 0 ||
filters.sortBy !== "name" ||
filters.sortOrder !== "asc";
@@ -290,6 +311,40 @@ export function FilterBar({
)}
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
const isSelected = filters.selectedRepositories.includes(repo.url);
return (
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(url => url !== repo.url)
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repo.url]
});
}
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
isSelected
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repo.url)}</span>
</Button>
);
})}
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button

View File

@@ -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>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -25,6 +25,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
{ id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock },
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'repositories' as HelpSection, label: 'Repositories', icon: GitBranch },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
@@ -379,6 +380,106 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</div>
);
case 'repositories':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Repositories</h3>
<p className="text-muted-foreground mb-6">
Manage script repositories (GitHub repositories) and configure which repositories to use for syncing scripts.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Are Repositories?</h4>
<p className="text-sm text-muted-foreground mb-2">
Repositories are GitHub repositories that contain scripts and their metadata. Scripts are organized by repositories, allowing you to add custom repositories or manage which repositories are active.
</p>
<p className="text-sm text-muted-foreground">
You can add custom repositories or manage existing ones in General Settings &gt; Repositories.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Repository Structure</h4>
<p className="text-sm text-muted-foreground mb-2">
For a repository to work with this system, it must follow this structure:
</p>
<ul className="text-sm text-muted-foreground space-y-2 ml-4 list-disc">
<li><strong>JSON files:</strong> Must be located in a <code className="bg-muted px-1 rounded">frontend/public/json/</code> folder at the repository root. Each JSON file contains metadata for a script (name, description, installation methods, etc.).</li>
<li><strong>Script files:</strong> Must be organized in subdirectories:
<ul className="ml-4 mt-1 space-y-1 list-disc">
<li><code className="bg-muted px-1 rounded">ct/</code> - Container scripts (LXC)</li>
<li><code className="bg-muted px-1 rounded">install/</code> - Installation scripts</li>
<li><code className="bg-muted px-1 rounded">tools/</code> - Tool scripts</li>
<li><code className="bg-muted px-1 rounded">vm/</code> - Virtual machine scripts</li>
</ul>
</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Default Repositories</h4>
<p className="text-sm text-muted-foreground mb-2">
The system comes with two default repositories that cannot be deleted:
</p>
<ul className="text-sm text-muted-foreground space-y-2 ml-4 list-disc">
<li><strong>Main Repository (ProxmoxVE):</strong> The primary repository at <code className="bg-muted px-1 rounded">github.com/community-scripts/ProxmoxVE</code>. This is enabled by default and contains stable, production-ready scripts. This repository cannot be deleted.</li>
<li><strong>Dev Repository (ProxmoxVED):</strong> The development/testing repository at <code className="bg-muted px-1 rounded">github.com/community-scripts/ProxmoxVED</code>. This is disabled by default and contains experimental or in-development scripts. This repository cannot be deleted.</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Enable vs Disable</h4>
<p className="text-sm text-muted-foreground mb-2">
You can enable or disable repositories to control which scripts are available:
</p>
<ul className="text-sm text-muted-foreground space-y-2 ml-4 list-disc">
<li><strong>Enabled:</strong> Scripts from this repository are included in the Available Scripts tab and will be synced when you sync repositories. Enabled repositories are checked for updates during sync operations.</li>
<li><strong>Disabled:</strong> Scripts from this repository are excluded from the Available Scripts tab and will not be synced. Scripts already downloaded from a disabled repository remain on your system but won&apos;t appear in the list. Disabled repositories are not checked for updates.</li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
<strong>Note:</strong> Disabling a repository doesn&apos;t delete scripts you&apos;ve already downloaded from it. They remain on your system but are hidden from the Available Scripts list.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Repository Filter Buttons</h4>
<p className="text-sm text-muted-foreground mb-2">
When multiple repositories are enabled, filter buttons appear in the filter bar on the Available Scripts tab.
</p>
<ul className="text-sm text-muted-foreground space-y-2 ml-4 list-disc">
<li>Each enabled repository gets its own filter button</li>
<li>Click a repository button to toggle showing/hiding scripts from that repository</li>
<li>Active buttons are highlighted with primary styling</li>
<li>Inactive buttons have muted styling</li>
<li>This allows you to quickly focus on scripts from specific repositories</li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
<strong>Note:</strong> Filter buttons only appear when more than one repository is enabled. If only one repository is enabled, all scripts from that repository are shown by default.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Adding Custom Repositories</h4>
<p className="text-sm text-muted-foreground mb-2">
You can add your own GitHub repositories to access custom scripts:
</p>
<ol className="text-sm text-muted-foreground space-y-2 ml-4 list-decimal">
<li>Go to General Settings &gt; Repositories</li>
<li>Enter the GitHub repository URL (format: <code className="bg-muted px-1 rounded">https://github.com/owner/repo</code>)</li>
<li>Choose whether to enable it immediately</li>
<li>Click &quot;Add Repository&quot;</li>
</ol>
<p className="text-xs text-muted-foreground mt-2">
<strong>Important:</strong> Custom repositories must follow the repository structure described above. Repositories that don&apos;t follow this structure may not work correctly.
</p>
</div>
</div>
</div>
);
case 'available-scripts':
return (
<div className="space-y-6">
@@ -407,6 +508,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li>• <strong>Update Status:</strong> Show only scripts with available updates</li>
<li>• <strong>Search Query:</strong> Search within script names and descriptions</li>
<li>• <strong>Categories:</strong> Filter by specific script categories</li>
<li>• <strong>Repositories:</strong> Filter scripts by repository source (only shown when multiple repositories are enabled). Click repository buttons to toggle visibility of scripts from that repository.</li>
</ul>
</div>

View File

@@ -42,7 +42,7 @@ export function ResyncButton() {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Sync scripts with ProxmoxVE repo
Sync scripts with configured repositories
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-2">

View File

@@ -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 */}

View File

@@ -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'

View File

@@ -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>

View File

@@ -29,6 +29,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
@@ -245,6 +246,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
filters.searchQuery?.trim() !== '' ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.selectedRepositories.length > 0 ||
filters.sortBy !== 'name' ||
filters.sortOrder !== 'asc' ||
selectedCategory !== null
@@ -318,6 +320,22 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) {
return false;
}
// Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl);
});
}
// Exclude newest scripts from main grid when no filters are active (they'll be shown in carousel)
if (!hasActiveFilters) {
const newestScriptSlugs = new Set(newestScripts.map(script => script.slug).filter(Boolean));

View File

@@ -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

View 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'
};
}
})
});

View File

@@ -3,8 +3,9 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { scriptManager } from "~/server/lib/scripts";
import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader";
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
import { AutoSyncService } from "~/server/services/autoSyncService";
import { repositoryService } from "~/server/services/repositoryService";
import type { ScriptCard } from "~/types/script";
export const scriptsRouter = createTRPCRouter({
@@ -166,14 +167,18 @@ export const scriptsRouter = createTRPCRouter({
getScriptCardsWithCategories: publicProcedure
.query(async () => {
try {
const [cards, metadata] = await Promise.all([
const [cards, metadata, enabledRepos] = await Promise.all([
localScriptsService.getScriptCards(),
localScriptsService.getMetadata()
localScriptsService.getMetadata(),
repositoryService.getEnabledRepositories()
]);
// Get all scripts to access their categories
const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
if (metadata?.categories) {
@@ -213,10 +218,26 @@ 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;
});
return { success: true, cards: cardsWithCategories, metadata };
// Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter(card => {
const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility
if (!repoUrl) {
return true;
}
// Only include scripts from enabled repositories
return enabledRepoUrls.has(repoUrl);
});
return { success: true, cards: filteredCards, metadata };
} catch (error) {
console.error('Error in getScriptCardsWithCategories:', error);
return {

View File

@@ -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
*/

View File

@@ -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.ts';
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

View File

@@ -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);
@@ -79,7 +80,47 @@ export class LocalScriptsService {
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as Script;
const script = JSON.parse(content) as Script;
// Ensure repository_url is set (backward compatibility)
// If missing, try to determine which repo it came from by checking all enabled repos
// Note: This is a fallback for scripts synced before repository_url was added
if (!script.repository_url) {
const { repositoryService } = await import('./repositoryService');
const enabledRepos = await repositoryService.getEnabledRepositories();
// Check each repo in priority order to see which one has this script
// We check in priority order so that if a script exists in multiple repos,
// we use the highest priority repo (same as sync logic)
let foundRepo: string | null = null;
for (const repo of enabledRepos) {
try {
const { githubJsonService } = await import('./githubJsonService.js');
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
if (repoScript) {
foundRepo = repo.url;
// Don't break - continue to check higher priority repos first
// Actually, repos are already sorted by priority, so first match is highest priority
break;
}
} catch {
// Continue checking other repos
}
}
// Set repository_url to found repo or default to main repo
const { env } = await import('~/env.js');
script.repository_url = foundRepo ?? env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
// Update the JSON file with the repository_url for future loads
try {
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
} catch {
// If we can't write, that's okay - at least we have it in memory
}
}
return script;
} catch {
// If file doesn't exist, return null instead of throwing
return null;

View 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();

View File

@@ -16,40 +16,105 @@ export class ScriptDownloaderService {
}
}
/**
* Validates that a directory path doesn't contain nested directories with the same name
* (e.g., prevents ct/ct or install/install)
*/
validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
// Check for consecutive duplicate directory names
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === parts[i + 1] && parts[i] !== '') {
throw new Error(`Invalid directory path: nested directory detected (${parts[i]}/${parts[i + 1]}) in path: ${dirPath}`);
}
}
return true;
}
/**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
*/
validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names
const normalized = finalTargetDir.replace(/\\/g, '/');
const parts = normalized.split('/');
// Check for consecutive duplicate directory names
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === parts[i + 1]) {
console.warn(`[Path Validation] Detected nested directory pattern "${parts[i]}/${parts[i + 1]}" in finalTargetDir: ${finalTargetDir}. Using base directory "${targetDir}" instead.`);
return targetDir; // Return the base directory instead
}
}
return finalTargetDir;
}
async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name
this.validateDirectoryPath(dirPath);
try {
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (error) {
if (error.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
throw error;
}
// Directory already exists, which is fine
console.log(`[Directory Creation] Directory already exists: ${dirPath}`);
}
}
async downloadFileFromGitHub(filePath) {
extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
return `${match[1]}/${match[2]}`;
}
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
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');
}
// Extract repo path from URL
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
const [, owner, repo] = match;
const repoPath = this.extractRepoPath(repoUrl);
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePath}`;
const headers = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `token ${process.env.GITHUB_TOKEN}`;
}
console.log(`Downloading from GitHub: ${url}`);
const response = await fetch(url);
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();
}
getRepoUrlForScript(script) {
// Use repository_url from script if available, otherwise fallback to env or default
if (script.repository_url) {
return script.repository_url;
}
this.initializeConfig();
return this.repoUrl;
}
modifyScriptContent(content) {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
@@ -62,6 +127,10 @@ export class ScriptDownloaderService {
this.initializeConfig();
try {
const files = [];
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
console.log(`Loading script "${script.name}" (${script.slug}) from repository: ${repoUrl}`);
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
@@ -76,9 +145,9 @@ export class ScriptDownloaderService {
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Download from GitHub
console.log(`Downloading script file: ${scriptPath}`);
const content = await this.downloadFileFromGitHub(scriptPath);
// Download from GitHub using the script's repository URL
console.log(`Downloading script file: ${scriptPath} from ${repoUrl}`);
const content = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
// Determine target directory based on script path
let targetDir;
@@ -88,6 +157,8 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
@@ -98,6 +169,8 @@ export class ScriptDownloaderService {
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
@@ -108,6 +181,8 @@ export class ScriptDownloaderService {
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
@@ -116,6 +191,8 @@ export class ScriptDownloaderService {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
finalTargetDir = targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
@@ -133,8 +210,8 @@ export class ScriptDownloaderService {
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName}`);
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
console.log(`Downloading install script: install/${installScriptName} from ${repoUrl}`);
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}`);
@@ -157,8 +234,8 @@ export class ScriptDownloaderService {
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName} from ${repoUrl}`);
const alpineInstallContent = await this.downloadFileFromGitHub(repoUrl, `install/${alpineInstallScriptName}`, branch);
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
files.push(`install/${alpineInstallScriptName}`);
@@ -394,6 +471,8 @@ export class ScriptDownloaderService {
this.initializeConfig();
const differences = [];
let hasDifferences = false;
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
try {
// First check if any local files exist
@@ -438,7 +517,7 @@ export class ScriptDownloaderService {
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
@@ -460,7 +539,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;
@@ -486,7 +565,7 @@ export class ScriptDownloaderService {
try {
await access(localAlpineInstallPath);
comparisonPromises.push(
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
@@ -512,15 +591,17 @@ export class ScriptDownloaderService {
}
}
async compareSingleFile(remotePath, filePath) {
async compareSingleFile(script, remotePath, filePath) {
try {
const localPath = join(this.scriptsDirectory, filePath);
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.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;
@@ -539,6 +620,138 @@ export class ScriptDownloaderService {
return { hasDifferences: false, filePath };
}
}
async getScriptDiff(script, filePath) {
this.initializeConfig();
try {
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
let localContent = null;
let remoteContent = null;
if (filePath.startsWith('ct/')) {
// Handle CT script
const fileName = filePath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory, 'ct', fileName);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
// Error reading local CT script
}
try {
// 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(repoUrl, method.script, branch);
remoteContent = this.modifyScriptContent(downloadedContent);
}
} catch {
// Error downloading remote CT script
}
}
} else if (filePath.startsWith('install/')) {
// Handle install script
const localPath = join(this.scriptsDirectory, filePath);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
// Error reading local install script
}
try {
remoteContent = await this.downloadFileFromGitHub(repoUrl, filePath, branch);
} catch {
// Error downloading remote install script
}
}
if (!localContent || !remoteContent) {
return { diff: null, localContent, remoteContent };
}
// Generate diff using a simple line-by-line comparison
const diff = this.generateDiff(localContent, remoteContent);
return { diff, localContent, remoteContent };
} catch (error) {
console.error('Error getting script diff:', error);
return { diff: null, localContent: null, remoteContent: null };
}
}
generateDiff(localContent, remoteContent) {
const localLines = localContent.split('\n');
const remoteLines = remoteContent.split('\n');
let diff = '';
let i = 0;
let j = 0;
while (i < localLines.length || j < remoteLines.length) {
const localLine = localLines[i];
const remoteLine = remoteLines[j];
if (i >= localLines.length) {
// Only remote lines left
diff += `+${j + 1}: ${remoteLine}\n`;
j++;
} else if (j >= remoteLines.length) {
// Only local lines left
diff += `-${i + 1}: ${localLine}\n`;
i++;
} else if (localLine === remoteLine) {
// Lines are the same
diff += ` ${i + 1}: ${localLine}\n`;
i++;
j++;
} else {
// Lines are different - find the best match
let found = false;
for (let k = j + 1; k < Math.min(j + 10, remoteLines.length); k++) {
if (localLine === remoteLines[k]) {
// Found match in remote, local line was removed
for (let l = j; l < k; l++) {
diff += `+${l + 1}: ${remoteLines[l]}\n`;
}
diff += ` ${i + 1}: ${localLine}\n`;
i++;
j = k + 1;
found = true;
break;
}
}
if (!found) {
for (let k = i + 1; k < Math.min(i + 10, localLines.length); k++) {
if (remoteLine === localLines[k]) {
// Found match in local, remote line was added
diff += `-${i + 1}: ${localLine}\n`;
for (let l = i + 1; l < k; l++) {
diff += `-${l + 1}: ${localLines[l]}\n`;
}
diff += `+${j + 1}: ${remoteLine}\n`;
i = k + 1;
j++;
found = true;
break;
}
}
}
if (!found) {
// No match found, lines are different
diff += `-${i + 1}: ${localLine}\n`;
diff += `+${j + 1}: ${remoteLine}\n`;
i++;
j++;
}
}
}
return diff;
}
}
export const scriptDownloaderService = new ScriptDownloaderService();

View File

@@ -1,773 +0,0 @@
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
import { env } from '~/env.js';
import type { Script } from '~/types/script';
export class ScriptDownloaderService {
private scriptsDirectory: string | null = null;
private repoUrl: string | null = null;
constructor() {
// Initialize lazily to avoid accessing env vars during module load
}
private initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = env.REPO_URL ?? '';
}
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await mkdir(dirPath, { recursive: true });
} catch {
// Directory might already exist, ignore error
}
}
private async downloadFileFromGitHub(filePath: string): Promise<string> {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set');
}
const url = `https://raw.githubusercontent.com/${this.extractRepoPath()}/main/${filePath}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${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');
}
return `${match[1]}/${match[2]}`;
}
private modifyScriptContent(content: string): string {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
const newPattern = 'SCRIPT_DIR="$(dirname "$0")" \nsource "$SCRIPT_DIR/../core/build.func"';
return content.replace(oldPattern, newPattern);
}
async loadScript(script: Script): Promise<{ success: boolean; message: string; files: string[] }> {
this.initializeConfig();
try {
const files: string[] = [];
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'vm'));
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Download from GitHub
const content = await this.downloadFileFromGitHub(scriptPath);
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory!, finalTargetDir));
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory!, finalTargetDir));
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory!, finalTargetDir));
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
finalTargetDir = targetDir;
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory!, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
files.push(`${finalTargetDir}/${fileName}`);
}
}
}
}
// Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
} catch {
// Install script might not exist, that's okay
}
}
return {
success: true,
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
files
};
} catch (error) {
console.error('Error loading script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to load script',
files: []
};
}
}
/**
* Auto-download new scripts that haven't been downloaded yet
*/
async autoDownloadNewScripts(allScripts: Script[]): Promise<{ downloaded: string[]; errors: string[] }> {
this.initializeConfig();
const downloaded: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is already downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (!isDownloaded) {
const result = await this.loadScript(script);
if (result.success) {
downloaded.push(script.name || script.slug);
console.log(`Auto-downloaded new script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-download script ${script.slug}:`, error);
}
}
return { downloaded, errors };
}
/**
* Auto-update existing scripts to newer versions
*/
async autoUpdateExistingScripts(allScripts: Script[]): Promise<{ updated: string[]; errors: string[] }> {
this.initializeConfig();
const updated: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (isDownloaded) {
// Check if update is needed by comparing content
const needsUpdate = await this.scriptNeedsUpdate(script);
if (needsUpdate) {
const result = await this.loadScript(script);
if (result.success) {
updated.push(script.name || script.slug);
console.log(`Auto-updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-update script ${script.slug}:`, error);
}
}
return { updated, errors };
}
/**
* Check if a script is already downloaded
*/
async isScriptDownloaded(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
// Check if ALL script files are downloaded
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
await readFile(filePath, 'utf8');
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
return false;
}
}
}
}
// All files exist, script is downloaded
return true;
}
/**
* Check if a script needs updating by comparing local and remote content
*/
private async scriptNeedsUpdate(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
// Read local content
const localContent = await readFile(filePath, 'utf8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(scriptPath);
// Compare content (simple string comparison for now)
// In a more sophisticated implementation, you might want to compare
// file modification times or use content hashing
return localContent !== remoteContent;
} catch {
// If we can't read local or download remote, assume update needed
return true;
}
}
}
}
return false;
}
async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> {
this.initializeConfig();
const files: string[] = [];
let ctExists = false;
let installExists = false;
try {
// Check scripts based on their install methods
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir: string;
let localPath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
localPath = join(this.scriptsDirectory!, targetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true;
files.push(`${targetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
const finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
localPath = join(this.scriptsDirectory!, finalTargetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for tools scripts too for UI consistency
files.push(`${finalTargetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
const finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
localPath = join(this.scriptsDirectory!, finalTargetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VM scripts too for UI consistency
files.push(`${finalTargetDir}/${fileName}`);
} catch {
// File doesn't exist
}
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
const finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
localPath = join(this.scriptsDirectory!, finalTargetDir, fileName);
try {
await readFile(localPath, 'utf-8');
ctExists = true; // Use ctExists for VW scripts too for UI consistency
files.push(`${finalTargetDir}/${fileName}`);
} catch {
// File doesn't exist
}
}
}
}
}
}
// Only check install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
try {
await readFile(localInstallPath, 'utf-8');
installExists = true;
files.push(`install/${installScriptName}`);
} catch {
// File doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
console.error('Error checking script existence:', error);
return { ctExists: false, installExists: false, files: [] };
}
}
async deleteScript(script: Script): Promise<{ success: boolean; message: string; deletedFiles: string[] }> {
this.initializeConfig();
const deletedFiles: string[] = [];
try {
// Get the list of files that exist for this script
const fileCheck = await this.checkScriptExists(script);
if (fileCheck.files.length === 0) {
return {
success: false,
message: 'No script files found to delete',
deletedFiles: []
};
}
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory!, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
// Log error but continue deleting other files
console.error(`Error deleting file ${filePath}:`, error);
}
}
if (deletedFiles.length === 0) {
return {
success: false,
message: 'Failed to delete any script files',
deletedFiles: []
};
}
return {
success: true,
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
deletedFiles
};
} catch (error) {
console.error('Error deleting script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles
};
}
}
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
this.initializeConfig();
const differences: string[] = [];
let hasDifferences = false;
try {
// First check if any local files exist
const localFilesExist = await this.checkScriptExists(script);
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
// No local files exist, so no comparison needed
return { hasDifferences: false, differences: [] };
}
// If we have local files, proceed with comparison
// Use Promise.all to run comparisons in parallel
const comparisonPromises: Promise<void>[] = [];
// Compare scripts only if they exist locally
if (localFilesExist.ctExists && script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir: string;
let finalTargetDir: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else {
continue; // Skip unknown script types
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
}
}
}
// Compare install script only if it exists locally
if (localFilesExist.installExists) {
const installScriptName = `${script.slug}-install.sh`;
const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [] };
}
}
private async compareSingleFile(remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
try {
const localPath = join(this.scriptsDirectory!, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(remotePath);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent: string;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools, vm, or vw scripts
}
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
}
}
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
this.initializeConfig();
try {
let localContent: string | null = null;
let remoteContent: string | null = null;
if (filePath.startsWith('ct/')) {
// Handle CT script
const fileName = filePath.split('/').pop();
if (fileName) {
const localPath = join(this.scriptsDirectory!, 'ct', fileName);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
// Error reading local CT script
}
try {
// 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);
remoteContent = this.modifyScriptContent(downloadedContent);
}
} catch {
// Error downloading remote CT script
}
}
} else if (filePath.startsWith('install/')) {
// Handle install script
const localPath = join(this.scriptsDirectory!, filePath);
try {
localContent = await readFile(localPath, 'utf-8');
} catch {
// Error reading local install script
}
try {
remoteContent = await this.downloadFileFromGitHub(filePath);
} catch {
// Error downloading remote install script
}
}
if (!localContent || !remoteContent) {
return { diff: null, localContent, remoteContent };
}
// Generate diff using a simple line-by-line comparison
const diff = this.generateDiff(localContent, remoteContent);
return { diff, localContent, remoteContent };
} catch (error) {
console.error('Error getting script diff:', error);
return { diff: null, localContent: null, remoteContent: null };
}
}
private generateDiff(localContent: string, remoteContent: string): string {
const localLines = localContent.split('\n');
const remoteLines = remoteContent.split('\n');
let diff = '';
let i = 0;
let j = 0;
while (i < localLines.length || j < remoteLines.length) {
const localLine = localLines[i];
const remoteLine = remoteLines[j];
if (i >= localLines.length) {
// Only remote lines left
diff += `+${j + 1}: ${remoteLine}\n`;
j++;
} else if (j >= remoteLines.length) {
// Only local lines left
diff += `-${i + 1}: ${localLine}\n`;
i++;
} else if (localLine === remoteLine) {
// Lines are the same
diff += ` ${i + 1}: ${localLine}\n`;
i++;
j++;
} else {
// Lines are different - find the best match
let found = false;
for (let k = j + 1; k < Math.min(j + 10, remoteLines.length); k++) {
if (localLine === remoteLines[k]) {
// Found match in remote, local line was removed
for (let l = j; l < k; l++) {
diff += `+${l + 1}: ${remoteLines[l]}\n`;
}
diff += ` ${i + 1}: ${localLine}\n`;
i++;
j = k + 1;
found = true;
break;
}
}
if (!found) {
for (let k = i + 1; k < Math.min(i + 10, localLines.length); k++) {
if (remoteLine === localLines[k]) {
// Found match in local, remote line was added
diff += `-${i + 1}: ${localLine}\n`;
for (let l = i + 1; l < k; l++) {
diff += `-${l + 1}: ${localLines[l]}\n`;
}
diff += `+${j + 1}: ${remoteLine}\n`;
i = k + 1;
j++;
found = true;
break;
}
}
}
if (!found) {
// No match found, lines are different
diff += `-${i + 1}: ${localLine}\n`;
diff += `+${j + 1}: ${remoteLine}\n`;
i++;
j++;
}
}
}
return diff;
}
}
// Singleton instance
export const scriptDownloaderService = new ScriptDownloaderService();

View File

@@ -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 {