feat: Add multi-repository support for script synchronization

- Add Repository model to Prisma schema with migration
- Create repositoryService for managing repositories
- Add repositories API router with CRUD operations
- Update GitHubJsonService to support multiple repositories
- Update ScriptDownloaderService to use repository URL from scripts
- Add repository_url field to Script and ScriptCard types
- Add repository management UI tab to GeneralSettingsModal
- Display repository source on script cards and detail modal
- Implement repository deletion with JSON file cleanup
- Initialize default repositories (main and dev) on server startup
This commit is contained in:
Michel Roegl-Brunner
2025-11-13 14:12:01 +01:00
parent dab2da4b70
commit 72ffc5597f
18 changed files with 1016 additions and 112 deletions

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "repositories" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"url" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_removable" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 0,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "repositories_url_key" ON "repositories"("url");

View File

@@ -95,3 +95,16 @@ model LXCConfig {
@@map("lxc_configs") @@map("lxc_configs")
} }
model Repository {
id Int @id @default(autoincrement())
url String @unique
enabled Boolean @default(true)
is_default Boolean @default(false)
is_removable Boolean @default(true)
priority Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("repositories")
}

44
scripts/ct/debian.sh Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../core/build.func"
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
APP="Debian"
var_tags="${var_tags:-os}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_info "Updating $APP LXC"
$STD apt update
$STD apt -y upgrade
msg_ok "Updated $APP LXC"
msg_ok "Updated successfully!"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
$STD apt -y clean
msg_ok "Cleaned"

View File

