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:
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user