Merge pull request #318 from community-scripts/feat/add_repo
feat: Add multi-repository support with repository filtering
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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'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't delete scripts you'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 > 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 "Add Repository"</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'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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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