Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
440d5246bd chore: add VERSION v0.4.13 2025-11-24 20:08:17 +00:00
15 changed files with 1902 additions and 1657 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [24.x] node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:

View File

@@ -43,10 +43,6 @@ const config = {
'http://192.168.*', 'http://192.168.*',
], ],
turbopack: {
// Disable Turbopack and use Webpack instead for compatibility
// This is necessary for server-side code that uses child_process
},
webpack: (config, { dev, isServer }) => { webpack: (config, { dev, isServer }) => {
if (dev && !isServer) { if (dev && !isServer) {
config.watchOptions = { config.watchOptions = {
@@ -54,15 +50,12 @@ const config = {
aggregateTimeout: 300, aggregateTimeout: 300,
}; };
} }
// Handle server-side modules
if (isServer) {
config.externals = config.externals || [];
if (!config.externals.includes('child_process')) {
config.externals.push('child_process');
}
}
return config; return config;
}, },
// Ignore ESLint errors during build (they can be fixed separately)
eslint: {
ignoreDuringBuilds: true,
},
// Ignore TypeScript errors during build (they can be fixed separately) // Ignore TypeScript errors during build (they can be fixed separately)
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,

2696
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,11 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build --webpack", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --webpack", "dev": "next dev",
"dev:server": "node server.js", "dev:server": "node server.js",
"dev:next": "next dev --webpack", "dev:next": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
@@ -43,14 +43,14 @@
"cron-validator": "^1.2.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.554.0", "lucide-react": "^0.553.0",
"next": "^16.0.4", "next": "^15.1.6",
"node-cron": "^4.2.1", "node-cron": "^3.0.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^15.6.6",
"refractor": "^5.0.0", "refractor": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
@@ -74,10 +74,10 @@
"@types/react": "^19.2.4", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^4.0.13", "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^4.0.13", "@vitest/ui": "^3.2.4",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.4", "eslint-config-next": "^15.1.6",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
@@ -86,16 +86,13 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.2",
"vitest": "^4.0.13" "vitest": "^3.2.4"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "npm@10.9.3", "packageManager": "npm@10.9.3",
"engines": {
"node": ">=24.0.0"
},
"overrides": { "overrides": {
"prismjs": "^1.30.0" "prismjs": "^1.30.0"
} }
} }

View File

