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.
-
setName(event.target.value)} placeholder="Production PostgreSQL" />
+
setName(event.target.value)} placeholder="Production Database" />
-
+
setType(val)} />
setVersion(event.target.value)} />
diff --git a/apps/web/src/components/logos/DatabaseLogos.tsx b/apps/web/src/components/logos/DatabaseLogos.tsx new file mode 100644 index 0000000..7c52014 --- /dev/null +++ b/apps/web/src/components/logos/DatabaseLogos.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +interface LogoProps extends React.SVGProps { + className?: string; + size?: number; +} + +export function PostgresLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function MySQLLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function RedisLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + ); +} + +export function MongoLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function MariaDBLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function ClickHouseLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function SQLiteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function getDatabaseLogo(engine: string, className = 'h-4 w-4'): React.ReactNode { + const engineLower = engine.toLowerCase(); + if (engineLower.includes('postgre') || engineLower.includes('postgres') || engineLower === 'pg') { + return ; + } + if (engineLower.includes('mysql')) { + return ; + } + if (engineLower.includes('redis') || engineLower.includes('valkey')) { + return ; + } + if (engineLower.includes('mongo')) { + return ; + } + if (engineLower.includes('maria')) { + return ; + } + if (engineLower.includes('clickhouse')) { + return ; + } + if (engineLower.includes('sqlite') || engineLower.includes('libsql')) { + return ; + } + return ; +} diff --git a/apps/web/src/components/logos/FrameworkLogos.tsx b/apps/web/src/components/logos/FrameworkLogos.tsx new file mode 100644 index 0000000..42690ab --- /dev/null +++ b/apps/web/src/components/logos/FrameworkLogos.tsx @@ -0,0 +1,226 @@ +import React from 'react'; + +interface LogoProps extends React.SVGProps { + className?: string; + size?: number; +} + +export function NextjsLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +export function ReactLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + ); +} + +export function ViteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function VueLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function NuxtLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function AstroLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function AngularLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function NodeLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function RemixLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + + ); +} + +export function SvelteLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function PythonLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function GoLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function DockerLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + ); +} + +export function OtherLogo({ className = 'h-4 w-4', size, ...props }: LogoProps) { + return ( + + + + + ); +} + +export function getFrameworkLogo(icon: string, className = 'h-4 w-4'): React.ReactNode { + const iconLower = icon.toLowerCase(); + if (iconLower.includes('next')) return ; + if (iconLower.includes('react')) return ; + if (iconLower.includes('vite')) return ; + if (iconLower.includes('vue')) return ; + if (iconLower.includes('nuxt')) return ; + if (iconLower.includes('astro')) return ; + if (iconLower.includes('angular')) return ; + if (iconLower.includes('remix')) return ; + if (iconLower.includes('svelte')) return ; + if (iconLower.includes('node') || iconLower.includes('express')) return ; + if (iconLower.includes('python') || iconLower.includes('django') || iconLower.includes('fastapi')) return ; + if (iconLower.includes('go') || iconLower.includes('golang')) return ; + if (iconLower.includes('container') || iconLower.includes('docker')) return ; + return ; +} diff --git a/apps/web/src/components/project/create/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 */} +
+ + +
+
+ ); +} 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 && ( +
+ {/* Build Command */} +
+ + setBuildCommand(e.target.value)} + placeholder="e.g. npm run build" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ + {/* Output Directory */} +
+ + setOutputDir(e.target.value)} + placeholder="e.g. .next or dist" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ + {/* Install Command */} +
+ + setInstallCommand(e.target.value)} + placeholder="e.g. npm install" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ + {/* Start Command (for Web Service) */} + {projectType === 'web' && ( +
+ + setStartCommand(e.target.value)} + placeholder="e.g. npm run start" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ )} + + {/* Port */} +
+ + setPort(e.target.value)} + placeholder="Auto-detected (e.g. 3000)" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+ + {/* Root Directory */} +
+ + setSourceDir(e.target.value)} + placeholder="./ (Root)" + className="bg-[#121215] border-[#22222a] focus:border-orange-500 text-zinc-100 h-9 rounded-xl font-mono text-xs" + /> +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/project/create/BuildStrategySection.tsx b/apps/web/src/components/project/create/BuildStrategySection.tsx new file mode 100644 index 0000000..d56ec82 --- /dev/null +++ b/apps/web/src/components/project/create/BuildStrategySection.tsx @@ -0,0 +1,333 @@ +import { FrameworkPreset } from '../../../utils/presets'; +import { FrameworkSelect } from '../../ui/FrameworkSelect'; +import { Input } from '../../ui/input'; +import { Button } from '../../ui/button'; +import { Container, Globe, Box, FolderCode, Terminal, Sliders, Play, Plus, Trash2 } from 'lucide-react'; +import { cn } from '../../../lib/utils'; + +export interface ComposeServiceRow { + id: string; + serviceName: string; + port: string; + subdomain: string; +} + +interface BuildStrategySectionProps { + buildType: 'railpack' | 'compose'; + setBuildType: (v: 'railpack' | 'compose') => void; + projectType: string; + setProjectType: (v: string) => void; + selectedPresetId: string; + onSelectPreset: (preset: FrameworkPreset) => void; + buildCommand: string; + setBuildCommand: (v: string) => void; + installCommand: string; + setInstallCommand: (v: string) => void; + startCommand: string; + setStartCommand: (v: string) => void; + outputDir: string; + setOutputDir: (v: string) => void; + port: string; + setPort: (v: string) => void; + sourceDir: string; + setSourceDir: (v: string) => void; + composeServicesList: ComposeServiceRow[]; + addComposeServiceRow: () => void; + removeComposeServiceRow: (id: string) => void; + updateComposeServiceRow: (id: string, field: 'serviceName' | 'port' | 'subdomain', value: string) => void; +} + +export function BuildStrategySection({ + buildType, + setBuildType, + projectType, + setProjectType, + selectedPresetId, + onSelectPreset, + buildCommand, + setBuildCommand, + installCommand, + setInstallCommand, + startCommand, + setStartCommand, + outputDir, + setOutputDir, + port, + setPort, + sourceDir, + setSourceDir, + composeServicesList, + addComposeServiceRow, + removeComposeServiceRow, + updateComposeServiceRow, +}: BuildStrategySectionProps) { + return ( +
+
+

+ + Build & Runtime Strategy +

+

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

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

Compose Ingress Service Mapping

+

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

+
+ +
+ +
+ {composeServicesList.map((row) => ( +
+
+ + updateComposeServiceRow(row.id, 'serviceName', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+
+ + updateComposeServiceRow(row.id, 'port', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+
+
+ + updateComposeServiceRow(row.id, 'subdomain', e.target.value)} + className="bg-[#181820] border-[#2a2a36] text-xs font-mono h-8" + /> +
+ {composeServicesList.length > 1 && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/project/create/EnvVarsSection.tsx b/apps/web/src/components/project/create/EnvVarsSection.tsx new file mode 100644 index 0000000..de7b202 --- /dev/null +++ b/apps/web/src/components/project/create/EnvVarsSection.tsx @@ -0,0 +1,243 @@ +import { useState, useRef } from 'react'; +import { Input } from '../../ui/input'; +import { Button } from '../../ui/button'; +import { Key, Plus, Trash2, FileText, Upload, ChevronDown } from 'lucide-react'; + +export interface StagedEnv { + key: string; + value: string; + environment?: string; +} + +interface EnvVarsSectionProps { + stagedEnvs: StagedEnv[]; + setStagedEnvs: React.Dispatch>; +} + +export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProps) { + const [isExpanded, setIsExpanded] = useState(true); + const [key, setKey] = useState(''); + const [val, setVal] = useState(''); + const [bulkText, setBulkText] = useState(''); + const [mode, setMode] = useState<'single' | 'bulk' | 'file'>('single'); + const [fileError, setFileError] = useState(''); + const fileInputRef = useRef(null); + + const addEnv = () => { + if (!key.trim()) return; + setStagedEnvs((prev) => [...prev, { key: key.trim(), value: val.trim() }]); + setKey(''); + setVal(''); + }; + + const removeEnv = (index: number) => { + setStagedEnvs((prev) => prev.filter((_, i) => i !== index)); + }; + + const parseEnvContent = (content: string) => { + const lines = content.split(/\r?\n/); + const newEnvs: StagedEnv[] = []; + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + const eqIdx = trimmed.indexOf('='); + if (eqIdx !== -1) { + const k = trimmed.slice(0, eqIdx).trim(); + const v = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); + if (k) newEnvs.push({ key: k, value: v }); + } + }); + return newEnvs; + }; + + const importBulk = () => { + if (!bulkText.trim()) return; + const parsed = parseEnvContent(bulkText); + setStagedEnvs((prev) => [...prev, ...parsed]); + setBulkText(''); + setMode('single'); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileError(''); + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const parsed = parseEnvContent(content); + if (parsed.length === 0) { + setFileError('No valid KEY=VALUE pairs found in file.'); + } else { + setStagedEnvs((prev) => [...prev, ...parsed]); + setMode('single'); + } + } catch { + setFileError('Failed to parse file content.'); + } + }; + reader.onerror = () => setFileError('Error reading file.'); + reader.readAsText(file); + }; + + return ( +
+
setIsExpanded(!isExpanded)} + > +
+

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

+

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

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