feat: Add repository status and update functionality
- Add ORIGINAL_REPO_URL environment variable for repository updates - Create RepoStatusButton component with status display and update functionality - Enhance GitManager with fullUpdate() method (git pull + npm install + build) - Add fullUpdateRepo API endpoint for complete repository updates - Display repository status with visual indicators (up-to-date, updates available, etc.) - Show real-time progress during update process - Add manual refresh capability for repository status - Integrate repository status component into main page
This commit is contained in:
185
src/app/_components/RepoStatusButton.tsx
Normal file
185
src/app/_components/RepoStatusButton.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
export function RepoStatusButton() {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const [updateSteps, setUpdateSteps] = useState<string[]>([]);
|
||||
const [showSteps, setShowSteps] = useState(false);
|
||||
|
||||
// Query repository status
|
||||
const { data: repoStatus, refetch: refetchStatus } = api.scripts.getRepoStatus.useQuery();
|
||||
|
||||
// Full update mutation
|
||||
const fullUpdateMutation = api.scripts.fullUpdateRepo.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsUpdating(false);
|
||||
setUpdateMessage(data.message);
|
||||
setUpdateSteps(data.steps);
|
||||
setShowSteps(true);
|
||||
|
||||
if (data.success) {
|
||||
// Refetch status after successful update
|
||||
setTimeout(() => {
|
||||
refetchStatus();
|
||||
}, 1000);
|
||||
|
||||
// Clear message after 5 seconds for success
|
||||
setTimeout(() => {
|
||||
setUpdateMessage(null);
|
||||
setShowSteps(false);
|
||||
}, 5000);
|
||||
} else {
|
||||
// Clear message after 10 seconds for errors
|
||||
setTimeout(() => {
|
||||
setUpdateMessage(null);
|
||||
setShowSteps(false);
|
||||
}, 10000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUpdating(false);
|
||||
setUpdateMessage(`Error: ${error.message}`);
|
||||
setUpdateSteps([`❌ ${error.message}`]);
|
||||
setShowSteps(true);
|
||||
setTimeout(() => {
|
||||
setUpdateMessage(null);
|
||||
setShowSteps(false);
|
||||
}, 10000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFullUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
setUpdateMessage(null);
|
||||
setUpdateSteps([]);
|
||||
setShowSteps(false);
|
||||
fullUpdateMutation.mutate();
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!repoStatus?.isRepo) return 'text-gray-500';
|
||||
if (repoStatus.isBehind) return 'text-orange-500';
|
||||
return 'text-green-500';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!repoStatus?.isRepo) return '❓';
|
||||
if (repoStatus.isBehind) return '⚠️';
|
||||
return '✅';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!repoStatus?.isRepo) return 'Not a git repository';
|
||||
if (repoStatus.isBehind) return 'Updates available';
|
||||
return 'Up to date';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Status Display */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-2xl">{getStatusIcon()}</span>
|
||||
<div>
|
||||
<div className={`font-medium ${getStatusColor()}`}>
|
||||
Repository Status: {getStatusText()}
|
||||
</div>
|
||||
{repoStatus?.isRepo && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Branch: {repoStatus.branch || 'unknown'} |
|
||||
Last commit: {repoStatus.lastCommit ? repoStatus.lastCommit.substring(0, 8) : 'unknown'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{repoStatus?.isBehind && (
|
||||
<button
|
||||
onClick={handleFullUpdate}
|
||||
disabled={isUpdating}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isUpdating
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-orange-600 text-white hover:bg-orange-700'
|
||||
}`}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Update Repository</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => refetchStatus()}
|
||||
className="flex items-center space-x-2 px-3 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Refresh status"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Message */}
|
||||
{updateMessage && (
|
||||
<div className={`p-4 rounded-lg ${
|
||||
updateMessage.includes('Error') || updateMessage.includes('Failed')
|
||||
? 'bg-red-100 text-red-700 border border-red-200'
|
||||
: 'bg-green-100 text-green-700 border border-green-200'
|
||||
}`}>
|
||||
<div className="font-medium mb-2">{updateMessage}</div>
|
||||
{showSteps && updateSteps.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setShowSteps(!showSteps)}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{showSteps ? 'Hide' : 'Show'} update steps
|
||||
</button>
|
||||
{showSteps && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{updateSteps.map((step, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
{step}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update Steps (always show when updating) */}
|
||||
{isUpdating && updateSteps.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="font-medium text-blue-800 mb-2">Update Progress:</div>
|
||||
<div className="space-y-1">
|
||||
{updateSteps.map((step, index) => (
|
||||
<div key={index} className="text-sm font-mono text-blue-700">
|
||||
{step}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,15 +23,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
|
||||
// Check if script files exist locally
|
||||
const { data: scriptFilesData, refetch: refetchScriptFiles } = api.scripts.checkScriptFiles.useQuery(
|
||||
const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery(
|
||||
{ slug: script?.slug ?? '' },
|
||||
{ enabled: !!script && isOpen }
|
||||
);
|
||||
|
||||
// Compare local and remote script content
|
||||
const { data: comparisonData, refetch: refetchComparison } = api.scripts.compareScriptContent.useQuery(
|
||||
// Compare local and remote script content (run in parallel, not dependent on scriptFilesData)
|
||||
const { data: comparisonData, refetch: refetchComparison, isLoading: comparisonLoading } = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? '' },
|
||||
{ enabled: !!script && isOpen && scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) }
|
||||
{ enabled: !!script && isOpen }
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
@@ -81,10 +81,10 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
const handleInstallScript = () => {
|
||||
if (!script || !onInstallScript) return;
|
||||
|
||||
// Find the CT script path
|
||||
const ctScript = script.install_methods?.find(method => method.script?.startsWith('ct/'));
|
||||
if (ctScript?.script) {
|
||||
const scriptPath = `scripts/${ctScript.script}`;
|
||||
// Find the script path (CT or tools)
|
||||
const scriptMethod = script.install_methods?.find(method => method.script);
|
||||
if (scriptMethod?.script) {
|
||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||
const scriptName = script.name;
|
||||
onInstallScript(scriptPath, scriptName);
|
||||
onClose(); // Close the modal when starting installation
|
||||
@@ -270,7 +270,16 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
)}
|
||||
|
||||
{/* Script Files Status */}
|
||||
{scriptFilesData?.success && (
|
||||
{(scriptFilesLoading || comparisonLoading) && (
|
||||
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span>Loading script status...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scriptFilesData?.success && !scriptFilesLoading && (
|
||||
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -281,7 +290,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
|
||||
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
|
||||
</div>
|
||||
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && (
|
||||
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && !comparisonLoading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${comparisonData.hasDifferences ? 'bg-orange-500' : 'bg-green-500'}`}></div>
|
||||
<span>Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'}</span>
|
||||
|
||||
@@ -215,9 +215,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCard
|
||||
key={script.slug ?? `script-${index}`}
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
|
||||
@@ -29,8 +29,12 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [ctResponse, installResponse] = await Promise.allSettled([
|
||||
// Try to load from different possible locations
|
||||
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
]);
|
||||
|
||||
@@ -43,6 +47,27 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { RepoStatusButton } from './_components/RepoStatusButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
|
||||
export default function Home() {
|
||||
@@ -30,6 +31,11 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Repository Status and Update */}
|
||||
<div className="mb-8">
|
||||
<RepoStatusButton />
|
||||
</div>
|
||||
|
||||
{/* Resync Button */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
||||
@@ -13,6 +13,7 @@ export const env = createEnv({
|
||||
.default("development"),
|
||||
// Repository Configuration
|
||||
REPO_URL: z.string().url().optional(),
|
||||
ORIGINAL_REPO_URL: z.string().url().optional(),
|
||||
REPO_BRANCH: z.string().default("main"),
|
||||
SCRIPTS_DIRECTORY: z.string().default("scripts"),
|
||||
JSON_FOLDER: z.string().default("json"),
|
||||
@@ -41,6 +42,7 @@ export const env = createEnv({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// Repository Configuration
|
||||
REPO_URL: process.env.REPO_URL,
|
||||
ORIGINAL_REPO_URL: process.env.ORIGINAL_REPO_URL,
|
||||
REPO_BRANCH: process.env.REPO_BRANCH,
|
||||
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
|
||||
JSON_FOLDER: process.env.JSON_FOLDER,
|
||||
|
||||
@@ -41,6 +41,13 @@ export const scriptsRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Full update repository (git pull, npm install, build)
|
||||
fullUpdateRepo: publicProcedure
|
||||
.mutation(async () => {
|
||||
const result = await gitManager.fullUpdate();
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Get script content for viewing
|
||||
getScriptContent: publicProcedure
|
||||
.input(z.object({ path: z.string() }))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { env } from '~/env.js';
|
||||
import { join } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class GitManager {
|
||||
private git: SimpleGit;
|
||||
@@ -18,7 +22,7 @@ export class GitManager {
|
||||
*/
|
||||
async isBehindRemote(): Promise<boolean> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
if (!env.ORIGINAL_REPO_URL) {
|
||||
return false; // No remote configured
|
||||
}
|
||||
|
||||
@@ -41,7 +45,7 @@ export class GitManager {
|
||||
*/
|
||||
async pullUpdates(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
if (!env.ORIGINAL_REPO_URL) {
|
||||
return { success: false, message: 'No remote repository configured' };
|
||||
}
|
||||
|
||||
@@ -68,18 +72,102 @@ export class GitManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full update process: git pull, npm install, and restart server
|
||||
*/
|
||||
async fullUpdate(): Promise<{ success: boolean; message: string; steps: string[] }> {
|
||||
const steps: string[] = [];
|
||||
|
||||
try {
|
||||
if (!env.ORIGINAL_REPO_URL) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No remote repository configured',
|
||||
steps: ['❌ No remote repository configured']
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: Git pull
|
||||
steps.push('🔄 Pulling latest changes from repository...');
|
||||
const pullResult = await this.pullUpdates();
|
||||
if (!pullResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: pullResult.message,
|
||||
steps: [...steps, `❌ ${pullResult.message}`]
|
||||
};
|
||||
}
|
||||
steps.push(`✅ ${pullResult.message}`);
|
||||
|
||||
// Step 2: npm install
|
||||
steps.push('📦 Installing/updating dependencies...');
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('npm install', { cwd: this.repoPath });
|
||||
if (stderr && !stderr.includes('npm WARN')) {
|
||||
console.warn('npm install warnings:', stderr);
|
||||
}
|
||||
steps.push('✅ Dependencies updated successfully');
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to install dependencies: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
steps.push(`❌ ${errorMsg}`);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Build the application
|
||||
steps.push('🔨 Building application...');
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('npm run build', { cwd: this.repoPath });
|
||||
if (stderr && !stderr.includes('npm WARN')) {
|
||||
console.warn('npm build warnings:', stderr);
|
||||
}
|
||||
steps.push('✅ Application built successfully');
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to build application: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
steps.push(`❌ ${errorMsg}`);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Restart server (this will be handled by the process manager)
|
||||
steps.push('🔄 Server restart required - please restart manually or use a process manager');
|
||||
steps.push('✅ Update process completed successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Repository updated successfully. Please restart the server to apply changes.',
|
||||
steps
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Update failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
steps.push(`❌ ${errorMsg}`);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
steps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the repository if it doesn't exist
|
||||
*/
|
||||
private async cloneRepository(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
if (!env.ORIGINAL_REPO_URL) {
|
||||
return { success: false, message: 'No repository URL configured' };
|
||||
}
|
||||
|
||||
|
||||
// Clone the repository
|
||||
await this.git.clone(env.REPO_URL, this.repoPath, [
|
||||
await this.git.clone(env.ORIGINAL_REPO_URL, this.repoPath, [
|
||||
'--branch', env.REPO_BRANCH,
|
||||
'--single-branch',
|
||||
'--depth', '1'
|
||||
@@ -87,7 +175,7 @@ export class GitManager {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully cloned repository from ${env.REPO_URL}`
|
||||
message: `Successfully cloned repository from ${env.ORIGINAL_REPO_URL}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cloning repository:', error);
|
||||
@@ -103,7 +191,7 @@ export class GitManager {
|
||||
*/
|
||||
async initializeRepository(): Promise<void> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
if (!env.ORIGINAL_REPO_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export class GitHubJsonService {
|
||||
private branch: string;
|
||||
private jsonFolder: string;
|
||||
private localJsonDirectory: string;
|
||||
private scriptCache: Map<string, Script> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.repoUrl = env.REPO_URL ?? "";
|
||||
@@ -130,14 +131,49 @@ export class GitHubJsonService {
|
||||
|
||||
async getScriptBySlug(slug: string): Promise<Script | null> {
|
||||
try {
|
||||
const scripts = await this.getAllScripts();
|
||||
return scripts.find(script => script.slug === slug) ?? null;
|
||||
// Try to get from local cache first
|
||||
const localScript = await this.getScriptFromLocal(slug);
|
||||
if (localScript) {
|
||||
return localScript;
|
||||
}
|
||||
|
||||
// If not found locally, try to download just this specific script
|
||||
try {
|
||||
const script = await this.downloadJsonFile(`${this.jsonFolder}/${slug}.json`);
|
||||
return script;
|
||||
} catch (error) {
|
||||
console.log(`Script ${slug} not found in repository`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching script by slug:', error);
|
||||
throw new Error(`Failed to fetch script: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScriptFromLocal(slug: string): Promise<Script | null> {
|
||||
try {
|
||||
// Check cache first
|
||||
if (this.scriptCache.has(slug)) {
|
||||
return this.scriptCache.get(slug)!;
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
|
||||
const filePath = join(this.localJsonDirectory, `${slug}.json`);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content) as Script;
|
||||
|
||||
// Cache the script
|
||||
this.scriptCache.set(slug, script);
|
||||
|
||||
return script;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
|
||||
try {
|
||||
// Get all scripts from GitHub (1 API call + raw downloads)
|
||||
|
||||
@@ -73,8 +73,17 @@ export class LocalScriptsService {
|
||||
|
||||
async getScriptBySlug(slug: string): Promise<Script | null> {
|
||||
try {
|
||||
const scripts = await this.getAllScripts();
|
||||
return scripts.find(script => script.slug === slug) ?? null;
|
||||
// Try to read the specific script file directly instead of loading all scripts
|
||||
const filename = `${slug}.json`;
|
||||
const filePath = join(this.scriptsDirectory, filename);
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as Script;
|
||||
} catch (fileError) {
|
||||
// If file doesn't exist, return null instead of throwing
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching script by slug:', error);
|
||||
throw new Error(`Failed to fetch script: ${slug}`);
|
||||
|
||||
@@ -59,11 +59,12 @@ export class ScriptDownloaderService {
|
||||
// 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'));
|
||||
|
||||
// Download and save CT script
|
||||
if (script.install_methods?.length) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script?.startsWith('ct/')) {
|
||||
if (method.script) {
|
||||
const scriptPath = method.script;
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
@@ -71,28 +72,57 @@ export class ScriptDownloaderService {
|
||||
// Download from GitHub
|
||||
const content = await this.downloadFileFromGitHub(scriptPath);
|
||||
|
||||
// Modify the content
|
||||
const modifiedContent = this.modifyScriptContent(content);
|
||||
// Determine target directory based on script path
|
||||
let targetDir: string;
|
||||
let filePath: string;
|
||||
|
||||
// Save to local directory
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
await writeFile(localPath, modifiedContent, 'utf-8');
|
||||
if (scriptPath.startsWith('ct/')) {
|
||||
targetDir = 'ct';
|
||||
// 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';
|
||||
// Don't modify content for tools scripts
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
} else if (scriptPath.startsWith('vm/')) {
|
||||
targetDir = 'vm';
|
||||
// Don't modify content for VM scripts
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
} else if (scriptPath.startsWith('vw/')) {
|
||||
targetDir = 'vw';
|
||||
// Don't modify content for VW scripts
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
} else {
|
||||
// Handle other script types (fallback to ct directory)
|
||||
targetDir = 'ct';
|
||||
const modifiedContent = this.modifyScriptContent(content);
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
await writeFile(filePath, modifiedContent, 'utf-8');
|
||||
}
|
||||
|
||||
files.push(`ct/${fileName}`);
|
||||
files.push(`${targetDir}/${fileName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download and save install script
|
||||
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
|
||||
// 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 {
|
||||
@@ -116,27 +146,67 @@ export class ScriptDownloaderService {
|
||||
let installExists = false;
|
||||
|
||||
try {
|
||||
// Check CT script
|
||||
// Check scripts based on their install methods
|
||||
if (script.install_methods?.length) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script?.startsWith('ct/')) {
|
||||
const fileName = method.script.split('/').pop();
|
||||
if (method.script) {
|
||||
const scriptPath = method.script;
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
if (fileName) {
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
try {
|
||||
await readFile(localPath, 'utf-8');
|
||||
ctExists = true;
|
||||
files.push(`ct/${fileName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
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';
|
||||
localPath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
try {
|
||||
await readFile(localPath, 'utf-8');
|
||||
ctExists = true; // Use ctExists for tools scripts too for UI consistency
|
||||
files.push(`${targetDir}/${fileName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
} else if (scriptPath.startsWith('vm/')) {
|
||||
targetDir = 'vm';
|
||||
localPath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
try {
|
||||
await readFile(localPath, 'utf-8');
|
||||
ctExists = true; // Use ctExists for VM scripts too for UI consistency
|
||||
files.push(`${targetDir}/${fileName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
} else if (scriptPath.startsWith('vw/')) {
|
||||
targetDir = 'vw';
|
||||
localPath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
try {
|
||||
await readFile(localPath, 'utf-8');
|
||||
ctExists = true; // Use ctExists for VW scripts too for UI consistency
|
||||
files.push(`${targetDir}/${fileName}`);
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check install script
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
// 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');
|
||||
@@ -145,6 +215,7 @@ export class ScriptDownloaderService {
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return { ctExists, installExists, files };
|
||||
} catch (error) {
|
||||
@@ -165,31 +236,44 @@ export class ScriptDownloaderService {
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
|
||||
// Compare CT script only if it exists locally
|
||||
// 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?.startsWith('ct/')) {
|
||||
const fileName = method.script.split('/').pop();
|
||||
if (method.script) {
|
||||
const scriptPath = method.script;
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
if (fileName) {
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
try {
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(method.script);
|
||||
|
||||
// Apply the same modification that would be applied during load
|
||||
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
|
||||
// Compare content
|
||||
if (localContent !== modifiedRemoteContent) {
|
||||
hasDifferences = true;
|
||||
differences.push(`ct/${fileName}`);
|
||||
}
|
||||
} catch {
|
||||
// Don't add to differences if there's an error reading files
|
||||
}
|
||||
let targetDir: string;
|
||||
|
||||
if (scriptPath.startsWith('ct/')) {
|
||||
targetDir = 'ct';
|
||||
} else if (scriptPath.startsWith('tools/')) {
|
||||
targetDir = 'tools';
|
||||
} else if (scriptPath.startsWith('vm/')) {
|
||||
targetDir = 'vm';
|
||||
} else if (scriptPath.startsWith('vw/')) {
|
||||
targetDir = 'vw';
|
||||
} else {
|
||||
continue; // Skip unknown script types
|
||||
}
|
||||
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(scriptPath, `${targetDir}/${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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,27 +282,25 @@ export class ScriptDownloaderService {
|
||||
// Compare install script only if it exists locally
|
||||
if (localFilesExist.installExists) {
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
|
||||
try {
|
||||
// Read local content
|
||||
const localContent = await readFile(localInstallPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
|
||||
|
||||
// Apply the same modification that would be applied during load
|
||||
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
|
||||
// Compare content
|
||||
if (localContent !== modifiedRemoteContent) {
|
||||
hasDifferences = true;
|
||||
differences.push(`install/${installScriptName}`);
|
||||
}
|
||||
} catch {
|
||||
// Don't add to differences if there's an error reading files
|
||||
}
|
||||
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);
|
||||
@@ -226,6 +308,34 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
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 }> {
|
||||
try {
|
||||
let localContent: string | null = null;
|
||||
|
||||
Reference in New Issue
Block a user