diff --git a/apps/api/src/orchestrator/__tests__/railpack-config.test.ts b/apps/api/src/orchestrator/__tests__/railpack-config.test.ts new file mode 100644 index 0000000..274eddb --- /dev/null +++ b/apps/api/src/orchestrator/__tests__/railpack-config.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect } from "bun:test"; +import { generateDynamicRailpackJson } from "../railpack-config-utils"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const setupWorkspace = () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-railpack-config-")); + mkdirSync(join(dir, "client"), { recursive: true }); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +}; + +describe("generateDynamicRailpackJson static subdirectory detection", () => { + test("configures dequel-serve.js when index.html exists without package.json", async () => { + const { dir, cleanup } = setupWorkspace(); + try { + writeFileSync(join(dir, "client", "index.html"), ""); + const logs: string[] = []; + await generateDynamicRailpackJson( + dir, + "client", + "static", + null, + null, + async (line) => logs.push(line), + ); + const serveScript = join(dir, "dequel-serve.js"); + const railpackJson = join(dir, "railpack.json"); + expect(existsSync(serveScript)).toBe(true); + expect(existsSync(railpackJson)).toBe(true); + const parsedConfig = JSON.parse(readFileSync(railpackJson, "utf8")); + expect(parsedConfig.deploy?.startCommand).toBe("bun dequel-serve.js"); + } finally { + cleanup(); + } + }); + + test("does not overwrite a user-provided Staticfile", async () => { + const { dir, cleanup } = setupWorkspace(); + try { + writeFileSync(join(dir, "client", "index.html"), ""); + writeFileSync(join(dir, "Staticfile"), "root: public\n"); + await generateDynamicRailpackJson( + dir, + "client", + "railpack", + null, + null, + async () => {}, + ); + expect(readFileSync(join(dir, "Staticfile"), "utf8")).toBe("root: public\n"); + } finally { + cleanup(); + } + }); + + test("does not emit Staticfile when package.json exists in source dir", async () => { + const { dir, cleanup } = setupWorkspace(); + try { + writeFileSync(join(dir, "client", "index.html"), ""); + writeFileSync(join(dir, "client", "package.json"), JSON.stringify({ name: "app", scripts: { start: "node dist/index.js" } })); + const logs: string[] = []; + await generateDynamicRailpackJson( + dir, + "client", + "railpack", + null, + null, + async (line) => logs.push(line), + ); + expect(existsSync(join(dir, "Staticfile"))).toBe(false); + expect(logs.some((l) => l.includes("Detected static site"))).toBe(false); + } finally { + cleanup(); + } + }); +}); diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index 03e8296..6b02003 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -9,10 +9,9 @@ import { listAllDatabases, updateDeploymentStatus, updateDeploymentCommitSha, + listVolumes, } from "../db/repo"; import { listEnvironmentVariablesForDeploy } from "../db/repo"; -import { listVolumes } from "../db/repo"; -import { filterBuildEnvVars } from "../utils/env-filter"; import { logBus } from "./log-bus"; import { DeploymentQueue } from "./queue"; import { buildWithRailpack, CancelledError } from "./railpack"; @@ -407,7 +406,7 @@ export class PipelineOrchestrator { const cacheKey = deployment.projectId || deploymentId; - const envVars = deployment.projectId ? await listEnvironmentVariablesForDeploy(deployment.projectId, "production") : []; + const envVars = deployment.projectId ? await listEnvironmentVariablesForDeploy(deployment.projectId, deployment.environment ?? "production") : []; if (project?.buildType === "compose") { const envMap: Record = {}; for (const v of envVars) envMap[v.key] = v.value; @@ -444,7 +443,7 @@ export class PipelineOrchestrator { installCommand: project?.installCommand, outputDir: project?.outputDir, startCommand: project?.startCommand, - environmentVariables: filterBuildEnvVars(envVars), + environmentVariables: envVars, signal: controller.signal, clearCache: deployment.clearCache }, diff --git a/apps/api/src/orchestrator/railpack-config-utils.ts b/apps/api/src/orchestrator/railpack-config-utils.ts index 5e48932..f77efe2 100644 --- a/apps/api/src/orchestrator/railpack-config-utils.ts +++ b/apps/api/src/orchestrator/railpack-config-utils.ts @@ -140,39 +140,21 @@ export const generateDynamicRailpackJson = async ( let configured = false; - // 1. Check if user provided manual build / start / install command overrides - if (buildCommandOverride || startCommandOverride || installCommandOverride) { - await onLog("Applying custom build/start/install settings"); - if (installCommandOverride) { - config.steps.install = { - commands: [ - cleanSourceDir - ? `cd ${cleanSourceDir} && ${installCommandOverride}` - : installCommandOverride, - ], - }; - } - if (buildCommandOverride) { - config.steps.build = { - commands: [ - cleanSourceDir - ? `cd ${cleanSourceDir} && ${buildCommandOverride}` - : buildCommandOverride, - ], - }; + if (cleanSourceDir && !(await Bun.file(join(workspace, "package.json")).exists())) { + const sourcePkgExists = await Bun.file(join(workspace, cleanSourceDir, "package.json")).exists(); + if (sourcePkgExists) { + await Bun.write(join(workspace, "package.json"), JSON.stringify({ + name: "dequel-monorepo-root", + private: true, + workspaces: [cleanSourceDir] + }, null, 2)); } - if (startCommandOverride) { - config.deploy.startCommand = cleanSourceDir - ? `cd ${cleanSourceDir} && ${startCommandOverride}` - : startCommandOverride; - } - configured = true; } const hasPackageJson = await Bun.file( join(buildDir, "package.json"), ).exists(); - if (hasPackageJson && !configured) { + if (hasPackageJson) { try { const packageJson = await Bun.file( join(buildDir, "package.json"), @@ -238,7 +220,7 @@ export const generateDynamicRailpackJson = async ( if (scripts.build) { const buildCmds = [ cleanSourceDir - ? `cd ${cleanSourceDir} && ${pm} run build` + ? `[ -d "${cleanSourceDir}" ] && cd -- "${cleanSourceDir}" && ${pm} run build` : `${pm} run build`, ]; if (hasNext) { @@ -260,81 +242,7 @@ export const generateDynamicRailpackJson = async ( } } - const hasServerScript = !!scripts.server; - const isStatic = projectType === "static" || (!scripts.start && !hasServerScript && (scripts.build || hasPackageJson)); - - if (isStatic) { - const serveScript = ` -const fs = require("fs"); -const path = require("path"); -const PORT = Number(process.env.PORT || 3000); -const cleanSourceDir = "${cleanSourceDir}"; -const cleanOutputDir = "${cleanOutputDir}"; -let staticDir = "."; -const candidates = [ - ...(cleanOutputDir ? [path.join(cleanSourceDir, cleanOutputDir), cleanOutputDir] : []), - path.join(cleanSourceDir, "dist"), - path.join(cleanSourceDir, "build"), - path.join(cleanSourceDir, "out"), - path.join(cleanSourceDir, ".next/server/app"), - path.join(cleanSourceDir, ".next/server/pages"), - path.join(cleanSourceDir, "public"), - "dist", - "build", - "out", - ".next/server/app", - ".next/server/pages", - "public", - "." -]; -for (const dir of candidates) { - const fullPath = path.join(process.cwd(), dir); - if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { - if (fs.existsSync(path.join(fullPath, "index.html"))) { - staticDir = dir; - break; - } - } -} -console.log("Serving static directory:", staticDir, "on port", PORT); -Bun.serve({ - port: PORT, - async fetch(req) { - const url = new URL(req.url); - let decodedPathname = "/"; - try { - decodedPathname = decodeURIComponent(url.pathname); - } catch { - decodedPathname = url.pathname; - } - let filePath = path.join(staticDir, decodedPathname); - if (decodedPathname.endsWith("/")) { - filePath = path.join(filePath, "index.html"); - } - let file = Bun.file(filePath); - if (await file.exists()) { - return new Response(file, { - headers: { - "content-type": file.type || "application/octet-stream" - } - }); - } - const fallbackPath = path.join(staticDir, "index.html"); - const fallbackFile = Bun.file(fallbackPath); - if (await fallbackFile.exists()) { - return new Response(fallbackFile, { - headers: { - "content-type": fallbackFile.type || "text/html" - } - }); - } - return new Response("Not Found", { status: 404 }); - } -}); -`; - await Bun.write(join(workspace, "dequel-serve.js"), serveScript); - config.deploy.startCommand = "bun dequel-serve.js"; - } else if (scripts.start) { + if (scripts.start) { config.deploy.startCommand = cleanSourceDir ? `cd ${cleanSourceDir} && ${pm} run start` @@ -359,6 +267,8 @@ Bun.serve({ } } + + const hasCargoToml = await Bun.file( join(buildDir, "Cargo.toml"), ).exists(); @@ -606,18 +516,23 @@ Bun.serve({ configured = true; } - if (!configured && projectType === "static") { + if (projectType === "static" || (!configured && ((await Bun.file(join(buildDir, "index.html")).exists()) || (await Bun.file(join(workspace, "index.html")).exists())))) { const serveScript = ` const fs = require("fs"); const path = require("path"); const PORT = Number(process.env.PORT || 3000); const cleanSourceDir = "${cleanSourceDir}"; +const cleanOutputDir = "${cleanOutputDir}"; let staticDir = "."; const candidates = [ + ...(cleanOutputDir ? [path.join(cleanSourceDir, cleanOutputDir), cleanOutputDir] : []), path.join(cleanSourceDir, "dist"), path.join(cleanSourceDir, "build"), path.join(cleanSourceDir, "out"), + path.join(cleanSourceDir, ".next/server/app"), + path.join(cleanSourceDir, ".next/server/pages"), path.join(cleanSourceDir, "public"), + cleanSourceDir || ".", "dist", "build", "out", @@ -670,6 +585,17 @@ Bun.serve({ }); `; await Bun.write(join(workspace, "dequel-serve.js"), serveScript); + const rootPkgPath = join(workspace, "package.json"); + if (!(await Bun.file(rootPkgPath).exists())) { + await Bun.write(rootPkgPath, JSON.stringify({ + name: "dequel-static-app", + private: true, + scripts: { + start: "bun dequel-serve.js" + } + }, null, 2)); + } + config.steps.build = config.steps.build || { commands: [] }; config.deploy.startCommand = "bun dequel-serve.js"; configured = true; } @@ -680,6 +606,30 @@ Bun.serve({ ); } + if (buildCommandOverride || startCommandOverride || installCommandOverride) { + await onLog("Applying custom build/start/install settings"); + if (installCommandOverride) { + const hasCustomInstall = installCommandOverride !== "npm install" && installCommandOverride !== "npm ci" && installCommandOverride !== "pnpm install" && installCommandOverride !== "yarn install" && installCommandOverride !== "bun install"; + if (hasCustomInstall) { + config.steps.install = config.steps.install || {}; + config.steps.install.commands = [installCommandOverride]; + } + } + if (buildCommandOverride) { + config.steps.build = config.steps.build || {}; + config.steps.build.commands = [ + cleanSourceDir + ? `cd ${cleanSourceDir} && ${buildCommandOverride}` + : buildCommandOverride, + ]; + } + if (startCommandOverride) { + config.deploy.startCommand = cleanSourceDir + ? `cd ${cleanSourceDir} && ${startCommandOverride}` + : startCommandOverride; + } + } + await Bun.write( configPath, JSON.stringify(config, null, 2), diff --git a/apps/api/src/utils/__tests__/env-filter.test.ts b/apps/api/src/utils/__tests__/env-filter.test.ts deleted file mode 100644 index a8c79cf..0000000 --- a/apps/api/src/utils/__tests__/env-filter.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { isBuildSafeEnv, filterBuildEnvVars } from "../env-filter"; - -describe("isBuildSafeEnv", () => { - test("allows NEXT_PUBLIC_ prefix", () => { - expect(isBuildSafeEnv("NEXT_PUBLIC_API_URL")).toBe(true); - expect(isBuildSafeEnv("NEXT_PUBLIC_STRIPE_KEY")).toBe(true); - }); - - test("allows VITE_ prefix", () => { - expect(isBuildSafeEnv("VITE_API_URL")).toBe(true); - expect(isBuildSafeEnv("VITE_APP_TITLE")).toBe(true); - }); - - test("allows REACT_APP_ prefix", () => { - expect(isBuildSafeEnv("REACT_APP_API_URL")).toBe(true); - }); - - test("allows NUXT_PUBLIC_ prefix", () => { - expect(isBuildSafeEnv("NUXT_PUBLIC_API_BASE")).toBe(true); - }); - - test("allows NUXT_ENV_ prefix", () => { - expect(isBuildSafeEnv("NUXT_ENV_COOL_WORD")).toBe(true); - }); - - test("allows GATSBY_ prefix", () => { - expect(isBuildSafeEnv("GATSBY_API_URL")).toBe(true); - }); - - test("allows PUBLIC_ prefix (Astro/SvelteKit)", () => { - expect(isBuildSafeEnv("PUBLIC_SUPABASE_URL")).toBe(true); - expect(isBuildSafeEnv("PUBLIC_ANALYTICS_ID")).toBe(true); - }); - - - test("allows STORYBOOK_ prefix", () => { - expect(isBuildSafeEnv("STORYBOOK_API_URL")).toBe(true); - }); - - test("allows safe exact matches", () => { - expect(isBuildSafeEnv("NODE_ENV")).toBe(true); - expect(isBuildSafeEnv("CI")).toBe(true); - expect(isBuildSafeEnv("GENERATE_SOURCEMAP")).toBe(true); - expect(isBuildSafeEnv("SENTRY_DSN")).toBe(true); - }); - - test("rejects secrets and runtime vars", () => { - expect(isBuildSafeEnv("DATABASE_URL")).toBe(false); - expect(isBuildSafeEnv("API_SECRET_KEY")).toBe(false); - expect(isBuildSafeEnv("JWT_SECRET")).toBe(false); - expect(isBuildSafeEnv("SMTP_PASSWORD")).toBe(false); - expect(isBuildSafeEnv("REDIS_URL")).toBe(false); - expect(isBuildSafeEnv("AWS_SECRET_ACCESS_KEY")).toBe(false); - expect(isBuildSafeEnv("STRIPE_SECRET_KEY")).toBe(false); - }); - - test("rejects partial prefix matches", () => { - expect(isBuildSafeEnv("NEXT_PRIVATE_KEY")).toBe(false); - expect(isBuildSafeEnv("VITEPRESS_KEY")).toBe(false); - }); -}); - -describe("filterBuildEnvVars", () => { - test("filters mixed env vars correctly", () => { - const input = [ - { key: "NEXT_PUBLIC_API_URL", value: "https://api.example.com" }, - { key: "DATABASE_URL", value: "postgres://user:pass@host/db" }, - { key: "VITE_APP_TITLE", value: "My App" }, - { key: "JWT_SECRET", value: "supersecret" }, - { key: "NODE_ENV", value: "production" }, - { key: "STRIPE_SECRET_KEY", value: "sk_live_xxx" }, - { key: "PUBLIC_SUPABASE_URL", value: "https://xxx.supabase.co" }, - ]; - - const result = filterBuildEnvVars(input); - expect(result).toEqual([ - { key: "NEXT_PUBLIC_API_URL", value: "https://api.example.com" }, - { key: "VITE_APP_TITLE", value: "My App" }, - { key: "NODE_ENV", value: "production" }, - { key: "PUBLIC_SUPABASE_URL", value: "https://xxx.supabase.co" }, - ]); - }); - - test("returns empty array when no build-safe vars", () => { - const input = [ - { key: "DATABASE_URL", value: "postgres://..." }, - { key: "API_KEY", value: "secret" }, - ]; - expect(filterBuildEnvVars(input)).toEqual([]); - }); - - test("returns all vars when all are build-safe", () => { - const input = [ - { key: "NEXT_PUBLIC_URL", value: "https://..." }, - { key: "NODE_ENV", value: "production" }, - ]; - expect(filterBuildEnvVars(input)).toEqual(input); - }); -}); diff --git a/apps/api/src/utils/env-filter.ts b/apps/api/src/utils/env-filter.ts deleted file mode 100644 index 3ce1921..0000000 --- a/apps/api/src/utils/env-filter.ts +++ /dev/null @@ -1,42 +0,0 @@ -const BUILD_SAFE_PREFIXES = [ - "NEXT_PUBLIC_", - "VITE_", - "REACT_APP_", - "NUXT_PUBLIC_", - "NUXT_ENV_", - "GATSBY_", - "PUBLIC_", - "STORYBOOK_", -]; - -const BUILD_SAFE_EXACT = new Set([ - "NODE_ENV", - "NODE_OPTIONS", - "NPM_CONFIG_PRODUCTION", - "GENERATE_SOURCEMAP", - "INLINE_RUNTIME_CHUNK", - "CI", - "BUILD_PATH", - "DISABLE_ESLINT_PLUGIN", - "TSC_COMPILE_ON_ERROR", - "IMAGE_INLINE_SIZE_LIMIT", - "FAST_REFRESH", - "ESLINT_NO_DEV_ERRORS", - "TAILWIND_MODE", - "ANALYZE", - "SENTRY_DSN", - "SENTRY_AUTH_TOKEN", -]); - -export const isBuildSafeEnv = (key: string): boolean => { - if (BUILD_SAFE_EXACT.has(key)) return true; - return BUILD_SAFE_PREFIXES.some((prefix) => - key.startsWith(prefix), - ); -}; - -export const filterBuildEnvVars = ( - vars: { key: string; value: string }[], -): { key: string; value: string }[] => { - return vars.filter((v) => isBuildSafeEnv(v.key)); -}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index bdba666..dae8f98 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,6 +1,7 @@ import type { Project, Deployment, + CreateProjectInput, EnvironmentVariable, Volume, Database, @@ -76,21 +77,7 @@ export const listProjects = () => apiFetch("/projects"); export const getProject = (id: string) => apiFetch(`/projects/${id}`); -export const createProject = (data: { - name: string; - description?: string; - baseDomain?: string; - repoUrl?: string; - repoBranch?: string; - cpuLimit?: number; - memoryLimitMb?: number; - port?: number; - sourceDir?: string; - sourceType?: string; - projectType?: string; - buildCommand?: string; - startCommand?: string; -}) => +export const createProject = (data: CreateProjectInput) => apiFetch("/projects", { method: "POST", body: JSON.stringify(data), @@ -571,3 +558,15 @@ export const removeRepoHook = (owner: string, repo: string) => apiFetch<{ ok: boolean; removed: boolean }>(`/github/repos/${owner}/${repo}/hook`, { method: "DELETE", }); + +export const setEnvVar = (projectId: string, key: string, value: string, environment?: string) => + createEnvVar(projectId, { key, value, environment }); + +export const uploadSourceZip = (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return apiFetch<{ filePath: string }>("/upload", { + method: "POST", + body: formData, + }); +}; diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx index da2e1fb..d74dd4b 100644 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ b/apps/web/src/components/databases/CreateDatabaseDialog.tsx @@ -6,6 +6,8 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Input } from "../ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { DatabaseSelect, DATABASE_ENGINES } from "../ui/DatabaseSelect"; + interface CreateDatabaseDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -18,7 +20,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj const [name, setName] = useState(""); const [projectId, setProjectId] = useState(defaultProjectId ?? "standalone"); const [type, setType] = useState("postgresql"); - const [version, setVersion] = useState("18"); + const [version, setVersion] = useState("16"); const [cpu, setCpu] = useState("1"); const [memory, setMemory] = useState("512"); const [storage, setStorage] = useState("10240"); @@ -28,7 +30,8 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj const [error, setError] = useState(null); useEffect(() => { - setVersion(type === "postgresql" ? "18" : type === "mysql" ? "8.4" : "8.0"); + const engine = DATABASE_ENGINES.find((item) => item.type === type); + if (engine) setVersion(engine.defaultVersion); }, [type]); const create = async () => { @@ -63,10 +66,10 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj Public access is enabled by default and protected by database credentials plus the network allowlist.
-
setName(event.target.value)} placeholder="Production PostgreSQL" />
+
setName(event.target.value)} placeholder="Production Database" />
-
+
setType(val)} />
setVersion(event.target.value)} />
diff --git a/apps/web/src/components/logos/DatabaseLogos.tsx b/apps/web/src/components/logos/DatabaseLogos.tsx new file mode 100644 index 0000000..7c52014 --- /dev/null +++ b/apps/web/src/components/logos/DatabaseLogos.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +interface LogoProps extends React.SVGProps { + className?: string; + size?: number; +} + +export function PostgresLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function MySQLLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function RedisLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + ); +} + +export function MongoLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function MariaDBLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function ClickHouseLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function SQLiteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function getDatabaseLogo(engine: string, className = 'h-4 w-4'): React.ReactNode { + const engineLower = engine.toLowerCase(); + if (engineLower.includes('postgre') || engineLower.includes('postgres') || engineLower === 'pg') { + return ; + } + if (engineLower.includes('mysql')) { + return ; + } + if (engineLower.includes('redis') || engineLower.includes('valkey')) { + return ; + } + if (engineLower.includes('mongo')) { + return ; + } + if (engineLower.includes('maria')) { + return ; + } + if (engineLower.includes('clickhouse')) { + return ; + } + if (engineLower.includes('sqlite') || engineLower.includes('libsql')) { + return ; + } + return ; +} diff --git a/apps/web/src/components/logos/FrameworkLogos.tsx b/apps/web/src/components/logos/FrameworkLogos.tsx new file mode 100644 index 0000000..42690ab --- /dev/null +++ b/apps/web/src/components/logos/FrameworkLogos.tsx @@ -0,0 +1,226 @@ +import React from 'react'; + +interface LogoProps extends React.SVGProps { + className?: string; + size?: number; +} + +export function NextjsLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +export function ReactLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + ); +} + +export function ViteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function VueLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function NuxtLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function AstroLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function AngularLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function NodeLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function RemixLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function SvelteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function PythonLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function GoLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function DockerLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function OtherLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function getFrameworkLogo(icon: string, className = 'h-4 w-4'): React.ReactNode { + const iconLower = icon.toLowerCase(); + if (iconLower.includes('next')) return ; + if (iconLower.includes('react')) return ; + if (iconLower.includes('vite')) return ; + if (iconLower.includes('vue')) return ; + if (iconLower.includes('nuxt')) return ; + if (iconLower.includes('astro')) return ; + if (iconLower.includes('angular')) return ; + if (iconLower.includes('remix')) return ; + if (iconLower.includes('svelte')) return ; + if (iconLower.includes('node') || iconLower.includes('express')) return ; + if (iconLower.includes('python') || iconLower.includes('django') || iconLower.includes('fastapi')) return ; + if (iconLower.includes('go') || iconLower.includes('golang')) return ; + if (iconLower.includes('container') || iconLower.includes('docker')) return ; + return ; +} diff --git a/apps/web/src/components/project/create/BuildStrategySection.tsx b/apps/web/src/components/project/create/BuildStrategySection.tsx new file mode 100644 index 0000000..d56ec82 --- /dev/null +++ b/apps/web/src/components/project/create/BuildStrategySection.tsx @@ -0,0 +1,333 @@ +import { FrameworkPreset } from '../../../utils/presets'; +import { FrameworkSelect } from '../../ui/FrameworkSelect'; +import { Input } from '../../ui/input'; +import { Button } from '../../ui/button'; +import { Container, Globe, Box, FolderCode, Terminal, Sliders, Play, Plus, Trash2 } from 'lucide-react'; +import { cn } from '../../../lib/utils'; + +export interface ComposeServiceRow { + id: string; + serviceName: string; + port: string; + subdomain: string; +} + +interface BuildStrategySectionProps { + buildType: 'railpack' | 'compose'; + setBuildType: (v: 'railpack' | 'compose') => void; + projectType: string; + setProjectType: (v: string) => void; + selectedPresetId: string; + onSelectPreset: (preset: FrameworkPreset) => void; + buildCommand: string; + setBuildCommand: (v: string) => void; + installCommand: string; + setInstallCommand: (v: string) => void; + startCommand: string; + setStartCommand: (v: string) => void; + outputDir: string; + setOutputDir: (v: string) => void; + port: string; + setPort: (v: string) => void; + sourceDir: string; + setSourceDir: (v: string) => void; + composeServicesList: ComposeServiceRow[]; + addComposeServiceRow: () => void; + removeComposeServiceRow: (id: string) => void; + updateComposeServiceRow: (id: string, field: 'serviceName' | 'port' | 'subdomain', value: string) => void; +} + +export function BuildStrategySection({ + buildType, + setBuildType, + projectType, + setProjectType, + selectedPresetId, + onSelectPreset, + buildCommand, + setBuildCommand, + installCommand, + setInstallCommand, + startCommand, + setStartCommand, + outputDir, + setOutputDir, + port, + setPort, + sourceDir, + setSourceDir, + composeServicesList, + addComposeServiceRow, + removeComposeServiceRow, + updateComposeServiceRow, +}: BuildStrategySectionProps) { + return ( +
+
+

+ + Build & Runtime Strategy +

+

+ Select how Dequel builds and manages your application containers. +

+
+ + {/* Build Strategy Selection Cards (Railpack vs Docker Compose) */} +
+ + + +
+ + {/* Railpack Configuration */} + {buildType === 'railpack' && ( +
+ {/* Framework Preset Select (with SVG logos) */} +
+ + +
+ + {/* Project Type Card Selection (Web Service vs Static Site) */} +
+ +
+ + + +
+
+ + {/* Detailed Build Commands */} +
+
+ + setBuildCommand(e.target.value)} + placeholder="e.g. npm run build" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ +
+ + setOutputDir(e.target.value)} + placeholder="e.g. .next or dist" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ +
+ + setInstallCommand(e.target.value)} + placeholder="e.g. npm install" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ + {projectType === 'web' && ( +
+ + setStartCommand(e.target.value)} + placeholder="e.g. npm run start" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ )} + +
+ + setPort(e.target.value)} + placeholder="3000" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ +
+ + setSourceDir(e.target.value)} + placeholder="./" + className="bg-[#121215] border-[#22222a] text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+
+
+ )} + + {/* Docker Compose Service Gateway Mapping */} + {buildType === 'compose' && ( +
+
+
+

Compose Ingress Service Mapping

+

+ Map HTTP ingress routing to specific services defined in your compose file. +

+
+ +
+ +
+ {composeServicesList.map((row) => ( +
+
+ + updateComposeServiceRow(row.id, 'serviceName', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+
+ + updateComposeServiceRow(row.id, 'port', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+
+
+ + updateComposeServiceRow(row.id, 'subdomain', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+ {composeServicesList.length > 1 && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx index d409ad0..c283c6b 100644 --- a/apps/web/src/components/project/create/CreateProjectDialog.tsx +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -15,6 +15,7 @@ import { Plus } from "lucide-react"; import * as api from "../../../api/client"; import { getGithubIntegration } from "../../../api/client"; import type { GithubRepo, DatabaseType } from "../../../types"; +import type { FrameworkPreset } from "../../../utils/presets"; import { StepBasics } from "./StepBasics"; import { StepEnvironment } from "./StepEnvironment"; @@ -62,6 +63,8 @@ export function CreateProjectDialog({ useState("git"); const [projectType, setProjectType] = useState("web"); + const [selectedPresetId, setSelectedPresetId] = + useState(""); const [zipFile, setZipFile] = useState(null); const [cpuLimit, setCpuLimit] = useState(""); @@ -70,6 +73,14 @@ export function CreateProjectDialog({ const [port, setPort] = useState(""); const [sourceDir, setSourceDir] = useState(""); + const [buildCommand, setBuildCommand] = + useState(""); + const [installCommand, setInstallCommand] = + useState(""); + const [startCommand, setStartCommand] = + useState(""); + const [outputDir, setOutputDir] = + useState(""); const provisionDb = false; const setProvisionDb = (_value: boolean) => {}; const [dbType, setDbType] = useState("postgresql"); @@ -116,6 +127,18 @@ export function CreateProjectDialog({ .catch(() => { }); }, [open]); + const handleSelectPreset = ( + preset: FrameworkPreset, + ) => { + setSelectedPresetId(preset.id); + setProjectType(preset.projectType); + setBuildCommand(preset.buildCommand || ""); + setInstallCommand(preset.installCommand || ""); + setStartCommand(preset.startCommand || ""); + setOutputDir(preset.outputDir || ""); + if (preset.defaultPort) setPort(String(preset.defaultPort)); + }; + const handleOpenChange = ( isOpen: boolean, ) => { @@ -130,6 +153,11 @@ export function CreateProjectDialog({ setSourceDir(""); setSelectedRepo(null); setSourceType("git"); + setSelectedPresetId(""); + setBuildCommand(""); + setInstallCommand(""); + setStartCommand(""); + setOutputDir(""); setPort(""); setZipFile(null); } @@ -175,6 +203,18 @@ export function CreateProjectDialog({ undefined, sourceType, projectType, + buildCommand: + buildCommand.trim() || + undefined, + installCommand: + installCommand.trim() || + undefined, + startCommand: + startCommand.trim() || + undefined, + outputDir: + outputDir.trim() || + undefined, }); if (stagedEnvs.length > 0) { @@ -255,8 +295,6 @@ export function CreateProjectDialog({ hasEnvs={ stagedEnvs.length > 0 } - hasDb={provisionDb} - dbType={dbType} onRetry={() => setSubmittingStatus( "idle", @@ -413,9 +451,11 @@ export function CreateProjectDialog({ } port={port} setPort={setPort} - zipFile={zipFile} - setZipFile={setZipFile} - /> + zipFile={zipFile} + setZipFile={setZipFile} + selectedPresetId={selectedPresetId} + onSelectPreset={handleSelectPreset} + /> )} {step === 2 && ( diff --git a/apps/web/src/components/project/create/CreationStatusOverlay.tsx b/apps/web/src/components/project/create/CreationStatusOverlay.tsx index 3643ff0..d8a475e 100644 --- a/apps/web/src/components/project/create/CreationStatusOverlay.tsx +++ b/apps/web/src/components/project/create/CreationStatusOverlay.tsx @@ -5,8 +5,6 @@ interface CreationStatusOverlayProps { submittingStatus: 'idle' | 'creating_project' | 'creating_envs' | 'creating_db' | 'done' | 'error'; errorMessage: string; hasEnvs: boolean; - hasDb: boolean; - dbType: 'postgresql' | 'mysql'; onRetry: () => void; } @@ -14,8 +12,6 @@ export function CreationStatusOverlay({ submittingStatus, errorMessage, hasEnvs, - hasDb, - dbType, onRetry }: CreationStatusOverlayProps) { if (submittingStatus === 'idle') return null; @@ -56,23 +52,6 @@ export function CreationStatusOverlay({ )}
)} - - {hasDb && ( -
- 3. Provision dedicated {dbType} database instance - {(submittingStatus === 'creating_project' || submittingStatus === 'creating_envs') ? ( -
- ) : submittingStatus === 'creating_db' ? ( - - ) : submittingStatus === 'error' && errorMessage.includes('database') ? ( - - ) : submittingStatus === 'done' ? ( - - ) : ( -
- )} -
- )}
{submittingStatus === 'done' && ( diff --git a/apps/web/src/components/project/create/EnvVarsSection.tsx b/apps/web/src/components/project/create/EnvVarsSection.tsx new file mode 100644 index 0000000..de7b202 --- /dev/null +++ b/apps/web/src/components/project/create/EnvVarsSection.tsx @@ -0,0 +1,243 @@ +import { useState, useRef } from 'react'; +import { Input } from '../../ui/input'; +import { Button } from '../../ui/button'; +import { Key, Plus, Trash2, FileText, Upload, ChevronDown } from 'lucide-react'; + +export interface StagedEnv { + key: string; + value: string; + environment?: string; +} + +interface EnvVarsSectionProps { + stagedEnvs: StagedEnv[]; + setStagedEnvs: React.Dispatch>; +} + +export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProps) { + const [isExpanded, setIsExpanded] = useState(true); + const [key, setKey] = useState(''); + const [val, setVal] = useState(''); + const [bulkText, setBulkText] = useState(''); + const [mode, setMode] = useState<'single' | 'bulk' | 'file'>('single'); + const [fileError, setFileError] = useState(''); + const fileInputRef = useRef(null); + + const addEnv = () => { + if (!key.trim()) return; + setStagedEnvs((prev) => [...prev, { key: key.trim(), value: val.trim() }]); + setKey(''); + setVal(''); + }; + + const removeEnv = (index: number) => { + setStagedEnvs((prev) => prev.filter((_, i) => i !== index)); + }; + + const parseEnvContent = (content: string) => { + const lines = content.split(/\r?\n/); + const newEnvs: StagedEnv[] = []; + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + const eqIdx = trimmed.indexOf('='); + if (eqIdx !== -1) { + const k = trimmed.slice(0, eqIdx).trim(); + const v = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); + if (k) newEnvs.push({ key: k, value: v }); + } + }); + return newEnvs; + }; + + const importBulk = () => { + if (!bulkText.trim()) return; + const parsed = parseEnvContent(bulkText); + setStagedEnvs((prev) => [...prev, ...parsed]); + setBulkText(''); + setMode('single'); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileError(''); + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const parsed = parseEnvContent(content); + if (parsed.length === 0) { + setFileError('No valid KEY=VALUE pairs found in file.'); + } else { + setStagedEnvs((prev) => [...prev, ...parsed]); + setMode('single'); + } + } catch { + setFileError('Failed to parse file content.'); + } + }; + reader.onerror = () => setFileError('Error reading file.'); + reader.readAsText(file); + }; + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+

+ + Environment Variables ({stagedEnvs.length}) +

+

+ Add environment secrets, API keys, and configuration variables. +

+
+ +
+ + {isExpanded && ( +
+
+ + + +
+ + {mode === 'single' ? ( +
+ setKey(e.target.value)} + className="bg-[#121215] border-[#22222a] text-xs font-mono" + /> + setVal(e.target.value)} + className="bg-[#121215] border-[#22222a] text-xs font-mono" + /> + +
+ ) : mode === 'bulk' ? ( +
+