@@ -79,27 +79,14 @@ class ScriptExecutionHandler {
* @param {import('http').Server} server * @param {import('http').Server} server
*/ */
constructor(server) { constructor(server) {
// Create WebSocketServer without attaching to server
// We'll handle upgrades manually to avoid interfering with Next.js HMR
this.wss = new WebSocketServer({ this.wss = new WebSocketServer({
noServer: true server,
path: '/ws/script-execution'
}); });
this.activeExecutions = new Map(); this.activeExecutions = new Map();
this.db = getDatabase(); this.db = getDatabase();
this.setupWebSocket(); this.setupWebSocket();
} }
/**
* Handle WebSocket upgrade for our endpoint
* @param {import('http').IncomingMessage} request
* @param {import('stream').Duplex} socket
* @param {Buffer} head
*/
handleUpgrade(request, socket, head) {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss.emit('connection', ws, request);
});
}
/** /**
* Parse Container ID from terminal output * Parse Container ID from terminal output
@@ -1172,22 +1159,12 @@ app.prepare().then(() => {
const parsedUrl = parse(req.url || '', true); const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl; const { pathname, query } = parsedUrl;
// Check if this is a WebSocket upgrade request if (pathname === '/ws/script-execution') {
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
// Only intercept WebSocket upgrades for /ws/script-execution
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
// WebSocket upgrade will be handled by the WebSocket server // WebSocket upgrade will be handled by the WebSocket server
// Don't call handle() for this path - let WebSocketServer handle it
return; return;
} }
// Let Next.js handle all other requests including: // Let Next.js handle all other requests including HMR
// - HTTP requests to /ws/script-execution (non-WebSocket)
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
// - All static assets (_next routes)
// - All other routes
await handle(req, res, parsedUrl); await handle(req, res, parsedUrl);
} catch (err) { } catch (err) {
console.error('Error occurred handling', req.url, err); console.error('Error occurred handling', req.url, err);
@@ -1198,33 +1175,6 @@ app.prepare().then(() => {
// Create WebSocket handlers // Create WebSocket handlers
const scriptHandler = new ScriptExecutionHandler(httpServer); const scriptHandler = new ScriptExecutionHandler(httpServer);
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
// Save any existing upgrade listeners (Next.js might have set them up)
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
httpServer.removeAllListeners('upgrade');
// Add our upgrade handler that routes based on path
httpServer.on('upgrade', (request, socket, head) => {
const parsedUrl = parse(request.url || '', true);
const { pathname } = parsedUrl;
if (pathname === '/ws/script-execution') {
// Handle our custom WebSocket endpoint
scriptHandler.handleUpgrade(request, socket, head);
} else {
// For all other paths (including Next.js HMR), call existing listeners
// This allows Next.js to handle its own WebSocket upgrades
for (const listener of existingUpgradeListeners) {
try {
listener.call(httpServer, request, socket, head);
} catch (err) {
console.error('Error in upgrade listener:', err);
}
}
}
});
// Note: TerminalHandler removed as it's not being used by the current application // Note: TerminalHandler removed as it's not being used by the current application
httpServer httpServer

View File

@@ -187,10 +187,9 @@ export function CategorySidebar({
'Miscellaneous': 'box' 'Miscellaneous': 'box'
}; };
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically // Sort categories by count (descending) and then alphabetically
const sortedCategories = categories const sortedCategories = categories
.map(category => [category, categoryCounts[category] ?? 0] as const) .map(category => [category, categoryCounts[category] ?? 0] as const)
.filter(([, count]) => count > 0) // Only show categories with at least one script
.sort(([a, countA], [b, countB]) => { .sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA; if (countB !== countA) return countB - countA;
return a.localeCompare(b); return a.localeCompare(b);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon'; import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -9,8 +9,6 @@ export function ResyncButton() {
const [isResyncing, setIsResyncing] = useState(false); const [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null); const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null); const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = useRef<boolean>(false);
const resyncMutation = api.scripts.resyncScripts.useMutation({ const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -18,38 +16,24 @@ export function ResyncButton() {
setLastSync(new Date()); setLastSync(new Date());
if (data.success) { if (data.success) {
setSyncMessage(data.message ?? 'Scripts synced successfully'); setSyncMessage(data.message ?? 'Scripts synced successfully');
// Only reload if this was triggered by user action // Reload the page after successful sync
if (isUserInitiatedRef.current && !hasReloadedRef.current) { setTimeout(() => {
hasReloadedRef.current = true; window.location.reload();
setTimeout(() => { }, 2000); // Wait 2 seconds to show the success message
window.location.reload();
}, 2000); // Wait 2 seconds to show the success message
} else {
// Reset flag if reload didn't happen
isUserInitiatedRef.current = false;
}
} else { } else {
setSyncMessage(data.error ?? 'Failed to sync scripts'); setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors // Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000); setTimeout(() => setSyncMessage(null), 3000);
isUserInitiatedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setIsResyncing(false); setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`); setSyncMessage(`Error: ${error.message}`);
setTimeout(() => setSyncMessage(null), 3000); setTimeout(() => setSyncMessage(null), 3000);
isUserInitiatedRef.current = false;
}, },
}); });
const handleResync = async () => { const handleResync = async () => {
// Prevent multiple simultaneous sync operations
if (isResyncing) return;
// Mark as user-initiated before starting
isUserInitiatedRef.current = true;
hasReloadedRef.current = false;
setIsResyncing(true); setIsResyncing(true);
setSyncMessage(null); setSyncMessage(null);
resyncMutation.mutate(); resyncMutation.mutate();

View File

@@ -61,11 +61,7 @@ export function ScriptDetailModal({
isLoading: comparisonLoading, isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery( } = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" }, { slug: script?.slug ?? "" },
{ { enabled: !!script && isOpen },
enabled: !!script && isOpen,
refetchOnMount: true,
staleTime: 0,
},
); );
// Load script mutation // Load script mutation
@@ -551,60 +547,19 @@ export function ScriptDetailModal({
</div> </div>
{scriptFilesData?.success && {scriptFilesData?.success &&
(scriptFilesData.ctExists || (scriptFilesData.ctExists ||
scriptFilesData.installExists) && ( scriptFilesData.installExists) &&
comparisonData?.success &&
!comparisonLoading && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{comparisonData?.success ? ( <div
<> className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
<div ></div>
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`} <span>
></div> Status:{" "}
<span> {comparisonData.hasDifferences
Status:{" "} ? "Update available"
{comparisonData.hasDifferences : "Up to date"}
? "Update available" </span>
: "Up to date"}
</span>
</>
) : comparisonLoading ? (
<>
<div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div>
<span>Checking for updates...</span>
</>
) : comparisonData?.error ? (
<>
<div className="h-2 w-2 rounded-full bg-destructive"></div>
<span className="text-destructive">Error: {comparisonData.error}</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-muted"></div>
<span>Status: Unknown</span>
</>
)}
<button
onClick={() => void refetchComparison()}
disabled={comparisonLoading}
className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
title="Refresh comparison"
>
{comparisonLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
) : (
<svg
className="h-4 w-4 text-muted-foreground hover:text-foreground"
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>
)} )}
</div> </div>
@@ -882,7 +837,7 @@ export function ScriptDetailModal({
<TextViewer <TextViewer
scriptName={ scriptName={
script.install_methods script.install_methods
?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/"))) ?.find((method) => method.script?.startsWith("ct/"))
?.script?.split("/") ?.script?.split("/")
.pop() ?? `${script.slug}.sh` .pop() ?? `${script.slug}.sh`
} }

View File

@@ -14,9 +14,9 @@ interface TextViewerProps {
} }
interface ScriptContent { interface ScriptContent {
mainScript?: string; ctScript?: string;
installScript?: string; installScript?: string;
alpineMainScript?: string; alpineCtScript?: string;
alpineInstallScript?: string; alpineInstallScript?: string;
} }
@@ -24,27 +24,18 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
const [scriptContent, setScriptContent] = useState<ScriptContent>({}); const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main'); const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default'); const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
// Extract slug from script name (remove .sh extension) // Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, ''); const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Get default and alpine install methods
const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine');
// Check if alpine variant exists // Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod; const hasAlpineVariant = script?.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
// Get script paths from install_methods // Get script names for default and alpine versions
const defaultScriptPath = defaultMethod?.script;
const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/');
// Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, ''); const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`; const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
@@ -53,72 +44,116 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
setError(null); setError(null);
try { try {
// Build fetch requests based on actual script paths from install_methods // Build fetch requests for default version
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
// Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
);
requestTypes.push('default-main');
}
// Default install script (only for ct/ scripts) // Default CT script
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) { requests.push(
requests.push( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) );
);
requestTypes.push('default-install');
}
// Alpine main script // Tools, VM, VW scripts
if (hasAlpineVariant && alpineScriptPath) { requests.push(
requests.push( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`) );
); requests.push(
requestTypes.push('alpine-main'); fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
} );
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
);
// Alpine install script (only for ct/ scripts) // Default install script
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) { requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
);
// Alpine versions if variant exists
if (hasAlpineVariant) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
);
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`) fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
); );
requestTypes.push('alpine-install');
} }
const responses = await Promise.allSettled(requests); const responses = await Promise.allSettled(requests);
const content: ScriptContent = {};
// Process responses based on their types const content: ScriptContent = {};
await Promise.all(responses.map(async (response, index) => { let responseIndex = 0;
if (response.status === 'fulfilled' && response.value.ok) {
try { // Default CT script
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; const ctResponse = responses[responseIndex];
const type = requestTypes[index]; if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
if (data.result?.data?.json?.success && data.result.data.json.content) { const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
switch (type) { if (ctData.result?.data?.json?.success) {
case 'default-main': content.ctScript = ctData.result.data.json.content;
content.mainScript = data.result.data.json.content; }
break; }
case 'default-install':
content.installScript = data.result.data.json.content; responseIndex++;
break; // Tools script
case 'alpine-main': const toolsResponse = responses[responseIndex];
content.alpineMainScript = data.result.data.json.content; if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
break; const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
case 'alpine-install': if (toolsData.result?.data?.json?.success) {
content.alpineInstallScript = data.result.data.json.content; content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
break; }
} }
}
} catch { responseIndex++;
// Ignore errors // VM script
const vmResponse = responses[responseIndex];
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
}
}
responseIndex++;
// VW script
const vwResponse = responses[responseIndex];
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
}
}
responseIndex++;
// Default install script
const installResponse = responses[responseIndex];
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) {
content.installScript = installData.result.data.json.content;
}
}
responseIndex++;
// Alpine CT script
if (hasAlpineVariant) {
const alpineCtResponse = responses[responseIndex];
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineCtData.result?.data?.json?.success) {
content.alpineCtScript = alpineCtData.result.data.json.content;
} }
} }
})); responseIndex++;
}
// Alpine install script
if (hasAlpineVariant) {
const alpineInstallResponse = responses[responseIndex];
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineInstallData.result?.data?.json?.success) {
content.alpineInstallScript = alpineInstallData.result.data.json.content;
}
}
}
setScriptContent(content); setScriptContent(content);
} catch (err) { } catch (err) {
@@ -126,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]); }, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
useEffect(() => { useEffect(() => {
if (isOpen && scriptName) { if (isOpen && scriptName) {
@@ -172,25 +207,23 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</Button> </Button>
</div> </div>
)} )}
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) || {((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && ( (selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === 'main' ? 'outline' : 'ghost'} variant={activeTab === 'ct' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('main')} onClick={() => setActiveTab('ct')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Script CT Script
</Button>
<Button
variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')}
className="px-3 py-1 text-sm"
>
Install Script
</Button> </Button>
{hasInstallScript && (
<Button
variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')}
className="px-3 py-1 text-sm"
>
Install Script
</Button>
)}
</div> </div>
)} )}
</div> </div>
@@ -216,8 +249,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === 'main' && ( {activeTab === 'ct' && (
selectedVersion === 'default' && scriptContent.mainScript ? ( selectedVersion === 'default' && scriptContent.ctScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
@@ -231,9 +264,9 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.mainScript} {scriptContent.ctScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? ( ) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
@@ -247,12 +280,12 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.alpineMainScript} {scriptContent.alpineCtScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground"> <div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'} {selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
</div> </div>
</div> </div>
) )

View File

@@ -1,175 +0,0 @@
'use client';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface UpdateConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
releaseInfo: {
tagName: string;
name: string;
publishedAt: string;
htmlUrl: string;
body?: string;
} | null;
currentVersion: string;
latestVersion: string;
}
export function UpdateConfirmationModal({
isOpen,
onClose,
onConfirm,
releaseInfo,
currentVersion,
latestVersion
}: UpdateConfirmationModalProps) {
useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose });
if (!isOpen || !releaseInfo) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-warning" />
<div>
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
<p className="text-sm text-muted-foreground mt-1">
Review the changelog before proceeding with the update
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Version Info */}
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-card-foreground">
{releaseInfo.name || releaseInfo.tagName}
</h3>
<Badge variant="default" className="text-xs">
Latest
</Badge>
</div>
<Button
variant="ghost"
size="sm"
asChild
className="h-8 w-8 p-0"
>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<div className="flex items-center gap-1">
<Tag className="h-4 w-4" />
<span>{releaseInfo.tagName}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</div>
<div className="text-sm text-muted-foreground">
<span>Updating from </span>
<span className="font-medium text-card-foreground">v{currentVersion}</span>
<span> to </span>
<span className="font-medium text-card-foreground">v{latestVersion}</span>
</div>
</div>
{/* Changelog */}
{releaseInfo.body ? (
<div className="border rounded-lg p-6 border-border bg-card">
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="text-card-foreground">{children}</li>,
a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
}}
>
{releaseInfo.body}
</ReactMarkdown>
</div>
</div>
) : (
<div className="border rounded-lg p-6 border-border bg-card">
<p className="text-muted-foreground">No changelog available for this release.</p>
</div>
)}
{/* Warning */}
<div className="bg-warning/10 border border-warning/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
<div className="text-sm text-card-foreground">
<p className="font-medium mb-1">Important:</p>
<p className="text-muted-foreground">
Please review the changelog above for any breaking changes or important updates before proceeding.
The server will restart automatically after the update completes.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={onConfirm} variant="destructive" className="gap-2">
<span>Proceed with Update</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,10 +4,9 @@ import { api } from "~/trpc/react";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon"; import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react"; import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef } from "react";
interface VersionDisplayProps { interface VersionDisplayProps {
onOpenReleaseNotes?: () => void; onOpenReleaseNotes?: () => void;
@@ -86,12 +85,8 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const [updateLogs, setUpdateLogs] = useState<string[]>([]); const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false); const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null); const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
const lastLogTimeRef = useRef<number>(Date.now()); const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null); const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUpdatingRef = useRef<boolean>(false);
const isNetworkErrorRef = useRef<boolean>(false);
const executeUpdate = api.version.executeUpdate.useMutation({ const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
@@ -103,13 +98,11 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
setUpdateLogs(['Update started...']); setUpdateLogs(['Update started...']);
} else { } else {
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on failure
} }
}, },
onError: (error) => { onError: (error) => {
setUpdateResult({ success: false, message: error.message }); setUpdateResult({ success: false, message: error.message });
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on error
} }
}); });
@@ -120,49 +113,63 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
}); });
// Attempt to reconnect and reload page when server is back // Update logs when data changes
// Memoized with useCallback to prevent recreation on every render useEffect(() => {
// Only depends on refs to avoid stale closures if (updateLogsData?.success && updateLogsData.logs) {
const startReconnectAttempts = useCallback(() => { lastLogTimeRef.current = Date.now();
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts setUpdateLogs(updateLogsData.logs);
// Only start if we're actually updating and haven't already started
// Double-check isUpdating state to prevent false triggers from stale data if (updateLogsData.isComplete) {
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) { setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
return; setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
} }
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Attempt to reconnect and reload page when server is back
const startReconnectAttempts = () => {
if (reconnectIntervalRef.current) return;
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']); setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => { reconnectIntervalRef.current = setInterval(() => {
void (async () => { void (async () => {
// Guard: Only proceed if we're still updating and in network error state
// Check refs directly to avoid stale closures
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) {
// Clear interval if we're no longer updating
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
return;
}
try { try {
// Try to fetch the root path to check if server is back // Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' }); const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) { if (response.ok || response.status === 200) {
// Double-check we're still updating before reloading
if (!isUpdatingRef.current || hasReloadedRef.current) {
return;
}
// Mark that we're about to reload to prevent multiple reloads
hasReloadedRef.current = true;
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']); setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload // Clear interval and reload
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
} }
setTimeout(() => { setTimeout(() => {
@@ -174,101 +181,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
} }
})(); })();
}, 2000); }, 2000);
}, []); // Empty deps - only uses refs which are stable };
// Update logs when data changes // Cleanup reconnect interval on unmount
useEffect(() => { useEffect(() => {
// CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating) {
return;
}
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
// CRITICAL: Only process isComplete if we're actually updating
// Double-check isUpdating state to prevent false triggers
if (updateLogsData.isComplete && isUpdating) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData, startReconnectAttempts, isUpdating]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
// Early return: only run if we're actually updating
if (!shouldSubscribe || !isUpdating) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
// Check refs first to ensure we're still updating
if (!isUpdatingRef.current || hasReloadedRef.current) {
return;
}
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
// Additional guard: check refs again before triggering
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
// Keep refs in sync with state
useEffect(() => {
isUpdatingRef.current = isUpdating;
}, [isUpdating]);
useEffect(() => {
isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]);
// Clear reconnect interval when update completes or component unmounts
useEffect(() => {
// If we're no longer updating, clear the reconnect interval and reset subscription
if (!isUpdating) {
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
// Reset subscription to prevent stale polling
setShouldSubscribe(false);
}
return () => { return () => {
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
} }
}; };
}, [isUpdating]); }, []);
const handleUpdate = () => { const handleUpdate = () => {
// Show confirmation modal instead of starting update directly
setShowUpdateConfirmation(true);
};
const handleConfirmUpdate = () => {
// Close the confirmation modal
setShowUpdateConfirmation(false);
// Start the actual update process
setIsUpdating(true); setIsUpdating(true);
setUpdateResult(null); setUpdateResult(null);
setIsNetworkError(false); setIsNetworkError(false);
@@ -276,12 +200,6 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
setShouldSubscribe(false); setShouldSubscribe(false);
setUpdateStartTime(Date.now()); setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now(); lastLogTimeRef.current = Date.now();
hasReloadedRef.current = false; // Reset reload flag when starting new update
// Clear any existing reconnect interval
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
executeUpdate.mutate(); executeUpdate.mutate();
}; };
@@ -315,18 +233,6 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
{/* Loading overlay */} {/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />} {isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
{/* Update Confirmation Modal */}
{versionStatus?.releaseInfo && (
<UpdateConfirmationModal
isOpen={showUpdateConfirmation}
onClose={() => setShowUpdateConfirmation(false)}
onConfirm={handleConfirmUpdate}
releaseInfo={versionStatus.releaseInfo}
currentVersion={versionStatus.currentVersion}
latestVersion={versionStatus.latestVersion}
/>
)}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2"> <div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge <Badge
variant={isUpToDate ? "default" : "secondary"} variant={isUpToDate ? "default" : "secondary"}

View File

@@ -95,13 +95,6 @@ export default function Home() {
downloaded: (() => { downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string => (s ?? '')
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
// First deduplicate GitHub scripts using Map by slug // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
@@ -117,36 +110,13 @@ export default function Home() {
const localScripts = localScriptsData.scripts ?? []; const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions // Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter(script => { return deduplicatedGithubScripts.filter(script => {
if (!script?.name) return false; if (!script?.name) return false;
// Check if there's a corresponding local script
return localScripts.some(local => { return localScripts.some(local => {
if (!local?.name) return false; if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
// Primary: Exact slug-to-slug matching (most reliable) return localName.toLowerCase() === script.name.toLowerCase() ||
if (local.slug && script.slug) { localName.toLowerCase() === (script.slug ?? '').toLowerCase();
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (normalizeId(local.slug) === normalizeId(script.slug)) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching
if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) {
return true;
}
return false;
}); });
}).length; }).length;
})(), })(),