@@ -8,7 +8,7 @@ import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js'; import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
// Load environment variables from .env file // Load environment variables from .env file
@@ -978,10 +978,13 @@ app.prepare().then(() => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}) })
.listen(port, hostname, () => { .listen(port, hostname, async () => {
console.log(`> Ready on http://${hostname}:${port}`); console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
// Initialize default repositories
await initializeRepositories();
// Initialize auto-sync service // Initialize auto-sync service
initializeAutoSync(); initializeAutoSync();

View File

@@ -9,6 +9,7 @@ import { useTheme } from './ThemeProvider';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider'; import { useAuth } from './AuthProvider';
import { Trash2, ExternalLink } from 'lucide-react';
interface GeneralSettingsModalProps { interface GeneralSettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,7 +20,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { isAuthenticated, expirationTime, checkAuth } = useAuth(); 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 [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState(''); const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false); const [saveFilter, setSaveFilter] = useState(false);
@@ -54,6 +55,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(null); const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(null);
const [cronValidationError, setCronValidationError] = useState(''); 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 // Load existing settings when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -601,6 +616,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
> >
Auto-Sync Auto-Sync
</Button> </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> </nav>
</div> </div>
@@ -1245,6 +1272,191 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
</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> </div>
</div> </div>

View File

@@ -26,6 +26,15 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
} }
}; };
const getRepoName = (url?: string): string => {
if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
return `${match[1]}/${match[2]}`;
}
return url;
};
return ( return (
<div <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" 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"> <div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {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> </div>
{/* Download Status */} {/* Download Status */}

View File

@@ -44,6 +44,15 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
return script.categoryNames.join(', '); 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 ( return (
<div <div
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative" 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"> <div className="flex items-center space-x-3 flex-wrap gap-2">
<TypeBadge type={script.type ?? 'unknown'} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {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="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${ <div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error' script.isDownloaded ? 'bg-success' : 'bg-error'

View File

@@ -234,6 +234,18 @@ export function ScriptDetailModal({
<TypeBadge type={script.type} /> <TypeBadge type={script.type} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.privileged && <PrivilegedBadge />} {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>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { scriptsRouter } from "~/server/api/routers/scripts";
import { installedScriptsRouter } from "~/server/api/routers/installedScripts"; import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
import { serversRouter } from "~/server/api/routers/servers"; import { serversRouter } from "~/server/api/routers/servers";
import { versionRouter } from "~/server/api/routers/version"; import { versionRouter } from "~/server/api/routers/version";
import { repositoriesRouter } from "~/server/api/routers/repositories";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/** /**
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
installedScripts: installedScriptsRouter, installedScripts: installedScriptsRouter,
servers: serversRouter, servers: serversRouter,
version: versionRouter, version: versionRouter,
repositories: repositoriesRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -0,0 +1,114 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { repositoryService } from "~/server/services/repositoryService";
export const repositoriesRouter = createTRPCRouter({
// Get all repositories
getAll: publicProcedure.query(async () => {
try {
const repositories = await repositoryService.getAllRepositories();
return {
success: true,
repositories
};
} catch (error) {
console.error('Error fetching repositories:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch repositories',
repositories: []
};
}
}),
// Get enabled repositories
getEnabled: publicProcedure.query(async () => {
try {
const repositories = await repositoryService.getEnabledRepositories();
return {
success: true,
repositories
};
} catch (error) {
console.error('Error fetching enabled repositories:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch enabled repositories',
repositories: []
};
}
}),
// Create a new repository
create: publicProcedure
.input(z.object({
url: z.string().url(),
enabled: z.boolean().optional().default(true),
priority: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
const repository = await repositoryService.createRepository({
url: input.url,
enabled: input.enabled,
priority: input.priority
});
return {
success: true,
repository
};
} catch (error) {
console.error('Error creating repository:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create repository'
};
}
}),
// Update a repository
update: publicProcedure
.input(z.object({
id: z.number(),
enabled: z.boolean().optional(),
url: z.string().url().optional(),
priority: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
const { id, ...data } = input;
const repository = await repositoryService.updateRepository(id, data);
return {
success: true,
repository
};
} catch (error) {
console.error('Error updating repository:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update repository'
};
}
}),
// Delete a repository
delete: publicProcedure
.input(z.object({
id: z.number()
}))
.mutation(async ({ input }) => {
try {
await repositoryService.deleteRepository(input.id);
return {
success: true
};
} catch (error) {
console.error('Error deleting repository:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete repository'
};
}
})
});

View File

@@ -213,6 +213,8 @@ export const scriptsRouter = createTRPCRouter({
// Add interface port // Add interface port
interface_port: script?.interface_port, interface_port: script?.interface_port,
install_basenames, install_basenames,
// Add repository_url from script
repository_url: script?.repository_url ?? card.repository_url,
} as ScriptCard; } as ScriptCard;
}); });

View File

@@ -1,8 +1,23 @@
import { AutoSyncService } from '../services/autoSyncService.js'; import { AutoSyncService } from '../services/autoSyncService.js';
import { repositoryService } from '../services/repositoryService.ts';
let autoSyncService = null; let autoSyncService = null;
let isInitialized = false; 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 * Initialize auto-sync service and schedule cron job if enabled
*/ */

View File

@@ -1,11 +1,10 @@
import { writeFile, mkdir, readdir } from 'fs/promises'; import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { env } from '../../env.js'; import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script'; import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { repositoryService } from './repositoryService.js';
export class GitHubJsonService { export class GitHubJsonService {
private baseUrl: string | null = null;
private repoUrl: string | null = null;
private branch: string | null = null; private branch: string | null = null;
private jsonFolder: string | null = null; private jsonFolder: string | null = null;
private localJsonDirectory: string | null = null; private localJsonDirectory: string | null = null;
@@ -16,31 +15,33 @@ export class GitHubJsonService {
} }
private initializeConfig() { private initializeConfig() {
if (this.repoUrl === null) { if (this.branch === null) {
this.repoUrl = env.REPO_URL ?? "";
this.branch = env.REPO_BRANCH; this.branch = env.REPO_BRANCH;
this.jsonFolder = env.JSON_FOLDER; this.jsonFolder = env.JSON_FOLDER;
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json'); 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> { private getBaseUrl(repoUrl: string): string {
this.initializeConfig(); 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 = { const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
@@ -52,7 +53,7 @@ export class GitHubJsonService {
headers.Authorization = `token ${env.GITHUB_TOKEN}`; 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.ok) {
if (response.status === 403) { if (response.status === 403) {
@@ -66,15 +67,16 @@ export class GitHubJsonService {
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
private async downloadJsonFile(filePath: string): Promise<Script> { private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
this.initializeConfig(); 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 = { const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0', '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) { if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`; headers.Authorization = `token ${env.GITHUB_TOKEN}`;
} }
@@ -90,64 +92,56 @@ export class GitHubJsonService {
} }
const content = await response.text(); 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(); 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 { try {
const files = await this.fetchFromGitHub<GitHubFile[]>( const files = await this.fetchFromGitHub<GitHubFile[]>(
repoUrl,
`/contents/${this.jsonFolder!}?ref=${this.branch!}` `/contents/${this.jsonFolder!}?ref=${this.branch!}`
); );
// Filter for JSON files only // Filter for JSON files only
return files.filter(file => file.name.endsWith('.json')); return files.filter(file => file.name.endsWith('.json'));
} catch (error) { } catch (error) {
console.error('Error fetching JSON files from GitHub:', error); console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
throw new Error('Failed to fetch script files from repository'); throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
} }
} }
async getAllScripts(): Promise<Script[]> { async getAllScripts(repoUrl: string): Promise<Script[]> {
try { try {
// First, get the list of JSON files (1 API call) // First, get the list of JSON files (1 API call)
const jsonFiles = await this.getJsonFiles(); const jsonFiles = await this.getJsonFiles(repoUrl);
const scripts: Script[] = []; const scripts: Script[] = [];
// Then download each JSON file using raw URLs (no rate limit) // Then download each JSON file using raw URLs (no rate limit)
for (const file of jsonFiles) { for (const file of jsonFiles) {
try { try {
const script = await this.downloadJsonFile(file.path); const script = await this.downloadJsonFile(repoUrl, file.path);
scripts.push(script); scripts.push(script);
} catch (error) { } 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 // Continue with other files even if one fails
} }
} }
return scripts; return scripts;
} catch (error) { } catch (error) {
console.error('Error fetching all scripts:', error); console.error(`Error fetching all scripts from ${repoUrl}:`, error);
throw new Error('Failed to fetch scripts from repository'); throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
} }
} }
async getScriptCards(): Promise<ScriptCard[]> { async getScriptCards(repoUrl: string): Promise<ScriptCard[]> {
try { try {
const scripts = await this.getAllScripts(); const scripts = await this.getAllScripts(repoUrl);
return scripts.map(script => ({ return scripts.map(script => ({
name: script.name, name: script.name,
@@ -157,29 +151,50 @@ export class GitHubJsonService {
type: script.type, type: script.type,
updateable: script.updateable, updateable: script.updateable,
website: script.website, website: script.website,
repository_url: script.repository_url,
})); }));
} catch (error) { } catch (error) {
console.error('Error creating script cards:', error); console.error(`Error creating script cards from ${repoUrl}:`, error);
throw new Error('Failed to create script cards'); 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 {
// Try to get from local cache first // Try to get from local cache first
const localScript = await this.getScriptFromLocal(slug); const localScript = await this.getScriptFromLocal(slug);
if (localScript) { if (localScript) {
// If repoUrl is specified and doesn't match, return null
if (repoUrl && localScript.repository_url !== repoUrl) {
return null;
}
return localScript; return localScript;
} }
// If not found locally, try to download just this specific script // If not found locally and repoUrl is provided, try to download from that repo
try { if (repoUrl) {
this.initializeConfig(); try {
const script = await this.downloadJsonFile(`${this.jsonFolder!}/${slug}.json`); this.initializeConfig();
return script; const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder!}/${slug}.json`);
} catch { return script;
return null; } 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) { } catch (error) {
console.error('Error fetching script by slug:', error); console.error('Error fetching script by slug:', error);
throw new Error(`Failed to fetch script: ${slug}`); throw new Error(`Failed to fetch script: ${slug}`);
@@ -193,14 +208,16 @@ export class GitHubJsonService {
return this.scriptCache.get(slug)!; return this.scriptCache.get(slug)!;
} }
const { readFile } = await import('fs/promises');
const { join } = await import('path');
this.initializeConfig(); this.initializeConfig();
const filePath = join(this.localJsonDirectory!, `${slug}.json`); const filePath = join(this.localJsonDirectory!, `${slug}.json`);
const content = await readFile(filePath, 'utf-8'); const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content) as Script; 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 // Cache the script
this.scriptCache.set(slug, 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 { try {
console.log('Starting fast incremental JSON sync...'); console.log(`Starting JSON sync from repository: ${repoUrl}`);
// Get file list from GitHub // Get file list from GitHub
console.log('Fetching file list from GitHub...'); console.log(`Fetching file list from GitHub (${repoUrl})...`);
const githubFiles = await this.getJsonFiles(); const githubFiles = await this.getJsonFiles(repoUrl);
console.log(`Found ${githubFiles.length} JSON files in repository`); console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
// Get local files // Get local files
const localFiles = await this.getLocalJsonFiles(); const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} files in local directory`); console.log(`Found ${localFiles.length} local JSON files`);
console.log(`Found ${localFiles.filter(f => f.endsWith('.json')).length} local JSON files`);
// Compare and find files that need syncing // Compare and find files that need syncing
const filesToSync = this.findFilesToSync(githubFiles, localFiles); // For multi-repo support, we need to check if file exists AND if it's from this repo
console.log(`Found ${filesToSync.length} files that need syncing`); const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
if (filesToSync.length === 0) { if (filesToSync.length === 0) {
return { return {
success: true, success: true,
message: 'All JSON files are up to date', message: `All JSON files are up to date for repository: ${repoUrl}`,
count: 0, count: 0,
syncedFiles: [] syncedFiles: []
}; };
} }
// Download and save only the files that need syncing // Download and save only the files that need syncing
const syncedFiles = await this.syncSpecificFiles(filesToSync); const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
return { return {
success: true, success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`, message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
count: syncedFiles.length, count: syncedFiles.length,
syncedFiles syncedFiles
}; };
} catch (error) { } 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 { return {
success: false, success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`, 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[]> { private async getLocalJsonFiles(): Promise<string[]> {
this.initializeConfig(); this.initializeConfig();
try { try {
@@ -267,13 +389,47 @@ export class GitHubJsonService {
} }
} }
private findFilesToSync(githubFiles: GitHubFile[], localFiles: string[]): GitHubFile[] { /**
const localFileSet = new Set(localFiles); * Find files that need syncing for a specific repository
// Return only files that don't exist locally * This checks if file exists locally AND if it's from the same repository
return githubFiles.filter(ghFile => !localFileSet.has(ghFile.name)); */
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(); this.initializeConfig();
const syncedFiles: string[] = []; const syncedFiles: string[] = [];
@@ -281,19 +437,25 @@ export class GitHubJsonService {
for (const file of filesToSync) { for (const file of filesToSync) {
try { try {
const script = await this.downloadJsonFile(file.path); const script = await this.downloadJsonFile(repoUrl, file.path);
const filename = `${script.slug}.json`; const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename); 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'); await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename); syncedFiles.push(filename);
// Clear cache for this script
this.scriptCache.delete(script.slug);
} catch (error) { } catch (error) {
console.error(`Failed to sync ${file.name}:`, error); console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
} }
} }
return syncedFiles; return syncedFiles;
} }
} }
// Singleton instance // Singleton instance

View File

@@ -64,6 +64,7 @@ export class LocalScriptsService {
type: script.type, type: script.type,
updateable: script.updateable, updateable: script.updateable,
website: script.website, website: script.website,
repository_url: script.repository_url,
})); }));
} catch (error) { } catch (error) {
console.error('Error creating script cards:', error); console.error('Error creating script cards:', error);

View File

@@ -0,0 +1,224 @@
import { prisma } from '../db.ts';
export class RepositoryService {
/**
* Initialize default repositories if they don't exist
*/
async initializeDefaultRepositories(): Promise<void> {
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
// Check if repositories already exist
const existingRepos = await prisma.repository.findMany({
where: {
url: {
in: [mainRepoUrl, devRepoUrl]
}
}
});
const existingUrls = new Set(existingRepos.map(r => r.url));
// Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) {
await prisma.repository.create({
data: {
url: mainRepoUrl,
enabled: true,
is_default: true,
is_removable: false,
priority: 1
}
});
console.log('Initialized main repository:', mainRepoUrl);
}
// Create dev repo if it doesn't exist
if (!existingUrls.has(devRepoUrl)) {
await prisma.repository.create({
data: {
url: devRepoUrl,
enabled: false,
is_default: true,
is_removable: false,
priority: 2
}
});
console.log('Initialized dev repository:', devRepoUrl);
}
}
/**
* Get all repositories, sorted by priority
*/
async getAllRepositories() {
return await prisma.repository.findMany({
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get enabled repositories, sorted by priority
*/
async getEnabledRepositories() {
return await prisma.repository.findMany({
where: {
enabled: true
},
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get repository by URL
*/
async getRepositoryByUrl(url: string) {
return await prisma.repository.findUnique({
where: { url }
});
}
/**
* Create a new repository
*/
async createRepository(data: {
url: string;
enabled?: boolean;
priority?: number;
}) {
// Validate GitHub URL
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}
// Check for duplicates
const existing = await this.getRepositoryByUrl(data.url);
if (existing) {
throw new Error('Repository already exists');
}
// Get max priority for user-added repos
const maxPriority = await prisma.repository.aggregate({
_max: {
priority: true
}
});
return await prisma.repository.create({
data: {
url: data.url,
enabled: data.enabled ?? true,
is_default: false,
is_removable: true,
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
}
});
}
/**
* Update repository
*/
async updateRepository(id: number, data: {
enabled?: boolean;
url?: string;
priority?: number;
}) {
// If updating URL, validate it
if (data.url) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
}
// Check for duplicates (excluding current repo)
const existing = await prisma.repository.findFirst({
where: {
url: data.url,
id: { not: id }
}
});
if (existing) {
throw new Error('Repository URL already exists');
}
}
return await prisma.repository.update({
where: { id },
data
});
}
/**
* Delete repository and associated JSON files
*/
async deleteRepository(id: number) {
const repo = await prisma.repository.findUnique({
where: { id }
});
if (!repo) {
throw new Error('Repository not found');
}
if (!repo.is_removable) {
throw new Error('Cannot delete default repository');
}
// Delete associated JSON files
await this.deleteRepositoryJsonFiles(repo.url);
// Delete repository
await prisma.repository.delete({
where: { id }
});
return { success: true };
}
/**
* Delete all JSON files associated with a repository
*/
private async deleteRepositoryJsonFiles(repoUrl: string): Promise<void> {
const { readdir, unlink, readFile } = await import('fs/promises');
const { join } = await import('path');
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
try {
const files = await readdir(jsonDirectory);
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = join(jsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
// If script has repository_url matching the repo, delete it
if (script.repository_url === repoUrl) {
await unlink(filePath);
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
}
} catch (error) {
// Skip files that can't be read or parsed
console.error(`Error processing file ${file}:`, error);
}
}
} catch (error) {
// Directory might not exist, which is fine
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error deleting repository JSON files:', error);
}
}
}
}
// Singleton instance
export const repositoryService = new RepositoryService();

View File

@@ -14,7 +14,7 @@ export class ScriptDownloaderService {
private initializeConfig() { private initializeConfig() {
if (this.scriptsDirectory === null) { if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts'); this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = env.REPO_URL ?? ''; this.repoUrl = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
} }
} }
@@ -26,29 +26,48 @@ export class ScriptDownloaderService {
} }
} }
private async downloadFileFromGitHub(filePath: string): Promise<string> { private extractRepoPath(repoUrl: string): string {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
return `${match[1]}/${match[2]}`;
}
private async downloadFileFromGitHub(repoUrl: string, filePath: string, branch: string = 'main'): Promise<string> {
this.initializeConfig(); this.initializeConfig();
if (!this.repoUrl) { if (!repoUrl) {
throw new Error('REPO_URL environment variable is not set'); throw new Error('Repository URL is not set');
} }
const url = `https://raw.githubusercontent.com/${this.extractRepoPath()}/main/${filePath}`; const repoPath = this.extractRepoPath(repoUrl);
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
const response = await fetch(url); const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
const { env } = await import('~/env.js');
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(url, { headers });
if (!response.ok) { 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(); return response.text();
} }
private extractRepoPath(): string { private getRepoUrlForScript(script: Script): string {
this.initializeConfig(); // Use repository_url from script if available, otherwise fallback to env or default
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl!); if (script.repository_url) {
if (!match) { return script.repository_url;
throw new Error('Invalid GitHub repository URL');
} }
return `${match[1]}/${match[2]}`; this.initializeConfig();
return this.repoUrl!;
} }
private modifyScriptContent(content: string): string { private modifyScriptContent(content: string): string {
@@ -64,6 +83,9 @@ export class ScriptDownloaderService {
this.initializeConfig(); this.initializeConfig();
try { try {
const files: string[] = []; const files: string[] = [];
const repoUrl = this.getRepoUrlForScript(script);
const { env } = await import('~/env.js');
const branch = env.REPO_BRANCH ?? 'main';
// Ensure directories exist // Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'ct')); await this.ensureDirectoryExists(join(this.scriptsDirectory!, 'ct'));
@@ -78,8 +100,8 @@ export class ScriptDownloaderService {
const fileName = scriptPath.split('/').pop(); const fileName = scriptPath.split('/').pop();
if (fileName) { if (fileName) {
// Download from GitHub // Download from GitHub using the script's repository URL
const content = await this.downloadFileFromGitHub(scriptPath); const content = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
// Determine target directory based on script path // Determine target directory based on script path
let targetDir: string; let targetDir: string;
@@ -143,7 +165,7 @@ export class ScriptDownloaderService {
if (hasCtScript) { if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`; const installScriptName = `${script.slug}-install.sh`;
try { try {
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`); const installContent = await this.downloadFileFromGitHub(repoUrl, `install/${installScriptName}`, branch);
const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName); const localInstallPath = join(this.scriptsDirectory!, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8'); await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`); files.push(`install/${installScriptName}`);
@@ -303,6 +325,10 @@ export class ScriptDownloaderService {
private async scriptNeedsUpdate(script: Script): Promise<boolean> { private async scriptNeedsUpdate(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false; if (!script.install_methods?.length) return false;
const repoUrl = this.getRepoUrlForScript(script);
const { env } = await import('~/env.js');
const branch = env.REPO_BRANCH ?? 'main';
for (const method of script.install_methods) { for (const method of script.install_methods) {
if (method.script) { if (method.script) {
const scriptPath = method.script; const scriptPath = method.script;
@@ -346,8 +372,8 @@ export class ScriptDownloaderService {
// Read local content // Read local content
const localContent = await readFile(filePath, 'utf8'); const localContent = await readFile(filePath, 'utf8');
// Download remote content // Download remote content from the script's repository
const remoteContent = await this.downloadFileFromGitHub(scriptPath); const remoteContent = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
// Compare content (simple string comparison for now) // Compare content (simple string comparison for now)
// In a more sophisticated implementation, you might want to compare // In a more sophisticated implementation, you might want to compare
@@ -566,7 +592,7 @@ export class ScriptDownloaderService {
} }
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`) this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => { .then(result => {
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
@@ -588,7 +614,7 @@ export class ScriptDownloaderService {
const installScriptPath = `install/${installScriptName}`; const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath) this.compareSingleFile(script, installScriptPath, installScriptPath)
.then(result => { .then(result => {
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
@@ -611,15 +637,18 @@ export class ScriptDownloaderService {
} }
} }
private async compareSingleFile(remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> { private async compareSingleFile(script: Script, remotePath: string, filePath: string): Promise<{ hasDifferences: boolean; filePath: string }> {
try { try {
const localPath = join(this.scriptsDirectory!, filePath); const localPath = join(this.scriptsDirectory!, filePath);
const repoUrl = this.getRepoUrlForScript(script);
const { env } = await import('~/env.js');
const branch = env.REPO_BRANCH ?? 'main';
// Read local content // Read local content
const localContent = await readFile(localPath, 'utf-8'); const localContent = await readFile(localPath, 'utf-8');
// Download remote content // Download remote content from the script's repository
const remoteContent = await this.downloadFileFromGitHub(remotePath); const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
// Apply modification only for CT scripts, not for other script types // Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent: string; let modifiedRemoteContent: string;
@@ -642,6 +671,9 @@ export class ScriptDownloaderService {
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> { async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
this.initializeConfig(); this.initializeConfig();
try { try {
const repoUrl = this.getRepoUrlForScript(script);
const { env } = await import('~/env.js');
const branch = env.REPO_BRANCH ?? 'main';
let localContent: string | null = null; let localContent: string | null = null;
let remoteContent: string | null = null; let remoteContent: string | null = null;
@@ -660,7 +692,7 @@ export class ScriptDownloaderService {
// Find the corresponding script path in install_methods // Find the corresponding script path in install_methods
const method = script.install_methods?.find(m => m.script === filePath); const method = script.install_methods?.find(m => m.script === filePath);
if (method?.script) { if (method?.script) {
const downloadedContent = await this.downloadFileFromGitHub(method.script); const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch);
remoteContent = this.modifyScriptContent(downloadedContent); remoteContent = this.modifyScriptContent(downloadedContent);
} }
} catch { } catch {
@@ -677,7 +709,7 @@ export class ScriptDownloaderService {
} }
try { try {
remoteContent = await this.downloadFileFromGitHub(filePath); remoteContent = await this.downloadFileFromGitHub(repoUrl, filePath, branch);
} catch { } catch {
// Error downloading remote install script // Error downloading remote install script
} }

View File

@@ -39,6 +39,7 @@ export interface Script {
install_methods: ScriptInstallMethod[]; install_methods: ScriptInstallMethod[];
default_credentials: ScriptCredentials; default_credentials: ScriptCredentials;
notes: (ScriptNote | string)[]; notes: (ScriptNote | string)[];
repository_url?: string;
} }
export interface ScriptCard { export interface ScriptCard {
@@ -62,6 +63,7 @@ export interface ScriptCard {
interface_port?: number | null; interface_port?: number | null;
// Optional: basenames of install scripts (without extension) // Optional: basenames of install scripts (without extension)
install_basenames?: string[]; install_basenames?: string[];
repository_url?: string;
} }
export interface GitHubFile { export interface GitHubFile {