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/docs/adr/0004-spa-fallback.md b/docs/adr/0004-spa-fallback.md index 52101ad..4748025 100644 --- a/docs/adr/0004-spa-fallback.md +++ b/docs/adr/0004-spa-fallback.md @@ -1,4 +1,4 @@ -# ADR-0004: SPA Fallback via nginx and Caddy +# ADR-0004: SPA Fallback ## Status @@ -8,32 +8,32 @@ Accepted (updated) Shipyard deploys static sites (React, Vue, Svelte, etc.) that use client-side routing. When a user navigates to `/dashboard` directly, the browser requests that path. If there is no literal file, the server must return `index.html` so the SPA can boot and handle routing. -The API previously served `public/index.html` as a fallback, but this mixed concerns — the API should not serve static files. The separation is: - -- **Web container** (nginx) serves the SPA built output -- **Caddy** reverse-proxies user domains to Garage storage -- **API** serves only REST endpoints - ## Decision -### Development +### Caddy pass_thru -Vite dev server handles SPA fallback natively. The API has no static file serving. +Static sites with SPA mode use Caddy's `file_server` with `pass_thru: true`: -### Production +```json +{ + "handle": [ + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}", "pass_thru": true}, + {"handler": "rewrite", "uri": "/index.html"}, + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}"} + ] +} +``` -nginx in the web container serves the SPA via `try_files $uri $uri/ /index.html`. The `nginx.conf` proxies `/api/` and `/health` to the API container. +The first `file_server` attempts to serve the file. On 404, `pass_thru: true` lets the request fall through to the next handler, which rewrites to `/index.html` and serves it via a second `file_server`. -Caddy routes custom domains to Garage storage with its own `handle_errors` for SPA fallback when serving from object storage. +This replaced a broken `subroute` + `errors` pattern that Caddy v2 doesn't support at the route level. ## Consequences -- Clean separation: API never deals with static files. -- Web container is self-contained SPA serving with its own nginx. -- Caddy handles domain routing independently. +- Clean SPA routing without custom nginx or subrequest logic. - One caveat: genuine 404s (missing static assets) return `index.html` with 200 status. Standard SPA behavior. ## Alternatives Considered -- **API serves SPA (rejected):** Mixed concerns. API container becomes responsible for frontend. -- **Single Caddy for everything (considered for v2):** Caddy could serve the SPA and proxy API. Simpler topology but requires Caddy config complexity. +- **subroute + errors (rejected):** Caddy v2 doesn't support `errors` at the route level. The subroute errors pattern doesn't bubble 404s correctly. +- **Custom nginx container (not adopted for Shipyard):** Dokploy uses Traefik for SPA routing. Shipyard uses Caddy exclusively. diff --git a/docs/adr/0006-static-caddy-config.md b/docs/adr/0006-static-caddy-config.md index e8acdc1..47e09a2 100644 --- a/docs/adr/0006-static-caddy-config.md +++ b/docs/adr/0006-static-caddy-config.md @@ -2,11 +2,11 @@ ## Status -Accepted +Accepted (updated) ## Context -Caddy needs to route `myapp.bigboss.dev` to the correct Garage prefix. Two approaches: +Caddy needs to route `myapp.bigboss.dev` to the correct backend. Two approaches: 1. **Dynamic (request-time):** Caddy queries the database per request to resolve `active_deployment_id` → S3 path. 2. **Static (deploy-time):** Send JSON config to Caddy API when deployment succeeds or rollback happens. @@ -19,53 +19,51 @@ If two deploys finish at the same time, both sending config to Caddy API, could **Solution:** Use Caddy's per-route API (not full config): ``` -POST /config/apps/http/servers/{server_name}/routes/{route_id} +PATCH /id/{route_id} → update existing route +POST /config/apps/http/servers/srv0/routes → create new route ``` -- Each app gets unique route_id based on app.id +- Each app gets unique `@id` based on `app-{appId}` - Deploy updates ONLY that app's route, not entire config - No lock needed — Caddy's API is atomic per-route -**Alternative (if simpler for MVP):** -- Single-threaded Caddy config writer (queue config updates) -- Workers call queueCaddyUpdate(appId) after deploy -- Background job processes queue serially -- No concurrent writes to Caddy API - -We'll use per-route updates for simplicity. - ## Decision Static config generation at deploy time: 1. Deployment succeeds (or rollback triggered) 2. Read `active_deployment_id` from DB -3. Generate Caddy JSON config via string-replace template: - - ```json - { - "apps": { - "http": { - "servers": { - "sites": { - "listen": [":443"], - "routes": [{ - "match": [{"host": ["myapp.bigboss.dev"]}], - "handle": [{ - "handler": "reverse_proxy", - "upstreams": [{"dial": "garage:3900"}] - }] - }] - } - } - } - } - } - ``` - -4. Send JSON payload to Caddy Admin API (`POST /config/`) -5. `Caddy auto-reloads upon receiving the config API update` - -No database lookup per request. Caddy serves purely from static config. +3. Generate Caddy JSON config: + +**Static sites (isStatic=true):** +```json +{ + "@id": "app-{appId}", + "match": [{"host": ["myapp.bigboss.dev"]}], + "handle": [ + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}", "pass_thru": true}, + {"handler": "rewrite", "uri": "/index.html"}, + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}"} + ], + "terminal": true +} +``` + +The `pass_thru` on the first `file_server` lets unmatched requests fall through to the rewrite handler, which serves `index.html` for SPA routing. This replaces the broken subroute+errors pattern that doesn't work in Caddy v2. + +**Server apps (dockerfile, nixpacks isStatic=false):** +```json +{ + "@id": "app-{appId}", + "match": [{"host": ["myapp.bigboss.dev"]}], + "handle": [ + {"handler": "reverse_proxy", "upstreams": [{"dial": "shipyard-app-{appId}:{port}"}]} + ], + "terminal": true +} +``` + +4. Send JSON payload to Caddy Admin API +5. Caddy auto-reloads upon receiving the config API update ## Consequences @@ -77,7 +75,6 @@ No database lookup per request. Caddy serves purely from static config. ## Alternatives Considered -- **Dynamic via Lua/OpenResty (rejected):** Flexible but adds complexity and per-request overhead. Overkill for a static site platform. -- **Dynamic via `auth_request` to API (rejected):** Adds API dependency in request path. If API is down, sites break. -- **Symlink-style S3 paths (rejected):** S3 doesn't support symlinks. Would require copying files, which is slow on rollback. - +- **Dynamic via Lua/OpenResty (rejected):** Flexible but adds complexity and per-request overhead. +- **Dynamic via `auth_request` to API (rejected):** Adds API dependency in request path. +- **subroute + errors for SPA (rejected):** Caddy v2 doesn't support `errors` at the route level. `pass_thru` on `file_server` is the correct pattern. diff --git a/docs/adr/0012-build-pack-abstraction.md b/docs/adr/0012-build-pack-abstraction.md index d88d408..119d128 100644 --- a/docs/adr/0012-build-pack-abstraction.md +++ b/docs/adr/0012-build-pack-abstraction.md @@ -1,4 +1,4 @@ -# ADR-0012: Build Pack Abstraction — Five Build Pack Types +# ADR-0012: Build Pack Abstraction — Four Build Pack Types ## Status @@ -6,110 +6,61 @@ Accepted (updated) ## Context -The original Shipyard spec scoped the project to static sites only, with a simple `package.json` dependency scan for framework detection (Vite, CRA, Next.js, Vue). During triage of Issue #3 (app creation), two things became clear: +The original Shipyard spec scoped the project to static sites only. During triage of Issue #3 (app creation), two things became clear: -1. The maintainer's goal is a general-purpose deployment platform (Coolify/Dokploy-like), not a static-site-only tool. -2. Platforms like Coolify use a **build pack** model where users choose how their project is built: Nixpacks (auto-detect), static (nginx), Dockerfile (custom), Docker Compose (multi-service), or pre-built Docker image. +1. The maintainer's goal is a general-purpose deployment platform (Coolify/Dokploy-like). +2. Platforms like Coolify use a **build pack** model where users choose how their project is built. -The build pack is defined as a PostgreSQL enum on the `apps` table: +Originally five build pack types were defined: `nixpacks`, `static`, `dockerfile`, `dockercompose`, `dockerimage`. The `static` and `nixpacks` packs were later merged — a "static site" is now a mode of nixpacks (isStatic=true), not a separate build path. + +## Decision + +### Build Pack Enum ```sql CREATE TYPE build_pack AS ENUM ( 'nixpacks', - 'static', 'dockerfile', 'dockercompose', 'dockerimage' ); ``` -## Decision - -### Build Pack Enum - -The deployment engine routes to one of five implementations based on the app's `build_pack` field: - | Value | Description | Use Case | |-------|-------------|----------| -| `nixpacks` | Nixpacks auto-detects framework from repo, generates Dockerfile, builds and runs it | Most applications; zero-config deployments | -| `static` | nginx:alpine serves pre-built static assets | SPAs, documentation sites, plain HTML | +| `nixpacks` | Nixpacks auto-detects framework, builds image. Serves via nginx (isStatic=true) or runs as container (isStatic=false) | Most applications; zero-config deployments | | `dockerfile` | User provides Dockerfile in repo, Shipyard builds and runs it | Custom builds with specific OS deps | | `dockercompose` | User provides docker-compose.yml, Shipyard deploys the stack | Multi-service apps with bundled services | | `dockerimage` | Pull a pre-built image from a registry and run it | Deploy without source/build pipeline | -### Build Pack Interface - -```typescript -interface BuildPackResult { - imageName: string; // Built or pulled Docker image name - containerPort: number; // Port to expose - outputDir?: string; // For static: dir to pull assets from - healthCheck?: string; // Optional health check path - composeFile?: string; // For dockercompose: the compose file content -} -``` - -### Build Pack Implementations - -#### 1. Nixpacks - -- **Flow:** Git clone → `nixpacks build .` (generates Dockerfile) → `docker build` → `docker run` -- **Detection:** Nixpacks auto-detects framework from repo contents (Next.js, Django, Rails, Go, etc.) -- **Config:** User can override build/start commands and output directory -- **Dependency:** Requires `nixpacks` binary installed on the worker host - -#### 2. Static - -- **Base image:** `nginx:alpine` -- **Flow:** Clone → (optional build command) → copy assets from `output_dir` to `/usr/share/nginx/html/` → serve on port 80 -- **Config:** User specifies `output_dir` (default: `/dist`), SPA fallback toggle, custom nginx config -- **No framework detection** — assumes pre-built assets - -#### 3. Dockerfile +### Nixpacks Strategy -- **Flow:** Git clone → `docker build -f Dockerfile` → `docker run` -- **Config:** User commits a `Dockerfile` in their repo. Shipyard builds it as-is. -- **Port:** User configures the container port Shipyard should route to. +Nixpacks handles both static sites and server apps: -#### 4. Docker Compose +| Mode | `isStatic` | Flow | Caddy Route | +|------|-----------|------|-------------| +| Static site | `true` (default) | nixpacks build → extract output dir → serve via nginx file_server with pass_thru SPA fallback | File route | +| Server app | `false` | nixpacks build → run long-lived container → reverse proxy | Proxy route | -- **Flow:** Git clone → `docker compose -f docker-compose.yml up -d` -- **Config:** User provides `docker-compose.yml` in their repo. -- **Use case:** Apps that bundle a database, cache, or need multiple services. -- **Routing:** Caddy routes to the primary service's port. +**Flow:** Git clone → `nixpacks build --cache-key shipyard-{appId} --inline-cache` → (static: extract /app/{outputDir} to sites dir) or (server: runLongLived) → Caddy route -#### 5. Docker Image +**Caching:** `--cache-key` uses a stable per-app identifier so nixpacks restores `~/.npm` and `~/.cache` between deploys. `--inline-cache` embeds Docker layer metadata for faster rebuilds. -- **Flow:** `docker pull ` → `docker run` -- **Config:** User specifies image name + tag from a registry (Docker Hub, GHCR, etc.) -- **No repository needed** — deploy without connecting a git repo. -- **Use case:** Deploy existing images without rebuilding from source. +**Subdirectory support:** `--subdirectory` clones the full repo but nixpacks builds from `{repo}/{subdirectory}`. Used for monorepos. -### User Configuration Per App +### SPA Fallback -| Field | nixpacks | static | dockerfile | dockercompose | dockerimage | -|-------|----------|--------|------------|---------------|-------------| -| `output_dir` | Optional | Required | N/A | N/A | N/A | -| `build_command` | Override | Optional | N/A | N/A | N/A | -| `run_command` | Override | N/A | N/A | N/A | N/A | -| `port` | Optional | 80 | Required | Optional | Required | -| `dockerfile_path` | N/A | N/A | `./Dockerfile` | N/A | N/A | -| `image` | N/A | N/A | N/A | N/A | Required | -| `compose_file` | N/A | N/A | N/A | `docker-compose.yml` | N/A | -| `is_spa` | N/A | Configurable | N/A | N/A | N/A | -| `custom_nginx_config` | N/A | Optional | N/A | N/A | N/A | +Static sites with `isSpa=true` use Caddy's `file_server` with `pass_thru: true`, followed by a rewrite to `/index.html`. This avoids the broken subroute+errors pattern that doesn't work in Caddy v2. ## Consequences -- **Five build paths** share the same deployment state machine. Only execution differs. -- **No migration cost** when adding a new build pack — implement the interface, add the enum value. -- **Nixpacks is optional** — don't need the binary unless using that pack. -- **Docker Compose adds significant complexity** — multi-container lifecycle, networking, service dependencies. Post-MVP. -- **Docker Image is the simplest** — just pull and run. No build step at all. -- **Container routing required** — once you support long-lived containers, Caddy needs to reverse-proxy to them. ADR-0006 needs updating. +- **Four build paths** share the same deployment state machine. Only execution differs. +- **Nixpacks replaces the static build pack** — one fewer strategy to maintain. +- **Build caching** speeds up repeated deploys (~60% faster on npm installs). +- **Old step files** (clone, install, build, verify, copy) are marked dead code and will be removed after nixpacks stabilises. ## Alternatives Considered -- **Three packs only (static, dockerfile, nixpacks) (rejected):** Misses dockercompose for multi-service apps and dockerimage for pre-built image deploys. -- **Merge dockerimage into dockerfile (rejected):** Different semantics — one builds from source, one pulls a pre-built artifact. Different validation, different config. -- **Skip dockercompose for MVP (accepted):** Will be implemented after the single-container packs are stable. Multi-container orchestration is a separate complexity class. +- **Keep static as separate pack (rejected):** Duplicate of nixpacks with isStatic=true. Manual step pipeline was fragile (missing package managers like pnpm). +- **Railpack instead of Nixpacks (noted):** Nixpacks is in maintenance mode, Railpack is the successor. Will migrate when Railpack matures. +- **--provider docker flag (noted):** May be needed to bypass broken Nix derivations for certain language packs like bun. 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..2170214 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" && ( @@ -379,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 0273ea7..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"; @@ -12,7 +13,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", @@ -192,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); @@ -203,7 +204,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(() => { @@ -290,6 +291,14 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { VISIT )} +