diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 41ed32c..c97848c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,9 @@ }, "mounts": [ "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly", - "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind" + "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind", + "source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume", + "source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume" ], "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} @@ -29,7 +31,7 @@ "esbenp.prettier-vscode" ], "settings": { - "python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python" + "python.defaultInterpreterPath": "/opt/venv/bin/python" } } } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d002adf..a92a38d 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -6,12 +6,34 @@ set -e REPO=/srv/kirche-einfach +# Venv lives outside the bind-mount (see backend/Dockerfile). +export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv + echo "=== Setting up kirche-einfach ===" -# Backend deps into the venv volume (setup runs via sudo; handed to vscode below). +# Writable dirs first: this script runs via sudo as root, but the editor and +# most commands run as vscode. (A root-created backend/data/ or venv used to +# break the DB and the interpreter for vscode.) +mkdir -p "$REPO/backend/data" \ + "$REPO/frontend/node_modules" \ + /opt/python /opt/venv \ + /home/vscode/.cache \ + /home/vscode/.local/share/uv \ + /home/vscode/.local/share/opencode \ + /home/vscode/.config/opencode +chown -R vscode:vscode "$REPO/backend/data" \ + "$REPO/frontend/node_modules" \ + /opt/python /opt/venv \ + /home/vscode/.cache \ + /home/vscode/.local/share/uv \ + /home/vscode/.local/share/opencode \ + /home/vscode/.config/opencode 2>/dev/null || true + +# Backend deps into /opt/venv, as vscode (a root-owned venv is unusable for +# the editor; uv resolves at setup time, no lockfile is committed). echo "--- Installing Python dependencies ---" cd "$REPO/backend" -uv sync +su vscode -c "uv sync" # opencode CLI (dev tooling; intentionally not baked into the image). if [ ! -x /home/vscode/.opencode/bin/opencode ]; then @@ -57,13 +79,8 @@ EOF # entrypoint on every start). echo "--- Syncing canonical flows ---" cd "$REPO/backend" -uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)" +su vscode -c "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)" echo "" echo "=== Setup complete ===" echo "Forgejo username: $FORGEJO_USERNAME" - -# Hand dirs back to vscode (this script runs via sudo; uv creates caches as root) -chown -R vscode:vscode "$REPO/backend/.venv" \ - "$REPO/frontend/node_modules" \ - /home/vscode/.cache 2>/dev/null || true diff --git a/.env.example b/.env.example index e65f05c..58f66f3 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ # Copy to `.env` and fill in values. `.env` is gitignored. -# It holds only ubiquitous variables (identical value in dev and prod). -# Dev-only variables travel via devcontainer.json `containerEnv` -# (e.g. APP_ENV=dev); paths resolve from the repo root in code. +# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas). +# Prod overrides via this .env file (e.g. APP_ENV=prod). +# Paths resolve from the repo root in code. FORGEJO_TOKEN= -# --- langflow (authoring UI) --- +# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep --- +# LFX contract: major.minor of Langflow and LFX must match (patches are +# independent). Bump deliberately on both sides together (compose image pin +# below + Dockerfile.lfx ARG + backend dev dep `lfx~=`), then re-check +# backend/flows/*.json with `lfx upgrade --upgrade-flow=safe`. +LANGFLOW_VERSION=1.12.0 LANGFLOW_URL=http://langflow:7860 LANGFLOW_SUPERUSER=langflow LANGFLOW_SUPERUSER_PASSWORD= @@ -23,5 +28,18 @@ CALLBACK_BASE_URL=http://api:8000 # --- frontend mode: dev server with HMR (prod .env must set `preview`) --- FRONTEND_MODE=dev +# --- access management (request + approval, invite via email) --- +# Public frontend URL (magic links point here). +FRONTEND_URL=http://localhost:3000 +# Comma-separated emails that become admins (role assignment + bootstrap). +ADMIN_EMAILS= +# Reply-To on outgoing mail; also receives new access-request notifications. +CONTACT_TO= +MAIL_FROM= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= + # --- LLM (optional; tests mock the provider without it) --- # LLM_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index ba92757..2e99234 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ backend/data/ *.swp *.swo +# Devcontainer +.devcontainer/devcontainer-lock.json +.devcontainer/opencode-data/ + # OS .DS_Store Thumbs.db diff --git a/Dockerfile.lfx b/Dockerfile.lfx index 00514b0..378b23d 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -8,7 +8,10 @@ ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # lfx = flow runtime; requests = convenience for custom-component code. -RUN pip install lfx requests +# Pinned to the Langflow minor (contract: major.minor must match Langflow; +# patches independent). Bump together with LANGFLOW_VERSION (.env.example). +ARG LFX_VERSION=1.12.0 +RUN pip install "lfx~=${LFX_VERSION}" requests RUN mkdir -p /flows VOLUME /flows diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md new file mode 100644 index 0000000..b0e1afc --- /dev/null +++ b/PLAN_NEXT.md @@ -0,0 +1,158 @@ +# Continuation plan — kirche-einfach next steps (written after Phase 0) + +> For a future agent restoring this session (e.g. after a container rebuild). +> Read this file + `AGENTS.md` + `README.md` first. All code/comments in +> English; only user-visible website strings are German. + +## 0. Where we are + +**Phase 0 (this commit) is done** — Docker/devcontainer reproducibility: + +* `langflow` image pinned via `${LANGFLOW_VERSION:-1.12.0}` (was `:latest` + with nightly Watchtower drift). `Dockerfile.lfx` pins `lfx~=` + (`ARG LFX_VERSION=1.12.0`). Backend test dep pinned `lfx~=1.12.0`. + Reason: official LFX contract — `major.minor` of Langflow and LFX must + match, patches are independent. +* `langflow` mounts `backend/components` (`LANGFLOW_COMPONENTS_PATH`) and + `backend/flows` (`LANGFLOW_LOAD_FLOWS_PATH`), both `:ro`. Files stay + source of truth; the API watcher (`app/flows/sync.py`) remains the ONLY + sync path — never add a second watcher. +* Backend venv moved to `/opt/venv` (image-local, `UV_PROJECT_ENVIRONMENT`), + managed Python to `/opt/python` (`UV_PYTHON_INSTALL_DIR`), both `a+rX`. + Removed `kirche-venv` + `kirche-node-modules` named volumes (they shadowed + image content with empty volumes on first start = split-brain). + `backend/entrypoint.sh` uses `/opt/venv/bin/*`, ensures `data/` exists and + is `vscode`-owned. `frontend/entrypoint.sh` runs `npm ci` when + `node_modules/.bin/vite` is missing (covers fresh prod checkouts). +* `devcontainer.json`: opencode session persistence via named volumes + `kirche-opencode-share → ~/.local/share/opencode` (sessions DB) and + `kirche-opencode-config → ~/.config/opencode`; interpreter path is now + `/opt/venv/bin/python`. `setup.sh` chowns first, then runs `uv sync` as + `vscode` (was: root `uv sync` into `/root`-backed venv → `Permission + denied` for vscode; `backend/data` root-owned → DB unwritable). +* Stale local `backend/.venv/` (bind-mount leftover) can be deleted manually + after rebuild: `rm -rf backend/.venv`. It is gitignored; nothing references + it anymore. + +**Verified in this session:** `uv run pytest` green (after local venv repair, +see §5). NOT verifiable here (no node/docker in this sandbox): `npm run +build`, `docker compose config`, container rebuild. CI covers all three +(`.forgejo/workflows/`). After rebuilding the devcontainer, re-run: `uv run +pytest` (attached api, from `backend/`), `docker compose exec frontend npm +run build`, `docker compose config --quiet`. + +**Still open (docs-only):** `README.md` + `.env.example` uncommitted diff +describes the target vision (access mgmt, dynamic forms, new Web UI) that is +NOT yet implemented: `app/auth/` is empty, `backend/components/kirche/` is +empty, frontend is still the old 3-tab UI, no `input-schema` endpoint. + +## 1. Locked decisions (do not re-ask) + +* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, + store `sha256`, `used_at` set before session creation), session = opaque id + in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`. + SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`, + `MAIL_FROM`, `CONTACT_TO`); dev fallback logs the link when SMTP unset. +* `FileInput` is a **stub** for now (string reference only); real upload later. +* Frontend: `react-router-dom` + RJSF (`@rjsf/core` + `@rjsf/validator-ajv8`). + RJSF renders backend-supplied `{json_schema, ui_schema}`; server + re-validates (client validation is UX only). +* **Routes are pure English** (user correction — no German slugs anywhere, + including URLs): + `/`, `/access` (was once sketched as `/zugang` — do NOT use), + `/login?token=`, `/app/workflows`, `/app/tasks`, `/app/admin`. + Visible labels/nav are German (`Start`, `Zugang`, `Workflows`, `Aufgaben`, + `Verwaltung`); code, props, comments, routes English. +* Langflow preload + mount (done in Phase 0). Custom-component lib in + `backend/components/kirche/` **must stay GUI-editable**: category folder + + `__init__.py` + `Component` subclasses using `lfx.io` inputs only + (stdlib + `lfx`, so the LFX runtime can execute them). +* Watchtower: `langflow` label removed (pinned images must not auto-update). + Version bumps = deliberate `LANGFLOW_VERSION` + `LFX_VERSION` change, then + `lfx upgrade --upgrade-flow=safe` check over `backend/flows/*.json`. + +## 2. Phase 1 — Backend auth (magic-link + RBAC) + +Files: `backend/app/models/user.py` (`User`, `AccessRequest`, +`MagicLinkToken`, `Session`), `backend/app/auth/{schemas,service,mail,api, +deps}.py`, wire router in `app/main.py`, settings in `app/settings.py` +(`frontend_url, admin_emails, contact_to, mail_from, smtp_*`), ONE new +Alembic revision (never edit applied migrations). + +Endpoints: + +* `POST /api/access/requests` public, rate-limited: `{name,email,reason}` + → row `pending` + notify `CONTACT_TO`. +* `POST /api/access/requests/{id}/approve|reject` admin: approve → + upsert `User` + emit magic-link + SMTP send. +* `POST /api/auth/magic/consume {token}` → session cookie; `GET + /api/auth/me`; `POST /api/auth/logout` (delete session row). +* `POST /api/auth/admin-bootstrap`: only when user table empty AND email in + `ADMIN_EMAILS`. +* Tenant from session (`user.tenant_id`); keep old `tenant_id` body param as + deprecated fallback. `require_admin` dependency; keep API handlers thin + (DB only via `app/flows/store.py` + new `app/auth/*` service funcs). + +Tests (all in `backend/tests/`): request→approve→consume→me→logout happy +path; token expiry boundary (15 min); token reuse → 400; admin-only → 403; +bootstrap-once; cookie flags (`HttpOnly`, `SameSite`, `Path`); assert no +token/secret in logs. + +## 3. Phase 2 — `CustomInput*` library (GUI-editable) + +* `backend/components/kirche/__init__.py` + + `backend/components/kirche/form_inputs.py`: + `class CustomInputForm(Component)` with inputs `StrInput`, + `MultilineInput`, `SecretStrInput`, `IntInput`, `FloatInput`, `BoolInput`, + `DropdownInput`, `MultiselectInput`, `SliderInput`, `FileInput` (stub) + + `Output`. Category dir `kirche/` = appears in Langflow GUI. +* Regenerate/add fixture flow using it via + `backend/scripts/make_flow_fixtures.py`; keep `simple_rule.json` as the + textarea-fallback case. Commit flow JSON (source of truth). + +## 4. Phase 3 — Dynamic-form parsing (no second watcher) + +* New `backend/app/flows/input_schema.py`: + `extract_form_schema(langflow_json) -> {json_schema, ui_schema}`. + Find nodes with `type`/`display_name` starting `CustomInput`; iterate + `template` minus `code`; map `_input_type` → JSON Schema type/widget + (`StrInput`/`MessageTextInput`→string, `MultilineInput`→string+textarea, + `SecretStrInput`→string+password, `IntInput`→integer, + `FloatInput`→number, `BoolInput`→boolean, `DropdownInput`→string+enum, + `MultiselectInput`→array+enum, `SliderInput`→number+range, + `FileInput`→string stub); `display_name/info/value/options/required/ + advanced` → `title/description/default/enum/required/uiSchema`. + No such node → `{fallback: "textarea"}`. +* Called from existing `sync_from_directory` (cache per version in new + nullable `input_schema` JSON column via Alembic) + new `GET + /api/flows/{id}/input-schema → {flow_id, version_no, json_schema, + ui_schema}` (latest **deployed** version; 404 when none). +* Tests: per-type mapping table, fallback, sync persistence, endpoint 404. + +## 5. Phase 4 — Frontend (router + RJSF, German UI, English routes) + +Deps: `react-router-dom`, `@rjsf/core`, `@rjsf/validator-ajv8`. +Routes (English, see §1): `/`, `/access`, `/login`, `/app/workflows` +(flow select → `GET input-schema` → RJSF `
` → `POST run`), +`/app/tasks` (own runs + approval states), `/app/admin` (admin-only: +access-request queue, ALL tasks incl. open human-intervention with +approve/reject + note, import/sync/deploy). `fetch` with +`credentials: "include"`. Refactor `FlowsTab/ApprovalsTab/AdminTab` into +route components; drop old tab shell. Verify with `npm run build`. + +## 6. Operational notes for the next agent + +* Commands: backend from `backend/`: `uv run pytest`, + `uv run alembic upgrade head`, `uv run python scripts/sync_flows.py + --sync`; frontend via `docker compose exec frontend npm run build`. +* Local venv repair (this sandbox had a root-backed broken `.venv`): + `rm -rf backend/.venv && uv sync` (network needed for `uv python + install`). In rebuilt containers this is unnecessary (`/opt/venv`). +* `.env` (gitignored) still lacks `FRONTEND_URL`/`ADMIN_EMAILS` from the + `.env.example` diff — copy them over locally when starting Phase 1. Never + commit `.env`. +* Flow JSON that fails LFX after a version bump: run + `lfx upgrade --upgrade-flow=safe` on the file, review diff, commit. +* Definition of done per `AGENTS.md`: new behavior has tests, `uv run + pytest` green, `npm run build` green when frontend changed, schema changes + as new Alembic revisions. diff --git a/README.md b/README.md index 8ca5514..7061d29 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,22 @@ needs judgment. Every step is documented and traceable. deploy to the lightweight LFX runtime and can run synchronously or streamed. - **Approvals** — a flow can pause and ask a human for a decision. Approving can automatically start a follow-up ("continuation") flow. -- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben* - (approvals), *Administration* (import, sync, deploy). +- **Access management** — public landing page (German) with an access-request + form (request + approval). Approved users get a passwordless magic-link + invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants + come from the session. First admin via `POST /api/auth/admin-bootstrap` + (only on an empty user table, email must be in `ADMIN_EMAILS`). +- **Dynamic forms** — flows declare form fields as typed inputs on a + `CustomInput*` custom component (reusable library in + `backend/components/`, mounted into Langflow via + `LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a + JSON Schema + RJSF uiSchema from the deployed version; the webapp renders + the matching form automatically. Flows without such a node fall back to a + single textarea. +- **Web UI** — landing page (*Start*), access request (*Zugang*), and the + logged-in webapp: *Workflows* (choose + run with dynamic form), + *Aufgaben* (approvals), *Verwaltung* (admin: access requests, import, + sync, deploy). ## Infrastructure diff --git a/backend/Dockerfile b/backend/Dockerfile index 7c5cdbb..06d304e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,14 @@ FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + # Venv + managed Python live outside the repo bind-mount: a venv under + # backend/ used to be shadowed by a named volume (empty on first start) + # and backed by a root-owned interpreter vscode could not execute. + # /opt is image-local, world-readable, identical in dev and prod. + UV_PYTHON_INSTALL_DIR=/opt/python \ + UV_PROJECT_ENVIRONMENT=/opt/venv +ENV PATH="/opt/venv/bin:$PATH" COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv @@ -14,7 +21,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends sudo openssh-client \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -s /bin/bash vscode \ - && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode + && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \ + && mkdir -p /opt/python /opt/venv \ + && chmod 755 /opt/python /opt/venv WORKDIR /srv/kirche-einfach/backend COPY pyproject.toml alembic.ini entrypoint.sh ./ @@ -23,7 +32,8 @@ COPY migrations ./migrations COPY scripts ./scripts # No lockfile is committed; dependencies resolve at build time. # Full groups (incl. dev): pytest and friends must work in dev with zero steps. -RUN uv sync && chmod +x entrypoint.sh +# a+rX: the image builds as root, but the attached devcontainer runs as vscode. +RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv ENV FLOWS_WATCH_ENABLED=true EXPOSE 8000 diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 71b0868..f329967 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -6,10 +6,18 @@ set -e cd /srv/kirche-einfach/backend -.venv/bin/alembic upgrade head +# Runtime SQLite file lives in the bind-mount: ensure the dir exists and is +# owned by vscode (the entrypoint runs as root, but the editor runs as +# vscode — a root-owned data/ dir made the DB unwritable). +mkdir -p data +if id vscode >/dev/null 2>&1; then + chown -R vscode:vscode data 2>/dev/null || true +fi + +/opt/venv/bin/alembic upgrade head if [ "${APP_ENV:-prod}" = "dev" ]; then - exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload else - exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 + exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 fi diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bfec02f..e001a6f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,9 @@ testpaths = ["tests"] [dependency-groups] dev = [ - "lfx", + # Pinned to the Langflow minor (contract: major.minor must match). + # Bump together with LANGFLOW_VERSION (.env.example) + Dockerfile.lfx. + "lfx~=1.12.0", "pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=6", diff --git a/docker-compose.yml b/docker-compose.yml index cc0eec2..6a6313f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,11 @@ services: env_file: - .env volumes: + # No venv volume by design: the venv lives at /opt/venv (image-local, + # see backend/Dockerfile). A named volume here used to shadow the image + # content with an empty dir on first start (split-brain with the host + # checkout, plus root-owned interpreters). - .:/srv/kirche-einfach - - kirche-venv:/srv/kirche-einfach/backend/.venv ports: - "8000:8000" # FastAPI depends_on: @@ -45,8 +48,10 @@ services: # Dev default (HMR); prod .env sets `preview` (build + serve bundle). FRONTEND_MODE: ${FRONTEND_MODE:-dev} volumes: + # No node_modules volume by design: deps live in the bind-mount and the + # entrypoint runs `npm ci` on first start (a volume used to shadow the + # image install with an empty dir and diverge from the host checkout). - .:/srv/kirche-einfach - - kirche-node-modules:/srv/kirche-einfach/frontend/node_modules ports: - "3000:3000" # Vite depends_on: @@ -55,7 +60,10 @@ services: restart: unless-stopped langflow: - image: langflowai/langflow:latest + # Pinned (never :latest): Langflow and LFX are versioned together, their + # major.minor must match. Bump via LANGFLOW_VERSION in .env (see + # .env.example) together with Dockerfile.lfx + backend `lfx~=` pin. + image: langflowai/langflow:${LANGFLOW_VERSION:-1.12.0} env_file: - .env environment: @@ -68,12 +76,17 @@ services: # SQLite file inside the langflow-data volume (absolute path required # by Langflow); overrides any postgres URL that may linger in .env. LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db + # Custom form-input components (backend/components, category folders). + LANGFLOW_COMPONENTS_PATH: /app/components + # Seed the authoring UI with the canonical flows (read-only: the files + # stay the source of truth, the API watcher is the only sync path). + LANGFLOW_LOAD_FLOWS_PATH: /app/flows volumes: - langflow-data:/app/langflow + - ./backend/components:/app/components:ro + - ./backend/flows:/app/flows:ro ports: - "7860:7860" - labels: - com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped healthcheck: test: @@ -110,6 +123,8 @@ services: # Nightly auto-updates of :latest images (prod). Never started in dev # (not in devcontainer.json runServices; also needs a docker socket). + # langflow/lfx carry no watchtower label on purpose: both are version-pinned + # (major.minor must match) and update only via deliberate version bumps. watchtower: image: nickfedor/watchtower:latest volumes: @@ -124,7 +139,5 @@ services: restart: unless-stopped volumes: - kirche-venv: - kirche-node-modules: langflow-data: lfx-data: diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 13d0ea0..7da02cf 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -6,6 +6,12 @@ set -e cd /srv/kirche-einfach/frontend +# node_modules lives in the repo bind-mount (no named volume by design): +# install on first start after a fresh checkout (dev and prod). +if [ ! -x "node_modules/.bin/vite" ]; then + npm ci +fi + if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then exec npm run dev -- --host 0.0.0.0 --port 3000 else