From da3e31f17bc576bd532fa1de706b1afe86623453 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Mon, 18 May 2026 07:34:27 +0300 Subject: [PATCH 1/8] feat(worker): add nixpacks build pack with static/server paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "static" build pack, merge into nixpacks with isStatic toggle - Add isStatic column to apps table (default true) - Update pipeline to route nixpacks to new strategy - New nixpacks.ts: clone → nixpacks build → extract (static) or runLongLived (server) → Caddy route - Install nixpacks + docker-buildx in worker image - Fix Caddy SPA fallback: use file_server pass_thru instead of subroute errors - Mock child_process in pipeline tests for nixpacks CLI calls Closes #12 --- docker-compose.yaml | 1 + drizzle/0007_medical_terror.sql | 6 + drizzle/meta/0007_snapshot.json | 1110 +++++++++++++++++ drizzle/meta/_journal.json | 7 + packages/api/src/services/apps.ts | 2 + packages/shared/src/constants/build-pack.ts | 4 - packages/shared/src/schema.ts | 4 +- packages/shared/src/validators/app.ts | 7 +- .../web/src/components/BuildPackSelector.tsx | 6 - .../web/src/components/CreateAppModal.tsx | 218 ++-- packages/web/src/pages/Dashboard.tsx | 3 +- packages/worker/Dockerfile | 8 +- packages/worker/Dockerfile.dev | 8 +- packages/worker/src/deployments/pipeline.ts | 7 +- .../src/deployments/strategies/nixpacks.ts | 382 ++++++ packages/worker/src/index.ts | 17 +- .../infrastructure/caddy/config-builder.ts | 32 +- .../test/unit/deployments/pipeline.test.ts | 218 ++-- 18 files changed, 1770 insertions(+), 270 deletions(-) create mode 100644 drizzle/0007_medical_terror.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 packages/worker/src/deployments/strategies/nixpacks.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index 43a93ad..64f6741 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -92,6 +92,7 @@ services: container_name: shipyard-worker volumes: - shipyard_sites:/var/lib/shipyard/sites + - /var/run/docker.sock:/var/run/docker.sock environment: DATABASE_URL: postgres://${POSTGRES_USER:-shipyard}:${POSTGRES_PASSWORD:-shipyard}@postgres:5432/${POSTGRES_DB:-shipyard} REDIS_URL: redis://redis:6379 diff --git a/drizzle/0007_medical_terror.sql b/drizzle/0007_medical_terror.sql new file mode 100644 index 0000000..ec86058 --- /dev/null +++ b/drizzle/0007_medical_terror.sql @@ -0,0 +1,6 @@ +ALTER TABLE "apps" ALTER COLUMN "build_pack" SET DEFAULT 'nixpacks';--> statement-breakpoint +ALTER TABLE "apps" ADD COLUMN "is_static" boolean DEFAULT true;--> statement-breakpoint +ALTER TABLE "public"."apps" ALTER COLUMN "build_pack" SET DATA TYPE text;--> statement-breakpoint +DROP TYPE "public"."build_pack";--> statement-breakpoint +CREATE TYPE "public"."build_pack" AS ENUM('nixpacks', 'dockerfile', 'dockercompose', 'dockerimage');--> statement-breakpoint +ALTER TABLE "public"."apps" ALTER COLUMN "build_pack" SET DATA TYPE "public"."build_pack" USING "build_pack"::"public"."build_pack"; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..b0c110e --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1110 @@ +{ + "id": "4a94366c-d07d-4188-9d37-66d879a8b8fc", + "prevId": "2bc0d5a8-be1b-46c0-b40b-195af132de1c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.apps": { + "name": "apps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_repo": { + "name": "github_repo", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "build_command": { + "name": "build_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "output_dir": { + "name": "output_dir", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subdirectory": { + "name": "subdirectory", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_static": { + "name": "is_static", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "branch": { + "name": "branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "build_timeout": { + "name": "build_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 900 + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "build_pack": { + "name": "build_pack", + "type": "build_pack", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nixpacks'" + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 80 + }, + "run_command": { + "name": "run_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "install_command": { + "name": "install_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "dockerfile_path": { + "name": "dockerfile_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "'./Dockerfile'" + }, + "is_spa": { + "name": "is_spa", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "custom_nginx_config": { + "name": "custom_nginx_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_apps_org_id": { + "name": "idx_apps_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_apps_org_name_unique": { + "name": "idx_apps_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apps_organization_id_organizations_id_fk": { + "name": "apps_organization_id_organizations_id_fk", + "tableFrom": "apps", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_jobs": { + "name": "build_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_build_jobs_deployment_id": { + "name": "idx_build_jobs_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "build_jobs_deployment_id_deployments_id_fk": { + "name": "build_jobs_deployment_id_deployments_id_fk", + "tableFrom": "build_jobs", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_files": { + "name": "deployment_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_files_deployment_id": { + "name": "idx_deployment_files_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_files_deployment_id_deployments_id_fk": { + "name": "deployment_files_deployment_id_deployments_id_fk", + "tableFrom": "deployment_files", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_logs": { + "name": "deployment_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_logs_deployment_id": { + "name": "idx_deployment_logs_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "commit_message": { + "name": "commit_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "detected_framework": { + "name": "detected_framework", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "output_dir": { + "name": "output_dir", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployments_app_id": { + "name": "idx_deployments_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_status": { + "name": "idx_deployments_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_app_id_apps_id_fk": { + "name": "deployments_app_id_apps_id_fk", + "tableFrom": "deployments", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_app_id": { + "name": "idx_domains_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_domains_domain": { + "name": "idx_domains_domain", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_app_id_apps_id_fk": { + "name": "domains_app_id_apps_id_fk", + "tableFrom": "domains", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_domain_unique": { + "name": "domains_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.env_vars": { + "name": "env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_env_vars_app_id": { + "name": "idx_env_vars_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "env_vars_app_id_apps_id_fk": { + "name": "env_vars_app_id_apps_id_fk", + "tableFrom": "env_vars", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_members_user_id": { + "name": "idx_org_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_members_org_id": { + "name": "idx_org_members_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_organizations_slug": { + "name": "idx_organizations_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_org_id_organizations_id_fk": { + "name": "sessions_org_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "github_username": { + "name": "github_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "github_access_token": { + "name": "github_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_github_id": { + "name": "idx_users_github_id", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.build_pack": { + "name": "build_pack", + "schema": "public", + "values": ["nixpacks", "dockerfile", "dockercompose", "dockerimage"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0a98eef..bd15107 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1779071059753, "tag": "0006_magenta_polaris", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1779075369464, + "tag": "0007_medical_terror", + "breakpoints": true } ] } diff --git a/packages/api/src/services/apps.ts b/packages/api/src/services/apps.ts index a71cbd8..7a41594 100644 --- a/packages/api/src/services/apps.ts +++ b/packages/api/src/services/apps.ts @@ -24,6 +24,8 @@ const safeColumns = { buildPack: apps.buildPack, port: apps.port, runCommand: apps.runCommand, + installCommand: apps.installCommand, + isStatic: apps.isStatic, dockerfilePath: apps.dockerfilePath, isSpa: apps.isSpa, customNginxConfig: apps.customNginxConfig, diff --git a/packages/shared/src/constants/build-pack.ts b/packages/shared/src/constants/build-pack.ts index afdfa22..0a7de03 100644 --- a/packages/shared/src/constants/build-pack.ts +++ b/packages/shared/src/constants/build-pack.ts @@ -1,6 +1,5 @@ export const BUILD_PACKS = [ "nixpacks", - "static", "dockerfile", "dockercompose", "dockerimage", @@ -10,7 +9,6 @@ export type BuildPack = (typeof BUILD_PACKS)[number]; export const BUILD_PACK_LABELS: Record = { nixpacks: "Nixpacks (auto-detect)", - static: "Static (nginx)", dockerfile: "Dockerfile", dockercompose: "Docker Compose", dockerimage: "Docker Image", @@ -19,8 +17,6 @@ export const BUILD_PACK_LABELS: Record = { export const BUILD_PACK_DESCRIPTIONS: Record = { nixpacks: "Automatic detection and building via Nixpacks. Zero-config deployments for Node.js, PHP, Python, etc.", - static: - "Static site builder using Nginx. SPAs (React, Vue, Svelte), documentation sites, or plain HTML.", dockerfile: "Custom Dockerfile-based builds. Applications requiring specific OS dependencies or complex build stages.", dockercompose: diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index e399b86..190824a 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -13,7 +13,6 @@ import { export const buildPackEnum = pgEnum("build_pack", [ "nixpacks", - "static", "dockerfile", "dockercompose", "dockerimage", @@ -79,11 +78,12 @@ export const apps = pgTable( buildCommand: varchar("build_command", { length: 500 }), outputDir: varchar("output_dir", { length: 255 }), subdirectory: varchar("subdirectory", { length: 255 }), + isStatic: boolean("is_static").default(true), branch: varchar("branch", { length: 100 }).default("main"), buildTimeout: integer("build_timeout").default(900), activeDeploymentId: uuid("active_deployment_id"), webhookSecret: varchar("webhook_secret", { length: 255 }), - buildPack: buildPackEnum("build_pack").notNull().default("static"), + buildPack: buildPackEnum("build_pack").notNull().default("nixpacks"), port: integer("port").default(80), runCommand: varchar("run_command", { length: 500 }), installCommand: varchar("install_command", { length: 500 }), diff --git a/packages/shared/src/validators/app.ts b/packages/shared/src/validators/app.ts index 52c5faa..d0b8eb0 100644 --- a/packages/shared/src/validators/app.ts +++ b/packages/shared/src/validators/app.ts @@ -36,6 +36,7 @@ const appFieldDefs = { .transform((val) => val?.trim() ? val.replace(/^\/+|\/+$/g, "") : undefined, ), + isStatic: z.boolean(), port: z.number().int().positive(), runCommand: safeCommandSchema.optional(), installCommand: installCommandSchema.optional(), @@ -52,6 +53,7 @@ export const appRefinement = < buildPack?: string; githubRepo?: string; outputDir?: string; + isStatic?: boolean; image?: string; }, >( @@ -66,8 +68,8 @@ export const appRefinement = < }); } if ( - data.buildPack && - (data.buildPack === "static" || data.buildPack === "nixpacks") && + data.buildPack === "nixpacks" && + data.isStatic !== false && !data.outputDir ) { ctx.addIssue({ @@ -93,6 +95,7 @@ const withDefaults = z.object({ buildCommand: appFieldDefs.buildCommand, outputDir: appFieldDefs.outputDir, subdirectory: appFieldDefs.subdirectory, + isStatic: appFieldDefs.isStatic.default(true), port: appFieldDefs.port.default(80), runCommand: appFieldDefs.runCommand, installCommand: appFieldDefs.installCommand, diff --git a/packages/web/src/components/BuildPackSelector.tsx b/packages/web/src/components/BuildPackSelector.tsx index 516f584..6347937 100644 --- a/packages/web/src/components/BuildPackSelector.tsx +++ b/packages/web/src/components/BuildPackSelector.tsx @@ -5,12 +5,6 @@ const PACKS = [ desc: "Auto-detect framework via Nixpacks", tag: "NX", }, - { - value: "static", - label: "Static", - desc: "Serve pre-built assets via nginx", - tag: "ST", - }, { value: "dockerfile", label: "Dockerfile", diff --git a/packages/web/src/components/CreateAppModal.tsx b/packages/web/src/components/CreateAppModal.tsx index 012f377..f12b79c 100644 --- a/packages/web/src/components/CreateAppModal.tsx +++ b/packages/web/src/components/CreateAppModal.tsx @@ -9,6 +9,7 @@ function validate(input: { name: string; githubRepo: string; buildPack: BuildPackValue; + isStatic: boolean; image: string; port: number; outputDir: string; @@ -36,7 +37,8 @@ function validate(input: { } if ( - (input.buildPack === "static" || input.buildPack === "nixpacks") && + input.buildPack === "nixpacks" && + input.isStatic && !input.outputDir.trim() ) { errs.outputDir = "Output directory is required"; @@ -78,7 +80,8 @@ interface Props { } export function CreateAppModal({ open, onClose }: Props) { - const [buildPack, setBuildPack] = useState("static"); + const [buildPack, setBuildPack] = useState("nixpacks"); + const [isStatic, setIsStatic] = useState(true); const [name, setName] = useState(""); const [githubRepo, setGithubRepo] = useState(""); const [branch, setBranch] = useState("main"); @@ -100,7 +103,8 @@ export function CreateAppModal({ open, onClose }: Props) { setName(""); setGithubRepo(""); setBranch("main"); - setBuildPack("static"); + setBuildPack("nixpacks"); + setIsStatic(true); setBuildCommand(""); setOutputDir("dist"); setSubdirectory(""); @@ -123,6 +127,7 @@ export function CreateAppModal({ open, onClose }: Props) { name, githubRepo, buildPack, + isStatic, image, port, outputDir, @@ -145,22 +150,23 @@ export function CreateAppModal({ open, onClose }: Props) { if (buildCommand) payload.buildCommand = buildCommand; - if (buildPack === "static") { - payload.outputDir = outputDir; - payload.isSpa = isSpa; - if (subdirectory) payload.subdirectory = subdirectory; + if (buildPack === "nixpacks") { + payload.isStatic = isStatic; + if (isStatic) { + payload.outputDir = outputDir; + payload.isSpa = isSpa; + if (subdirectory) payload.subdirectory = subdirectory; + } else { + if (runCommand) payload.runCommand = runCommand; + if (outputDir) payload.outputDir = outputDir; + if (subdirectory) payload.subdirectory = subdirectory; + } } if (buildPack === "dockerfile") { payload.dockerfilePath = dockerfilePath; } - if (buildPack === "nixpacks") { - if (runCommand) payload.runCommand = runCommand; - if (outputDir) payload.outputDir = outputDir; - if (subdirectory) payload.subdirectory = subdirectory; - } - if (buildPack === "dockerimage") { payload.image = image; } @@ -279,76 +285,124 @@ export function CreateAppModal({ open, onClose }: Props) { className="space-y-3 border-t border-ship-deck/30 pt-4 overflow-hidden" > - {(buildPack === "static" || buildPack === "nixpacks") && ( - -
- - { - setOutputDir(e.target.value); - if (fieldErrors.outputDir) - setFieldErrors((p) => ({ ...p, outputDir: "" })); - }} - placeholder="dist" - className={inputCls(!!fieldErrors.outputDir)} - /> - -
-
- - setSubdirectory(e.target.value)} - placeholder="e.g. frontend, packages/web" - className="w-full bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" - /> -
-
-
-
- )} - - {buildPack === "static" && ( - - - + + + + {isStatic && ( +
+ + { + setOutputDir(e.target.value); + if (fieldErrors.outputDir) + setFieldErrors((p) => ({ + ...p, + outputDir: "", + })); + }} + placeholder="dist" + className={inputCls(!!fieldErrors.outputDir)} + /> + +
+ )} +
+ + setSubdirectory(e.target.value)} + placeholder="e.g. frontend, packages/web" + className="w-full bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+
+ + setBuildCommand(e.target.value)} + placeholder="npm run build" + className="w-full bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+
+ + {isStatic && ( + + + + )} + + {!isStatic && ( + + + setRunCommand(e.target.value)} + placeholder="npm start" + className="w-full max-w-xs bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> + + )} + )} {buildPack === "dockerfile" && ( diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index 0273ea7..f902dc5 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -12,7 +12,6 @@ import { const BUILD_PACK_COLORS: Record = { nixpacks: "text-purple-400 border-purple-900/50 bg-purple-950/20", - static: "text-ship-buoy border-ship-buoy/20 bg-ship-buoy/5", dockerfile: "text-blue-400 border-blue-900/50 bg-blue-950/20", dockercompose: "text-yellow-400 border-yellow-900/50 bg-yellow-950/20", dockerimage: "text-orange-400 border-orange-900/50 bg-orange-950/20", @@ -203,7 +202,7 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { const deployStatus = latestDeployment?.status ?? "idle"; const packColor = - BUILD_PACK_COLORS[app.buildPack] ?? BUILD_PACK_COLORS.static; + BUILD_PACK_COLORS[app.buildPack] ?? BUILD_PACK_COLORS.nixpacks; const dotColor = STATUS_DOT[deployStatus] ?? STATUS_DOT.idle; useEffect(() => { diff --git a/packages/worker/Dockerfile b/packages/worker/Dockerfile index 70ebe72..12a211d 100644 --- a/packages/worker/Dockerfile +++ b/packages/worker/Dockerfile @@ -25,7 +25,13 @@ COPY packages/worker/package.json packages/worker/ RUN npm install -g pnpm@11.1.1 && \ pnpm install --frozen-lockfile --prod --filter @shipyard/worker... -RUN apk add --no-cache docker-cli +RUN apk add --no-cache docker-cli curl tar gzip +RUN mkdir -p /usr/local/lib/docker/cli-plugins && \ + curl -fsSL https://github.com/docker/buildx/releases/download/v0.20.0/buildx-v0.20.0.linux-amd64 \ + -o /usr/local/lib/docker/cli-plugins/docker-buildx && \ + chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx +RUN curl -fsSL https://github.com/railwayapp/nixpacks/releases/download/v1.41.0/nixpacks-v1.41.0-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz -C /usr/local/bin nixpacks COPY --from=builder /app/packages/shared/dist packages/shared/dist/ COPY --from=builder /app/packages/worker/dist packages/worker/dist/ diff --git a/packages/worker/Dockerfile.dev b/packages/worker/Dockerfile.dev index 18ecf48..6b6e9d9 100644 --- a/packages/worker/Dockerfile.dev +++ b/packages/worker/Dockerfile.dev @@ -9,4 +9,10 @@ COPY packages/worker/package.json packages/worker/ RUN npm install -g pnpm@11.1.1 && \ pnpm install --frozen-lockfile -RUN apk add --no-cache docker-cli +RUN apk add --no-cache docker-cli curl tar gzip +RUN mkdir -p /usr/local/lib/docker/cli-plugins && \ + curl -fsSL https://github.com/docker/buildx/releases/download/v0.20.0/buildx-v0.20.0.linux-amd64 \ + -o /usr/local/lib/docker/cli-plugins/docker-buildx && \ + chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx +RUN curl -fsSL https://github.com/railwayapp/nixpacks/releases/download/v1.41.0/nixpacks-v1.41.0-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz -C /usr/local/bin nixpacks diff --git a/packages/worker/src/deployments/pipeline.ts b/packages/worker/src/deployments/pipeline.ts index 391fe8a..64c0227 100644 --- a/packages/worker/src/deployments/pipeline.ts +++ b/packages/worker/src/deployments/pipeline.ts @@ -13,7 +13,7 @@ type DB = PostgresJsDatabase>; import type { Env } from "../config/env.js"; import type { DockerRunner } from "../infrastructure/docker/docker-runner.js"; import { deployDockerfile } from "./strategies/dockerfile.js"; -import { deployBuildPack } from "./strategies/static.js"; +import { deployNixpacks } from "./strategies/nixpacks.js"; export interface OrchestratorDeps { db: DB; @@ -66,7 +66,6 @@ export class DeploymentOrchestrator { async process(deploymentId: string): Promise { const ctx = await this.fetchDeploymentContext(deploymentId); const app: App = ctx.app; - const userId: string = ctx.userId; const githubAccessToken: string | null = ctx.githubAccessToken; this.deps.logger.info( @@ -88,16 +87,16 @@ export class DeploymentOrchestrator { return; } - await deployBuildPack( + await deployNixpacks( deploymentId, app, - userId, githubAccessToken, this.deps.db, this.deps.env, this.deps.logger, this.deps.runner, this.deps.upsertFileRoute, + this.deps.upsertProxyRoute, ); } } diff --git a/packages/worker/src/deployments/strategies/nixpacks.ts b/packages/worker/src/deployments/strategies/nixpacks.ts new file mode 100644 index 0000000..1de18f8 --- /dev/null +++ b/packages/worker/src/deployments/strategies/nixpacks.ts @@ -0,0 +1,382 @@ +import { execSync, spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { apps, deployments, domains } from "@shipyard/shared"; +import type { App } from "@shipyard/shared/schema"; +import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { getEnv } from "../../config/env.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import { fetchDecryptedEnvVars } from "../env-vars.js"; +import { + createBuildJobRow, + createWorkspace, + finalizeBuildJobRow, + insertStructuredEvent, +} from "../events.js"; + +type DB = PostgresJsDatabase>; + +function checkNixpacksInstalled(): void { + try { + spawnSync("nixpacks", ["--version"], { stdio: "pipe" }); + } catch { + throw new Error( + "Nixpacks is not installed. Install it with: curl -fsSL https://nixpacks.com/install.sh | sh", + ); + } +} + +function runNixpacksBuild( + workspacePath: string, + app: App, + envMap: Record, + subdirectory: string, + isStatic: boolean, +): Promise { + return new Promise((resolve, reject) => { + const repoDir = subdirectory + ? path.join(workspacePath, "repo", subdirectory) + : path.join(workspacePath, "repo"); + const imageTag = `shipyard-${app.id}:${workspacePath.split("/").pop()}`; + const args = ["build", repoDir, "--name", imageTag]; + + if (isStatic) { + args.push("--no-error-without-start"); + } + if (app.installCommand) { + args.push("--install-cmd", app.installCommand); + } + if (app.buildCommand) { + args.push("--build-cmd", app.buildCommand); + } + if (app.runCommand) { + args.push("--start-cmd", app.runCommand); + } + + for (const [key, value] of Object.entries(envMap)) { + args.push("--env", `${key}=${value}`); + } + + const proc = spawn("nixpacks", args, { + cwd: repoDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + const chunks: Buffer[] = []; + proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk)); + proc.stderr?.on("data", (chunk: Buffer) => chunks.push(chunk)); + + proc.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + const output = Buffer.concat(chunks).toString(); + reject( + new Error( + `Nixpacks build failed (exit ${code}): ${output.slice(0, 500)}`, + ), + ); + } + }); + + proc.on("error", (err) => { + reject(new Error(`Failed to start nixpacks: ${err.message}`)); + }); + }); +} + +async function extractStaticOutput( + appId: string, + outputDir: string, + workspacePath: string, +): Promise { + const imageTag = `shipyard-${appId}:${workspacePath.split("/").pop()}`; + const containerName = `shipyard-extract-${appId}-${Date.now()}`; + const sitesPath = path.join(getEnv().SITES_DIR, appId); + + try { + execSync(`docker create --name ${containerName} ${imageTag}`, { + stdio: "pipe", + }); + + const containerPath = `/app/${outputDir}`; + + try { + execSync(`docker cp ${containerName}:${containerPath}/. ${sitesPath}`, { + stdio: "pipe", + }); + } catch { + execSync(`docker cp ${containerName}:/app/. ${sitesPath}`, { + stdio: "pipe", + }); + } + + execSync(`docker rm ${containerName}`, { stdio: "pipe" }); + } catch (err) { + const msg = err instanceof Error ? err.message : "extraction failed"; + throw new Error(`Failed to extract static output: ${msg}`); + } +} + +export async function deployNixpacks( + deploymentId: string, + app: App, + githubAccessToken: string | null, + db: DB, + env: ReturnType, + logger: { + info: (obj: Record, msg?: string) => void; + warn: (obj: Record, msg?: string) => void; + error: (obj: Record, msg?: string) => void; + }, + runner: DockerRunner, + upsertFileRoute: ( + appId: string, + domain: string, + isSpa: boolean, + ) => Promise, + upsertProxyRoute: ( + appId: string, + domain: string, + port: number, + ) => Promise, +) { + checkNixpacksInstalled(); + + if (!githubAccessToken) { + await db + .update(deployments) + .set({ status: "failed", finishedAt: new Date() }) + .where(eq(deployments.id, deploymentId)); + await insertStructuredEvent( + db, + deploymentId, + "clone", + "No GitHub token available. The owner needs to re-authenticate.", + ); + logger.warn({ deploymentId }, "Deployment failed: no GitHub token"); + return; + } + + const workspacePath = createWorkspace(env, deploymentId); + const imageTag = `shipyard-${app.id}:${workspacePath.split("/").pop()}`; + const subdirectory = app.subdirectory ?? ""; + + try { + const envMap = await fetchDecryptedEnvVars(db, env, app.id); + + await db + .update(deployments) + .set({ status: "building", startedAt: new Date() }) + .where(eq(deployments.id, deploymentId)); + + // Step 1: Clone + await createBuildJobRow(db, deploymentId, "clone"); + await insertStructuredEvent( + db, + deploymentId, + "clone", + 'Step "clone" started', + ); + logger.info({ deploymentId }, "Clone step started"); + + const repoUrl = `https://${githubAccessToken}@github.com/${app.githubRepo}.git`; + const exitCode = await runner.runOnce({ + image: "alpine/git", + cmd: ["git", "clone", "--depth", "1", repoUrl, "/workspace/repo"], + binds: [`${workspacePath}:/workspace`], + env: {}, + }); + + const cloneOk = exitCode !== undefined && exitCode === 0; + await finalizeBuildJobRow(db, deploymentId, "clone", cloneOk, 1); + + if (!cloneOk) { + await insertStructuredEvent( + db, + deploymentId, + "clone", + `Git clone failed with exit code ${exitCode}`, + ); + await db + .update(deployments) + .set({ status: "failed", finishedAt: new Date() }) + .where(eq(deployments.id, deploymentId)); + logger.warn({ deploymentId, exitCode }, "Clone step failed"); + return; + } + + await insertStructuredEvent( + db, + deploymentId, + "clone", + 'Step "clone" completed', + ); + logger.info({ deploymentId }, "Clone step completed"); + + // Step 2: Nixpacks build + await createBuildJobRow(db, deploymentId, "nixpacks-build"); + await insertStructuredEvent( + db, + deploymentId, + "nixpacks-build", + 'Step "nixpacks-build" started', + ); + + const isStatic = app.isStatic ?? true; + + try { + await runNixpacksBuild( + workspacePath, + app, + envMap, + subdirectory, + isStatic, + ); + await finalizeBuildJobRow(db, deploymentId, "nixpacks-build", true, 1); + } catch (err) { + await finalizeBuildJobRow(db, deploymentId, "nixpacks-build", false, 0); + throw err; + } + await insertStructuredEvent( + db, + deploymentId, + "nixpacks-build", + 'Step "nixpacks-build" completed', + ); + logger.info({ deploymentId }, "Nixpacks build completed"); + + if (isStatic) { + // Static path: extract output directory, serve via nginx + const outputDir = app.outputDir ?? "dist"; + await createBuildJobRow(db, deploymentId, "extract"); + logger.info({ deploymentId, outputDir }, "Extracting static output"); + + try { + await extractStaticOutput(app.id, outputDir, workspacePath); + await finalizeBuildJobRow(db, deploymentId, "extract", true, 1); + } catch (err) { + await finalizeBuildJobRow(db, deploymentId, "extract", false, 0); + throw err; + } + + await insertStructuredEvent( + db, + deploymentId, + "extract", + 'Step "extract" completed', + ); + + // Activate + Caddy file route + await db + .update(apps) + .set({ activeDeploymentId: deploymentId }) + .where(eq(apps.id, app.id)); + logger.info({ deploymentId }, "Deployment activated"); + + try { + const results = await db + .select({ domain: domains.domain }) + .from(domains) + .where(and(eq(domains.appId, app.id), eq(domains.isPrimary, true))); + const primaryDomain = Array.isArray(results) ? results[0] : undefined; + const domain = + primaryDomain?.domain ?? + `${app.name}.${env.BASE_DOMAIN ?? "bigboss.dev"}`; + await upsertFileRoute(app.id, domain, app.isSpa ?? false); + logger.info({ domain }, "Caddy file route updated"); + } catch (err) { + logger.warn( + { err, deploymentId }, + "Caddy route update failed — site may not be accessible", + ); + } + } else { + // Server path: stop old, start new long-lived container + const port = app.port ?? 80; + const containerName = `shipyard-app-${app.id}`; + + await createBuildJobRow(db, deploymentId, "start"); + logger.info({ deploymentId }, "Starting long-lived container"); + + await runner.stopByName(containerName); + await new Promise((r) => setTimeout(r, 2000)); + + try { + await runner.runLongLived({ + image: imageTag, + containerName, + containerPort: port, + envVars: envMap, + labels: { + "shipyard.managed": "true", + "shipyard.type": "app", + "shipyard.app-id": app.id, + "shipyard.worker-id": env.WORKER_ID ?? "worker-unknown", + }, + }); + await finalizeBuildJobRow(db, deploymentId, "start", true, 1); + } catch (err) { + await finalizeBuildJobRow(db, deploymentId, "start", false, 0); + throw err; + } + + await insertStructuredEvent( + db, + deploymentId, + "start", + 'Step "start" completed', + ); + logger.info({ containerName, port }, "Long-lived container started"); + + await runner.pruneOldImageTags(app.id, imageTag); + + // Activate + Caddy proxy route + await db + .update(apps) + .set({ activeDeploymentId: deploymentId }) + .where(eq(apps.id, app.id)); + logger.info({ deploymentId }, "Deployment activated"); + + try { + const results = await db + .select({ domain: domains.domain }) + .from(domains) + .where(and(eq(domains.appId, app.id), eq(domains.isPrimary, true))); + const primaryDomain = Array.isArray(results) ? results[0] : undefined; + const domain = + primaryDomain?.domain ?? + `${app.name}.${env.BASE_DOMAIN ?? "bigboss.dev"}`; + await upsertProxyRoute(app.id, domain, port); + logger.info({ domain, port }, "Caddy proxy route updated"); + } catch (err) { + logger.warn( + { err, deploymentId }, + "Caddy route update failed — site may not be accessible", + ); + } + } + + await db + .update(deployments) + .set({ status: "success", finishedAt: new Date() }) + .where(eq(deployments.id, deploymentId)); + logger.info({ deploymentId }, "Deployment succeeded"); + } catch (err) { + logger.error({ err, deploymentId }, "Nixpacks deployment failed"); + await insertStructuredEvent( + db, + deploymentId, + "system", + `Nixpacks build error: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + await db + .update(deployments) + .set({ status: "failed", finishedAt: new Date() }) + .where(eq(deployments.id, deploymentId)); + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + logger.info({ workspacePath }, "Workspace cleaned up"); + } +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 755de79..b73d3fd 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -48,12 +48,16 @@ async function reconcileCaddyRoutes(): Promise { let restored = 0; for (const row of rows) { + const app = row.app as Record; const domain = row.primaryDomain ?? - `${(row.app as Record).name}.${env.BASE_DOMAIN}`; - const app = row.app as Record; + `${(app.name as string) ?? "app"}.${env.BASE_DOMAIN}`; try { - if ((app.buildPack as string) === "dockerfile") { + const buildPack = app.buildPack as string; + if ( + buildPack === "dockerfile" || + (buildPack === "nixpacks" && !(app.isStatic as boolean)) + ) { await upsertProxyRoute( app.id as string, domain, @@ -67,13 +71,10 @@ async function reconcileCaddyRoutes(): Promise { ); } restored++; - logger.info( - { appId: (row.app as Record).id, domain }, - "Caddy route restored", - ); + logger.info({ appId: app.id as string, domain }, "Caddy route restored"); } catch (err) { logger.warn( - { err, appId: (row.app as Record).id, domain }, + { err, appId: app.id as string, domain }, "Failed to restore Caddy route", ); } diff --git a/packages/worker/src/infrastructure/caddy/config-builder.ts b/packages/worker/src/infrastructure/caddy/config-builder.ts index 835c18d..2219c0c 100644 --- a/packages/worker/src/infrastructure/caddy/config-builder.ts +++ b/packages/worker/src/infrastructure/caddy/config-builder.ts @@ -32,27 +32,17 @@ export function buildRouteConfig( match: [{ host: [domain] }], handle: [ { - handler: "subroute", - routes: [ - { - handle: [ - { - handler: "file_server", - root, - }, - ], - }, - ], - errors: { - routes: [ - { - handle: [ - { handler: "rewrite", uri: "/index.html" }, - { handler: "file_server", root }, - ], - }, - ], - }, + handler: "file_server", + root, + pass_thru: true, + }, + { + handler: "rewrite", + uri: "/index.html", + }, + { + handler: "file_server", + root, }, ], terminal: true, diff --git a/packages/worker/test/unit/deployments/pipeline.test.ts b/packages/worker/test/unit/deployments/pipeline.test.ts index 390b8d7..7427f08 100644 --- a/packages/worker/test/unit/deployments/pipeline.test.ts +++ b/packages/worker/test/unit/deployments/pipeline.test.ts @@ -1,6 +1,30 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => { + const createSpawn = () => { + const fn = (..._args: unknown[]) => { + const self = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((_event: string, cb: (code: number) => void) => { + cb(0); + return self; + }), + }; + return self; + }; + return fn; + }; + const spawnSync = vi.fn(() => ({ + status: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + })); + return { spawnSync, spawn: createSpawn(), execSync: vi.fn() }; +}); + import type { OrchestratorDeps } from "../../../src/deployments/pipeline.js"; import { DeploymentOrchestrator } from "../../../src/deployments/pipeline.js"; @@ -86,22 +110,24 @@ function makeDeps(selectResults?: unknown[][], buildDir?: string) { } describe("DeploymentOrchestrator", () => { - describe("static build pack", () => { + describe("nixpacks static (isStatic=true)", () => { const BUILD_DIR = "/tmp/shipyard-test/builds"; - const SITES_DIR = "/tmp/shipyard-test/sites"; - function makeAppContext() { + function makeStaticAppContext() { return [ { - deployment: { id: "deploy-1" }, + deployment: { id: "deploy-nx-1" }, app: { - id: "app-1", - name: "myapp", + id: "app-nx-1", + name: "myapp-nx", githubRepo: "user/repo", - buildTimeout: 900, + buildPack: "nixpacks", + isStatic: true, outputDir: "dist", isSpa: false, + buildTimeout: 900, branch: "main", + port: 80, }, githubAccessToken: "gh_token_123", userId: "user-1", @@ -109,49 +135,26 @@ describe("DeploymentOrchestrator", () => { ]; } - function setupOutput(deploymentId: string) { - const dir = path.join(BUILD_DIR, deploymentId, "repo", "dist"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "index.html"), "

test

"); - } - afterEach(() => { fs.rmSync(BUILD_DIR, { recursive: true, force: true, maxRetries: 3 }); - fs.rmSync(SITES_DIR, { recursive: true, force: true, maxRetries: 3 }); }); - it("runs all steps and activates deployment on success", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); + it("routes to nixpacks static — clones repo then extracts output", async () => { + const deps = makeDeps([makeStaticAppContext(), [], []]); const orchestrator = new DeploymentOrchestrator(deps); - await orchestrator.process("deploy-1"); + await orchestrator.process("deploy-nx-1"); - expect(deps.runner.create).toHaveBeenCalledTimes(1); - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - expect(deps.upsertFileRoute).toHaveBeenCalledWith( - "app-1", - "myapp.bigboss.dev", - false, + expect(deps.runner.runOnce).toHaveBeenCalledWith( + expect.objectContaining({ image: "alpine/git" }), ); }); - it("marks deployment as 'building' on start", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); + it("mark deployment as 'success' after completion", async () => { + const deps = makeDeps([makeStaticAppContext(), [], []]); const orchestrator = new DeploymentOrchestrator(deps); - await orchestrator.process("deploy-1"); - - expect((deps.db as any).update).toHaveBeenCalled(); - }); - - it("marks deployment as 'success' after completion", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-1"); + await orchestrator.process("deploy-nx-1"); const updates = (deps.db as any).update.mock.results; const lastSet = updates[updates.length - 1].value.set; @@ -160,11 +163,16 @@ describe("DeploymentOrchestrator", () => { ); }); - it("fails on missing GitHub token — no container created", async () => { + it("fails on missing GitHub token — no clone", async () => { const ctx = [ { - deployment: { id: "deploy-2" }, - app: { id: "app-2", name: "myapp", githubRepo: "user/repo" }, + deployment: { id: "deploy-nx-2" }, + app: { + id: "app-nx-2", + name: "myapp-nx", + githubRepo: "user/repo", + buildPack: "nixpacks", + }, githubAccessToken: null, userId: "user-2", }, @@ -172,131 +180,67 @@ describe("DeploymentOrchestrator", () => { const deps = makeDeps([ctx, [], []]); const orchestrator = new DeploymentOrchestrator(deps); - await orchestrator.process("deploy-2"); - - expect(deps.runner.create).not.toHaveBeenCalled(); - expect(deps.runner.remove).not.toHaveBeenCalled(); - }); - - it("marks deployment as 'failed' when GitHub token is missing", async () => { - const ctx = [ - { - deployment: { id: "deploy-3" }, - app: { id: "app-3", name: "myapp", githubRepo: "user/repo" }, - githubAccessToken: null, - userId: "user-3", - }, - ]; - const deps = makeDeps([ctx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-3"); - - const updates = (deps.db as any).update.mock.results; - const failedUpdate = updates - .map((r: any) => r.value.set.mock.calls[0]?.[0]) - .find((s: any) => s?.status === "failed"); - expect(failedUpdate).toBeDefined(); - }); - - it("times out when build takes too long", async () => { - setupOutput("deploy-4"); - const appCtx = makeAppContext(); - appCtx[0].app.buildTimeout = 0; - const deps = makeDeps([appCtx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-4"); + await orchestrator.process("deploy-nx-2"); - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - expect(deps.logger.error).toHaveBeenCalled(); - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "failed" }), - ); + expect(deps.runner.runOnce).not.toHaveBeenCalled(); }); - it("marks deployment as failed when clone step fails", async () => { - setupOutput("deploy-5"); - const deps = makeDeps([makeAppContext(), [], []]); - (deps.runner as any).exec = vi.fn().mockResolvedValue({ - exitCode: 128, - oomKilled: false, - stdout: "", - stderr: "Permission denied", - }); + it("marks deployment as failed when clone fails", async () => { + const deps = makeDeps([makeStaticAppContext(), [], []]); + deps.runner.runOnce = vi.fn().mockResolvedValue(128); const orchestrator = new DeploymentOrchestrator(deps); - await orchestrator.process("deploy-5"); + await orchestrator.process("deploy-nx-1"); - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - expect(deps.upsertFileRoute).not.toHaveBeenCalled(); - expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); const updates = (deps.db as any).update.mock.results; const lastSet = updates[updates.length - 1].value.set; expect(lastSet).toHaveBeenCalledWith( expect.objectContaining({ status: "failed" }), ); }); + }); - it("cleans up container and workspace on unexpected error", async () => { - setupOutput("deploy-6"); - const deps = makeDeps([makeAppContext(), [], []]); - (deps.runner as any).create = vi - .fn() - .mockRejectedValue(new Error("docker error")); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-6"); - - const updates = (deps.db as any).update.mock.results; - const failedSet = updates - .map((r: any) => r.value.set.mock.calls[0]?.[0]) - .find((s: any) => s?.status === "failed"); - expect(failedSet).toBeDefined(); - }); + describe("nixpacks server (isStatic=false)", () => { + const BUILD_DIR = "/tmp/shipyard-test/builds"; - it("works with subdirectory — verify step checks repo/{subdir}/dist", async () => { - const appCtx = [ + function makeServerAppContext() { + return [ { - deployment: { id: "deploy-sub-1" }, + deployment: { id: "deploy-nx-srv-1" }, app: { - id: "app-sub-1", - name: "myapp-sub", + id: "app-nx-srv-1", + name: "myapp-nx-srv", githubRepo: "user/repo", + buildPack: "nixpacks", + isStatic: false, + port: 3000, + runCommand: "npm start", buildTimeout: 900, - outputDir: "dist", - subdirectory: "frontend", - isSpa: false, branch: "main", }, githubAccessToken: "gh_token_123", userId: "user-1", }, ]; - const dir = path.join( - BUILD_DIR, - "deploy-sub-1", - "repo", - "frontend", - "dist", - ); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "index.html"), "

sub

"); + } - const deps = makeDeps([appCtx, [], []]); + afterEach(() => { + fs.rmSync(BUILD_DIR, { recursive: true, force: true, maxRetries: 3 }); + }); + + it("routes to nixpacks server — clones, runs, and proxies", async () => { + const deps = makeDeps([makeServerAppContext(), [], []]); const orchestrator = new DeploymentOrchestrator(deps); - await orchestrator.process("deploy-sub-1"); + await orchestrator.process("deploy-nx-srv-1"); - expect(deps.runner.create).toHaveBeenCalledTimes(1); - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - expect(deps.upsertFileRoute).toHaveBeenCalledWith( - "app-sub-1", - "myapp-sub.bigboss.dev", - false, + expect(deps.runner.runOnce).toHaveBeenCalledWith( + expect.objectContaining({ image: "alpine/git" }), + ); + expect(deps.runner.stopByName).toHaveBeenCalledWith( + "shipyard-app-app-nx-srv-1", ); + expect(deps.runner.runLongLived).toHaveBeenCalled(); }); }); From 5cda718a637b6c9c14043ad43ba0407594571d77 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Mon, 18 May 2026 08:31:41 +0300 Subject: [PATCH 2/8] feat(web): add app settings page and edit hooks New /app/:id route for editing app config and redeploying without recreating the app. Uses existing PUT /api/apps/:id endpoint. - AppSettings page with dynamic fields per build pack - useApp and useUpdateApp hooks - Settings button on Dashboard app cards - Saves only dirty fields to avoid validation errors - remove duplicate nixpacks run command in CreateAppModal --- .../web/src/components/CreateAppModal.tsx | 21 -- packages/web/src/hooks/useApps.ts | 33 ++ packages/web/src/main.tsx | 7 + packages/web/src/pages/AppSettings.tsx | 320 ++++++++++++++++++ packages/web/src/pages/Dashboard.tsx | 10 + 5 files changed, 370 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/pages/AppSettings.tsx diff --git a/packages/web/src/components/CreateAppModal.tsx b/packages/web/src/components/CreateAppModal.tsx index f12b79c..2170214 100644 --- a/packages/web/src/components/CreateAppModal.tsx +++ b/packages/web/src/components/CreateAppModal.tsx @@ -433,27 +433,6 @@ export function CreateAppModal({ open, onClose }: Props) { )} - {buildPack === "nixpacks" && ( - - - setRunCommand(e.target.value)} - placeholder="npm start" - className="w-full max-w-xs bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" - /> - - )} - {buildPack === "dockerimage" && ( { + const res = await fetch(`/api/apps/${appId}`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch app"); + return res.json(); + }, + }); +} + +export function useUpdateApp() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...data }: Record) => { + const res = await fetch(`/api/apps/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data), + }); + const body = await res.json(); + if (!res.ok) + throw new Error(body.message ?? body.error ?? "Failed to update app"); + return body; + }, + onSuccess: (_, vars) => { + qc.invalidateQueries({ queryKey: ["app", vars.id] }); + qc.invalidateQueries({ queryKey: ["apps"] }); + }, + }); +} + export function useDeleteApp() { const qc = useQueryClient(); return useMutation({ diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 650dcbd..1a91bbe 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -28,6 +28,13 @@ const router = createBrowserRouter([ return { Component: Dashboard }; }, }, + { + path: "/app/:id", + lazy: async () => { + const { AppSettings } = await import("./pages/AppSettings"); + return { Component: AppSettings }; + }, + }, { path: "/", lazy: async () => { diff --git a/packages/web/src/pages/AppSettings.tsx b/packages/web/src/pages/AppSettings.tsx new file mode 100644 index 0000000..5e97b32 --- /dev/null +++ b/packages/web/src/pages/AppSettings.tsx @@ -0,0 +1,320 @@ +import { ArrowLeft, Loader2, Rocket } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + BuildPackSelector, + type BuildPackValue, +} from "../components/BuildPackSelector"; +import { ProtectedRoute } from "../components/ProtectedRoute"; +import { useApp, useUpdateApp } from "../hooks/useApps"; +import { useDeployApp } from "../hooks/useDeployments"; + +const LABELS: Record = { + name: "App Name", + githubRepo: "GitHub Repo", + branch: "Branch", + buildPack: "Build Pack", + buildCommand: "Build Command", + outputDir: "Output Dir", + subdirectory: "Subdirectory", + port: "Port", + runCommand: "Run Command", + installCommand: "Install Command", + dockerfilePath: "Dockerfile Path", + isSpa: "SPA Fallback", + isStatic: "Static Site", + image: "Image", +}; + +function inputCls() { + return "w-full bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors"; +} + +function AppSettingsContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: app, isLoading } = useApp(id!); + const updateApp = useUpdateApp(); + const deployApp = useDeployApp(); + const [dirty, setDirty] = useState(false); + const [saved, setSaved] = useState(false); + + const [form, setForm] = useState>({}); + + useEffect(() => { + if (app) { + setForm((prev) => { + const merged: Record = {}; + for (const key of Object.keys(LABELS)) { + merged[key] = app[key] ?? prev[key] ?? ""; + } + return merged; + }); + } + }, [app]); + + function set(key: K, val: (typeof form)[K]) { + setForm((f) => ({ ...f, [key]: val })); + setDirty(true); + setSaved(false); + } + + async function handleSave() { + const payload: Record = { id: id! }; + for (const key of Object.keys(form)) { + const val = form[key]; + const original = app?.[key]; + if (val === original) continue; + if (val === "" && (original === null || original === undefined)) continue; + payload[key] = val; + } + if (Object.keys(payload).length === 1) return; + await updateApp.mutateAsync(payload); + setDirty(false); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } + + async function handleDeploy() { + if (dirty) await handleSave(); + await deployApp.mutateAsync(id!); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!app) { + return ( +
+

App not found

+
+ ); + } + + const isStatic = form.buildPack === "nixpacks" && form.isStatic !== false; + const isServer = form.buildPack === "nixpacks" && form.isStatic === false; + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ {app.name} +

+

+ {app.githubRepo} +

+
+
+
+ {saved && ( + Saved + )} + + +
+
+ + {/* Form */} +
{ + e.preventDefault(); + handleSave(); + }} + className="space-y-5" + > + {/* Name & Repo */} +
+
+ + set("name", e.target.value)} + className={inputCls()} + /> +
+
+ + set("githubRepo", e.target.value)} + className={inputCls()} + /> +
+
+ + {/* Branch */} +
+ + set("branch", e.target.value)} + className="w-full max-w-xs bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+ + {/* Build Pack */} + set("buildPack", v)} + /> + + {/* Build-specific fields */} +
+ {(isStatic || form.buildPack === "nixpacks") && ( + <> + {isStatic && ( +
+ + set("outputDir", e.target.value)} + className={inputCls()} + /> +
+ )} + +
+ + set("subdirectory", e.target.value)} + placeholder="e.g. frontend, packages/web" + className="max-w-xs bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+ +
+ + set("buildCommand", e.target.value)} + placeholder="npm run build" + className={inputCls()} + /> +
+ + )} + + {isStatic && ( + + )} + + {isServer && ( +
+ + set("runCommand", e.target.value)} + placeholder="npm start" + className="max-w-xs bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white placeholder:text-ship-deck focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+ )} + + {form.buildPack === "dockerfile" && ( +
+ + set("dockerfilePath", e.target.value)} + className={inputCls()} + /> +
+ )} + + {form.buildPack === "dockerimage" && ( +
+ + set("image", e.target.value)} + placeholder="nginx:alpine" + className={inputCls()} + /> +
+ )} + + {/* Port — shown for all build packs */} +
+ + + set( + "port", + e.target.value === "" ? 0 : Number(e.target.value), + ) + } + min={1} + max={65535} + className="w-24 bg-ship-deep border border-ship-deck/50 px-3 py-2 font-mono text-sm text-white focus:outline-none focus:border-ship-buoy/50 transition-colors" + /> +
+
+ +
+
+ ); +} + +function Label({ children }: { children: string }) { + return ( + + ); +} + +export function AppSettings() { + return ( + + + + ); +} diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index f902dc5..f797e7b 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { ChevronDown, Loader2, Plus, Settings, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { CreateAppModal } from "../components/CreateAppModal"; import { ProtectedRoute } from "../components/ProtectedRoute"; import { useApps, useDeleteApp } from "../hooks/useApps"; @@ -191,6 +192,7 @@ interface AppData { } function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { + const navigate = useNavigate(); const [confirmDelete, setConfirmDelete] = useState(false); const [showDeployments, setShowDeployments] = useState(false); const [deployVersion, setDeployVersion] = useState(0); @@ -289,6 +291,14 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { VISIT )} +