From 343f207a1263a3f2a38ecd2a6c996b4919be7175 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Tue, 4 Aug 2026 17:01:26 +0100
Subject: [PATCH 1/6] feat(api): custom install command/output dir support and
concise deployment errors
Add install_command and output_dir project columns (migration 0008) and
wire them through the railpack config generator. Summarize deployment and
rollback failures to the most actionable line instead of the full raw
stderr transcript.
---
apps/api/src/api/projects/index.ts | 6 +-
apps/api/src/db/migrate.ts | 26 +
.../db/migrations/0008_closed_whirlwind.sql | 34 +
.../src/db/migrations/meta/0008_snapshot.json | 1442 +++++++++++++++++
apps/api/src/db/migrations/meta/_journal.json | 9 +-
apps/api/src/db/repo/projects.ts | 6 +
apps/api/src/db/schema.ts | 2 +
.../__tests__/deployment-errors.test.ts | 37 +
.../api/src/orchestrator/deployment-errors.ts | 24 +
apps/api/src/orchestrator/pipeline.ts | 17 +-
.../src/orchestrator/railpack-config-utils.ts | 44 +-
apps/api/src/orchestrator/railpack.ts | 4 +
apps/api/src/types.ts | 4 +
13 files changed, 1640 insertions(+), 15 deletions(-)
create mode 100644 apps/api/src/db/migrations/0008_closed_whirlwind.sql
create mode 100644 apps/api/src/db/migrations/meta/0008_snapshot.json
create mode 100644 apps/api/src/orchestrator/__tests__/deployment-errors.test.ts
create mode 100644 apps/api/src/orchestrator/deployment-errors.ts
diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts
index a0cf7b9..e4d19a9 100644
--- a/apps/api/src/api/projects/index.ts
+++ b/apps/api/src/api/projects/index.ts
@@ -74,6 +74,8 @@ export const projectsRoutes = new Elysia()
composePort: body.composePort ? Number(body.composePort) : null,
composeServices: body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null,
buildCommand: body.buildCommand || undefined,
+ installCommand: body.installCommand || undefined,
+ outputDir: body.outputDir || undefined,
startCommand: body.startCommand || undefined,
});
return project;
@@ -96,13 +98,15 @@ export const projectsRoutes = new Elysia()
cpuLimit: body?.cpuLimit,
memoryLimitMb: body?.memoryLimitMb,
port: body?.port ? Number(body.port) : body?.port === null ? null : undefined,
- sourceDir: body?.sourceDir ?? undefined,
+ sourceDir: "sourceDir" in (body ?? {}) ? (body.sourceDir || null) : undefined,
projectType: body?.projectType ?? undefined,
buildType: body?.buildType ?? undefined,
composeService: "composeService" in (body ?? {}) ? (body.composeService || null) : undefined,
composePort: "composePort" in (body ?? {}) ? (body.composePort ? Number(body.composePort) : null) : undefined,
composeServices: "composeServices" in (body ?? {}) ? (body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null) : undefined,
buildCommand: "buildCommand" in (body ?? {}) ? (body.buildCommand ?? "") || null : undefined,
+ installCommand: "installCommand" in (body ?? {}) ? (body.installCommand ?? "") || null : undefined,
+ outputDir: "outputDir" in (body ?? {}) ? (body.outputDir ?? "") || null : undefined,
startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined,
});
if (!project) {
diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts
index f7e8cc4..dc6359d 100644
--- a/apps/api/src/db/migrate.ts
+++ b/apps/api/src/db/migrate.ts
@@ -31,9 +31,35 @@ export const migrate = async () => {
await addClearCacheColumn(db);
await addFinishedAtColumn(db);
+ await addInstallCommandColumn(db);
+ await addOutputDirColumn(db);
await seedFromConfig();
};
+const addOutputDirColumn = async (db: ReturnType) => {
+ try {
+ db.run(sql`ALTER TABLE projects ADD COLUMN output_dir text`);
+ console.log("[Migrate] Added output_dir column to projects table");
+ } catch (err) {
+ const cause = err instanceof Error && "cause" in err ? err.cause : err;
+ if (cause instanceof Error && cause.message.includes("duplicate column name")) return;
+ console.error("[Migrate] Failed to add output_dir column:", err);
+ throw err;
+ }
+};
+
+const addInstallCommandColumn = async (db: ReturnType) => {
+ try {
+ db.run(sql`ALTER TABLE projects ADD COLUMN install_command text`);
+ console.log("[Migrate] Added install_command column to projects table");
+ } catch (err) {
+ const cause = err instanceof Error && "cause" in err ? err.cause : err;
+ if (cause instanceof Error && cause.message.includes("duplicate column name")) return;
+ console.error("[Migrate] Failed to add install_command column:", err);
+ throw err;
+ }
+};
+
const addClearCacheColumn = async (db: ReturnType) => {
try {
db.run(sql`ALTER TABLE deployments ADD COLUMN clear_cache integer NOT NULL DEFAULT 0`);
diff --git a/apps/api/src/db/migrations/0008_closed_whirlwind.sql b/apps/api/src/db/migrations/0008_closed_whirlwind.sql
new file mode 100644
index 0000000..0ad8a82
--- /dev/null
+++ b/apps/api/src/db/migrations/0008_closed_whirlwind.sql
@@ -0,0 +1,34 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_databases` (
+ `id` text PRIMARY KEY NOT NULL,
+ `project_id` text,
+ `name` text NOT NULL,
+ `type` text NOT NULL,
+ `version` text,
+ `database_name` text NOT NULL,
+ `username` text NOT NULL,
+ `password` text NOT NULL,
+ `internal_host` text NOT NULL,
+ `internal_port` integer NOT NULL,
+ `cpu_limit` real,
+ `memory_limit_mb` integer,
+ `storage_limit_mb` integer,
+ `storage_used_mb` integer DEFAULT 0 NOT NULL,
+ `public_access` integer DEFAULT 1 NOT NULL,
+ `allow_public_access_from_anywhere` integer DEFAULT 0 NOT NULL,
+ `allowed_cidrs` text DEFAULT '[]' NOT NULL,
+ `external_port` integer,
+ `proxy_container_name` text,
+ `volume_name` text NOT NULL,
+ `connection_string` text NOT NULL,
+ `status` text DEFAULT 'provisioning' NOT NULL,
+ `container_name` text,
+ `created_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+INSERT INTO `__new_databases`("id", "project_id", "name", "type", "version", "database_name", "username", "password", "internal_host", "internal_port", "cpu_limit", "memory_limit_mb", "storage_limit_mb", "storage_used_mb", "public_access", "allow_public_access_from_anywhere", "allowed_cidrs", "external_port", "proxy_container_name", "volume_name", "connection_string", "status", "container_name", "created_at", "updated_at") SELECT "id", "project_id", "name", "type", "version", "database_name", "username", "password", "internal_host", "internal_port", "cpu_limit", "memory_limit_mb", "storage_limit_mb", "storage_used_mb", "public_access", "allow_public_access_from_anywhere", "allowed_cidrs", "external_port", "proxy_container_name", "volume_name", "connection_string", "status", "container_name", "created_at", "updated_at" FROM `databases`;--> statement-breakpoint
+DROP TABLE `databases`;--> statement-breakpoint
+ALTER TABLE `__new_databases` RENAME TO `databases`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/0008_snapshot.json b/apps/api/src/db/migrations/meta/0008_snapshot.json
new file mode 100644
index 0000000..eb604d5
--- /dev/null
+++ b/apps/api/src/db/migrations/meta/0008_snapshot.json
@@ -0,0 +1,1442 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "0d86f3f9-9d67-47f7-9491-80f939b8775f",
+ "prevId": "c4054dd7-dc6f-44e1-90e6-7e5ae9eaa8f9",
+ "tables": {
+ "alerts": {
+ "name": "alerts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "threshold": {
+ "name": "threshold",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "destination": {
+ "name": "destination",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "alerts_project_id_projects_id_fk": {
+ "name": "alerts_project_id_projects_id_fk",
+ "tableFrom": "alerts",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'deploy:read'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "databases": {
+ "name": "databases",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "database_name": {
+ "name": "database_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "internal_host": {
+ "name": "internal_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "internal_port": {
+ "name": "internal_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cpu_limit": {
+ "name": "cpu_limit",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memory_limit_mb": {
+ "name": "memory_limit_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storage_limit_mb": {
+ "name": "storage_limit_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storage_used_mb": {
+ "name": "storage_used_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "public_access": {
+ "name": "public_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "allow_public_access_from_anywhere": {
+ "name": "allow_public_access_from_anywhere",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "allowed_cidrs": {
+ "name": "allowed_cidrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "external_port": {
+ "name": "external_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "proxy_container_name": {
+ "name": "proxy_container_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "volume_name": {
+ "name": "volume_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_string": {
+ "name": "connection_string",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provisioning'"
+ },
+ "container_name": {
+ "name": "container_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "databases_project_id_projects_id_fk": {
+ "name": "databases_project_id_projects_id_fk",
+ "tableFrom": "databases",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "deployment_logs": {
+ "name": "deployment_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "deployment_id": {
+ "name": "deployment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stage": {
+ "name": "stage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_logs_dep_seq": {
+ "name": "idx_logs_dep_seq",
+ "columns": [
+ "deployment_id",
+ "sequence"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "deployment_logs_deployment_id_deployments_id_fk": {
+ "name": "deployment_logs_deployment_id_deployments_id_fk",
+ "tableFrom": "deployment_logs",
+ "tableTo": "deployments",
+ "columnsFrom": [
+ "deployment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "deployments": {
+ "name": "deployments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_type": {
+ "name": "source_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_ref": {
+ "name": "source_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "image_tag": {
+ "name": "image_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "container_name": {
+ "name": "container_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "route_path": {
+ "name": "route_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "live_url": {
+ "name": "live_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commit_sha": {
+ "name": "commit_sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "environment": {
+ "name": "environment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "clear_cache": {
+ "name": "clear_cache",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "domains": {
+ "name": "domains",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ },
+ "validation_status": {
+ "name": "validation_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "ssl_status": {
+ "name": "ssl_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "target_service": {
+ "name": "target_service",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_port": {
+ "name": "target_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "domains_project_id_projects_id_fk": {
+ "name": "domains_project_id_projects_id_fk",
+ "tableFrom": "domains",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environment_variables": {
+ "name": "environment_variables",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value_encrypted": {
+ "name": "value_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "value_iv": {
+ "name": "value_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "value_tag": {
+ "name": "value_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment": {
+ "name": "environment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'production'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_env_vars_project": {
+ "name": "idx_env_vars_project",
+ "columns": [
+ "project_id",
+ "environment"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "environment_variables_project_id_projects_id_fk": {
+ "name": "environment_variables_project_id_projects_id_fk",
+ "tableFrom": "environment_variables",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "github_integrations": {
+ "name": "github_integrations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_name": {
+ "name": "app_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Dequel'"
+ },
+ "webhook_secret": {
+ "name": "webhook_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repo_url": {
+ "name": "repo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repo_branch": {
+ "name": "repo_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "base_domain": {
+ "name": "base_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpu_limit": {
+ "name": "cpu_limit",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memory_limit_mb": {
+ "name": "memory_limit_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_dir": {
+ "name": "source_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_type": {
+ "name": "source_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'git'"
+ },
+ "project_type": {
+ "name": "project_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'web'"
+ },
+ "build_type": {
+ "name": "build_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'railpack'"
+ },
+ "compose_service": {
+ "name": "compose_service",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "compose_port": {
+ "name": "compose_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "compose_services": {
+ "name": "compose_services",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "build_command": {
+ "name": "build_command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "install_command": {
+ "name": "install_command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "start_command": {
+ "name": "start_command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "github_token_encrypted": {
+ "name": "github_token_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "github_token_iv": {
+ "name": "github_token_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "github_token_tag": {
+ "name": "github_token_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refresh_tokens": {
+ "name": "refresh_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "blacklisted_at": {
+ "name": "blacklisted_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "refresh_tokens_token_hash_unique": {
+ "name": "refresh_tokens_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scaling_policies": {
+ "name": "scaling_policies",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "min_replicas": {
+ "name": "min_replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "max_replicas": {
+ "name": "max_replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "cpu_threshold_percent": {
+ "name": "cpu_threshold_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 70
+ },
+ "memory_threshold_percent": {
+ "name": "memory_threshold_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 85
+ },
+ "cooldown_seconds": {
+ "name": "cooldown_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 120
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "scaling_policies_project_id_unique": {
+ "name": "scaling_policies_project_id_unique",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "scaling_policies_project_id_projects_id_fk": {
+ "name": "scaling_policies_project_id_projects_id_fk",
+ "tableFrom": "scaling_policies",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "servers": {
+ "name": "servers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2375
+ },
+ "auth_token": {
+ "name": "auth_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "cpu_total": {
+ "name": "cpu_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memory_total_mb": {
+ "name": "memory_total_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "disk_total_mb": {
+ "name": "disk_total_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cpu_used_percent": {
+ "name": "cpu_used_percent",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memory_used_mb": {
+ "name": "memory_used_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_heartbeat": {
+ "name": "last_heartbeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "smtp_settings": {
+ "name": "smtp_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 587
+ },
+ "user": {
+ "name": "user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "pass_encrypted": {
+ "name": "pass_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pass_iv": {
+ "name": "pass_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pass_tag": {
+ "name": "pass_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_address": {
+ "name": "from_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dequel@localhost'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "volumes": {
+ "name": "volumes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mount_path": {
+ "name": "mount_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'/app/data'"
+ },
+ "size_mb": {
+ "name": "size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "docker_volume_name": {
+ "name": "docker_volume_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "volumes_project_id_projects_id_fk": {
+ "name": "volumes_project_id_projects_id_fk",
+ "tableFrom": "volumes",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json
index de357c6..3d6af4f 100644
--- a/apps/api/src/db/migrations/meta/_journal.json
+++ b/apps/api/src/db/migrations/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1785596400000,
"tag": "0007_standalone_databases",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "6",
+ "when": 1785816991600,
+ "tag": "0008_closed_whirlwind",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts
index 919b50d..2fd8256 100644
--- a/apps/api/src/db/repo/projects.ts
+++ b/apps/api/src/db/repo/projects.ts
@@ -34,6 +34,8 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({
composePort: row.composePort ?? null,
composeServices: row.composeServices ?? null,
buildCommand: row.buildCommand ?? null,
+ installCommand: row.installCommand ?? null,
+ outputDir: row.outputDir ?? null,
startCommand: row.startCommand ?? null,
githubTokenEncrypted: row.githubTokenEncrypted ?? null,
githubTokenIv: row.githubTokenIv ?? null,
@@ -64,6 +66,8 @@ export const createProject = async (input: CreateProjectInput): Promise
composePort: input.composePort ?? null,
composeServices: input.composeServices ?? null,
buildCommand: input.buildCommand ?? null,
+ installCommand: input.installCommand ?? null,
+ outputDir: input.outputDir ?? null,
startCommand: input.startCommand ?? null,
createdAt: timestamp,
updatedAt: timestamp,
@@ -108,6 +112,8 @@ export const updateProject = async (id: string, patch: Partial {
+ test("extracts an actionable runtime requirement from noisy build output", () => {
+ const error = new Error(`railpack build failed:
+DEBU Building /app/workspace/deployment-id
+0.917 > next build
+1.049 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
+ERRO failed to solve: process "sh -c pnpm run build" did not complete successfully: exit code: 1
+unrecognized image format`);
+
+ expect(summarizeDeploymentError(error)).toBe(
+ 'You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.',
+ );
+ });
+
+ test("extracts compiler and package errors regardless of deployment stack", () => {
+ expect(
+ summarizeDeploymentError(
+ new Error("docker compose build failed: step 8\nError: Module @repo/ui not found\nstack trace line"),
+ ),
+ ).toBe("Error: Module @repo/ui not found");
+ });
+
+ test("keeps an already concise error", () => {
+ expect(summarizeDeploymentError(new Error("Container exited with code 1"))).toBe(
+ "Container exited with code 1",
+ );
+ });
+
+ test("uses a generic message when output has no useful error", () => {
+ expect(summarizeDeploymentError(new Error("DEBU build stopped\nDONE"))).toBe(
+ "Deployment failed. Check the deployment logs for details.",
+ );
+ });
+});
diff --git a/apps/api/src/orchestrator/deployment-errors.ts b/apps/api/src/orchestrator/deployment-errors.ts
new file mode 100644
index 0000000..990b0b8
--- /dev/null
+++ b/apps/api/src/orchestrator/deployment-errors.ts
@@ -0,0 +1,24 @@
+const ansiPattern = /\u001b\[[0-9;]*m/g;
+const noisyLinePattern = /^(DEBU|DEBUG|DONE|CACHED|loading|resolving?|copy |transferring context|sha256:|[-─╭╰│▸↳⚠])/i;
+const wrapperPattern = /^(railpack build failed|docker compose (build|up) failed|failed to solve|error: process .* did not complete successfully|unrecognized image format)/i;
+const actionablePattern = /(error:|exception:|failed:|not found|cannot find|could not|requires?|required|unsupported|permission denied|out of memory|no space left|timed out|timeout|exited with code|address already in use|connection refused|ELIFECYCLE)/i;
+
+export const summarizeDeploymentError = (error: unknown): string => {
+ const raw = error instanceof Error ? error.message : String(error ?? "");
+ const lines = raw
+ .replace(ansiPattern, "")
+ .split("\n")
+ .map((line) => line.trim().replace(/^#\d+\s+(?:\d+\.\d+\s+)?/, "").replace(/^\d+\.\d+\s+/, ""))
+ .filter(Boolean);
+
+ const actionable = lines.find(
+ (line) => actionablePattern.test(line) && !wrapperPattern.test(line) && !noisyLinePattern.test(line),
+ );
+ if (actionable) return actionable.slice(0, 500);
+
+ if (lines.length === 1 && !noisyLinePattern.test(lines[0])) {
+ return lines[0].slice(0, 500);
+ }
+
+ return "Deployment failed. Check the deployment logs for details.";
+};
diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts
index de8f480..03e8296 100644
--- a/apps/api/src/orchestrator/pipeline.ts
+++ b/apps/api/src/orchestrator/pipeline.ts
@@ -32,6 +32,7 @@ import {
import { ensureProjectDashboard } from "../utils/grafana";
import { buildWithCompose, destroyComposeStack } from "./compose";
import { deployComposeStack } from "./compose-deploy";
+import { summarizeDeploymentError } from "./deployment-errors";
const now = () =>
new Date()
@@ -440,6 +441,8 @@ export class PipelineOrchestrator {
sourceDir: project?.sourceDir,
projectType: project?.projectType,
buildCommand: project?.buildCommand,
+ installCommand: project?.installCommand,
+ outputDir: project?.outputDir,
startCommand: project?.startCommand,
environmentVariables: filterBuildEnvVars(envVars),
signal: controller.signal,
@@ -690,10 +693,7 @@ export class PipelineOrchestrator {
if (error instanceof CancelledError) {
return true;
}
- const message =
- error instanceof Error
- ? error.message
- : "Unknown deployment failure";
+ const message = summarizeDeploymentError(error);
console.error(
`[Orchestrator] Deployment ${deploymentId} failed:`,
error,
@@ -701,7 +701,7 @@ export class PipelineOrchestrator {
await emitLog(
deploymentId,
"system",
- `CRITICAL FAILURE: ${message}`,
+ `Deployment failed: ${message}`,
);
await updateDeploymentStatus(
deploymentId,
@@ -936,10 +936,7 @@ export class PipelineOrchestrator {
"Rollback complete — deployment is running",
);
} catch (error) {
- const message =
- error instanceof Error
- ? error.message
- : "Unknown rollback failure";
+ const message = summarizeDeploymentError(error);
console.error(
`[Orchestrator] Rollback of ${targetDeploymentId} failed:`,
error,
@@ -947,7 +944,7 @@ export class PipelineOrchestrator {
await emitLog(
targetDeploymentId,
"system",
- `CRITICAL FAILURE: ${message}`,
+ `Rollback failed: ${message}`,
);
await updateDeploymentStatus(
targetDeploymentId,
diff --git a/apps/api/src/orchestrator/railpack-config-utils.ts b/apps/api/src/orchestrator/railpack-config-utils.ts
index 5a4f64b..5e48932 100644
--- a/apps/api/src/orchestrator/railpack-config-utils.ts
+++ b/apps/api/src/orchestrator/railpack-config-utils.ts
@@ -85,6 +85,23 @@ export const rewriteLocalhostBinding = async (
}
};
+const fixPnpmWorkspaceIfNeeded = async (workspacePath: string, onLog: (line: string) => Promise) => {
+ try {
+ const workspaceFilePath = join(workspacePath, "pnpm-workspace.yaml");
+ const file = Bun.file(workspaceFilePath);
+ if (await file.exists()) {
+ const content = await file.text();
+ if (!content.includes("packages:")) {
+ await onLog("Auto-repairing invalid pnpm-workspace.yaml (missing packages entry)...");
+ const updated = `${content.trim()}\n\npackages:\n - '.'\n`;
+ await Bun.write(workspaceFilePath, updated);
+ }
+ }
+ } catch {
+ // Ignore workspace repair errors
+ }
+};
+
export const generateDynamicRailpackJson = async (
workspace: string,
sourceDir: string | null,
@@ -92,10 +109,15 @@ export const generateDynamicRailpackJson = async (
buildCommandOverride: string | null,
startCommandOverride: string | null,
onLog: (line: string) => Promise,
+ installCommandOverride?: string | null,
+ outputDirOverride?: string | null,
): Promise => {
const cleanSourceDir = sourceDir
? sourceDir.replace(/^\//, "")
: "";
+ const cleanOutputDir = outputDirOverride
+ ? outputDirOverride.replace(/^\//, "")
+ : "";
const buildDir = cleanSourceDir
? join(workspace, cleanSourceDir)
: workspace;
@@ -103,6 +125,7 @@ export const generateDynamicRailpackJson = async (
buildDir,
onLog,
);
+ await fixPnpmWorkspaceIfNeeded(workspace, onLog);
const configPath = join(
workspace,
"railpack.json",
@@ -117,9 +140,18 @@ export const generateDynamicRailpackJson = async (
let configured = false;
- // 1. Check if user provided manual build / start command overrides
- if (buildCommandOverride || startCommandOverride) {
- await onLog("Applying custom build/start settings");
+ // 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: [
@@ -237,15 +269,21 @@ 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",
"."
];
diff --git a/apps/api/src/orchestrator/railpack.ts b/apps/api/src/orchestrator/railpack.ts
index cc6ac61..9617a14 100644
--- a/apps/api/src/orchestrator/railpack.ts
+++ b/apps/api/src/orchestrator/railpack.ts
@@ -280,6 +280,8 @@ export const buildWithRailpack = async (
sourceDir?: string | null;
projectType?: string | null;
buildCommand?: string | null;
+ installCommand?: string | null;
+ outputDir?: string | null;
startCommand?: string | null;
environmentVariables?: { key: string; value: string }[];
signal?: AbortSignal;
@@ -318,6 +320,8 @@ export const buildWithRailpack = async (
opts?.buildCommand ?? null,
opts?.startCommand ?? null,
onLog,
+ opts?.installCommand ?? null,
+ opts?.outputDir ?? null,
);
const args = [
diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts
index 69c10d1..e77f103 100644
--- a/apps/api/src/types.ts
+++ b/apps/api/src/types.ts
@@ -28,6 +28,8 @@ export interface Project {
composePort: number | null;
composeServices: string | null;
buildCommand: string | null;
+ installCommand: string | null;
+ outputDir: string | null;
startCommand: string | null;
githubTokenEncrypted: string | null;
githubTokenIv: string | null;
@@ -62,6 +64,8 @@ export interface CreateProjectInput {
composePort?: number | null;
composeServices?: string | null;
buildCommand?: string | null;
+ installCommand?: string | null;
+ outputDir?: string | null;
startCommand?: string | null;
}
From 049c3641451757696e00435e3082d2ab1104b0b6 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Tue, 4 Aug 2026 17:15:14 +0100
Subject: [PATCH 2/6] chore(ui): redesign project creation UI with
framework/database logos and fix routing
---
apps/web/src/api/client.ts | 12 +
.../databases/CreateDatabaseDialog.tsx | 10 +-
.../src/components/logos/DatabaseLogos.tsx | 131 ++
.../src/components/logos/FrameworkLogos.tsx | 226 +++
.../create/ApplicationPresetSection.tsx | 44 +
.../project/create/BuildSettingsSection.tsx | 154 ++
.../project/create/BuildStrategySection.tsx | 333 +++++
.../project/create/EnvVarsSection.tsx | 243 ++++
.../project/create/ImportHeaderCard.tsx | 66 +
.../project/create/ProjectNameSection.tsx | 106 ++
.../project/create/SourceSelectionSection.tsx | 190 +++
.../components/project/create/StepBasics.tsx | 15 +
.../project/deployments/deployment-logs.tsx | 7 +-
.../project/settings/ProjectSettingsTab.tsx | 92 +-
apps/web/src/components/ui/DatabaseSelect.tsx | 157 +++
.../web/src/components/ui/FrameworkSelect.tsx | 159 +++
apps/web/src/routes/CreateProjectPage.tsx | 1246 +++--------------
apps/web/src/types/index.ts | 2 +
apps/web/src/utils/presets.ts | 179 +++
19 files changed, 2335 insertions(+), 1037 deletions(-)
create mode 100644 apps/web/src/components/logos/DatabaseLogos.tsx
create mode 100644 apps/web/src/components/logos/FrameworkLogos.tsx
create mode 100644 apps/web/src/components/project/create/ApplicationPresetSection.tsx
create mode 100644 apps/web/src/components/project/create/BuildSettingsSection.tsx
create mode 100644 apps/web/src/components/project/create/BuildStrategySection.tsx
create mode 100644 apps/web/src/components/project/create/EnvVarsSection.tsx
create mode 100644 apps/web/src/components/project/create/ImportHeaderCard.tsx
create mode 100644 apps/web/src/components/project/create/ProjectNameSection.tsx
create mode 100644 apps/web/src/components/project/create/SourceSelectionSection.tsx
create mode 100644 apps/web/src/components/ui/DatabaseSelect.tsx
create mode 100644 apps/web/src/components/ui/FrameworkSelect.tsx
create mode 100644 apps/web/src/utils/presets.ts
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index bdba666..3e09c57 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -571,3 +571,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 7681e4f..359d5e2 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 } 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,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj
const [error, setError] = useState(null);
useEffect(() => {
- setVersion(type === "postgresql" ? "18" : type === "mysql" ? "8.4" : "8.0");
+ setVersion(type === "postgresql" ? "16" : type === "mysql" ? "8.4" : type === "redis" ? "7.4" : type === "mongodb" ? "7.0" : "11.4");
}, [type]);
const create = async () => {
@@ -63,10 +65,10 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj
Public access is enabled by default and protected by database credentials plus the network allowlist.
-
Name setName(event.target.value)} placeholder="Production PostgreSQL" />
+
Name setName(event.target.value)} placeholder="Production Database" />
Attach to project No project {projects.map((project) => {project.name} )}
-
Engine setType(value as DatabaseType)}>{["postgresql", "mysql", "redis", "mongodb"].map((engine) => {engine} )}
+
Database Engine setType(val)} />
Version 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/ApplicationPresetSection.tsx b/apps/web/src/components/project/create/ApplicationPresetSection.tsx
new file mode 100644
index 0000000..faeb24e
--- /dev/null
+++ b/apps/web/src/components/project/create/ApplicationPresetSection.tsx
@@ -0,0 +1,44 @@
+import { FrameworkPreset } from '../../../utils/presets';
+import { FrameworkSelect } from '../../ui/FrameworkSelect';
+import { Layers, Sparkles } from 'lucide-react';
+
+interface ApplicationPresetSectionProps {
+ selectedPresetId: string;
+ onSelectPreset: (preset: FrameworkPreset) => void;
+}
+
+export function ApplicationPresetSection({
+ selectedPresetId,
+ onSelectPreset,
+}: ApplicationPresetSectionProps) {
+ return (
+
+
+
+
+
+ Application Preset
+
+
+ Select your framework to automatically pre-map build commands and runtime options.
+
+
+
+
+ Framework Presets
+
+
+
+ {/* Main Framework Dropdown Select */}
+
+
+ Framework Preset
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/project/create/BuildSettingsSection.tsx b/apps/web/src/components/project/create/BuildSettingsSection.tsx
new file mode 100644
index 0000000..f608b69
--- /dev/null
+++ b/apps/web/src/components/project/create/BuildSettingsSection.tsx
@@ -0,0 +1,154 @@
+import { useState } from 'react';
+import { Input } from '../../ui/input';
+import { Terminal, Sliders, ChevronDown, FolderCode, Play } from 'lucide-react';
+
+interface BuildSettingsSectionProps {
+ 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;
+ projectType: string;
+}
+
+export function BuildSettingsSection({
+ buildCommand,
+ setBuildCommand,
+ installCommand,
+ setInstallCommand,
+ startCommand,
+ setStartCommand,
+ outputDir,
+ setOutputDir,
+ port,
+ setPort,
+ sourceDir,
+ setSourceDir,
+ projectType,
+}: BuildSettingsSectionProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+ Build & Output Settings
+
+
+ Pre-configured from preset. Expand to inspect or override build commands.
+
+
+
+
+
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+}
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) */}
+
+
setBuildType('railpack')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ buildType === 'railpack'
+ ? 'bg-orange-500/10 border-orange-500/40 text-orange-400 ring-1 ring-orange-500/20'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ Automatic Build
+
+
+ Railpack Engine
+
+ Auto-detect language, install dependencies, compile, and run in isolated container.
+
+
+
+
setBuildType('compose')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ buildType === 'compose'
+ ? 'bg-orange-500/10 border-orange-500/40 text-orange-400 ring-1 ring-orange-500/20'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ Multi-Container
+
+
+ Docker Compose
+
+ Orchestrate multi-service apps via docker-compose.yml in repo root.
+
+
+
+
+ {/* Railpack Configuration */}
+ {buildType === 'railpack' && (
+
+ {/* Framework Preset Select (with SVG logos) */}
+
+
+ Application Preset
+
+
+
+
+ {/* Project Type Card Selection (Web Service vs Static Site) */}
+
+
Project Type
+
+
setProjectType('web')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ projectType === 'web'
+ ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ Dynamic Server
+
+
+ Web Service
+
+ Node.js, Elysia, Express, Next.js (SSR), Go, Python server container.
+
+
+
+
setProjectType('static')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ projectType === 'static'
+ ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-400'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ Static Site
+
+
+ Static Site / SPA
+
+ React, Vite, Astro, Vue static export served via lightweight HTTP file server.
+
+
+
+
+
+ {/* Detailed Build Commands */}
+
+
+ )}
+
+ {/* Docker Compose Service Gateway Mapping */}
+ {buildType === 'compose' && (
+
+
+
+
Compose Ingress Service Mapping
+
+ Map HTTP ingress routing to specific services defined in your compose file.
+
+
+
+ Add Service
+
+
+
+
+ {composeServicesList.map((row) => (
+
+
+ Service Name
+ updateComposeServiceRow(row.id, 'serviceName', e.target.value)}
+ className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8"
+ />
+
+
+ Container Port
+ updateComposeServiceRow(row.id, 'port', e.target.value)}
+ className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8"
+ />
+
+
+
+ Subdomain Prefix
+ updateComposeServiceRow(row.id, 'subdomain', e.target.value)}
+ className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8"
+ />
+
+ {composeServicesList.length > 1 && (
+
removeComposeServiceRow(row.id)}
+ className="p-2 text-zinc-500 hover:text-red-400 shrink-0 mb-0.5"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
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 && (
+
+
+ setMode('single')}
+ className={`text-xs px-3 py-1.5 rounded-lg font-semibold transition-colors ${
+ mode === 'single'
+ ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
+ : 'bg-[#14141a] text-zinc-400 border border-[#22222c]'
+ }`}
+ >
+ Key-Value Builder
+
+ setMode('bulk')}
+ className={`text-xs px-3 py-1.5 rounded-lg font-semibold transition-colors ${
+ mode === 'bulk'
+ ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
+ : 'bg-[#14141a] text-zinc-400 border border-[#22222c]'
+ }`}
+ >
+ Paste .env Content
+
+ setMode('file')}
+ className={`text-xs px-3 py-1.5 rounded-lg font-semibold transition-colors ${
+ mode === 'file'
+ ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
+ : 'bg-[#14141a] text-zinc-400 border border-[#22222c]'
+ }`}
+ >
+ Upload .env File
+
+
+
+ {mode === 'single' ? (
+
+ ) : mode === 'bulk' ? (
+
+
+ ) : (
+
+
+
+
Select a `.env` file to upload
+
Supports standard .env formatting with key=value lines
+
fileInputRef.current?.click()}
+ className="bg-[#1c1c24] hover:bg-[#252530] text-zinc-200 text-xs border border-[#2e2e3a] mt-2"
+ >
+ Browse Files
+
+ {fileError &&
{fileError}
}
+
+ )}
+
+ {stagedEnvs.length > 0 && (
+
+ {stagedEnvs.map((env, i) => (
+
+
+ {env.key}
+ =
+ {env.value}
+
+
removeEnv(i)}
+ className="text-zinc-500 hover:text-red-400 p-1 shrink-0"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/project/create/ImportHeaderCard.tsx b/apps/web/src/components/project/create/ImportHeaderCard.tsx
new file mode 100644
index 0000000..949b9d2
--- /dev/null
+++ b/apps/web/src/components/project/create/ImportHeaderCard.tsx
@@ -0,0 +1,66 @@
+import { GitBranch, FolderGit2, Upload, Container, CheckCircle2 } from 'lucide-react';
+import type { GithubRepo } from '../../../types';
+
+interface ImportHeaderCardProps {
+ sourceType: 'git' | 'upload' | 'compose';
+ selectedRepo: GithubRepo | null;
+ repoUrl: string;
+ repoBranch: string;
+ zipFile: File | null;
+}
+
+export function ImportHeaderCard({
+ sourceType,
+ selectedRepo,
+ repoUrl,
+ repoBranch,
+ zipFile,
+}: ImportHeaderCardProps) {
+ const getRepoDisplayName = () => {
+ if (selectedRepo) return selectedRepo.fullName;
+ if (repoUrl) {
+ try {
+ const url = new URL(repoUrl);
+ return url.pathname.replace(/^\//, '').replace(/\.git$/, '');
+ } catch {
+ return repoUrl;
+ }
+ }
+ return 'Custom Git Repository';
+ };
+
+ return (
+
+
+
+ {sourceType === 'git' ? (
+
+ ) : sourceType === 'upload' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Importing from {sourceType === 'git' ? 'GitHub' : sourceType === 'upload' ? 'ZIP Archive' : 'Docker Compose'}
+
+
+
+
+
+ {sourceType === 'git' ? getRepoDisplayName() : sourceType === 'upload' ? (zipFile?.name || 'Uploaded Source ZIP') : 'docker-compose.yml'}
+
+ {sourceType === 'git' && (
+
+
+ {repoBranch || selectedRepo?.defaultBranch || 'main'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/project/create/ProjectNameSection.tsx b/apps/web/src/components/project/create/ProjectNameSection.tsx
new file mode 100644
index 0000000..9dc6a8a
--- /dev/null
+++ b/apps/web/src/components/project/create/ProjectNameSection.tsx
@@ -0,0 +1,106 @@
+import { Input } from '../../ui/input';
+import { Globe, UserCheck, ShieldCheck } from 'lucide-react';
+
+interface ProjectNameSectionProps {
+ name: string;
+ setName: (v: string) => void;
+ description: string;
+ setDescription: (v: string) => void;
+ baseDomain: string;
+ setBaseDomain: (v: string) => void;
+}
+
+export function ProjectNameSection({
+ name,
+ setName,
+ description,
+ setDescription,
+ baseDomain,
+ setBaseDomain,
+}: ProjectNameSectionProps) {
+ const generatedDomain = name
+ ? `${name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}.${baseDomain || 'localhost'}`
+ : `project-name.${baseDomain || 'localhost'}`;
+
+ return (
+
+
+
+
+
+ Project Details & Domain
+
+
+ Choose a unique project identifier and domain route.
+
+
+
+
+
+ {/* Scope / Environment */}
+
+
+ Dequel Environment Scope
+
+
+
+
+ Local Platform Node
+
+
+ Active
+
+
+
+
+ {/* Project Name Input */}
+
+
+ Project Name *
+
+ setName(e.target.value)}
+ placeholder="my-awesome-app"
+ className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-10 rounded-xl font-mono text-xs"
+ />
+
+
+
+ {/* Description */}
+
+
+ Description (Optional)
+
+ setDescription(e.target.value)}
+ placeholder="Production web application service with automated deployment"
+ className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl text-xs"
+ />
+
+
+ {/* Base Domain & Live Ingress Preview */}
+
+
+
+ Custom Base Domain
+
+
+
+ Ingress Domain: {generatedDomain}
+
+
+
setBaseDomain(e.target.value)}
+ placeholder="e.g. app.domain.com"
+ className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl text-xs"
+ />
+
+
+ );
+}
diff --git a/apps/web/src/components/project/create/SourceSelectionSection.tsx b/apps/web/src/components/project/create/SourceSelectionSection.tsx
new file mode 100644
index 0000000..eea4c0e
--- /dev/null
+++ b/apps/web/src/components/project/create/SourceSelectionSection.tsx
@@ -0,0 +1,190 @@
+import { Input } from '../../ui/input';
+import { Button } from '../../ui/button';
+import { RepoPicker } from '../../github/RepoPicker';
+import { GitBranch, Upload, FolderGit2, Link2, CheckCircle2, Lock } from 'lucide-react';
+import type { GithubRepo } from '../../../types';
+import { cn } from '../../../lib/utils';
+
+interface SourceSelectionSectionProps {
+ sourceType: 'git' | 'upload';
+ setSourceType: (v: 'git' | 'upload') => void;
+ selectedRepo: GithubRepo | null;
+ onSelectRepo: (repo: GithubRepo | null) => void;
+ repoUrl: string;
+ setRepoUrl: (v: string) => void;
+ repoBranch: string;
+ setRepoBranch: (v: string) => void;
+ showManualGit: boolean;
+ setShowManualGit: (v: boolean) => void;
+ githubConnected: boolean;
+ githubConfigured: boolean;
+ zipFile: File | null;
+ setZipFile: (f: File | null) => void;
+ onConnectGithub: () => void;
+}
+
+export function SourceSelectionSection({
+ sourceType,
+ setSourceType,
+ selectedRepo,
+ onSelectRepo,
+ repoUrl,
+ setRepoUrl,
+ repoBranch,
+ setRepoBranch,
+ showManualGit,
+ setShowManualGit,
+ githubConnected,
+ githubConfigured,
+ zipFile,
+ setZipFile,
+ onConnectGithub,
+}: SourceSelectionSectionProps) {
+ return (
+
+
+
+
+ Code Provider & Source
+
+
+ Choose where your application source code is hosted or uploaded from.
+
+
+
+ {/* Source Type Selector Cards (Git vs ZIP Upload) */}
+
+
setSourceType('git')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ sourceType === 'git'
+ ? 'bg-orange-500/10 border-orange-500/40 text-orange-400 ring-1 ring-orange-500/20'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ Git Repository
+
+
+ Git Provider
+
+ Import from GitHub repositories or any public/private Git clone URL.
+
+
+
+
setSourceType('upload')}
+ className={cn(
+ 'p-4 rounded-xl border text-left transition-all space-y-1.5',
+ sourceType === 'upload'
+ ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-400 ring-1 ring-emerald-500/20'
+ : 'bg-[#121215] border-[#22222a] text-zinc-400 hover:border-zinc-700'
+ )}
+ >
+
+
+
+ ZIP Upload
+
+
+ Local Archive
+
+ Upload source code archive directly from your local computer.
+
+
+
+
+ {/* Git Source Options */}
+ {sourceType === 'git' && (
+
+
+ Repository Details
+ setShowManualGit(!showManualGit)}
+ className="text-xs text-orange-400 hover:underline flex items-center gap-1 font-medium"
+ >
+
+ {showManualGit ? 'Use GitHub Integration' : 'Enter Manual Git URL'}
+
+
+
+ {!showManualGit && (
+
+ )}
+
+ {(showManualGit || !githubConnected) && (
+
+ )}
+
+ )}
+
+ {/* ZIP Upload Option */}
+ {sourceType === 'upload' && (
+
+
+
+
+
Upload Project Source Code (.zip)
+
+ Ensure your repository root contains your app files or docker-compose.yml file.
+
+
+
setZipFile(e.target.files?.[0] || null)}
+ className="hidden"
+ id="zip-source-input"
+ />
+
document.getElementById('zip-source-input')?.click()}
+ className="bg-[#1c1c24] hover:bg-[#262632] text-zinc-200 text-xs border border-[#2e2e3c]"
+ >
+ Browse Local ZIP
+
+ {zipFile && (
+
+
+ Selected: {zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx
index d23f9fa..a4d2f25 100644
--- a/apps/web/src/components/project/create/StepBasics.tsx
+++ b/apps/web/src/components/project/create/StepBasics.tsx
@@ -12,6 +12,8 @@ import { getGithubAuthUrl } from "../../../api/client";
import { RepoPicker } from "../../github/RepoPicker";
import type { GithubRepo } from "../../../types";
import { cn } from "../../../lib/utils";
+import { FrameworkSelect } from "../../ui/FrameworkSelect";
+import { FRAMEWORK_PRESETS } from "../../../utils/presets";
interface StepBasicsProps {
name: string;
@@ -177,6 +179,19 @@ export function StepBasics({
}
/>
+
+
+
+ Application Framework Preset
+
+ {
+ setProjectType(preset.projectType);
+ }}
+ />
+
+
Project Type
diff --git a/apps/web/src/components/project/deployments/deployment-logs.tsx b/apps/web/src/components/project/deployments/deployment-logs.tsx
index 1c4cece..1cc2bb3 100644
--- a/apps/web/src/components/project/deployments/deployment-logs.tsx
+++ b/apps/web/src/components/project/deployments/deployment-logs.tsx
@@ -23,6 +23,11 @@ export function formatTimeAgo(dateStr: string) {
return `${Math.floor(hours / 24)}d ago`;
}
+const isErrorLogLine = (message: string) =>
+ /^(CRITICAL|ERROR|Deployment failed|Rollback failed)/i.test(
+ message,
+ );
+
export function parseTimestamp(raw: string) {
if (!raw) return Date.now();
const normalized =
@@ -176,7 +181,7 @@ export function DeploymentLogs({
{logs.map((log, i) => (
[{log.stage}
diff --git a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
index b92cc4e..aac1ddd 100644
--- a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
+++ b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
@@ -20,6 +20,8 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
const [composePort, setComposePort] = useState("");
const [sourceDir, setSourceDir] = useState("");
const [buildCommand, setBuildCommand] = useState("");
+ const [installCommand, setInstallCommand] = useState("");
+ const [outputDir, setOutputDir] = useState("");
const [startCommand, setStartCommand] = useState("");
const [port, setPort] = useState("");
const [description, setDescription] = useState("");
@@ -40,6 +42,8 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
setComposePort(prt);
setSourceDir(project.sourceDir || "");
setBuildCommand(project.buildCommand || "");
+ setInstallCommand((project as any).installCommand || "");
+ setOutputDir((project as any).outputDir || "");
setStartCommand(project.startCommand || "");
setPort(project.port ? String(project.port) : "");
setDescription(project.description || "");
@@ -93,6 +97,8 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
buildType,
sourceDir: sourceDir.trim() || null,
buildCommand: buildCommand.trim() || null,
+ installCommand: installCommand.trim() || null,
+ outputDir: outputDir.trim() || null,
startCommand: startCommand.trim() || null,
port: port.trim() ? Number(port) || null : null,
description: description.trim() || null,
@@ -145,6 +151,50 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
+
+
+ Project Type
+
+
+
setProjectType("web")}
+ className={cn(
+ "flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all select-none active:scale-[0.98]",
+ projectType === "web"
+ ? "border-orange-500/30 bg-orange-500/5 text-zinc-200"
+ : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
+ )}
+ >
+
+
+ Web Service
+
+
+ Node.js, Elysia, Express, Next.js (SSR), Go, Python dynamic server container.
+
+
+
setProjectType("static")}
+ className={cn(
+ "flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all select-none active:scale-[0.98]",
+ projectType === "static"
+ ? "border-emerald-500/30 bg-emerald-500/5 text-zinc-200"
+ : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
+ )}
+ >
+
+
+ Static Site / SPA
+
+
+ React, Vite, Astro, HTML static export served via lightweight HTTP file server.
+
+
+
+
+
{/* Build Strategy */}
@@ -230,7 +280,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
updateComposeServiceRow(item.id, "serviceName", e.target.value)}
+ onChange={(e) => updateComposeServiceRow(item.id, 'serviceName', e.target.value)}
className="bg-[#141418] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
/>
@@ -242,7 +292,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
placeholder="e.g. 3001 or 8080"
type="number"
value={item.port}
- onChange={(e) => updateComposeServiceRow(item.id, "port", e.target.value)}
+ onChange={(e) => updateComposeServiceRow(item.id, 'port', e.target.value)}
className="bg-[#141418] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
/>
@@ -259,7 +309,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
updateComposeServiceRow(item.id, "subdomain", e.target.value)}
+ onChange={(e) => updateComposeServiceRow(item.id, 'subdomain', e.target.value)}
className="bg-[#141418] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
/>
)}
@@ -316,6 +366,23 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
+ {/* Install Command Override */}
+
+
+ Install Command
+
+
setInstallCommand(e.target.value)}
+ />
+
+ Override the dependency installation command. Leave blank for auto.
+
+
+
{/* Build Command Override */}
@@ -333,8 +400,25 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Start Command Override */}
+ {/* Output Directory */}
+
+ Output Directory
+
+
setOutputDir(e.target.value)}
+ />
+
+ Directory where compiled static assets are located (for Static Site deployments).
+
+
+
+ {/* Start Command Override */}
+
Start Command
diff --git a/apps/web/src/components/ui/DatabaseSelect.tsx b/apps/web/src/components/ui/DatabaseSelect.tsx
new file mode 100644
index 0000000..35873ab
--- /dev/null
+++ b/apps/web/src/components/ui/DatabaseSelect.tsx
@@ -0,0 +1,157 @@
+import { useState, useRef, useEffect } from 'react';
+import type { DatabaseType } from '../../types';
+import { getDatabaseLogo } from '../logos/DatabaseLogos';
+import { ChevronDown, Check } from 'lucide-react';
+import { cn } from '../../lib/utils';
+
+export interface DatabaseEngineInfo {
+ type: DatabaseType;
+ name: string;
+ defaultVersion: string;
+ description: string;
+}
+
+export const DATABASE_ENGINES: DatabaseEngineInfo[] = [
+ {
+ type: 'postgresql',
+ name: 'PostgreSQL',
+ defaultVersion: '16',
+ description: 'Enterprise relational database with JSONB & ACID compliance'
+ },
+ {
+ type: 'mysql',
+ name: 'MySQL',
+ defaultVersion: '8.4',
+ description: 'Popular open-source relational database'
+ },
+ {
+ type: 'redis',
+ name: 'Redis',
+ defaultVersion: '7.4',
+ description: 'Ultra-fast in-memory key-value data store & pub/sub cache'
+ },
+ {
+ type: 'mongodb',
+ name: 'MongoDB',
+ defaultVersion: '7.0',
+ description: 'Scalable document-oriented NoSQL database'
+ },
+ {
+ type: 'mariadb',
+ name: 'MariaDB',
+ defaultVersion: '11.4',
+ description: 'Community-developed relational database fork of MySQL'
+ }
+];
+
+interface DatabaseSelectProps {
+ value: DatabaseType;
+ onValueChange: (value: DatabaseType) => void;
+ className?: string;
+}
+
+export function DatabaseSelect({ value, onValueChange, className }: DatabaseSelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef
(null);
+
+ const selectedEngine =
+ DATABASE_ENGINES.find((e) => e.type === value) || DATABASE_ENGINES[0];
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className={cn(
+ 'w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl',
+ 'bg-[#121215] border border-[#22222a] hover:border-amber-500/40 transition-all text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-amber-500/20',
+ isOpen && 'border-amber-500/60 ring-2 ring-amber-500/20 bg-[#16161c]'
+ )}
+ >
+
+
+ {getDatabaseLogo(selectedEngine.type, 'h-4 w-4')}
+
+
+ {selectedEngine.name}
+
+ v{selectedEngine.defaultVersion}
+
+
+
+
+
+
+ {isOpen && (
+
+
+ {DATABASE_ENGINES.map((engine) => {
+ const isSelected = engine.type === value;
+ return (
+
{
+ onValueChange(engine.type);
+ setIsOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between p-2.5 rounded-lg text-left transition-all group',
+ isSelected
+ ? 'bg-amber-500/10 border border-amber-500/30 text-zinc-100'
+ : 'hover:bg-[#1c1c24] text-zinc-300 border border-transparent'
+ )}
+ >
+
+
+ {getDatabaseLogo(engine.type, 'h-4 w-4')}
+
+
+
+
+ {engine.name}
+
+
+ v{engine.defaultVersion}
+
+
+
{engine.description}
+
+
+ {isSelected && }
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/ui/FrameworkSelect.tsx b/apps/web/src/components/ui/FrameworkSelect.tsx
new file mode 100644
index 0000000..43b3dbc
--- /dev/null
+++ b/apps/web/src/components/ui/FrameworkSelect.tsx
@@ -0,0 +1,159 @@
+import { useState, useRef, useEffect } from 'react';
+import { FrameworkPreset, FRAMEWORK_PRESETS } from '../../utils/presets';
+import { getFrameworkLogo } from '../logos/FrameworkLogos';
+import { ChevronDown, Check, Search } from 'lucide-react';
+import { cn } from '../../lib/utils';
+
+interface FrameworkSelectProps {
+ selectedPresetId: string;
+ onSelectPreset: (preset: FrameworkPreset) => void;
+ className?: string;
+}
+
+export function FrameworkSelect({ selectedPresetId, onSelectPreset, className }: FrameworkSelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const containerRef = useRef(null);
+
+ const selectedPreset =
+ FRAMEWORK_PRESETS.find((p) => p.id === selectedPresetId) ||
+ FRAMEWORK_PRESETS.find((p) => p.id === 'other') ||
+ FRAMEWORK_PRESETS[0];
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const filteredPresets = FRAMEWORK_PRESETS.filter((preset) =>
+ preset.name.toLowerCase().includes(search.toLowerCase()) ||
+ preset.description.toLowerCase().includes(search.toLowerCase())
+ );
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className={cn(
+ 'w-full flex items-center justify-between gap-3 px-3.5 py-3 rounded-xl',
+ 'bg-[#121215] border border-[#22222a] hover:border-orange-500/40 transition-all text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500/20',
+ isOpen && 'border-orange-500/60 ring-2 ring-orange-500/20 bg-[#16161c]'
+ )}
+ >
+
+
+ {getFrameworkLogo(selectedPreset.icon || selectedPreset.name, 'h-5 w-5')}
+
+
+
+ {selectedPreset.name}
+
+ {selectedPreset.projectType === 'web' ? 'Web Service' : 'Static Site'}
+
+
+
{selectedPreset.description}
+
+
+
+
+
+ {isOpen && (
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search frameworks (Next.js, Vite, Astro, Express...)"
+ className="w-full bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 focus:outline-none py-1"
+ autoFocus
+ />
+
+
+
+ {filteredPresets.length === 0 ? (
+
No framework presets match "{search}"
+ ) : (
+ filteredPresets.map((preset) => {
+ const isSelected = preset.id === selectedPreset.id;
+ return (
+
{
+ onSelectPreset(preset);
+ setIsOpen(false);
+ setSearch('');
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between p-2.5 rounded-lg text-left transition-all group',
+ isSelected
+ ? 'bg-orange-500/10 border border-orange-500/30 text-zinc-100'
+ : 'hover:bg-[#1c1c24] text-zinc-300 border border-transparent'
+ )}
+ >
+
+
+ {getFrameworkLogo(preset.icon || preset.name, 'h-4 w-4')}
+
+
+
+
+ {preset.name}
+
+
+ {preset.projectType === 'web' ? 'Web' : 'Static'}
+
+
+
{preset.description}
+
+
+ {isSelected && }
+
+ );
+ })
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/routes/CreateProjectPage.tsx b/apps/web/src/routes/CreateProjectPage.tsx
index 6ba7038..c3f172f 100644
--- a/apps/web/src/routes/CreateProjectPage.tsx
+++ b/apps/web/src/routes/CreateProjectPage.tsx
@@ -2,74 +2,49 @@ import { useState, useEffect } from 'react';
import { useNavigate, Link } from '@tanstack/react-router';
import { useCreateProject } from '../hooks/useProjects';
import { Button } from '../components/ui/button';
-import { Input } from '../components/ui/input';
-import { cn } from '../lib/utils';
import * as api from '../api/client';
import { getGithubIntegration } from '../api/client';
-import type { GithubRepo } from '../types';
-import { RepoPicker } from '../components/github/RepoPicker';
+import { FRAMEWORK_PRESETS, type FrameworkPreset } from '../utils/presets';
import { CreationStatusOverlay } from '../components/project/create/CreationStatusOverlay';
-import {
- ArrowLeft,
- ArrowRight,
- GitBranch,
- Upload,
- Container,
- Sparkles,
- CheckCircle2,
- Key,
- Database,
- Plus,
- Trash2,
- Box,
- Server,
- FileText,
- Globe,
- Sliders,
- AlertCircle
-} from 'lucide-react';
-
-interface StagedEnv {
- key: string;
- value: string;
- environment?: string;
-}
+import { SourceSelectionSection } from '../components/project/create/SourceSelectionSection';
+import { ProjectNameSection } from '../components/project/create/ProjectNameSection';
+import { BuildStrategySection, ComposeServiceRow } from '../components/project/create/BuildStrategySection';
+import { EnvVarsSection, StagedEnv } from '../components/project/create/EnvVarsSection';
+import { ArrowLeft, Rocket, Server, AlertCircle, CheckCircle2, Sparkles } from 'lucide-react';
+import type { GithubRepo } from '../types';
export function CreateProjectPage() {
const navigate = useNavigate();
const createProject = useCreateProject();
- const [step, setStep] = useState(1);
-
- // Form State
- const [name, setName] = useState('');
- const [description, setDescription] = useState('');
- const [baseDomain, setBaseDomain] = useState('');
+ // Source State
const [sourceType, setSourceType] = useState<'git' | 'upload'>('git');
- const [buildType, setBuildType] = useState<'railpack' | 'compose'>('railpack');
-
- // Git Settings
+ const [selectedRepo, setSelectedRepo] = useState(null);
const [repoUrl, setRepoUrl] = useState('');
const [repoBranch, setRepoBranch] = useState('');
- const [selectedRepo, setSelectedRepo] = useState(null);
+ const [zipFile, setZipFile] = useState(null);
const [githubConnected, setGithubConnected] = useState(false);
const [githubConfigured, setGithubConfigured] = useState(false);
const [showManualGit, setShowManualGit] = useState(false);
- // ZIP Settings
- const [zipFile, setZipFile] = useState(null);
+ // Form State
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [baseDomain, setBaseDomain] = useState('');
- // Build & Runtime Options (Railpack)
+ // Build & Runtime Strategy State
+ const [buildType, setBuildType] = useState<'railpack' | 'compose'>('railpack');
const [projectType, setProjectType] = useState('web');
- const [port, setPort] = useState('');
+ const [selectedPresetId, setSelectedPresetId] = useState('nextjs-web');
+ const [port, setPort] = useState('3000');
const [sourceDir, setSourceDir] = useState('');
- const [buildCommand, setBuildCommand] = useState('');
- const [startCommand, setStartCommand] = useState('');
- const [cpuLimit, setCpuLimit] = useState('');
- const [memoryLimitMb, setMemoryLimitMb] = useState('');
+ const [buildCommand, setBuildCommand] = useState('npm run build');
+ const [installCommand, setInstallCommand] = useState('npm install');
+ const [outputDir, setOutputDir] = useState('.next');
+ const [startCommand, setStartCommand] = useState('npm run start');
- // Docker Compose Settings
- const [composeServicesList, setComposeServicesList] = useState<{ id: string; serviceName: string; port: string; subdomain: string }[]>([
+ // Docker Compose Gateway Mapping
+ const [composeServicesList, setComposeServicesList] = useState([
{ id: '1', serviceName: '', port: '', subdomain: '' }
]);
@@ -91,25 +66,12 @@ export function CreateProjectPage() {
);
};
- // Environment Variables State & Tabs
+ // Environment Variables State
const [stagedEnvs, setStagedEnvs] = useState([]);
- const [envTab, setEnvTab] = useState<'single' | 'bulk' | 'file'>('single');
- const [singleKey, setSingleKey] = useState('');
- const [singleVal, setSingleVal] = useState('');
- const [bulkText, setBulkText] = useState('');
- const [fileError, setFileError] = useState('');
-
- // Legacy project-scoped database controls are hidden in favor of /databases.
- const provisionDb = false;
- const setProvisionDb = (_value: boolean) => {};
- const [dbType, setDbType] = useState('postgresql');
- const [dbVersion, setDbVersion] = useState('16-alpine');
- const [dbCpu, setDbCpu] = useState('');
- const [dbMemory, setDbMemory] = useState('');
- // Status
+ // Status State
const [submittingStatus, setSubmittingStatus] = useState<
- 'idle' | 'creating_project' | 'creating_envs' | 'creating_db' | 'done' | 'error'
+ 'idle' | 'creating_project' | 'creating_envs' | 'done' | 'error'
>('idle');
const [errorMessage, setErrorMessage] = useState('');
@@ -118,7 +80,8 @@ export function CreateProjectPage() {
.then((status) => {
if ((status as any).configured) {
setGithubConfigured(true);
- api.getGithubUser()
+ api
+ .getGithubUser()
.then(() => setGithubConnected(true))
.catch(() => {});
} else {
@@ -128,1025 +91,252 @@ export function CreateProjectPage() {
.catch(() => {});
}, []);
- // Update default version when database engine changes
- useEffect(() => {
- if (dbType === 'postgresql') setDbVersion('16');
- else if (dbType === 'mysql') setDbVersion('8.0');
- else if (dbType === 'mariadb') setDbVersion('11.4');
- else if (dbType === 'redis') setDbVersion('7.4');
- else if (dbType === 'mongodb') setDbVersion('7.0');
- }, [dbType]);
-
- // Env Parsing helper
- const parseEnvText = (text: string): StagedEnv[] => {
- const lines = text
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter((line) => line && !line.startsWith('#'));
-
- const result: StagedEnv[] = [];
- for (const line of lines) {
- const idx = line.indexOf('=');
- if (idx <= 0) continue;
- const key = line.slice(0, idx).trim();
- let value = line.slice(idx + 1).trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- if (!key) continue;
- result.push({ key, value, environment: 'production' });
- }
- return result;
+ 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(preset.defaultPort);
};
- const handleAddSingleEnv = () => {
- if (!singleKey.trim() || !singleVal.trim()) return;
- setStagedEnvs((prev) => {
- const next = [...prev];
- const idx = next.findIndex((x) => x.key === singleKey.trim());
- if (idx >= 0) {
- next[idx] = { key: singleKey.trim(), value: singleVal.trim(), environment: 'production' };
- } else {
- next.push({ key: singleKey.trim(), value: singleVal.trim(), environment: 'production' });
+ const handleSelectRepo = (repo: GithubRepo | null) => {
+ setSelectedRepo(repo);
+ if (repo) {
+ setRepoUrl(repo.cloneUrl);
+ setRepoBranch(repo.defaultBranch);
+ if (!name) {
+ setName(repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
}
- return next;
- });
- setSingleKey('');
- setSingleVal('');
- };
-
- const handleBulkImport = () => {
- const parsed = parseEnvText(bulkText);
- if (parsed.length === 0) {
- setFileError('No valid KEY=VALUE pairs found in text.');
- return;
}
- setStagedEnvs((prev) => {
- const next = [...prev];
- for (const item of parsed) {
- const idx = next.findIndex((x) => x.key === item.key);
- if (idx >= 0) {
- next[idx] = item;
- } else {
- next.push(item);
- }
- }
- return next;
- });
- setBulkText('');
- setFileError('');
};
- const handleFileSelect = async (e: React.ChangeEvent) => {
- setFileError('');
- const file = e.target.files?.[0];
- if (!file) return;
- try {
- const text = await file.text();
- const parsed = parseEnvText(text);
- if (parsed.length === 0) {
- setFileError('No valid KEY=VALUE pairs found in file.');
- return;
- }
- setStagedEnvs((prev) => {
- const next = [...prev];
- for (const item of parsed) {
- const idx = next.findIndex((x) => x.key === item.key);
- if (idx >= 0) {
- next[idx] = item;
- } else {
- next.push(item);
- }
- }
- return next;
- });
- setFileError('');
- } catch {
- setFileError('Failed to read file.');
+ const handleSubmit = async () => {
+ if (!name.trim()) {
+ setErrorMessage('Please enter a project name.');
+ return;
}
- };
-
- const handleRemoveEnv = (index: number) => {
- setStagedEnvs((prev) => prev.filter((_, i) => i !== index));
- };
-
- const handleCreate = async () => {
- if (!name.trim()) return;
setSubmittingStatus('creating_project');
setErrorMessage('');
- let project: any = null;
-
try {
- const payload: any = {
+ let finalRepoUrl = repoUrl;
+
+ if (sourceType === 'upload' && zipFile) {
+ const uploadRes = await api.uploadSourceZip(zipFile);
+ finalRepoUrl = uploadRes.filePath;
+ }
+
+ let composeServicesPayload: string | undefined = undefined;
+ if (buildType === 'compose' && composeServicesList.length > 0) {
+ const validServices = composeServicesList.filter((s) => s.serviceName.trim());
+ if (validServices.length > 0) {
+ composeServicesPayload = JSON.stringify(validServices);
+ }
+ }
+
+ const projectPayload: any = {
name: name.trim(),
description: description.trim() || undefined,
baseDomain: baseDomain.trim() || undefined,
- repoUrl: repoUrl.trim() || undefined,
- repoBranch: repoBranch.trim() || undefined,
- port: port.trim() ? Number(port) || null : undefined,
- sourceDir: sourceDir.trim() || undefined,
sourceType,
- projectType,
+ repoUrl: finalRepoUrl,
+ repoBranch: repoBranch.trim() || undefined,
buildType,
- buildCommand: buildCommand.trim() || undefined,
- startCommand: startCommand.trim() || undefined,
- cpuLimit: cpuLimit.trim() ? Number(cpuLimit) || null : undefined,
- memoryLimitMb: memoryLimitMb.trim() ? Number(memoryLimitMb) || null : undefined,
+ projectType: buildType === 'railpack' ? projectType : 'web',
+ sourceDir: sourceDir.trim() || undefined,
+ buildCommand: buildType === 'railpack' ? buildCommand.trim() || undefined : undefined,
+ installCommand: buildType === 'railpack' ? installCommand.trim() || undefined : undefined,
+ outputDir: buildType === 'railpack' ? outputDir.trim() || undefined : undefined,
+ startCommand: buildType === 'railpack' ? startCommand.trim() || undefined : undefined,
+ port: port ? Number(port) : undefined,
+ composeServices: composeServicesPayload,
};
+ const project = await createProject.mutateAsync(projectPayload);
+
if (stagedEnvs.length > 0) {
setSubmittingStatus('creating_envs');
- await Promise.all(
- stagedEnvs.map((env) =>
- api.createEnvVar(project.id, {
- key: env.key.trim(),
- value: env.value.trim(),
- environment: env.environment || 'production',
- })
- )
- );
- }
-
- if (zipFile && sourceType === 'upload') {
- const form = new FormData();
- form.append('sourceType', 'upload');
- form.append('projectId', project.id);
- form.append('archive', zipFile);
- await api.createDeployment(form);
- } else if (sourceType === 'git' && repoUrl.trim()) {
- const form = new FormData();
- form.append('sourceType', 'git');
- form.append('projectId', project.id);
- form.append('gitUrl', repoUrl.trim());
- if (repoBranch.trim()) form.append('branch', repoBranch.trim());
- await api.createDeployment(form);
+ for (const env of stagedEnvs) {
+ await api.setEnvVar(project.id, env.key, env.value, env.environment);
+ }
}
setSubmittingStatus('done');
-
setTimeout(() => {
- navigate({ to: '/project/$projectId', params: { projectId: project.id } });
- }, 1200);
+ navigate({ to: '/projects/$id', params: { id: project.id } });
+ }, 1000);
} catch (err: any) {
- if (project) {
- api.deleteProject(project.id).catch(() => {});
- }
- console.error(err);
- setErrorMessage(err.message || 'An unexpected error occurred during project creation.');
setSubmittingStatus('error');
+ setErrorMessage(err.message || 'Failed to create project.');
}
};
return (
-
- {/* Top Header */}
-
-
+
+ {/* Top Bar Header */}
+
+
-
+
+ Back to Dashboard
-
-
- Create New Project
-
-
Deploy web applications, APIs, or multi-container stacks.
-
-
-
- {/* Stepper indicator */}
-
- {[1, 2, 3, 4].map((s) => (
-
s < step && setStep(s)}
- className={cn(
- 'w-8 h-8 rounded-lg text-xs font-bold transition-all flex items-center justify-center',
- step === s
- ? 'bg-amber-500 text-white font-mono shadow-lg shadow-amber-500/20'
- : s < step
- ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
- : 'bg-[#121215] text-zinc-600 border border-[#1c1c21]'
- )}
- >
- {s < step ? : s}
-
- ))}
+
New Project
+
+ Deploy an application from Git or ZIP upload with automated routing, SSL, and scaling.
+
- {submittingStatus !== 'idle' ? (
-
0}
- hasDb={provisionDb}
- dbType={dbType}
- onRetry={() => setSubmittingStatus('idle')}
- />
- ) : (
-
- {/* Form Content */}
-
- {/* STEP 1: General & Source */}
- {step === 1 && (
-
-
-
-
- 1. Identity & Source
-
-
Name your project, set your domain, and connect code.
-
-
-
-
-
- {/* Code Provider Selection */}
-
-
Code Provider
-
-
setSourceType('git')}
- className={cn(
- 'p-4 rounded-xl border text-left transition-all flex items-start gap-3',
- sourceType === 'git'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#121215] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
Git Repository
-
Deploy from GitHub or Git URL
-
-
-
-
setSourceType('upload')}
- className={cn(
- 'p-4 rounded-xl border text-left transition-all flex items-start gap-3',
- sourceType === 'upload'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#121215] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
ZIP Archive
-
Upload source code ZIP directly
-
-
-
-
-
- {/* Git Fields & RepoPicker */}
- {sourceType === 'git' && (
-
-
-
Repository Selection
-
- setShowManualGit(false)}
- className={cn(
- 'px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all',
- !showManualGit ? 'bg-orange-500 text-white' : 'text-zinc-400 hover:text-zinc-200'
- )}
- >
- GitHub Repos
-
- setShowManualGit(true)}
- className={cn(
- 'px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all',
- showManualGit ? 'bg-orange-500 text-white' : 'text-zinc-400 hover:text-zinc-200'
- )}
- >
- Manual URL
-
-
-
-
- {!showManualGit ? (
-
{
- setSelectedRepo(repo);
- if (repo) {
- setRepoUrl(repo.clone_url);
- setRepoBranch(repo.default_branch);
- if (!name) setName(repo.name);
- }
- }}
- onDisconnect={() => setGithubConnected(false)}
- />
- ) : (
-
- )}
-
- )}
+
+ {/* Left Column: Form Configuration */}
+
+ {/* Source Selection (Git Provider vs ZIP Upload) */}
+
{
+ const res = await api.getGithubAuthUrl();
+ window.location.href = res.url;
+ }}
+ />
+
+ {/* Project Details & Ingress Domain */}
+
+
+ {/* Build Strategy & Application Preset (with SVG logos) */}
+
+
+ {/* Environment Variables (with File Upload, Key-Value builder, and Bulk paste) */}
+
+
+ {/* Error Message Display */}
+ {errorMessage && (
+
+ )}
+
- {/* Upload Field */}
- {sourceType === 'upload' && (
-
- ZIP File
- setZipFile(e.target.files?.[0] || null)}
- className="block w-full text-xs text-zinc-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-orange-500/10 file:text-orange-400 hover:file:bg-orange-500/20 cursor-pointer"
- />
-
- )}
-
+ {/* Right Column: Live Summary Sidebar */}
+
+
+
+
+ Deployment Summary
+
+
+
+
+
Project Name
+
{name || '—'}
- )}
-
- {/* STEP 2: Build & Runtime Strategy */}
- {step === 2 && (
-
-
-
-
- 2. Build & Runtime Strategy
-
-
Select how Dequel builds and manages your containers.
-
- {/* Build Type Card Selection */}
-
-
setBuildType('railpack')}
- className={cn(
- 'p-5 rounded-xl border text-left transition-all space-y-2',
- buildType === 'railpack'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#121215] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- Default
-
-
- Railpack Engine
-
- Zero-config auto-detection for Node.js, Go, Python, Rust, Astro, Next.js, etc. Builds a single container.
-
-
-
-
setBuildType('compose')}
- className={cn(
- 'p-5 rounded-xl border text-left transition-all space-y-2',
- buildType === 'compose'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#121215] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- Multi-Container
-
-
- Docker Compose
-
- Build and deploy multi-service applications using a docker-compose.yml file.
-
-
+ {baseDomain && (
+
+
Base Ingress Domain
+
{baseDomain}
+ )}
- {/* Specific Config based on Build Type */}
- {buildType === 'railpack' ? (
-
- ) : (
- /* Compose Settings */
-
-
-
- Docker Compose Services
-
-
- Bind your docker-compose services to preferred subdomains (e.g. Service: server, Port: 3001, Preferred Subdomain: api points api.<projectliveurl> to server:3001).
-
-
-
-
- {composeServicesList.map((item, index) => (
-
-
-
-
- Service #{index + 1}
-
- {index === 0 && (
-
- ENTRY
-
- )}
-
- {composeServicesList.length > 1 && index > 0 && (
-
removeComposeServiceRow(item.id)}
- className="h-7 text-xs text-zinc-500 hover:text-rose-400 hover:bg-rose-500/10 px-2 rounded-lg"
- >
- Remove
-
- )}
-
-
-
-
- Service Name
-
- updateComposeServiceRow(item.id, 'serviceName', e.target.value)}
- className="bg-[#0c0c0e] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
- />
-
-
-
- Service Port
-
- updateComposeServiceRow(item.id, 'port', e.target.value)}
- className="bg-[#0c0c0e] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
- />
-
-
-
- Preferred Subdomain
-
- {index === 0 ? (
-
- ENTRY
- Primary Project Domain
-
- ) : (
-
updateComposeServiceRow(item.id, 'subdomain', e.target.value)}
- className="bg-[#0c0c0e] border-[#222227] text-zinc-200 text-xs h-9 font-mono focus:border-orange-500"
- />
- )}
-
-
-
- ))}
-
-
- Add Another Service Mapping
-
-
-
- )}
+
+
Code Provider
+
{sourceType}
- )}
-
- {/* STEP 3: Environment Variables & Database */}
- {step === 3 && (
-
-
-
-
- 3. Environment Variables & Database
-
-
Configure secrets and optionally provision a managed database.
-
-
- {/* Env Vars Section */}
-
-
-
-
Environment Variables
-
Inject build & runtime secrets into your containers.
-
-
-
setEnvTab('single')}
- className={cn(
- 'px-3 py-1 text-[11px] font-semibold rounded-md transition-all flex items-center gap-1',
- envTab === 'single' ? 'bg-orange-500 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'
- )}
- >
- Key-Value
-
-
setEnvTab('bulk')}
- className={cn(
- 'px-3 py-1 text-[11px] font-semibold rounded-md transition-all flex items-center gap-1',
- envTab === 'bulk' ? 'bg-orange-500 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'
- )}
- >
- Bulk Text
-
-
setEnvTab('file')}
- className={cn(
- 'px-3 py-1 text-[11px] font-semibold rounded-md transition-all flex items-center gap-1',
- envTab === 'file' ? 'bg-orange-500 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'
- )}
- >
- .env File
-
-
-
-
- {envTab === 'single' && (
-
- )}
-
- {envTab === 'bulk' && (
-
-
- )}
-
- {envTab === 'file' && (
-
-
-
- Click to upload or drag & drop .env file
- Supports .env, .env.local, .env.production
-
-
-
- )}
-
- {fileError && (
-
- )}
-
- {/* Staged Env List */}
- {stagedEnvs.length > 0 && (
-
-
- Staged Variables ({stagedEnvs.length})
- setStagedEnvs([])}
- className="text-zinc-500 hover:text-rose-400 text-[10px]"
- >
- Clear All
-
-
-
- {stagedEnvs.map((env, idx) => (
-
- {env.key}
- ••••••••
- handleRemoveEnv(idx)}
- className="text-zinc-500 hover:text-rose-400 transition-colors p-1"
- >
-
-
-
- ))}
-
-
- )}
-
-
- {/* Managed Database Selection Cards */}
-
-
-
-
- Provision Managed Database
-
-
Spin up an attached database container instance.
-
-
setProvisionDb(!provisionDb)}
- className={cn(
- 'w-11 h-6 rounded-full transition-colors relative focus:outline-none',
- provisionDb ? 'bg-orange-500' : 'bg-[#222227]'
- )}
- >
-
-
-
- {provisionDb && (
-
-
Select Database Engine
-
-
setDbType('postgresql')}
- className={cn(
- 'p-3.5 rounded-xl border text-left transition-all space-y-1.5',
- dbType === 'postgresql'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#0c0c0e] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- PostgreSQL
-
-
- PostgreSQL
- Relational SQL Engine
-
-
-
setDbType('mysql')}
- className={cn(
- 'p-3.5 rounded-xl border text-left transition-all space-y-1.5',
- dbType === 'mysql'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#0c0c0e] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- MySQL
-
-
- MySQL
- Relational SQL Engine
-
-
-
setDbType('redis')}
- className={cn(
- 'p-3.5 rounded-xl border text-left transition-all space-y-1.5',
- dbType === 'redis'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#0c0c0e] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- Redis
-
-
- Redis (KV)
- In-Memory Key-Value
-
-
-
setDbType('mongodb')}
- className={cn(
- 'p-3.5 rounded-xl border text-left transition-all space-y-1.5',
- dbType === 'mongodb'
- ? 'bg-orange-500/10 border-orange-500/40 text-orange-400'
- : 'bg-[#0c0c0e] border-[#1c1c21] text-zinc-400 hover:border-zinc-700'
- )}
- >
-
-
-
- MongoDB
-
-
- MongoDB
- NoSQL Document Store
-
-
-
-
- Version Tag
- setDbVersion(e.target.value)}
- className="w-full bg-[#0c0c0e] border border-[#222227] rounded-lg px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-orange-500"
- >
- {dbType === 'postgresql' && (
- <>
- 17
- 16
- 15
- >
- )}
- {dbType === 'mysql' && (
- <>
- 8.4
- 8.0
- 9.0
- >
- )}
- {dbType === 'redis' && (
- <>
- 7.4
- 7.2
- 7.0
- >
- )}
- {dbType === 'mongodb' && (
- <>
- 8.0
- 7.0
- 6.0
- >
- )}
-
-
-
- )}
-
+
+
Build Strategy
+
{buildType}
- )}
-
- {/* STEP 4: Summary & Launch */}
- {step === 4 && (
-
-
-
-
- 4. Ready to Deploy
-
-
Review your configuration before launching your service.
-
-
-
- Project Name:
- {name}
-
- {baseDomain && (
-
- Subdomain / Base Domain:
- {baseDomain}
-
- )}
-
- Code Provider:
- {sourceType}
-
-
- Build Strategy:
- {buildType}
-
- {buildType === 'compose' && (
-
- Compose Ingress:
-
- {composeServicesList[0]?.serviceName.trim() || 'Auto-detect'} : {composeServicesList[0]?.port.trim() || 'Auto-detect'}
-
-
- )}
-
-
Environment Vars:
-
{stagedEnvs.length} variables
+ {buildType === 'compose' ? (
+
+
Compose Ingress
+
+ {composeServicesList[0]?.serviceName.trim() || 'Auto-detect'}{' '}
+ {composeServicesList[0]?.port.trim() ? `:${composeServicesList[0].port.trim()}` : ''}
- {provisionDb && (
-
- Managed Database:
-
- {dbType} ({dbVersion})
-
-
- )}
-
-
- Launch Deployment
-
-
- )}
-
- {/* Stepper Control Buttons */}
-
- {step > 1 ? (
-
setStep((s) => s - 1)}
- className="bg-[#121215] border border-[#1c1c21] hover:bg-[#18181c] text-zinc-400 text-xs h-10 px-5 rounded-xl"
- >
- Back
-
- ) :
}
-
- {step < 4 && (
-
setStep((s) => s + 1)}
- className="bg-orange-500 hover:bg-orange-600 text-white text-xs h-10 px-6 rounded-xl font-bold ml-auto shadow-md shadow-orange-500/10"
- >
- Continue
-
- )}
-
-
-
- {/* Right Column: Live Summary Card */}
-
-
-
- Deployment Summary
-
-
-
+ ) : (
-
- {baseDomain && (
-
-
Domain
-
{baseDomain}
+
Type & Port
+
+ {projectType === 'web' ? 'Web Service' : 'Static Site'} ({port || '3000'})
- )}
-
-
-
Build Strategy
-
{buildType}
+ )}
- {buildType === 'compose' ? (
-
-
Compose Gateway
-
- {composeServicesList[0]?.serviceName.trim() || 'Auto-detect'} {composeServicesList[0]?.port.trim() ? `:${composeServicesList[0].port.trim()}` : ''}
-
-
- ) : (
-
-
Port
-
{port || 'Auto (3000)'}
-
- )}
+
+
Environment Vars
+
{stagedEnvs.length} variables
+
+
+
+ {submittingStatus === 'idle' ? 'Launch Deployment' : 'Deploying...'}
+
- )}
+
+
+
);
}
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts
index 5c88ea8..37d3f1b 100644
--- a/apps/web/src/types/index.ts
+++ b/apps/web/src/types/index.ts
@@ -27,6 +27,8 @@ export interface Project {
composePort?: number | null;
composeServices?: string | null;
buildCommand: string | null;
+ installCommand?: string | null;
+ outputDir?: string | null;
startCommand: string | null;
createdAt: string;
updatedAt: string;
diff --git a/apps/web/src/utils/presets.ts b/apps/web/src/utils/presets.ts
new file mode 100644
index 0000000..00abed4
--- /dev/null
+++ b/apps/web/src/utils/presets.ts
@@ -0,0 +1,179 @@
+export interface FrameworkPreset {
+ id: string;
+ name: string;
+ icon: string;
+ projectType: 'web' | 'static';
+ buildCommand: string;
+ installCommand: string;
+ startCommand: string;
+ outputDir: string;
+ defaultPort?: string;
+ description: string;
+ category?: 'popular' | 'frontend' | 'fullstack' | 'backend';
+}
+
+export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
+ {
+ id: 'nextjs-web',
+ name: 'Next.js',
+ icon: 'nextjs',
+ projectType: 'web',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: 'npm run start',
+ outputDir: '.next',
+ defaultPort: '3000',
+ description: 'Full-stack Next.js application with SSR, API routes, and Server Actions.',
+ category: 'popular'
+ },
+ {
+ id: 'vite-react',
+ name: 'Vite / React',
+ icon: 'react',
+ projectType: 'static',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: '',
+ outputDir: 'dist',
+ description: 'Fast React Single Page Application compiled to static assets.',
+ category: 'popular'
+ },
+ {
+ id: 'vite-vue',
+ name: 'Vite / Vue',
+ icon: 'vue',
+ projectType: 'static',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: '',
+ outputDir: 'dist',
+ description: 'Vue 3 Single Page Application bundled with Vite.',
+ category: 'frontend'
+ },
+ {
+ id: 'nuxt',
+ name: 'Nuxt',
+ icon: 'nuxt',
+ projectType: 'web',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: 'node .output/server/index.mjs',
+ outputDir: '.output',
+ defaultPort: '3000',
+ description: 'Intuitive Vue Framework with Nitro server engine.',
+ category: 'fullstack'
+ },
+ {
+ id: 'astro',
+ name: 'Astro',
+ icon: 'astro',
+ projectType: 'static',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: '',
+ outputDir: 'dist',
+ description: 'Content-driven website framework optimized for speed.',
+ category: 'popular'
+ },
+ {
+ id: 'angular',
+ name: 'Angular',
+ icon: 'angular',
+ projectType: 'static',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: '',
+ outputDir: 'dist',
+ description: 'Enterprise TypeScript web application framework.',
+ category: 'frontend'
+ },
+ {
+ id: 'remix',
+ name: 'Remix',
+ icon: 'remix',
+ projectType: 'web',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: 'npm run start',
+ outputDir: 'build',
+ defaultPort: '3000',
+ description: 'Full stack web framework focused on web standards.',
+ category: 'fullstack'
+ },
+ {
+ id: 'sveltekit',
+ name: 'Svelte / SvelteKit',
+ icon: 'svelte',
+ projectType: 'web',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: 'node build',
+ outputDir: 'build',
+ defaultPort: '3000',
+ description: 'Cybernetically enhanced web application framework.',
+ category: 'frontend'
+ },
+ {
+ id: 'express-node',
+ name: 'Express / Node.js API',
+ icon: 'node',
+ projectType: 'web',
+ buildCommand: 'npm run build',
+ installCommand: 'npm install',
+ startCommand: 'node dist/index.js',
+ outputDir: 'dist',
+ defaultPort: '3001',
+ description: 'Node.js backend service running compiled server bundle.',
+ category: 'backend'
+ },
+ {
+ id: 'python-service',
+ name: 'Python (FastAPI / Flask)',
+ icon: 'python',
+ projectType: 'web',
+ buildCommand: 'pip install -r requirements.txt',
+ installCommand: '',
+ startCommand: 'uvicorn main:app --host 0.0.0.0 --port 8000',
+ outputDir: '.',
+ defaultPort: '8000',
+ description: 'Python web service or API backend.',
+ category: 'backend'
+ },
+ {
+ id: 'go-service',
+ name: 'Go Web Server',
+ icon: 'go',
+ projectType: 'web',
+ buildCommand: 'go build -o server .',
+ installCommand: '',
+ startCommand: './server',
+ outputDir: '.',
+ defaultPort: '8080',
+ description: 'High-performance Go web service.',
+ category: 'backend'
+ },
+ {
+ id: 'docker',
+ name: 'Container / Dockerfile',
+ icon: 'container',
+ projectType: 'web',
+ buildCommand: '',
+ installCommand: '',
+ startCommand: '',
+ outputDir: '',
+ description: 'Build and run using custom root Dockerfile.',
+ category: 'popular'
+ },
+ {
+ id: 'other',
+ name: 'Other / Custom',
+ icon: 'other',
+ projectType: 'web',
+ buildCommand: '',
+ installCommand: '',
+ startCommand: '',
+ outputDir: '',
+ description: 'Custom configuration for any project or build script.',
+ category: 'popular'
+ }
+];
From 4e3b43eea73b605a96b6fcc0d02be1bc0f656634 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Sun, 9 Aug 2026 13:59:08 +0100
Subject: [PATCH 3/6] feat(orchestrator): auto-detect static sites via
index.html.
---
.../__tests__/railpack-config.test.ts | 76 +++++++++++++++++++
.../src/orchestrator/railpack-config-utils.ts | 19 +++++
apps/web/src/api/client.ts | 2 +-
.../project/create/ProjectNameSection.tsx | 72 ++++--------------
.../project/create/SourceSelectionSection.tsx | 60 +++++++++------
.../project/deployments/DeploymentsTab.tsx | 5 ++
apps/web/src/routes/CreateProjectPage.tsx | 55 ++++++++------
docker-compose.yml | 18 ++---
8 files changed, 195 insertions(+), 112 deletions(-)
create mode 100644 apps/api/src/orchestrator/__tests__/railpack-config.test.ts
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..43ee585
--- /dev/null
+++ b/apps/api/src/orchestrator/__tests__/railpack-config.test.ts
@@ -0,0 +1,76 @@
+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("writes Staticfile pointing at source dir 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",
+ "railpack",
+ null,
+ null,
+ async (line) => logs.push(line),
+ );
+ const staticfile = join(dir, "Staticfile");
+ expect(existsSync(staticfile)).toBe(true);
+ expect(readFileSync(staticfile, "utf8")).toBe("root: client\n");
+ expect(existsSync(join(dir, "railpack.json"))).toBe(true);
+ expect(logs.some((l) => l.includes("Detected static site in client"))).toBe(true);
+ } 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/railpack-config-utils.ts b/apps/api/src/orchestrator/railpack-config-utils.ts
index 5e48932..59f898a 100644
--- a/apps/api/src/orchestrator/railpack-config-utils.ts
+++ b/apps/api/src/orchestrator/railpack-config-utils.ts
@@ -359,6 +359,25 @@ Bun.serve({
}
}
+ const hasIndexHtmlInSourceDir =
+ cleanSourceDir &&
+ (await Bun.file(
+ join(buildDir, "index.html"),
+ ).exists());
+ if (hasIndexHtmlInSourceDir && !configured) {
+ const staticfilePath = join(workspace, "Staticfile");
+ if (!(await Bun.file(staticfilePath).exists())) {
+ await onLog(
+ `Detected static site in ${cleanSourceDir}, configuring Staticfile provider`,
+ );
+ await Bun.write(
+ staticfilePath,
+ `root: ${cleanSourceDir}\n`,
+ );
+ configured = true;
+ }
+ }
+
const hasCargoToml = await Bun.file(
join(buildDir, "Cargo.toml"),
).exists();
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 3e09c57..baf79f4 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -573,7 +573,7 @@ export const removeRepoHook = (owner: string, repo: string) =>
});
export const setEnvVar = (projectId: string, key: string, value: string, environment?: string) =>
- createEnvVar(projectId, key, value, environment);
+ createEnvVar(projectId, { key, value, environment });
export const uploadSourceZip = (file: File) => {
const formData = new FormData();
diff --git a/apps/web/src/components/project/create/ProjectNameSection.tsx b/apps/web/src/components/project/create/ProjectNameSection.tsx
index 9dc6a8a..16c1d12 100644
--- a/apps/web/src/components/project/create/ProjectNameSection.tsx
+++ b/apps/web/src/components/project/create/ProjectNameSection.tsx
@@ -15,44 +15,22 @@ export function ProjectNameSection({
setName,
description,
setDescription,
- baseDomain,
- setBaseDomain,
-}: ProjectNameSectionProps) {
- const generatedDomain = name
- ? `${name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}.${baseDomain || 'localhost'}`
- : `project-name.${baseDomain || 'localhost'}`;
-
+}: Omit) {
return (
- Project Details & Domain
+ Project Details
- Choose a unique project identifier and domain route.
+ Choose a unique project name and description for your deployment.
-
- {/* Scope / Environment */}
-
-
- Dequel Environment Scope
-
-
-
-
- Local Platform Node
-
-
- Active
-
-
-
-
+
{/* Project Name Input */}
@@ -66,40 +44,20 @@ export function ProjectNameSection({
className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-10 rounded-xl font-mono text-xs"
/>
-
- {/* Description */}
-
-
- Description (Optional)
-
- setDescription(e.target.value)}
- placeholder="Production web application service with automated deployment"
- className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl text-xs"
- />
-
-
- {/* Base Domain & Live Ingress Preview */}
-
);
diff --git a/apps/web/src/components/project/create/SourceSelectionSection.tsx b/apps/web/src/components/project/create/SourceSelectionSection.tsx
index eea4c0e..fee1eb6 100644
--- a/apps/web/src/components/project/create/SourceSelectionSection.tsx
+++ b/apps/web/src/components/project/create/SourceSelectionSection.tsx
@@ -21,6 +21,7 @@ interface SourceSelectionSectionProps {
zipFile: File | null;
setZipFile: (f: File | null) => void;
onConnectGithub: () => void;
+ onDisconnectGithub: () => void;
}
export function SourceSelectionSection({
@@ -39,6 +40,7 @@ export function SourceSelectionSection({
zipFile,
setZipFile,
onConnectGithub,
+ onDisconnectGithub,
}: SourceSelectionSectionProps) {
return (
@@ -102,31 +104,45 @@ export function SourceSelectionSection({
{/* Git Source Options */}
{sourceType === 'git' && (
-
-
Repository Details
-
setShowManualGit(!showManualGit)}
- className="text-xs text-orange-400 hover:underline flex items-center gap-1 font-medium"
- >
-
- {showManualGit ? 'Use GitHub Integration' : 'Enter Manual Git URL'}
-
+
+
Repository Import Mode
+
+ setShowManualGit(false)}
+ className={cn(
+ 'px-3 py-1.5 text-xs font-semibold rounded-lg transition-all flex items-center gap-1.5',
+ !showManualGit
+ ? 'bg-orange-500 text-white shadow-sm font-bold'
+ : 'text-zinc-400 hover:text-zinc-200'
+ )}
+ >
+
+ GitHub Account
+
+ setShowManualGit(true)}
+ className={cn(
+ 'px-3 py-1.5 text-xs font-semibold rounded-lg transition-all flex items-center gap-1.5',
+ showManualGit
+ ? 'bg-orange-500 text-white shadow-sm font-bold'
+ : 'text-zinc-400 hover:text-zinc-200'
+ )}
+ >
+
+ Manual Git URL
+
+
- {!showManualGit && (
+ {!showManualGit ? (
- )}
-
- {(showManualGit || !githubConnected) && (
+ ) : (
Git Clone URL *
@@ -138,7 +154,7 @@ export function SourceSelectionSection({
/>
-
Branch
+
Branch (Optional)
('railpack');
const [projectType, setProjectType] = useState('web');
- const [selectedPresetId, setSelectedPresetId] = useState('nextjs-web');
- const [port, setPort] = useState('3000');
+ const [selectedPresetId, setSelectedPresetId] = useState('');
+ const [port, setPort] = useState('');
const [sourceDir, setSourceDir] = useState('');
- const [buildCommand, setBuildCommand] = useState('npm run build');
- const [installCommand, setInstallCommand] = useState('npm install');
- const [outputDir, setOutputDir] = useState('.next');
- const [startCommand, setStartCommand] = useState('npm run start');
+ const [buildCommand, setBuildCommand] = useState('');
+ const [installCommand, setInstallCommand] = useState('');
+ const [outputDir, setOutputDir] = useState('');
+ const [startCommand, setStartCommand] = useState('');
// Docker Compose Gateway Mapping
const [composeServicesList, setComposeServicesList] = useState
([
@@ -82,13 +81,17 @@ export function CreateProjectPage() {
setGithubConfigured(true);
api
.getGithubUser()
- .then(() => setGithubConnected(true))
- .catch(() => {});
+ .then(() => {
+ setGithubConnected(true);
+ setShowManualGit(false);
+ })
+ .catch(() => setShowManualGit(true));
} else {
setGithubConfigured(false);
+ setShowManualGit(true);
}
})
- .catch(() => {});
+ .catch(() => setShowManualGit(true));
}, []);
const handleSelectPreset = (preset: FrameworkPreset) => {
@@ -112,6 +115,11 @@ export function CreateProjectPage() {
}
};
+ const handleRetry = () => {
+ setSubmittingStatus('idle');
+ setErrorMessage('');
+ };
+
const handleSubmit = async () => {
if (!name.trim()) {
setErrorMessage('Please enter a project name.');
@@ -140,7 +148,6 @@ export function CreateProjectPage() {
const projectPayload: any = {
name: name.trim(),
description: description.trim() || undefined,
- baseDomain: baseDomain.trim() || undefined,
sourceType,
repoUrl: finalRepoUrl,
repoBranch: repoBranch.trim() || undefined,
@@ -166,7 +173,7 @@ export function CreateProjectPage() {
setSubmittingStatus('done');
setTimeout(() => {
- navigate({ to: '/projects/$id', params: { id: project.id } });
+ navigate({ to: '/project/$projectId', params: { projectId: project.id }, search: { tab: 'deployments' } });
}, 1000);
} catch (err: any) {
setSubmittingStatus('error');
@@ -216,16 +223,18 @@ export function CreateProjectPage() {
const res = await api.getGithubAuthUrl();
window.location.href = res.url;
}}
+ onDisconnectGithub={() => {
+ setGithubConnected(false);
+ setSelectedRepo(null);
+ }}
/>
- {/* Project Details & Ingress Domain */}
+ {/* Project Details */}
{/* Build Strategy & Application Preset (with SVG logos) */}
@@ -283,13 +292,6 @@ export function CreateProjectPage() {
{name || '—'}
- {baseDomain && (
-
-
Base Ingress Domain
-
{baseDomain}
-
- )}
-
Code Provider
{sourceType}
@@ -336,7 +338,14 @@ export function CreateProjectPage() {
-
+
0}
+ hasDb={false}
+ dbType="postgresql"
+ onRetry={handleRetry}
+ />
);
}
diff --git a/docker-compose.yml b/docker-compose.yml
index e893ab2..974f48f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,11 +28,11 @@ services:
start_period: 5s
api:
- image: ghcr.io/lftobs/dequel/api:latest
+ # image: ghcr.io/lftobs/dequel/api:latest
restart: unless-stopped
- # build:
- # context: ./apps/api
- # dockerfile: Dockerfile
+ build:
+ context: ./apps/api
+ dockerfile: Dockerfile
environment:
PORT: 3001
DATABASE_PATH: /app/data/dequel.db
@@ -106,12 +106,12 @@ services:
start_period: 10s
web:
- image: ghcr.io/lftobs/dequel/web:latest
+ # image: ghcr.io/lftobs/dequel/web:latest
restart: unless-stopped
- # build:
- # context: ./apps/web
- # args:
- # DEQUEL_VERSION: ${DEQUEL_VERSION:-}
+ build:
+ context: ./apps/web
+ args:
+ DEQUEL_VERSION: ${DEQUEL_VERSION:-}
healthcheck:
test:
[
From c3999890084d3fdf5beadd16cab939744c8ed858 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Tue, 11 Aug 2026 09:33:13 +0100
Subject: [PATCH 4/6] chore(orchestrator): improve static site detection and
configuration
- Replace `Staticfile` with `dequel-serve.js` for
all static site deployments
- Automatically generate a root `package.json` for
static apps to standardize runtime configuration
- Remove build-time environment variable filtering
to allow full access to deployment environment
variables
- Update framework presets with correct default
ports
- Remove unused `env-filter` utility and tests
---
.../__tests__/railpack-config.test.ts | 15 +-
apps/api/src/orchestrator/pipeline.ts | 5 +-
.../src/orchestrator/railpack-config-utils.ts | 175 ++++++------------
.../src/utils/__tests__/env-filter.test.ts | 100 ----------
apps/api/src/utils/env-filter.ts | 42 -----
apps/web/src/utils/presets.ts | 6 +-
6 files changed, 68 insertions(+), 275 deletions(-)
delete mode 100644 apps/api/src/utils/__tests__/env-filter.test.ts
delete mode 100644 apps/api/src/utils/env-filter.ts
diff --git a/apps/api/src/orchestrator/__tests__/railpack-config.test.ts b/apps/api/src/orchestrator/__tests__/railpack-config.test.ts
index 43ee585..274eddb 100644
--- a/apps/api/src/orchestrator/__tests__/railpack-config.test.ts
+++ b/apps/api/src/orchestrator/__tests__/railpack-config.test.ts
@@ -11,7 +11,7 @@ const setupWorkspace = () => {
};
describe("generateDynamicRailpackJson static subdirectory detection", () => {
- test("writes Staticfile pointing at source dir when index.html exists without package.json", async () => {
+ test("configures dequel-serve.js when index.html exists without package.json", async () => {
const { dir, cleanup } = setupWorkspace();
try {
writeFileSync(join(dir, "client", "index.html"), "");
@@ -19,16 +19,17 @@ describe("generateDynamicRailpackJson static subdirectory detection", () => {
await generateDynamicRailpackJson(
dir,
"client",
- "railpack",
+ "static",
null,
null,
async (line) => logs.push(line),
);
- const staticfile = join(dir, "Staticfile");
- expect(existsSync(staticfile)).toBe(true);
- expect(readFileSync(staticfile, "utf8")).toBe("root: client\n");
- expect(existsSync(join(dir, "railpack.json"))).toBe(true);
- expect(logs.some((l) => l.includes("Detected static site in client"))).toBe(true);
+ 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();
}
diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts
index 03e8296..9894ed6 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";
@@ -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 59f898a..254e367 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,24 +267,7 @@ Bun.serve({
}
}
- const hasIndexHtmlInSourceDir =
- cleanSourceDir &&
- (await Bun.file(
- join(buildDir, "index.html"),
- ).exists());
- if (hasIndexHtmlInSourceDir && !configured) {
- const staticfilePath = join(workspace, "Staticfile");
- if (!(await Bun.file(staticfilePath).exists())) {
- await onLog(
- `Detected static site in ${cleanSourceDir}, configuring Staticfile provider`,
- );
- await Bun.write(
- staticfilePath,
- `root: ${cleanSourceDir}\n`,
- );
- configured = true;
- }
- }
+
const hasCargoToml = await Bun.file(
join(buildDir, "Cargo.toml"),
@@ -625,18 +516,23 @@ Bun.serve({
configured = true;
}
- if (!configured && projectType === "static") {
+ if (!configured && (projectType === "static" || (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",
@@ -689,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;
}
@@ -699,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/utils/presets.ts b/apps/web/src/utils/presets.ts
index 00abed4..b204ff3 100644
--- a/apps/web/src/utils/presets.ts
+++ b/apps/web/src/utils/presets.ts
@@ -35,6 +35,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
installCommand: 'npm install',
startCommand: '',
outputDir: 'dist',
+ defaultPort: '5173',
description: 'Fast React Single Page Application compiled to static assets.',
category: 'popular'
},
@@ -47,6 +48,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
installCommand: 'npm install',
startCommand: '',
outputDir: 'dist',
+ defaultPort: '5173',
description: 'Vue 3 Single Page Application bundled with Vite.',
category: 'frontend'
},
@@ -72,6 +74,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
installCommand: 'npm install',
startCommand: '',
outputDir: 'dist',
+ defaultPort: '4321',
description: 'Content-driven website framework optimized for speed.',
category: 'popular'
},
@@ -84,6 +87,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
installCommand: 'npm install',
startCommand: '',
outputDir: 'dist',
+ defaultPort: '4200',
description: 'Enterprise TypeScript web application framework.',
category: 'frontend'
},
@@ -122,7 +126,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
installCommand: 'npm install',
startCommand: 'node dist/index.js',
outputDir: 'dist',
- defaultPort: '3001',
+ defaultPort: '3000',
description: 'Node.js backend service running compiled server bundle.',
category: 'backend'
},
From 80a1baa529851ae1779396769b957b76fa63dbe5 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Tue, 11 Aug 2026 10:30:29 +0100
Subject: [PATCH 5/6] refactor(web): consolidate project creation components
- Refactor `StepBasics` to use
`StepBasicsGeneralSettings` and
`StepBasicsSourceSection` for better
organization.
- Remove redundant preset and build settings
components.
- Add `slugifyProjectName` helper.
- Update `CreateProjectInput` type definitions.
---
apps/web/src/api/client.ts | 17 +-
.../databases/CreateDatabaseDialog.tsx | 9 +-
.../create/ApplicationPresetSection.tsx | 44 --
.../project/create/BuildSettingsSection.tsx | 154 ----
.../project/create/CreateProjectDialog.tsx | 19 +-
.../project/create/SourceSelectionSection.tsx | 18 +-
.../components/project/create/StepBasics.tsx | 710 ++----------------
.../create/StepBasicsGeneralSettings.tsx | 165 ++++
.../create/StepBasicsSourceSection.tsx | 472 ++++++++++++
.../project/settings/ProjectSettingsTab.tsx | 19 +-
apps/web/src/components/ui/DatabaseSelect.tsx | 6 +-
apps/web/src/routes/CreateProjectPage.tsx | 32 +-
apps/web/src/types/index.ts | 22 +
apps/web/src/utils/slugify.ts | 7 +
14 files changed, 782 insertions(+), 912 deletions(-)
delete mode 100644 apps/web/src/components/project/create/ApplicationPresetSection.tsx
delete mode 100644 apps/web/src/components/project/create/BuildSettingsSection.tsx
create mode 100644 apps/web/src/components/project/create/StepBasicsGeneralSettings.tsx
create mode 100644 apps/web/src/components/project/create/StepBasicsSourceSection.tsx
create mode 100644 apps/web/src/utils/slugify.ts
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index baf79f4..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),
diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx
index 359d5e2..d74dd4b 100644
--- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx
+++ b/apps/web/src/components/databases/CreateDatabaseDialog.tsx
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
-import { DatabaseSelect } from "../ui/DatabaseSelect";
+import { DatabaseSelect, DATABASE_ENGINES } from "../ui/DatabaseSelect";
interface CreateDatabaseDialogProps {
open: boolean;
@@ -30,7 +30,8 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj
const [error, setError] = useState(null);
useEffect(() => {
- setVersion(type === "postgresql" ? "16" : type === "mysql" ? "8.4" : type === "redis" ? "7.4" : type === "mongodb" ? "7.0" : "11.4");
+ const engine = DATABASE_ENGINES.find((item) => item.type === type);
+ if (engine) setVersion(engine.defaultVersion);
}, [type]);
const create = async () => {
@@ -67,8 +68,8 @@ export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProj
Name setName(event.target.value)} placeholder="Production Database" />
-
Attach to project No project {projects.map((project) => {project.name} )}
-
Database Engine setType(val)} />
+
Attach to project No project {projects.map((project) => {project.name} )}
+
Database Engine setType(val)} />
Version setVersion(event.target.value)} />
diff --git a/apps/web/src/components/project/create/ApplicationPresetSection.tsx b/apps/web/src/components/project/create/ApplicationPresetSection.tsx
deleted file mode 100644
index faeb24e..0000000
--- a/apps/web/src/components/project/create/ApplicationPresetSection.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { FrameworkPreset } from '../../../utils/presets';
-import { FrameworkSelect } from '../../ui/FrameworkSelect';
-import { Layers, Sparkles } from 'lucide-react';
-
-interface ApplicationPresetSectionProps {
- selectedPresetId: string;
- onSelectPreset: (preset: FrameworkPreset) => void;
-}
-
-export function ApplicationPresetSection({
- selectedPresetId,
- onSelectPreset,
-}: ApplicationPresetSectionProps) {
- return (
-
-
-
-
-
- Application Preset
-
-
- Select your framework to automatically pre-map build commands and runtime options.
-
-
-
-
- Framework Presets
-
-
-
- {/* Main Framework Dropdown Select */}
-
-
- Framework Preset
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/project/create/BuildSettingsSection.tsx b/apps/web/src/components/project/create/BuildSettingsSection.tsx
deleted file mode 100644
index f608b69..0000000
--- a/apps/web/src/components/project/create/BuildSettingsSection.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useState } from 'react';
-import { Input } from '../../ui/input';
-import { Terminal, Sliders, ChevronDown, FolderCode, Play } from 'lucide-react';
-
-interface BuildSettingsSectionProps {
- 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;
- projectType: string;
-}
-
-export function BuildSettingsSection({
- buildCommand,
- setBuildCommand,
- installCommand,
- setInstallCommand,
- startCommand,
- setStartCommand,
- outputDir,
- setOutputDir,
- port,
- setPort,
- sourceDir,
- setSourceDir,
- projectType,
-}: BuildSettingsSectionProps) {
- const [isExpanded, setIsExpanded] = useState(true);
-
- return (
-
-
setIsExpanded(!isExpanded)}
- >
-
-
-
- Build & Output Settings
-
-
- Pre-configured from preset. Expand to inspect or override build commands.
-
-
-
-
-
-
-
- {isExpanded && (
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx
index d409ad0..870acd3 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("");
@@ -116,6 +119,13 @@ export function CreateProjectDialog({
.catch(() => { });
}, [open]);
+ const handleSelectPreset = (
+ preset: FrameworkPreset,
+ ) => {
+ setSelectedPresetId(preset.id);
+ setProjectType(preset.projectType);
+ };
+
const handleOpenChange = (
isOpen: boolean,
) => {
@@ -130,6 +140,7 @@ export function CreateProjectDialog({
setSourceDir("");
setSelectedRepo(null);
setSourceType("git");
+ setSelectedPresetId("");
setPort("");
setZipFile(null);
}
@@ -413,9 +424,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/SourceSelectionSection.tsx b/apps/web/src/components/project/create/SourceSelectionSection.tsx
index fee1eb6..cb3dc32 100644
--- a/apps/web/src/components/project/create/SourceSelectionSection.tsx
+++ b/apps/web/src/components/project/create/SourceSelectionSection.tsx
@@ -42,6 +42,17 @@ export function SourceSelectionSection({
onConnectGithub,
onDisconnectGithub,
}: SourceSelectionSectionProps) {
+ const handleSourceTypeChange = (type: 'git' | 'upload') => {
+ setSourceType(type);
+ if (type === 'upload') {
+ onSelectRepo(null);
+ setRepoUrl('');
+ setRepoBranch('');
+ } else {
+ setZipFile(null);
+ }
+ };
+
return (
@@ -54,11 +65,10 @@ export function SourceSelectionSection({
- {/* Source Type Selector Cards (Git vs ZIP Upload) */}
setSourceType('git')}
+ onClick={() => handleSourceTypeChange('git')}
className={cn(
'p-4 rounded-xl border text-left transition-all space-y-1.5',
sourceType === 'git'
@@ -80,7 +90,7 @@ export function SourceSelectionSection({
setSourceType('upload')}
+ onClick={() => handleSourceTypeChange('upload')}
className={cn(
'p-4 rounded-xl border text-left transition-all space-y-1.5',
sourceType === 'upload'
@@ -101,7 +111,6 @@ export function SourceSelectionSection({
- {/* Git Source Options */}
{sourceType === 'git' && (
@@ -167,7 +176,6 @@ export function SourceSelectionSection({
)}
- {/* ZIP Upload Option */}
{sourceType === 'upload' && (
diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx
index a4d2f25..4491fd5 100644
--- a/apps/web/src/components/project/create/StepBasics.tsx
+++ b/apps/web/src/components/project/create/StepBasics.tsx
@@ -1,19 +1,9 @@
-import { useState } from "react";
import { Input } from "../../ui/input";
-import {
- GitBranch,
- Globe,
- Upload,
- Container,
- Box,
- Lock,
-} from "lucide-react";
-import { getGithubAuthUrl } from "../../../api/client";
-import { RepoPicker } from "../../github/RepoPicker";
+import { Box } from "lucide-react";
import type { GithubRepo } from "../../../types";
-import { cn } from "../../../lib/utils";
-import { FrameworkSelect } from "../../ui/FrameworkSelect";
-import { FRAMEWORK_PRESETS } from "../../../utils/presets";
+import { StepBasicsGeneralSettings } from "./StepBasicsGeneralSettings";
+import { StepBasicsSourceSection } from "./StepBasicsSourceSection";
+import type { FrameworkPreset } from "../../../utils/presets";
interface StepBasicsProps {
name: string;
@@ -29,9 +19,7 @@ interface StepBasicsProps {
sourceDir: string;
setSourceDir: (v: string) => void;
selectedRepo: GithubRepo | null;
- setSelectedRepo: (
- v: GithubRepo | null,
- ) => void;
+ setSelectedRepo: (v: GithubRepo | null) => void;
onGithubConnected: () => boolean;
githubConfigured: boolean;
sourceType: string;
@@ -42,29 +30,10 @@ interface StepBasicsProps {
setPort: (v: string) => void;
zipFile: File | null;
setZipFile: (v: File | null) => void;
+ selectedPresetId: string;
+ onSelectPreset: (preset: FrameworkPreset) => void;
}
-const sourceOptions = [
- {
- value: "git",
- label: "Git Repository",
- icon: GitBranch,
- desc: "Clone from GitHub or any Git URL",
- },
- {
- value: "upload",
- label: "ZIP Upload",
- icon: Upload,
- desc: "Upload your source code as a ZIP archive",
- },
- {
- value: "compose",
- label: "Docker Compose",
- icon: Container,
- desc: "Deploy from a docker-compose.yml file",
- },
-];
-
export function StepBasics({
name,
setName,
@@ -90,522 +59,41 @@ export function StepBasics({
setPort,
zipFile,
setZipFile,
+ selectedPresetId,
+ onSelectPreset,
}: StepBasicsProps) {
- const [showManual, setShowManual] =
- useState(false);
- const connected = onGithubConnected();
-
- const handleConnectGithub = async () => {
- try {
- const { url } =
- await getGithubAuthUrl();
- window.location.href = url;
- } catch (err: any) {
- window.dispatchEvent(
- new CustomEvent(
- "opencode:notification",
- {
- detail: {
- type: "error",
- message:
- err.message ||
- "GitHub integration is not configured. Add your credentials in Settings.",
- },
- },
- ),
- );
- }
- };
-
- const handleSelectRepo = (
- repo: GithubRepo | null,
- ) => {
- setSelectedRepo(repo);
- if (repo) {
- setRepoUrl(repo.cloneUrl);
- setRepoBranch(repo.defaultBranch);
- } else {
- setRepoUrl("");
- setRepoBranch("");
- }
- };
-
return (
-
-
-
- General Settings
-
-
-
-
- Project Name *
-
-
- setName(
- e.target
- .value,
- )
- }
- autoFocus
- />
-
-
-
- Description
-
-
- setDescription(
- e.target
- .value,
- )
- }
- />
-
-
-
-
- Application Framework Preset
-
- {
- setProjectType(preset.projectType);
- }}
- />
-
-
-
-
- Project Type
-
-
-
setProjectType("web")}
- className={cn(
- "flex flex-col items-start gap-2 p-3 rounded-lg border text-left transition-all select-none active:scale-[0.98]",
- projectType === "web"
- ? "border-amber-500/30 bg-amber-500/5 text-zinc-200"
- : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
- )}
- >
-
-
- Web Application
-
-
- For Node, Go, Rust, APIs, or database-connected backends.
-
-
-
setProjectType("static")}
- className={cn(
- "flex flex-col items-start gap-2 p-3 rounded-lg border text-left transition-all select-none active:scale-[0.98]",
- projectType === "static"
- ? "border-amber-500/30 bg-amber-500/5 text-zinc-200"
- : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
- )}
- >
-
-
- Static Site
-
-
- For React, Vite, Astro, Vue, Docusaurus, or static HTML.
-
-
-
-
-
-
- Custom Ingress Base
- Domain
-
-
- setBaseDomain(
- e.target
- .value,
- )
- }
- />
-
- Leave empty to
- auto-assign a default
- hostname on localhost
- caddy ingress router.
-
-
-
-
-
-
-
-
- Deployment Source
-
-
- {sourceOptions.map((opt) => {
- const Icon = opt.icon;
- const active =
- sourceType ===
- opt.value;
- const disabled =
- opt.value === "git" &&
- !githubConfigured;
- const isCompose =
- opt.value ===
- "compose";
- return (
-
- !disabled &&
- setSourceType(
- opt.value,
- )
- }
- disabled={
- disabled
- }
- title={
- disabled
- ? "GitHub integration not configured in Settings"
- : undefined
- }
- className={cn(
- "relative p-3 rounded-lg border text-left flex items-start gap-2.5 transition-all",
- isCompose &&
- "col-span-2",
- active
- ? "bg-[#141418] border-amber-500/40 shadow-sm"
- : "bg-transparent border-zinc-800",
- disabled
- ? "opacity-40 cursor-not-allowed"
- : "hover:border-zinc-700 cursor-pointer",
- )}
- >
-
-
-
-
-
- {
- opt.label
- }
-
-
- {opt.desc}
-
- {disabled && (
-
- Configure
- in
- Settings
-
- )}
-
- {disabled && (
-
- )}
-
- );
- })}
-
-
-
- {sourceType === "git" && (
-
-
-
- Git Repository
-
-
- {connected ? (
-
-
{
- setSelectedRepo(
- null,
- );
- setRepoUrl(
- "",
- );
- setRepoBranch(
- "",
- );
- }}
- />
-
-
- setShowManual(
- !showManual,
- )
- }
- className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
- >
- {showManual
- ? "Hide manual input"
- : "Or paste URL manually"}
-
-
- {showManual ? (
-
- ) : (
-
- )}
-
-
- ) : (
-
-
-
-
-
- Connect
- GitHub
- Repository
-
-
- Browse and
- select
- from your
- personal
- and
- organization
- repos
-
-
-
-
-
-
- setShowManual(
- !showManual,
- )
- }
- className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
- >
- {showManual
- ? "Hide"
- : "Or enter URL manually"}
-
-
-
- {showManual && (
-
- )}
-
-
- )}
-
- )}
+
- {sourceType !== "git" && (
-
-
-
- Source Details
-
- {sourceType === "upload" && (
-
-
-
- ZIP Archive
-
-
-
-
- {zipFile
- ? zipFile.name
- : "Click to select a ZIP file"}
-
-
- {
- const file = e.target.files?.[0] || null;
- setZipFile(file);
- }}
- />
- {zipFile && (
- setZipFile(null)}
- className="text-[10px] text-zinc-600 hover:text-zinc-400 self-start"
- >
- Remove file
-
- )}
-
- Upload your source code as a ZIP archive. It will be deployed automatically after project creation.
-
-
-
- )}
- {sourceType === "compose" && (
-
- Provide a
- docker-compose.yml
- file URL or upload
- after project
- creation.
-
- )}
- {sourceType === "image" && (
-
-
-
- Container
- Image
-
-
- setRepoUrl(
- e
- .target
- .value,
- )
- }
- />
-
-
- )}
-
- )}
+
@@ -629,10 +117,7 @@ export function StepBasics({
className="bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 h-9"
value={port}
onChange={(e) =>
- setPort(
- e.target
- .value,
- )
+ setPort(e.target.value)
}
/>
@@ -641,10 +126,9 @@ export function StepBasics({
Important:
{" "}
- Set this to the port your
- application listens on inside
- the container. If the port
- doesn't match, you'll get a{" "}
+ Set this to the port your application
+ listens on inside the container. If the
+ port doesn't match, you'll get a{" "}
502 Bad Gateway
{" "}
@@ -654,113 +138,3 @@ export function StepBasics({
);
}
-
-function ManualGitInputs({
- repoUrl,
- setRepoUrl,
- repoBranch,
- setRepoBranch,
- showBranch,
-}: {
- repoUrl: string;
- setRepoUrl: (v: string) => void;
- repoBranch: string;
- setRepoBranch: (v: string) => void;
- showBranch: boolean;
-}) {
- return (
-
- );
-}
-
-function SourceDirInput({
- sourceDir,
- setSourceDir,
-}: {
- sourceDir: string;
- setSourceDir: (v: string) => void;
-}) {
- return (
-
-
- Source Directory{" "}
-
- (monorepo)
-
-
-
- setSourceDir(e.target.value)
- }
- />
-
- Leave empty if the app is at the
- repository root.
-
-
- );
-}
-
-function BranchInput({
- repoBranch,
- setRepoBranch,
-}: {
- repoBranch: string;
- setRepoBranch: (v: string) => void;
-}) {
- return (
-
-
-
- Branch
-
-
- setRepoBranch(
- e.target.value,
- )
- }
- />
-
-
- );
-}
diff --git a/apps/web/src/components/project/create/StepBasicsGeneralSettings.tsx b/apps/web/src/components/project/create/StepBasicsGeneralSettings.tsx
new file mode 100644
index 0000000..5838b6f
--- /dev/null
+++ b/apps/web/src/components/project/create/StepBasicsGeneralSettings.tsx
@@ -0,0 +1,165 @@
+import { Input } from "../../ui/input";
+import { Globe, Box } from "lucide-react";
+import { cn } from "../../../lib/utils";
+import { FrameworkSelect } from "../../ui/FrameworkSelect";
+import type { FrameworkPreset } from "../../../utils/presets";
+import { FRAMEWORK_PRESETS } from "../../../utils/presets";
+
+interface StepBasicsGeneralSettingsProps {
+ name: string;
+ setName: (v: string) => void;
+ description: string;
+ setDescription: (v: string) => void;
+ baseDomain: string;
+ setBaseDomain: (v: string) => void;
+ projectType: string;
+ setProjectType: (v: string) => void;
+ selectedPresetId: string;
+ onSelectPreset: (preset: FrameworkPreset) => void;
+}
+
+export function StepBasicsGeneralSettings({
+ name,
+ setName,
+ description,
+ setDescription,
+ baseDomain,
+ setBaseDomain,
+ projectType,
+ setProjectType,
+ selectedPresetId,
+ onSelectPreset,
+}: StepBasicsGeneralSettingsProps) {
+ const handleTypeChange = (type: string) => {
+ const preset = FRAMEWORK_PRESETS.find((p) => p.id === selectedPresetId);
+ if (preset && preset.projectType === type) {
+ setProjectType(type);
+ return;
+ }
+ setProjectType(type);
+ };
+
+ return (
+
+
+
+ General Settings
+
+
+
+
+ Project Name *
+
+
+ setName(e.target.value)
+ }
+ autoFocus
+ />
+
+
+
+ Description
+
+
+ setDescription(e.target.value)
+ }
+ />
+
+
+
+
+ Application Framework Preset
+
+
+
+
+
+
+ Project Type
+
+
+
handleTypeChange("web")}
+ className={cn(
+ "flex flex-col items-start gap-2 p-3 rounded-lg border text-left transition-all select-none active:scale-[0.98]",
+ projectType === "web"
+ ? "border-amber-500/30 bg-amber-500/5 text-zinc-200"
+ : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
+ )}
+ >
+
+
+ Web Application
+
+
+ For Node, Go, Rust, APIs, or database-connected backends.
+
+
+
handleTypeChange("static")}
+ className={cn(
+ "flex flex-col items-start gap-2 p-3 rounded-lg border text-left transition-all select-none active:scale-[0.98]",
+ projectType === "static"
+ ? "border-amber-500/30 bg-amber-500/5 text-zinc-200"
+ : "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
+ )}
+ >
+
+
+ Static Site
+
+
+ For React, Vite, Astro, Vue, Docusaurus, or static HTML.
+
+
+
+
+
+
+ Custom Ingress Base
+ Domain
+
+
+ setBaseDomain(e.target.value)
+ }
+ />
+
+ Leave empty to
+ auto-assign a default
+ hostname on localhost
+ caddy ingress router.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/project/create/StepBasicsSourceSection.tsx b/apps/web/src/components/project/create/StepBasicsSourceSection.tsx
new file mode 100644
index 0000000..24773e0
--- /dev/null
+++ b/apps/web/src/components/project/create/StepBasicsSourceSection.tsx
@@ -0,0 +1,472 @@
+import { useState } from "react";
+import { Input } from "../../ui/input";
+import { GitBranch, Upload, Container, Lock } from "lucide-react";
+import { getGithubAuthUrl } from "../../../api/client";
+import { RepoPicker } from "../../github/RepoPicker";
+import type { GithubRepo } from "../../../types";
+import { cn } from "../../../lib/utils";
+
+const sourceOptions = [
+ {
+ value: "git",
+ label: "Git Repository",
+ icon: GitBranch,
+ desc: "Clone from GitHub or any Git URL",
+ },
+ {
+ value: "upload",
+ label: "ZIP Upload",
+ icon: Upload,
+ desc: "Upload your source code as a ZIP archive",
+ },
+ {
+ value: "compose",
+ label: "Docker Compose",
+ icon: Container,
+ desc: "Deploy from a docker-compose.yml file",
+ },
+];
+
+interface StepBasicsSourceSectionProps {
+ sourceType: string;
+ setSourceType: (v: string) => void;
+ selectedRepo: GithubRepo | null;
+ setSelectedRepo: (v: GithubRepo | null) => void;
+ repoUrl: string;
+ setRepoUrl: (v: string) => void;
+ repoBranch: string;
+ setRepoBranch: (v: string) => void;
+ sourceDir: string;
+ setSourceDir: (v: string) => void;
+ zipFile: File | null;
+ setZipFile: (v: File | null) => void;
+ githubConfigured: boolean;
+ githubConnected: boolean;
+ onGithubConnected: () => boolean;
+}
+
+export function StepBasicsSourceSection({
+ sourceType,
+ setSourceType,
+ selectedRepo,
+ setSelectedRepo,
+ repoUrl,
+ setRepoUrl,
+ repoBranch,
+ setRepoBranch,
+ sourceDir,
+ setSourceDir,
+ zipFile,
+ setZipFile,
+ githubConfigured,
+ githubConnected,
+ onGithubConnected,
+}: StepBasicsSourceSectionProps) {
+ const [showManual, setShowManual] = useState(false);
+ const connected = onGithubConnected();
+
+ const handleConnectGithub = async () => {
+ try {
+ const { url } = await getGithubAuthUrl();
+ window.location.href = url;
+ } catch (err: any) {
+ window.dispatchEvent(
+ new CustomEvent("opencode:notification", {
+ detail: {
+ type: "error",
+ message:
+ err.message ||
+ "GitHub integration is not configured. Add your credentials in Settings.",
+ },
+ }),
+ );
+ }
+ };
+
+ const handleSelectRepo = (repo: GithubRepo | null) => {
+ setSelectedRepo(repo);
+ if (repo) {
+ setRepoUrl(repo.cloneUrl);
+ setRepoBranch(repo.defaultBranch);
+ } else {
+ setRepoUrl("");
+ setRepoBranch("");
+ }
+ };
+
+ return (
+ <>
+
+
+
+ Deployment Source
+
+
+ {sourceOptions.map((opt) => {
+ const Icon = opt.icon;
+ const active = sourceType === opt.value;
+ const disabled =
+ opt.value === "git" && !githubConfigured;
+ const isCompose = opt.value === "compose";
+ return (
+
+ !disabled && setSourceType(opt.value)
+ }
+ disabled={disabled}
+ title={
+ disabled
+ ? "GitHub integration not configured in Settings"
+ : undefined
+ }
+ className={cn(
+ "relative p-3 rounded-lg border text-left flex items-start gap-2.5 transition-all",
+ isCompose && "col-span-2",
+ active
+ ? "bg-[#141418] border-amber-500/40 shadow-sm"
+ : "bg-transparent border-zinc-800",
+ disabled
+ ? "opacity-40 cursor-not-allowed"
+ : "hover:border-zinc-700 cursor-pointer",
+ )}
+ >
+
+
+
+
+
+ {opt.label}
+
+
+ {opt.desc}
+
+ {disabled && (
+
+ Configure in Settings
+
+ )}
+
+ {disabled && (
+
+ )}
+
+ );
+ })}
+
+
+
+ {sourceType === "git" && (
+
+
+
+ Git Repository
+
+
+ {connected ? (
+
+
{
+ setSelectedRepo(null);
+ setRepoUrl("");
+ setRepoBranch("");
+ }}
+ />
+
+ setShowManual(!showManual)}
+ className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
+ >
+ {showManual
+ ? "Hide manual input"
+ : "Or paste URL manually"}
+
+
+ {showManual ? (
+
+ ) : (
+
+ )}
+
+
+ ) : (
+
+
+
+
+
+ Connect GitHub Repository
+
+
+ Browse and select from your personal and organization repos
+
+
+
+
+
+ setShowManual(!showManual)}
+ className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
+ >
+ {showManual
+ ? "Hide"
+ : "Or enter URL manually"}
+
+
+
+ {showManual && (
+
+ )}
+
+
+ )}
+
+ )}
+
+ {sourceType !== "git" && (
+
+
+
+ Source Details
+
+ {sourceType === "upload" && (
+
+
+
+ ZIP Archive
+
+
+
+
+ {zipFile
+ ? zipFile.name
+ : "Click to select a ZIP file"}
+
+
+ {
+ const file =
+ e.target.files?.[0] || null;
+ setZipFile(file);
+ }}
+ />
+ {zipFile && (
+ setZipFile(null)}
+ className="text-[10px] text-zinc-600 hover:text-zinc-400 self-start"
+ >
+ Remove file
+
+ )}
+
+ Upload your source code as a ZIP
+ archive. It will be deployed
+ automatically after project creation.
+
+
+
+ )}
+ {sourceType === "compose" && (
+
+ Provide a docker-compose.yml file URL or
+ upload after project creation.
+
+ )}
+ {sourceType === "image" && (
+
+
+
+ Container Image
+
+
+ setRepoUrl(e.target.value)
+ }
+ />
+
+
+ )}
+
+ )}
+ >
+ );
+}
+
+function ManualGitInputs({
+ repoUrl,
+ setRepoUrl,
+ repoBranch,
+ setRepoBranch,
+ showBranch,
+}: {
+ repoUrl: string;
+ setRepoUrl: (v: string) => void;
+ repoBranch: string;
+ setRepoBranch: (v: string) => void;
+ showBranch: boolean;
+}) {
+ return (
+
+ );
+}
+
+function SourceDirInput({
+ sourceDir,
+ setSourceDir,
+}: {
+ sourceDir: string;
+ setSourceDir: (v: string) => void;
+}) {
+ return (
+
+
+ Source Directory{" "}
+
+ (monorepo)
+
+
+ setSourceDir(e.target.value)}
+ />
+
+ Leave empty if the app is at the repository
+ root.
+
+
+ );
+}
+
+function BranchInput({
+ repoBranch,
+ setRepoBranch,
+}: {
+ repoBranch: string;
+ setRepoBranch: (v: string) => void;
+}) {
+ return (
+
+
+
+ Branch
+
+
+ setRepoBranch(e.target.value)
+ }
+ />
+
+
+ );
+}
diff --git a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
index aac1ddd..8d9306c 100644
--- a/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
+++ b/apps/web/src/components/project/settings/ProjectSettingsTab.tsx
@@ -151,6 +151,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
+ {buildType !== "compose" && (
Project Type
@@ -159,6 +160,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
setProjectType("web")}
+ aria-pressed={projectType === "web"}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all select-none active:scale-[0.98]",
projectType === "web"
@@ -166,7 +168,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
: "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
)}
>
-
+
Web Service
@@ -177,6 +179,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
setProjectType("static")}
+ aria-pressed={projectType === "static"}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all select-none active:scale-[0.98]",
projectType === "static"
@@ -184,7 +187,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
: "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
)}
>
-
+
Static Site / SPA
@@ -194,6 +197,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
+ )}
{/* Build Strategy */}
@@ -211,7 +215,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
: "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
)}
>
-
+
Railpack Auto-Detection
@@ -229,7 +233,7 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
: "border-[#222227] bg-[#141418] hover:bg-[#1a1a20] text-zinc-400"
)}
>
-
+
Docker Compose Stack
@@ -331,7 +335,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
)}
- {/* Root Directory / Source Dir */}
Root Directory
@@ -348,7 +351,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Internal Port */}
Port
@@ -366,7 +368,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Install Command Override */}
Install Command
@@ -383,7 +384,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Build Command Override */}
Build Command
@@ -400,7 +400,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Output Directory */}
Output Directory
@@ -417,7 +416,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Start Command Override */}
Start Command
@@ -434,7 +432,6 @@ export function ProjectSettingsTab({ projectId }: ProjectSettingsTabProps) {
- {/* Description */}
Project Description
diff --git a/apps/web/src/components/ui/DatabaseSelect.tsx b/apps/web/src/components/ui/DatabaseSelect.tsx
index 35873ab..709c5a3 100644
--- a/apps/web/src/components/ui/DatabaseSelect.tsx
+++ b/apps/web/src/components/ui/DatabaseSelect.tsx
@@ -48,9 +48,10 @@ interface DatabaseSelectProps {
value: DatabaseType;
onValueChange: (value: DatabaseType) => void;
className?: string;
+ id?: string;
}
-export function DatabaseSelect({ value, onValueChange, className }: DatabaseSelectProps) {
+export function DatabaseSelect({ value, onValueChange, className, id }: DatabaseSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
@@ -71,6 +72,7 @@ export function DatabaseSelect({ value, onValueChange, className }: DatabaseSele
setIsOpen(!isOpen)}
className={cn(
'w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl',
@@ -138,7 +140,7 @@ export function DatabaseSelect({ value, onValueChange, className }: DatabaseSele
>
{engine.name}
-
+
v{engine.defaultVersion}
diff --git a/apps/web/src/routes/CreateProjectPage.tsx b/apps/web/src/routes/CreateProjectPage.tsx
index 362e3b0..1dcef16 100644
--- a/apps/web/src/routes/CreateProjectPage.tsx
+++ b/apps/web/src/routes/CreateProjectPage.tsx
@@ -5,13 +5,14 @@ import { Button } from '../components/ui/button';
import * as api from '../api/client';
import { getGithubIntegration } from '../api/client';
import { FRAMEWORK_PRESETS, type FrameworkPreset } from '../utils/presets';
+import { slugifyProjectName } from '../utils/slugify';
import { CreationStatusOverlay } from '../components/project/create/CreationStatusOverlay';
import { SourceSelectionSection } from '../components/project/create/SourceSelectionSection';
import { ProjectNameSection } from '../components/project/create/ProjectNameSection';
import { BuildStrategySection, ComposeServiceRow } from '../components/project/create/BuildStrategySection';
import { EnvVarsSection, StagedEnv } from '../components/project/create/EnvVarsSection';
import { ArrowLeft, Rocket, Server, AlertCircle, CheckCircle2, Sparkles } from 'lucide-react';
-import type { GithubRepo } from '../types';
+import type { GithubRepo, CreateProjectInput } from '../types';
export function CreateProjectPage() {
const navigate = useNavigate();
@@ -110,7 +111,7 @@ export function CreateProjectPage() {
setRepoUrl(repo.cloneUrl);
setRepoBranch(repo.defaultBranch);
if (!name) {
- setName(repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
+ setName(slugifyProjectName(repo.name));
}
}
};
@@ -126,6 +127,16 @@ export function CreateProjectPage() {
return;
}
+ if (sourceType === 'git' && !repoUrl.trim()) {
+ setErrorMessage('Please select a repository or enter a Git clone URL.');
+ return;
+ }
+
+ if (sourceType === 'upload' && !zipFile) {
+ setErrorMessage('Please select a source ZIP archive.');
+ return;
+ }
+
setSubmittingStatus('creating_project');
setErrorMessage('');
@@ -145,14 +156,14 @@ export function CreateProjectPage() {
}
}
- const projectPayload: any = {
+ const projectPayload: CreateProjectInput = {
name: name.trim(),
description: description.trim() || undefined,
sourceType,
repoUrl: finalRepoUrl,
repoBranch: repoBranch.trim() || undefined,
buildType,
- projectType: buildType === 'railpack' ? projectType : 'web',
+ projectType,
sourceDir: sourceDir.trim() || undefined,
buildCommand: buildType === 'railpack' ? buildCommand.trim() || undefined : undefined,
installCommand: buildType === 'railpack' ? installCommand.trim() || undefined : undefined,
@@ -166,8 +177,17 @@ export function CreateProjectPage() {
if (stagedEnvs.length > 0) {
setSubmittingStatus('creating_envs');
- for (const env of stagedEnvs) {
- await api.setEnvVar(project.id, env.key, env.value, env.environment);
+ try {
+ for (const env of stagedEnvs) {
+ await api.setEnvVar(project.id, env.key, env.value, env.environment);
+ }
+ } catch (envErr: any) {
+ try { await api.deleteProject(project.id); } catch {}
+ setSubmittingStatus('error');
+ setErrorMessage(
+ `Failed to set environment variable "${stagedEnvs.find((e) => !e.key)?.key || stagedEnvs[0]?.key}": ${envErr.message || 'Unknown error'}. Created project was cleaned up.`,
+ );
+ return;
}
}
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts
index 37d3f1b..b6127cc 100644
--- a/apps/web/src/types/index.ts
+++ b/apps/web/src/types/index.ts
@@ -34,6 +34,28 @@ export interface Project {
updatedAt: string;
}
+export interface CreateProjectInput {
+ name: string;
+ description?: string;
+ baseDomain?: string;
+ repoUrl?: string;
+ repoBranch?: string;
+ cpuLimit?: number;
+ memoryLimitMb?: number;
+ port?: number | null;
+ sourceDir?: string;
+ sourceType?: string;
+ projectType?: string;
+ buildType?: string;
+ buildCommand?: string | null;
+ installCommand?: string | null;
+ outputDir?: string | null;
+ startCommand?: string | null;
+ composeService?: string | null;
+ composePort?: number | null;
+ composeServices?: string | null;
+}
+
export interface Deployment {
id: string;
projectId: string | null;
diff --git a/apps/web/src/utils/slugify.ts b/apps/web/src/utils/slugify.ts
new file mode 100644
index 0000000..13f7b70
--- /dev/null
+++ b/apps/web/src/utils/slugify.ts
@@ -0,0 +1,7 @@
+export function slugifyProjectName(value: string) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
From c1667eab15523e29a4abfbe5ce573e5c9dc196aa Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Wed, 12 Aug 2026 01:46:34 +0100
Subject: [PATCH 6/6] feat(project): add custom build commands and deployment
refinements
- Allow custom build, install, and start commands
in project creation
- Enable environment-aware variable fetching for
deployments
- Improve directory handling in build scripts
- Add retry logic and improved error tracking for
project creation
- Remove redundant database provisioning UI
components
---
apps/api/src/orchestrator/pipeline.ts | 2 +-
.../src/orchestrator/railpack-config-utils.ts | 4 +--
.../project/create/CreateProjectDialog.tsx | 31 +++++++++++++++++--
.../project/create/CreationStatusOverlay.tsx | 21 -------------
apps/web/src/routes/CreateProjectPage.tsx | 10 +++---
5 files changed, 37 insertions(+), 31 deletions(-)
diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts
index 9894ed6..6b02003 100644
--- a/apps/api/src/orchestrator/pipeline.ts
+++ b/apps/api/src/orchestrator/pipeline.ts
@@ -406,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;
diff --git a/apps/api/src/orchestrator/railpack-config-utils.ts b/apps/api/src/orchestrator/railpack-config-utils.ts
index 254e367..f77efe2 100644
--- a/apps/api/src/orchestrator/railpack-config-utils.ts
+++ b/apps/api/src/orchestrator/railpack-config-utils.ts
@@ -220,7 +220,7 @@ export const generateDynamicRailpackJson = async (
if (scripts.build) {
const buildCmds = [
cleanSourceDir
- ? `[ -d "${cleanSourceDir}" ] && cd ${cleanSourceDir}; ${pm} run build`
+ ? `[ -d "${cleanSourceDir}" ] && cd -- "${cleanSourceDir}" && ${pm} run build`
: `${pm} run build`,
];
if (hasNext) {
@@ -516,7 +516,7 @@ export const generateDynamicRailpackJson = async (
configured = true;
}
- if (!configured && (projectType === "static" || (await Bun.file(join(buildDir, "index.html")).exists()) || (await Bun.file(join(workspace, "index.html")).exists()))) {
+ 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");
diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx
index 870acd3..c283c6b 100644
--- a/apps/web/src/components/project/create/CreateProjectDialog.tsx
+++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx
@@ -73,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");
@@ -124,6 +132,11 @@ export function CreateProjectDialog({
) => {
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 = (
@@ -141,6 +154,10 @@ export function CreateProjectDialog({
setSelectedRepo(null);
setSourceType("git");
setSelectedPresetId("");
+ setBuildCommand("");
+ setInstallCommand("");
+ setStartCommand("");
+ setOutputDir("");
setPort("");
setZipFile(null);
}
@@ -186,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) {
@@ -266,8 +295,6 @@ export function CreateProjectDialog({
hasEnvs={
stagedEnvs.length > 0
}
- hasDb={provisionDb}
- dbType={dbType}
onRetry={() =>
setSubmittingStatus(
"idle",
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/routes/CreateProjectPage.tsx b/apps/web/src/routes/CreateProjectPage.tsx
index 1dcef16..8fcdaa8 100644
--- a/apps/web/src/routes/CreateProjectPage.tsx
+++ b/apps/web/src/routes/CreateProjectPage.tsx
@@ -177,15 +177,17 @@ export function CreateProjectPage() {
if (stagedEnvs.length > 0) {
setSubmittingStatus('creating_envs');
+ let failedEnvKey = '';
try {
for (const env of stagedEnvs) {
+ failedEnvKey = env.key;
await api.setEnvVar(project.id, env.key, env.value, env.environment);
}
} catch (envErr: any) {
try { await api.deleteProject(project.id); } catch {}
setSubmittingStatus('error');
setErrorMessage(
- `Failed to set environment variable "${stagedEnvs.find((e) => !e.key)?.key || stagedEnvs[0]?.key}": ${envErr.message || 'Unknown error'}. Created project was cleaned up.`,
+ `Failed to set environment variable "${failedEnvKey}": ${envErr.message || 'Unknown error'}. Created project was cleaned up.`,
);
return;
}
@@ -348,11 +350,11 @@ export function CreateProjectPage() {
- {submittingStatus === 'idle' ? 'Launch Deployment' : 'Deploying...'}
+ {submittingStatus === 'error' ? 'Retry Deployment' : submittingStatus === 'idle' ? 'Launch Deployment' : 'Deploying...'}
@@ -362,8 +364,6 @@ export function CreateProjectPage() {
submittingStatus={submittingStatus}
errorMessage={errorMessage}
hasEnvs={stagedEnvs.length > 0}
- hasDb={false}
- dbType="postgresql"
onRetry={handleRetry}
/>