Improve Laravel deploy support and add .env import to deploy wizard.

Set Laravel DB env vars in Helm and Kubernetes, fix default port to 80 with a storage-aware entrypoint, and let users upload a .env file during deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 18:48:03 +03:30
parent b24d1505b6
commit cca6cdc1a5
5 changed files with 172 additions and 3 deletions
+67 -1
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
@@ -58,6 +59,8 @@ export default function DeployPage() {
});
const [envKey, setEnvKey] = useState('');
const [envVal, setEnvVal] = useState('');
const [envFile, setEnvFile] = useState<File | null>(null);
const envFileInputRef = useRef<HTMLInputElement>(null);
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
const [zipFile, setZipFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
@@ -355,6 +358,29 @@ export default function DeployPage() {
setForm({ ...form, envVars: updated });
};
const handleEnvFileSelect = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
const content = typeof reader.result === 'string' ? reader.result : '';
const { vars, errors } = parseDotenv(content);
const count = Object.keys(vars).length;
if (count === 0) {
toast.error(errors.length > 0 ? errors[0] : 'No valid environment variables found in file');
return;
}
setForm((prev) => ({ ...prev, envVars: { ...prev.envVars, ...vars } }));
setEnvFile(file);
toast.success(`${count} environment variable${count === 1 ? '' : 's'} imported from ${file.name}`);
if (errors.length > 0) {
toast.warn(`${errors.length} line${errors.length === 1 ? '' : 's'} skipped`);
}
};
reader.onerror = () => toast.error('Failed to read environment file');
reader.readAsText(file);
};
const handleSubmit = () => {
const payload = { ...form };
// Format dbStorageSize with Gi suffix
@@ -570,7 +596,7 @@ export default function DeployPage() {
onClick={() => {
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; }
else if (opt.value === 'laravel') { updates.port = 80; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
@@ -2015,6 +2041,46 @@ export default function DeployPage() {
/>
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
</div>
<div className="mb-3">
{envFile ? (
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center space-x-2 min-w-0">
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
<span className="text-sm text-green-800 truncate">{envFile.name}</span>
</div>
<button
type="button"
onClick={() => {
setEnvFile(null);
if (envFileInputRef.current) envFileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium shrink-0 ml-2"
>
Clear
</button>
</div>
) : (
<button
type="button"
onClick={() => envFileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 p-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-600 hover:border-primary-400 hover:text-primary-600 transition-colors"
>
<Upload className="w-4 h-4" />
Upload .env file
</button>
)}
<input
ref={envFileInputRef}
type="file"
accept=".env,.env.local,.env.production,.env.example"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleEnvFileSelect(file);
if (envFileInputRef.current) envFileInputRef.current.value = '';
}}
/>
</div>
{Object.entries(form.envVars || {}).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
<span className="text-sm font-mono">
+49
View File
@@ -0,0 +1,49 @@
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
function unquote(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
const inner = trimmed.slice(1, -1);
if (trimmed.startsWith('"')) {
return inner.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
return inner;
}
return trimmed;
}
export function parseDotenv(content: string): {
vars: Record<string, string>;
errors: string[];
} {
const vars: Record<string, string> = {};
const errors: string[] = [];
const lines = content.split(/\r?\n/);
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed;
const eqIndex = withoutExport.indexOf('=');
if (eqIndex === -1) {
errors.push(`Line ${index + 1}: missing '='`);
return;
}
const key = withoutExport.slice(0, eqIndex).trim();
const rawValue = withoutExport.slice(eqIndex + 1);
if (!KEY_PATTERN.test(key)) {
errors.push(`Line ${index + 1}: invalid key "${key}"`);
return;
}
vars[key] = unquote(rawValue);
});
return { vars, errors };
}