View File

@@ -111,8 +111,7 @@ export const versionRouter = createTRPCRouter({
tagName: release.tag_name, tagName: release.tag_name,
name: release.name, name: release.name,
publishedAt: release.published_at, publishedAt: release.published_at,
htmlUrl: release.html_url, htmlUrl: release.html_url
body: release.body
} }
}; };
} catch (error) { } catch (error) {

View File

@@ -519,16 +519,13 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`) this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${scriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} }
@@ -544,16 +541,13 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, installScriptPath, installScriptPath) this.compareSingleFile(script, installScriptPath, installScriptPath)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} }
@@ -573,16 +567,13 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath) this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch((error) => { .catch(() => {
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error); // Don't add to differences if there's an error reading files
}) })
); );
} catch { } catch {
@@ -593,11 +584,10 @@ export class ScriptDownloaderService {
// Wait for all comparisons to complete // Wait for all comparisons to complete
await Promise.all(comparisonPromises); await Promise.all(comparisonPromises);
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences }; return { hasDifferences, differences };
} catch (error) { } catch (error) {
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error); console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [], error: error.message }; return { hasDifferences: false, differences: [] };
} }
} }
@@ -607,21 +597,16 @@ export class ScriptDownloaderService {
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main'; const branch = process.env.REPO_BRANCH || 'main';
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
// Read local content // Read local content
const localContent = await readFile(localPath, 'utf-8'); const localContent = await readFile(localPath, 'utf-8');
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
// Download remote content from the script's repository // Download remote content from the script's repository
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch); const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
// Apply modification only for CT scripts, not for other script types // Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent; let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) { if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent); modifiedRemoteContent = this.modifyScriptContent(remoteContent);
console.log(`[Comparison] Applied CT script modifications`);
} else { } else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
} }
@@ -629,17 +614,10 @@ export class ScriptDownloaderService {
// Compare content // Compare content
const hasDifferences = localContent !== modifiedRemoteContent; const hasDifferences = localContent !== modifiedRemoteContent;
if (hasDifferences) {
console.log(`[Comparison] Differences found in ${filePath}`);
} else {
console.log(`[Comparison] No differences in ${filePath}`);
}
return { hasDifferences, filePath }; return { hasDifferences, filePath };
} catch (error) { } catch (error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message); console.error(`Error comparing file ${filePath}:`, error);
// Return error information so it can be handled upstream return { hasDifferences: false, filePath };
return { hasDifferences: false, filePath, error: error.message };
} }
} }

View File

@@ -22,7 +22,7 @@
"noEmit": true, "noEmit": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "react-jsx", "jsx": "preserve",
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"