Skip to content

Commit 6ad18e1

Browse files
feat: Add alpine variant support for LXC scripts
- Download alpine install scripts (alpine-{slug}-install.sh) when alpine variant exists - Add ScriptVersionModal component for version selection (default/alpine) - Update ScriptDetailModal to show version selection before server selection - Update script execution to use selected version type - Support downloading both default and alpine variants of scripts
1 parent 73776ec commit 6ad18e1

3 files changed

Lines changed: 326 additions & 1 deletion

File tree

src/app/_components/ScriptDetailModal.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer";
88
import { TextViewer } from "./TextViewer";
99
import { ExecutionModeModal } from "./ExecutionModeModal";
1010
import { ConfirmationModal } from "./ConfirmationModal";
11+
import { ScriptVersionModal } from "./ScriptVersionModal";
1112
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
1213
import { Button } from "./ui/button";
1314
import { useRegisterModal } from './modal/ModalStackProvider';
@@ -38,6 +39,8 @@ export function ScriptDetailModal({
3839
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
3940
const [textViewerOpen, setTextViewerOpen] = useState(false);
4041
const [executionModeOpen, setExecutionModeOpen] = useState(false);
42+
const [versionModalOpen, setVersionModalOpen] = useState(false);
43+
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
4144
const [isDeleting, setIsDeleting] = useState(false);
4245
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
4346

@@ -133,16 +136,43 @@ export function ScriptDetailModal({
133136

134137
const handleInstallScript = () => {
135138
if (!script) return;
139+
140+
// Check if script has multiple variants (default and alpine)
141+
const installMethods = script.install_methods || [];
142+
const hasMultipleVariants = installMethods.filter(method =>
143+
method.type === 'default' || method.type === 'alpine'
144+
).length > 1;
145+
146+
if (hasMultipleVariants) {
147+
// Show version selection modal first
148+
setVersionModalOpen(true);
149+
} else {
150+
// Only one variant, proceed directly to execution mode
151+
// Use the first available method or default to 'default' type
152+
const defaultMethod = installMethods.find(method => method.type === 'default');
153+
const firstMethod = installMethods[0];
154+
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
155+
setExecutionModeOpen(true);
156+
}
157+
};
158+
159+
const handleVersionSelect = (versionType: string) => {
160+
setSelectedVersionType(versionType);
161+
setVersionModalOpen(false);
136162
setExecutionModeOpen(true);
137163
};
138164

139165
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
140166
if (!script || !onInstallScript) return;
141167

142-
// Find the script path (CT or tools)
168+
// Find the script path based on selected version type
169+
const versionType = selectedVersionType || 'default';
143170
const scriptMethod = script.install_methods?.find(
171+
(method) => method.type === versionType && method.script,
172+
) || script.install_methods?.find(
144173
(method) => method.script,
145174
);
175+
146176
if (scriptMethod?.script) {
147177
const scriptPath = `scripts/${scriptMethod.script}`;
148178
const scriptName = script.name;
@@ -804,6 +834,16 @@ export function ScriptDetailModal({
804834
/>
805835
)}
806836

837+
{/* Version Selection Modal */}
838+
{script && (
839+
<ScriptVersionModal
840+
script={script}
841+
isOpen={versionModalOpen}
842+
onClose={() => setVersionModalOpen(false)}
843+
onSelectVersion={handleVersionSelect}
844+
/>
845+
)}
846+
807847
{/* Execution Mode Modal */}
808848
{script && (
809849
<ExecutionModeModal
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import type { Script, ScriptInstallMethod } from '../../types/script';
5+
import { Button } from './ui/button';
6+
import { useRegisterModal } from './modal/ModalStackProvider';
7+
8+
interface ScriptVersionModalProps {
9+
isOpen: boolean;
10+
onClose: () => void;
11+
onSelectVersion: (versionType: string) => void;
12+
script: Script | null;
13+
}
14+
15+
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
16+
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
17+
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
18+
19+
if (!isOpen || !script) return null;
20+
21+
// Get available install methods
22+
const installMethods = script.install_methods || [];
23+
const defaultMethod = installMethods.find(method => method.type === 'default');
24+
const alpineMethod = installMethods.find(method => method.type === 'alpine');
25+
26+
const handleConfirm = () => {
27+
if (selectedVersion) {
28+
onSelectVersion(selectedVersion);
29+
onClose();
30+
}
31+
};
32+
33+
const handleVersionSelect = (versionType: string) => {
34+
setSelectedVersion(versionType);
35+
};
36+
37+
return (
38+
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
39+
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
40+
{/* Header */}
41+
<div className="flex items-center justify-between p-6 border-b border-border">
42+
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
43+
<Button
44+
onClick={onClose}
45+
variant="ghost"
46+
size="icon"
47+
className="text-muted-foreground hover:text-foreground"
48+
>
49+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
51+
</svg>
52+
</Button>
53+
</div>
54+
55+
{/* Content */}
56+
<div className="p-6">
57+
<div className="mb-6">
58+
<h3 className="text-lg font-medium text-foreground mb-2">
59+
Choose a version for &quot;{script.name}&quot;
60+
</h3>
61+
<p className="text-sm text-muted-foreground">
62+
Select the version you want to install. Each version has different resource requirements.
63+
</p>
64+
</div>
65+
66+
<div className="space-y-4">
67+
{/* Default Version */}
68+
{defaultMethod && (
69+
<div
70+
onClick={() => handleVersionSelect('default')}
71+
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
72+
selectedVersion === 'default'
73+
? 'border-primary bg-primary/10'
74+
: 'border-border bg-card hover:border-primary/50'
75+
}`}
76+
>
77+
<div className="flex items-start justify-between">
78+
<div className="flex-1">
79+
<div className="flex items-center space-x-3 mb-3">
80+
<div
81+
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
82+
selectedVersion === 'default'
83+
? 'border-primary bg-primary'
84+
: 'border-border'
85+
}`}
86+
>
87+
{selectedVersion === 'default' && (
88+
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
89+
<path
90+
fillRule="evenodd"
91+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
92+
clipRule="evenodd"
93+
/>
94+
</svg>
95+
)}
96+
</div>
97+
<h4 className="text-base font-semibold text-foreground capitalize">
98+
{defaultMethod.type}
99+
</h4>
100+
</div>
101+
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
102+
<div>
103+
<span className="text-muted-foreground">CPU: </span>
104+
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
105+
</div>
106+
<div>
107+
<span className="text-muted-foreground">RAM: </span>
108+
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
109+
</div>
110+
<div>
111+
<span className="text-muted-foreground">HDD: </span>
112+
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
113+
</div>
114+
<div>
115+
<span className="text-muted-foreground">OS: </span>
116+
<span className="text-foreground font-medium">
117+
{defaultMethod.resources.os} {defaultMethod.resources.version}
118+
</span>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
</div>
124+
)}
125+
126+
{/* Alpine Version */}
127+
{alpineMethod && (
128+
<div
129+
onClick={() => handleVersionSelect('alpine')}
130+
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
131+
selectedVersion === 'alpine'
132+
? 'border-primary bg-primary/10'
133+
: 'border-border bg-card hover:border-primary/50'
134+
}`}
135+
>
136+
<div className="flex items-start justify-between">
137+
<div className="flex-1">
138+
<div className="flex items-center space-x-3 mb-3">
139+
<div
140+
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
141+
selectedVersion === 'alpine'
142+
? 'border-primary bg-primary'
143+
: 'border-border'
144+
}`}
145+
>
146+
{selectedVersion === 'alpine' && (
147+
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
148+
<path
149+
fillRule="evenodd"
150+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
151+
clipRule="evenodd"
152+
/>
153+
</svg>
154+
)}
155+
</div>
156+
<h4 className="text-base font-semibold text-foreground capitalize">
157+
{alpineMethod.type}
158+
</h4>
159+
</div>
160+
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
161+
<div>
162+
<span className="text-muted-foreground">CPU: </span>
163+
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
164+
</div>
165+
<div>
166+
<span className="text-muted-foreground">RAM: </span>
167+
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
168+
</div>
169+
<div>
170+
<span className="text-muted-foreground">HDD: </span>
171+
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
172+
</div>
173+
<div>
174+
<span className="text-muted-foreground">OS: </span>
175+
<span className="text-foreground font-medium">
176+
{alpineMethod.resources.os} {alpineMethod.resources.version}
177+
</span>
178+
</div>
179+
</div>
180+
</div>
181+
</div>
182+
</div>
183+
)}
184+
</div>
185+
186+
{/* Action Buttons */}
187+
<div className="flex justify-end space-x-3 mt-6">
188+
<Button
189+
onClick={onClose}
190+
variant="outline"
191+
size="default"
192+
>
193+
Cancel
194+
</Button>
195+
<Button
196+
onClick={handleConfirm}
197+
disabled={!selectedVersion}
198+
variant="default"
199+
size="default"
200+
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
201+
>
202+
Continue
203+
</Button>
204+
</div>
205+
</div>
206+
</div>
207+
</div>
208+
);
209+
}
210+

src/server/services/scriptDownloader.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,35 @@ export class ScriptDownloaderService {
145145
}
146146
}
147147

148+
// Download alpine install script if alpine variant exists (only for CT scripts)
149+
const hasAlpineCtVariant = script.install_methods?.some(
150+
method => method.type === 'alpine' && method.script?.startsWith('ct/')
151+
);
152+
console.log(`[${script.slug}] Checking for alpine variant:`, {
153+
hasAlpineCtVariant,
154+
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
155+
});
156+
157+
if (hasAlpineCtVariant) {
158+
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
159+
try {
160+
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
161+
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
162+
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
163+
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
164+
files.push(`install/${alpineInstallScriptName}`);
165+
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
166+
} catch (error) {
167+
// Alpine install script might not exist, that's okay
168+
console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error);
169+
if (error instanceof Error) {
170+
console.error(`[${script.slug}] Error details:`, error.message, error.stack);
171+
}
172+
}
173+
} else {
174+
console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`);
175+
}
176+
148177
return {
149178
success: true,
150179
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
@@ -286,6 +315,23 @@ export class ScriptDownloaderService {
286315
}
287316
}
288317

318+
// Check alpine install script if alpine variant exists (only for CT scripts)
319+
const hasAlpineCtVariant = script.install_methods?.some(
320+
method => method.type === 'alpine' && method.script?.startsWith('ct/')
321+
);
322+
if (hasAlpineCtVariant) {
323+
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
324+
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
325+
326+
try {
327+
await access(alpineInstallPath);
328+
files.push(`install/${alpineInstallScriptName}`);
329+
installExists = true; // Mark as exists if alpine install script exists
330+
} catch {
331+
// File doesn't exist
332+
}
333+
}
334+
289335
return { ctExists, installExists, files };
290336
} catch (error) {
291337
console.error('Error checking script existence:', error);
@@ -427,6 +473,35 @@ export class ScriptDownloaderService {
427473
);
428474
}
429475

476+
// Compare alpine install script if alpine variant exists (only for CT scripts)
477+
const hasAlpineCtVariant = script.install_methods?.some(
478+
method => method.type === 'alpine' && method.script?.startsWith('ct/')
479+
);
480+
if (hasAlpineCtVariant) {
481+
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
482+
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
483+
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
484+
485+
// Check if alpine install script exists locally
486+
try {
487+
await access(localAlpineInstallPath);
488+
comparisonPromises.push(
489+
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
490+
.then(result => {
491+
if (result.hasDifferences) {
492+
hasDifferences = true;
493+
differences.push(result.filePath);
494+
}
495+
})
496+
.catch(() => {
497+
// Don't add to differences if there's an error reading files
498+
})
499+
);
500+
} catch {
501+
// Alpine install script doesn't exist locally, skip comparison
502+
}
503+
}
504+
430505
// Wait for all comparisons to complete
431506
await Promise.all(comparisonPromises);
432507

0 commit comments

Comments
 (0)