From e85946669c88fbe5d3ab5186143703e0d6510c55 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 14:16:26 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(cli):=20starter=20package=20=E2=80=94?= =?UTF-8?q?=20the=20bundled=20backend=20for=20scaffolded=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compose stack that boots BackAI from the published release images, pinned to the CLI's own version (a dev build pins :latest), plus what running it outside a checkout needs: a Go port of the port preflight (foreign conflicts move up, ports our own compose project holds stay), .env read/write seeded from .env.example, and readiness waits on the runtime and the demo agent. The two mounted files are copies of the repo's, pinned to it by a drift test. buildinfo carries the version main sets from its ldflag so init can pin images to the binary. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- services/cli/cmd/af-stack/main.go | 2 + services/cli/internal/buildinfo/buildinfo.go | 10 + .../starter/assets/docker-compose.yml | 190 ++++++++ .../starter/assets/litellm-config.yaml | 188 ++++++++ .../internal/starter/assets/postgres-init.sh | 65 +++ services/cli/internal/starter/starter.go | 431 ++++++++++++++++++ services/cli/internal/starter/starter_test.go | 303 ++++++++++++ 7 files changed, 1189 insertions(+) create mode 100644 services/cli/internal/buildinfo/buildinfo.go create mode 100644 services/cli/internal/starter/assets/docker-compose.yml create mode 100644 services/cli/internal/starter/assets/litellm-config.yaml create mode 100755 services/cli/internal/starter/assets/postgres-init.sh create mode 100644 services/cli/internal/starter/starter.go create mode 100644 services/cli/internal/starter/starter_test.go diff --git a/services/cli/cmd/af-stack/main.go b/services/cli/cmd/af-stack/main.go index 2e9d52d3..7d1e983e 100644 --- a/services/cli/cmd/af-stack/main.go +++ b/services/cli/cmd/af-stack/main.go @@ -62,6 +62,7 @@ import ( "github.com/Agent-Field/backai/services/cli/internal/admincmd" "github.com/Agent-Field/backai/services/cli/internal/billingcmd" + "github.com/Agent-Field/backai/services/cli/internal/buildinfo" "github.com/Agent-Field/backai/services/cli/internal/client" "github.com/Agent-Field/backai/services/cli/internal/conncmd" "github.com/Agent-Field/backai/services/cli/internal/dbcmd" @@ -86,6 +87,7 @@ func main() { // The global --no-telemetry flag may appear anywhere; strip it before // dispatch so subcommand flag parsers never see it. optOut, args := extractNoTelemetry(os.Args[1:]) + buildinfo.Version = version tel := telemetry.New(version, optOut, os.Stderr) cmdName := "help" diff --git a/services/cli/internal/buildinfo/buildinfo.go b/services/cli/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..898bf427 --- /dev/null +++ b/services/cli/internal/buildinfo/buildinfo.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package buildinfo exposes the CLI's own version to packages that need to +// pin published artifacts (release images) to the binary that wrote them. +// main sets Version from its -ldflags "-X main.version=..." value at startup. +package buildinfo + +// Version is the CLI version, or a development placeholder when the binary +// was built without the release linker flag. +var Version = "0.0.1" diff --git a/services/cli/internal/starter/assets/docker-compose.yml b/services/cli/internal/starter/assets/docker-compose.yml new file mode 100644 index 00000000..58971ca2 --- /dev/null +++ b/services/cli/internal/starter/assets/docker-compose.yml @@ -0,0 +1,190 @@ +# Bundled BackAI backend for this app. +# +# Written by `af-stack init`. `af-stack dev` (which `npm start` runs first via +# its prestart hook) brings this stack up detached, waits for the runtime to +# report ready, and writes AF_STACK_URL into .env. Stop it with +# `docker compose down` (add -v to also drop the data volumes). +# +# The images are BackAI's published release images, pinned to the CLI version +# that scaffolded this app. Set AF_STACK_VERSION in .env to run another +# release. Host ports: when a default below is busy, `af-stack dev` writes a +# free port for it into .env (AF_STACK_PORT, AGENTFIELD_PORT, ...). + +services: + postgres: + # pgvector-enabled Postgres so AgentField's vector memory works. + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: afstack + POSTGRES_PASSWORD: afstack + POSTGRES_MULTIPLE_DATABASES: afstack,agentfield + # Restricted serving role for the runtime (see backend/postgres-init.sh). + AF_STACK_APP_DB_PASSWORD: ${AF_STACK_APP_DB_PASSWORD:-afstack_app} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./backend/postgres-init.sh:/docker-entrypoint-initdb.d/postgres-init.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U afstack"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${AF_STACK_S3_ACCESS_KEY:-minio} + MINIO_ROOT_PASSWORD: ${AF_STACK_S3_SECRET_KEY:-minio-secret} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + + # LiteLLM proxy: every upstream model provider behind the runtime's + # OpenAI-compatible /api/v1/llm/* gateway. Only the provider keys you set + # in .env are used; with none set the runtime answers in demo mode. + litellm: + image: ghcr.io/berriai/litellm:main-stable + ports: + - "${LITELLM_PORT:-4000}:4000" + volumes: + - ./backend/litellm-config.yaml:/app/config.yaml:ro + environment: + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + GROQ_API_KEY: ${GROQ_API_KEY:-} + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-litellm-dev} + command: ["--config", "/app/config.yaml", "--port", "4000"] + restart: unless-stopped + + # AgentField control plane: agent registry, execution, and traces. + agentfield: + image: agentfield/control-plane:latest + environment: + AGENTFIELD_STORAGE_MODE: postgres + AGENTFIELD_STORAGE_POSTGRES_URL: postgres://afstack:afstack@postgres:5432/agentfield?sslmode=disable + AGENTFIELD_STORAGE_POSTGRES_ENABLE_MEMORY_FALLBACK: "false" + AGENTFIELD_STORAGE_POSTGRES_ENABLE_DID_FALLBACK: "false" + AGENTFIELD_STORAGE_POSTGRES_ENABLE_VC_FALLBACK: "false" + AGENTFIELD_STORAGE_POSTGRES_ENABLE_AUTO_MIGRATION: "true" + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ports: + - "${AGENTFIELD_PORT:-8081}:8080" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + # BackAI runtime: the one base URL your app talks to. + runtime: + image: ghcr.io/agent-field/af-stack-runtime:${AF_STACK_VERSION:-__AF_STACK_TAG__} + environment: + AF_STACK_HTTP_ADDR: ":8080" + AF_STACK_METRICS_ADDR: ":9090" + # Serve as the restricted role; migrate as the privileged owner. + AF_STACK_DATABASE_URL: postgres://afstack_app:${AF_STACK_APP_DB_PASSWORD:-afstack_app}@postgres:5432/afstack?sslmode=disable + AF_STACK_MIGRATE_DATABASE_URL: postgres://afstack:afstack@postgres:5432/afstack?sslmode=disable + AF_STACK_AGENTFIELD_URL: http://agentfield:8080 + # Dev secrets. Override both in .env before exposing this stack. + AF_STACK_AUTH_SECRET: ${AF_STACK_AUTH_SECRET:-dev-secret-change-me-in-prod} + AF_STACK_KMS_KEY: ${AF_STACK_KMS_KEY:-dev-secret-change-me} + # "saas" (auth + billing per the module flags) or "personal" (no login, + # no paywall). Flip with AF_STACK_MODE=personal in .env and restart. + AF_STACK_MODE: ${AF_STACK_MODE:-saas} + AF_STACK_MODULE_MULTI_TENANCY: ${AF_STACK_MODULE_MULTI_TENANCY:-false} + AF_STACK_MODULE_BILLING: ${AF_STACK_MODULE_BILLING:-true} + AF_STACK_LITELLM_URL: http://litellm:4000 + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-litellm-dev} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + GROQ_API_KEY: ${GROQ_API_KEY:-} + # Deterministic demo provider until a real provider key is set. + AF_STACK_DEMO_MODE: ${AF_STACK_DEMO_MODE:-auto} + AF_STACK_S3_ADAPTER: minio + AF_STACK_S3_ENDPOINT: minio:9000 + AF_STACK_S3_BUCKET: ${AF_STACK_S3_BUCKET:-af-stack} + AF_STACK_S3_ACCESS_KEY: ${AF_STACK_S3_ACCESS_KEY:-minio} + AF_STACK_S3_SECRET_KEY: ${AF_STACK_S3_SECRET_KEY:-minio-secret} + AF_STACK_S3_REGION: us-east-1 + # Sandbox adapter: docker uses the host daemon over the socket mounted + # below (dev only); set e2b/gvisor before exposing this stack. + AF_STACK_SANDBOX_ADAPTER: ${AF_STACK_SANDBOX_ADAPTER:-docker} + LOG_FORMAT: json + LOG_LEVEL: ${LOG_LEVEL:-info} + ports: + - "${AF_STACK_PORT:-8080}:8080" + - "${AF_STACK_METRICS_PORT:-9090}:9090" + # Root only so the docker sandbox adapter can use the host socket. + user: "0:0" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + postgres: + condition: service_healthy + agentfield: + condition: service_started + litellm: + condition: service_started + restart: unless-stopped + + # Operator console: keys, runs, costs, budgets, billing, logs. + dashboard: + image: ghcr.io/agent-field/af-stack-dashboard:${AF_STACK_VERSION:-__AF_STACK_TAG__} + environment: + DATABASE_URL: postgres://afstack:afstack@postgres:5432/afstack?sslmode=disable + AF_STACK_AUTH_SECRET: ${AF_STACK_AUTH_SECRET:-dev-secret-change-me-in-prod} + AF_STACK_MODE: ${AF_STACK_MODE:-saas} + # Seeded on first boot while no operator exists. + AF_STACK_DEFAULT_OPERATOR_EMAIL: ${AF_STACK_DEFAULT_OPERATOR_EMAIL:-operator@af-stack.local} + AF_STACK_DEFAULT_OPERATOR_PASSWORD: ${AF_STACK_DEFAULT_OPERATOR_PASSWORD:-changeme123} + AF_STACK_DEFAULT_OPERATOR_NAME: ${AF_STACK_DEFAULT_OPERATOR_NAME:-Default Operator} + BETTER_AUTH_URL: http://localhost:${AF_STACK_DASHBOARD_PORT:-33000} + BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:${AF_STACK_DASHBOARD_PORT:-33000}} + RUNTIME_URL: http://runtime:8080 + NEXT_PUBLIC_RUNTIME_URL: http://localhost:${AF_STACK_PORT:-8080} + NEXT_PUBLIC_RUNTIME_UI_URL: http://localhost:${AGENTFIELD_PORT:-8081} + NEXT_PUBLIC_DASHBOARD_URL: http://localhost:${AF_STACK_DASHBOARD_PORT:-33000} + NEXT_TELEMETRY_DISABLED: "1" + ports: + - "${AF_STACK_DASHBOARD_PORT:-33000}:3000" + depends_on: + postgres: + condition: service_healthy + runtime: + condition: service_started + restart: unless-stopped + + # Demo agent: registers `supportdesk` with a no-key `echo` reasoner, so + # `POST /api/v1/agents/supportdesk.echo` proves the wiring end to end. + supportdesk-agent: + image: ghcr.io/agent-field/af-stack-supportdesk-agent:${AF_STACK_VERSION:-__AF_STACK_TAG__} + environment: + AGENTFIELD_SERVER: http://agentfield:8080 + NODE_ID: supportdesk + AGENT_CALLBACK_URL: http://supportdesk-agent:8090 + depends_on: + agentfield: + condition: service_started + restart: unless-stopped + +volumes: + postgres-data: + minio-data: diff --git a/services/cli/internal/starter/assets/litellm-config.yaml b/services/cli/internal/starter/assets/litellm-config.yaml new file mode 100644 index 00000000..76d9b079 --- /dev/null +++ b/services/cli/internal/starter/assets/litellm-config.yaml @@ -0,0 +1,188 @@ +# LiteLLM Proxy config — handles every upstream LLM provider so the +# runtime doesn't ship a hand-rolled client per vendor. +# +# AF Stack forwards /api/v1/llm/* to this proxy (default URL: +# http://litellm:4000 inside docker-compose). LiteLLM picks the upstream +# from `model_list` and uses the appropriate provider key. +# +# Format reference: https://docs.litellm.ai/docs/proxy/configs +# +# Model naming: the `model_name` field is what AF Stack callers use +# (e.g. `model="qwen/qwen-2.5-72b-instruct"`). It's a label — LiteLLM +# resolves it to the `litellm_params.model` value when calling upstream. +# +# Adding a provider: drop in a model_list entry pointing at the +# `litellm_params.model` slug LiteLLM expects (`openrouter/...`, +# `gpt-4o`, `claude-3-5-sonnet-20241022`, `gemini/...`, +# `mistral/...`, `deepseek/...`, `groq/...`, `bedrock/...`, etc.). +# LiteLLM only activates an entry when the required env key is set — +# missing keys produce a clear runtime error, not a silent miss. +# +# Cost: when an upstream's pricing is known to LiteLLM, the proxy +# injects `response_cost` (USD) into the response. The AF Stack runtime +# prefers that number over its static pricing.EstimateCostUSD fallback. + +model_list: + # ───── OpenRouter (default; one key gets ~100 models) ───── + # AF Stack's default model is qwen/qwen-2.5-72b-instruct — cheap, + # capable, fast. The runtime's sample-agent points at this by default. + - model_name: qwen/qwen-2.5-72b-instruct + litellm_params: + model: openrouter/qwen/qwen-2.5-72b-instruct + api_key: os.environ/OPENROUTER_API_KEY + + - model_name: qwen/qwen-2.5-7b-instruct + litellm_params: + model: openrouter/qwen/qwen-2.5-7b-instruct + api_key: os.environ/OPENROUTER_API_KEY + + - model_name: meta-llama/llama-3.3-70b-instruct + litellm_params: + model: openrouter/meta-llama/llama-3.3-70b-instruct + api_key: os.environ/OPENROUTER_API_KEY + + # NOTE: slugs verified live on openrouter.ai/api/v1/models — the + # previous entries (google/gemini-flash-1.5, anthropic/claude-3.5-sonnet) + # were retired upstream and returned "No endpoints found", which + # permanently marked the whole openrouter provider degraded on the + # Health page. + - model_name: google/gemini-2.5-flash + litellm_params: + model: openrouter/google/gemini-2.5-flash + api_key: os.environ/OPENROUTER_API_KEY + + - model_name: anthropic/claude-sonnet-4.5 + litellm_params: + model: openrouter/anthropic/claude-sonnet-4.5 + api_key: os.environ/OPENROUTER_API_KEY + + # ───── OpenAI (direct) ───── + - model_name: openai/gpt-4o-mini + litellm_params: + model: gpt-4o-mini + api_key: os.environ/OPENAI_API_KEY + + - model_name: openai/gpt-4o + litellm_params: + model: gpt-4o + api_key: os.environ/OPENAI_API_KEY + + # ───── Anthropic (direct) ───── + - model_name: anthropic/claude-haiku-4-5-20251001 + litellm_params: + model: claude-haiku-4-5-20251001 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: anthropic/claude-sonnet-4-6 + litellm_params: + model: claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: anthropic/claude-opus-4-7 + litellm_params: + model: claude-opus-4-7 + api_key: os.environ/ANTHROPIC_API_KEY + + # ───── Google (direct via Gemini) ───── + - model_name: google/gemini-2.0-flash-exp + litellm_params: + model: gemini/gemini-2.0-flash-exp + api_key: os.environ/GEMINI_API_KEY + + # ───── Mistral (direct) — activates when MISTRAL_API_KEY set ───── + - model_name: mistral/mistral-small-latest + litellm_params: + model: mistral/mistral-small-latest + api_key: os.environ/MISTRAL_API_KEY + + - model_name: mistral/mistral-large-latest + litellm_params: + model: mistral/mistral-large-latest + api_key: os.environ/MISTRAL_API_KEY + + # ───── DeepSeek (direct) — activates when DEEPSEEK_API_KEY set ───── + - model_name: deepseek/deepseek-chat + litellm_params: + model: deepseek/deepseek-chat + api_key: os.environ/DEEPSEEK_API_KEY + + - model_name: deepseek/deepseek-reasoner + litellm_params: + model: deepseek/deepseek-reasoner + api_key: os.environ/DEEPSEEK_API_KEY + + # ───── Embeddings — suite memory + hybrid search ───── + # The runtime's default embedder id (memory.DefaultEmbeddingModel) is + # `openai/text-embedding-3-small`. Route it through OpenRouter's + # OpenAI-compatible /embeddings endpoint so the stock one-key setup + # (OPENROUTER_API_KEY) gets working vector memory + semantic search + # without a separate OpenAI key. + - model_name: openai/text-embedding-3-small + litellm_params: + model: openai/text-embedding-3-small + api_base: https://openrouter.ai/api/v1 + api_key: os.environ/OPENROUTER_API_KEY + + # ───── Groq (direct) — activates when GROQ_API_KEY set ───── + - model_name: groq/llama-3.3-70b-versatile + litellm_params: + model: groq/llama-3.3-70b-versatile + api_key: os.environ/GROQ_API_KEY + + - model_name: groq/mixtral-8x7b-32768 + litellm_params: + model: groq/mixtral-8x7b-32768 + api_key: os.environ/GROQ_API_KEY + + # ───────────────────────────────────────────────────────────── + # Multimodal (#14) — OpenAI audio + image surface via LiteLLM + # First-party adapters (ElevenLabs, Cartesia, Flux, fal.ai) + # bypass LiteLLM entirely; only the OpenAI catalog is mapped here. + # ───────────────────────────────────────────────────────────── + + # ───── Audio — text to speech (TTS) ───── + - model_name: openai/tts-1 + litellm_params: + model: tts-1 + api_key: os.environ/OPENAI_API_KEY + + - model_name: openai/tts-1-hd + litellm_params: + model: tts-1-hd + api_key: os.environ/OPENAI_API_KEY + + # ───── Audio — speech to text (STT) ───── + - model_name: openai/whisper-1 + litellm_params: + model: whisper-1 + api_key: os.environ/OPENAI_API_KEY + + # ───── Images — generation / edit / variations ───── + - model_name: openai/dall-e-2 + litellm_params: + model: dall-e-2 + api_key: os.environ/OPENAI_API_KEY + + - model_name: openai/dall-e-3 + litellm_params: + model: dall-e-3 + api_key: os.environ/OPENAI_API_KEY + + - model_name: openai/gpt-image-1 + litellm_params: + model: gpt-image-1 + api_key: os.environ/OPENAI_API_KEY + +general_settings: + # Internal sidecar auth. AF Stack runtime sends this on every call. + # Customers authenticate with their tenant API key one layer earlier + # (against the runtime), so they never see this value. + master_key: os.environ/LITELLM_MASTER_KEY + +litellm_settings: + # Drop the request to the upstream verbatim — AF Stack already + # validated the OpenAI-compat shape on its side. + drop_params: true + # Surface upstream cost when LiteLLM knows the pricing. The runtime + # prefers this number over its static pricing.EstimateCostUSD table. + set_verbose: false diff --git a/services/cli/internal/starter/assets/postgres-init.sh b/services/cli/internal/starter/assets/postgres-init.sh new file mode 100755 index 00000000..dd9fdfd0 --- /dev/null +++ b/services/cli/internal/starter/assets/postgres-init.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Postgres bootstrap for the compose stack. +# +# 1. Create the databases named in POSTGRES_MULTIPLE_DATABASES. +# 2. Optionally provision a restricted "serving" role for the runtime so that +# per-tenant row-level security is actually enforced. The default +# POSTGRES_USER is a superuser and OWNS every table, so it bypasses RLS +# unconditionally — tenant isolation is only real when the runtime connects +# as a NOSUPERUSER NOBYPASSRLS role. Migrations still run as POSTGRES_USER +# (via AF_STACK_MIGRATE_DATABASE_URL); the runtime serves as this role. +# +# Runs only on first init (empty data dir). For an already-initialized volume, +# run the same SQL by hand or recreate the volume. + +set -e + +if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then + for db in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do + # Skip if the database already exists (idempotent across container restarts). + exists=$(psql -tAc "SELECT 1 FROM pg_database WHERE datname='$db'" --username "$POSTGRES_USER" 2>/dev/null || echo "") + if [ "$exists" = "1" ]; then + echo "Database already exists: $db (skipping)" + continue + fi + echo "Creating database: $db" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE DATABASE $db; + GRANT ALL PRIVILEGES ON DATABASE $db TO $POSTGRES_USER; +EOSQL + done +fi + +# Restricted serving role for the runtime (real tenant isolation). +APP_ROLE="${AF_STACK_APP_DB_ROLE:-afstack_app}" +APP_DB="${AF_STACK_APP_DB_NAME:-afstack}" +if [ -n "$AF_STACK_APP_DB_PASSWORD" ]; then + echo "Provisioning restricted serving role: $APP_ROLE on $APP_DB" + # Role is cluster-wide; create it once (idempotent). No DDL/superuser/bypassrls. + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "postgres" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${APP_ROLE}') THEN + CREATE ROLE ${APP_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS + PASSWORD '${AF_STACK_APP_DB_PASSWORD}'; + ELSE + ALTER ROLE ${APP_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS + PASSWORD '${AF_STACK_APP_DB_PASSWORD}'; + END IF; + END + \$\$; +EOSQL + # Grants + default privileges are per-database; run inside the app DB. Default + # privileges "FOR ROLE $POSTGRES_USER" make every future table that the owner + # creates (i.e. each migration) auto-grant DML to the serving role. + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$APP_DB" <<-EOSQL + GRANT CONNECT ON DATABASE ${APP_DB} TO ${APP_ROLE}; + GRANT USAGE ON SCHEMA public TO ${APP_ROLE}; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${APP_ROLE}; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${APP_ROLE}; + ALTER DEFAULT PRIVILEGES FOR ROLE ${POSTGRES_USER} IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${APP_ROLE}; + ALTER DEFAULT PRIVILEGES FOR ROLE ${POSTGRES_USER} IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO ${APP_ROLE}; +EOSQL +fi diff --git a/services/cli/internal/starter/starter.go b/services/cli/internal/starter/starter.go new file mode 100644 index 00000000..333b1d22 --- /dev/null +++ b/services/cli/internal/starter/starter.go @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package starter is the bundled backend of an app scaffolded by +// `af-stack init `: the docker-compose stack that boots BackAI from +// the published release images, plus the pieces `af-stack dev` needs to run +// it there without a checkout — conflict-free host ports, the app's .env, +// and a readiness wait on the runtime. +package starter + +import ( + "bufio" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed assets/docker-compose.yml +var composeTemplate string + +//go:embed assets/postgres-init.sh +var postgresInit string + +//go:embed assets/litellm-config.yaml +var litellmConfig string + +// ComposeFile is the compose file `af-stack init` writes into the app. +const ComposeFile = "docker-compose.yml" + +// BackendDir holds the support files the compose stack mounts. +const BackendDir = "backend" + +const tagPlaceholder = "__AF_STACK_TAG__" + +// ImageTag maps the CLI version to the release image tag the bundled stack +// pins: the bare semver for a released binary, "latest" for a development +// build (the "0.0.1" placeholder, "dev", or anything that is not a version). +func ImageTag(version string) string { + v := strings.TrimPrefix(strings.TrimSpace(version), "v") + if v == "" || v == "0.0.1" || !semverLike.MatchString(v) { + return "latest" + } + return v +} + +var semverLike = regexp.MustCompile(`^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`) + +// BackendFiles returns the relative-path -> contents map of the bundled +// backend for a CLI of the given version. +func BackendFiles(version string) map[string]string { + return map[string]string{ + ComposeFile: strings.ReplaceAll(composeTemplate, tagPlaceholder, ImageTag(version)), + BackendDir + "/postgres-init.sh": postgresInit, + BackendDir + "/litellm-config.yaml": litellmConfig, + } +} + +// EnsureBackend writes any bundled-backend file missing from root and +// returns the paths it wrote, sorted. Existing files are left alone so an +// app that edited its compose file keeps its edits. +func EnsureBackend(root, version string) ([]string, error) { + var written []string + for rel, contents := range BackendFiles(version) { + path := filepath.Join(root, filepath.FromSlash(rel)) + if _, err := os.Stat(path); err == nil { + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return written, fmt.Errorf("create %s: %w", rel, err) + } + mode := os.FileMode(0o644) + if strings.HasSuffix(rel, ".sh") { + mode = 0o755 + } + // #nosec G306 -- compose stack and mounted config, not secrets. + if err := os.WriteFile(path, []byte(contents), mode); err != nil { + return written, fmt.Errorf("write %s: %w", rel, err) + } + written = append(written, rel) + } + sort.Strings(written) + return written, nil +} + +// HasBackend reports whether root carries the bundled compose file. +func HasBackend(root string) bool { + _, err := os.Stat(filepath.Join(root, ComposeFile)) + return err == nil +} + +// Port is one host port the bundled stack publishes. +type Port struct { + // Service and Target identify the compose service and in-container port, + // which is how `docker compose port` recognises a binding as ours. + Service string + Target int + // Env is the .env key that overrides the host port; Default is the + // documented default. + Env string + Default int + Label string +} + +// Ports lists every host port the bundled stack publishes, in the order the +// endpoint map prints them. +var Ports = []Port{ + {Service: "runtime", Target: 8080, Env: "AF_STACK_PORT", Default: 8080, Label: "API runtime"}, + {Service: "dashboard", Target: 3000, Env: "AF_STACK_DASHBOARD_PORT", Default: 33000, Label: "Operator dashboard"}, + {Service: "agentfield", Target: 8080, Env: "AGENTFIELD_PORT", Default: 8081, Label: "AgentField UI"}, + {Service: "runtime", Target: 9090, Env: "AF_STACK_METRICS_PORT", Default: 9090, Label: "Metrics"}, + {Service: "litellm", Target: 4000, Env: "LITELLM_PORT", Default: 4000, Label: "LiteLLM"}, + {Service: "minio", Target: 9000, Env: "MINIO_PORT", Default: 9000, Label: "MinIO API"}, + {Service: "minio", Target: 9001, Env: "MINIO_CONSOLE_PORT", Default: 9001, Label: "MinIO console"}, + {Service: "postgres", Target: 5432, Env: "POSTGRES_PORT", Default: 5432, Label: "Postgres"}, +} + +// OwnedPortFunc reports whether the compose project in root already binds +// host port p for the given service/target — i.e. the port is busy because +// OUR stack is running, which is not a conflict. +type OwnedPortFunc func(root string, p Port, hostPort int) bool + +// ComposeOwnsPort is the OwnedPortFunc backed by `docker compose port`. +func ComposeOwnsPort(root string, p Port, hostPort int) bool { + // #nosec G204 -- service and target come from the fixed Ports table above. + cmd := exec.Command("docker", "compose", "port", p.Service, strconv.Itoa(p.Target)) + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return false + } + suffix := ":" + strconv.Itoa(hostPort) + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.HasSuffix(strings.TrimSpace(line), suffix) { + return true + } + } + return false +} + +// Allocation is the outcome of AllocatePorts. +type Allocation struct { + // Resolved maps each .env key to the host port the stack will bind. + Resolved map[string]int + // Moved lists the ports that had to change, in Ports order. + Moved []Move + // Project is the compose project name. + Project string +} + +// Move records one reallocated port. +type Move struct { + Port Port + From int + To int +} + +// AllocatePorts is the Go counterpart of the checkout's preflight --fix: +// for every published port it keeps the configured value when it is free +// or already bound by this compose project, and otherwise probes upward +// for the next free host port. Changes are written to /.env (seeded +// from .env.example when .env does not exist) together with a stable +// COMPOSE_PROJECT_NAME derived from the directory name. +func AllocatePorts(root string, owned OwnedPortFunc) (*Allocation, error) { + env, err := ReadEnv(root) + if err != nil { + return nil, err + } + alloc := &Allocation{Resolved: map[string]int{}} + overrides := map[string]string{} + claimed := map[int]bool{} + + for _, p := range Ports { + want := p.Default + if v := strings.TrimSpace(env[p.Env]); v != "" { + n, convErr := strconv.Atoi(v) + if convErr != nil || n <= 0 || n > 65535 { + return nil, fmt.Errorf("%s=%q in .env is not a valid port", p.Env, v) + } + want = n + } + free := !claimed[want] && canBind(want) + if free || (!claimed[want] && owned != nil && owned(root, p, want)) { + claimed[want] = true + alloc.Resolved[p.Env] = want + continue + } + next, findErr := nextFreePort(want+1, claimed) + if findErr != nil { + return nil, findErr + } + claimed[next] = true + alloc.Resolved[p.Env] = next + alloc.Moved = append(alloc.Moved, Move{Port: p, From: want, To: next}) + overrides[p.Env] = strconv.Itoa(next) + } + + alloc.Project = strings.TrimSpace(env["COMPOSE_PROJECT_NAME"]) + if alloc.Project == "" { + alloc.Project = ProjectName(root) + overrides["COMPOSE_PROJECT_NAME"] = alloc.Project + } + if len(overrides) > 0 { + if err := SetEnv(root, overrides, "# Auto-allocated by `af-stack dev` to avoid port conflicts."); err != nil { + return nil, err + } + } + return alloc, nil +} + +// ProjectName is the compose project name for an app directory: its base +// name lowercased and slugged, "backai" when nothing survives. +func ProjectName(root string) string { + base := strings.ToLower(filepath.Base(root)) + var b strings.Builder + lastDash := true + for _, r := range base { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + return "backai" + } + return slug +} + +func canBind(port int) bool { + l, err := net.Listen("tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(port))) + if err != nil { + return false + } + _ = l.Close() + return true +} + +func nextFreePort(start int, claimed map[int]bool) (int, error) { + for p := start; p <= 65535; p++ { + if !claimed[p] && canBind(p) { + return p, nil + } + } + return 0, errors.New("no free host port found") +} + +// ReadEnv parses /.env into a map. A missing file is an empty map. +// Values from the process environment win, as they do for docker compose. +func ReadEnv(root string) (map[string]string, error) { + values := map[string]string{} + f, err := os.Open(filepath.Join(root, ".env")) + if err != nil { + if os.IsNotExist(err) { + return values, nil + } + return nil, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`) + } + if err := sc.Err(); err != nil { + return nil, err + } + for k := range values { + if v, ok := os.LookupEnv(k); ok { + values[k] = v + } + } + return values, nil +} + +// SetEnv writes key=value pairs into /.env, replacing existing lines +// for those keys and appending the rest under comment. When .env does not +// exist it is seeded from .env.example so the documented keys survive. +func SetEnv(root string, values map[string]string, comment string) error { + envPath := filepath.Join(root, ".env") + var lines []string + data, err := os.ReadFile(envPath) + switch { + case err == nil: + lines = strings.Split(strings.TrimRight(string(data), "\n"), "\n") + case os.IsNotExist(err): + if ex, exErr := os.ReadFile(filepath.Join(root, ".env.example")); exErr == nil { + lines = strings.Split(strings.TrimRight(string(ex), "\n"), "\n") + } + default: + return err + } + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + + remaining := map[string]string{} + for k, v := range values { + remaining[k] = v + } + for i, line := range lines { + m := envKeyLine.FindStringSubmatch(line) + if m == nil { + continue + } + if v, ok := remaining[m[1]]; ok { + lines[i] = m[1] + "=" + v + delete(remaining, m[1]) + } + } + if len(remaining) > 0 { + keys := make([]string, 0, len(remaining)) + for k := range remaining { + keys = append(keys, k) + } + sort.Strings(keys) + if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" { + lines = append(lines, "") + } + if comment != "" { + lines = append(lines, comment) + } + for _, k := range keys { + lines = append(lines, k+"="+remaining[k]) + } + } + // #nosec G306 G703 -- /.env of the app the user is running in; local dev config, not secrets. + return os.WriteFile(envPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +var envKeyLine = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=`) + +// WaitReady polls /ready until it answers 200, printing one dot +// per poll to progress. It returns the last status seen when timeout +// elapses so the caller can say what the runtime was doing. +func WaitReady(ctx context.Context, baseURL string, timeout time.Duration, progress io.Writer) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 3 * time.Second} + var last string + for { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/ready", nil) + resp, err := client.Do(req) + if err == nil { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + last = fmt.Sprintf("GET /ready -> %d %s", resp.StatusCode, strings.TrimSpace(string(body))) + } else if ctx.Err() != nil { + return ctx.Err() + } else { + last = err.Error() + } + if time.Now().After(deadline) { + return fmt.Errorf("runtime at %s did not become ready within %s (last: %s)", baseURL, timeout, last) + } + if progress != nil { + fmt.Fprint(progress, ".") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// WaitAgent polls /api/v1/agents until an agent with nodeID is +// registered, or timeout elapses. Registration lags the runtime by a few +// seconds, and the demo call the starter makes needs it. A timeout is not +// an error for the caller: it returns false and the app reports the state. +func WaitAgent(ctx context.Context, baseURL, nodeID string, timeout time.Duration, progress io.Writer) bool { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 3 * time.Second} + for { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/v1/agents", nil) + resp, err := client.Do(req) + if err == nil { + var payload struct { + Agents []struct { + NodeID string `json:"node_id"` + } `json:"agents"` + } + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload) + _ = resp.Body.Close() + if decodeErr == nil { + for _, a := range payload.Agents { + if a.NodeID == nodeID { + return true + } + } + } + } + if ctx.Err() != nil || time.Now().After(deadline) { + return false + } + if progress != nil { + fmt.Fprint(progress, ".") + } + select { + case <-ctx.Done(): + return false + case <-time.After(2 * time.Second): + } + } +} diff --git a/services/cli/internal/starter/starter_test.go b/services/cli/internal/starter/starter_test.go new file mode 100644 index 00000000..290b94ed --- /dev/null +++ b/services/cli/internal/starter/starter_test.go @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: Apache-2.0 + +package starter + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +// Contract: the support files the bundled stack mounts are the repo's own +// (the compose mounts them at the same paths the checkout stack uses), so +// they cannot drift from what the runtime and LiteLLM are tested against. +func TestBundledSupportFilesMatchRepo(t *testing.T) { + repo := filepath.Join("..", "..", "..", "..") + for asset, canonical := range map[string]string{ + "postgres-init.sh": filepath.Join(repo, "scripts", "postgres-init.sh"), + "litellm-config.yaml": filepath.Join(repo, "apps", "backend", "litellm-config.yaml"), + } { + want, err := os.ReadFile(canonical) + if err != nil { + t.Fatalf("read %s: %v", canonical, err) + } + got := BackendFiles("1.2.3")[BackendDir+"/"+asset] + if got != string(want) { + t.Errorf("assets/%s drifted from %s — copy it over", asset, canonical) + } + } +} + +// Contract: a released CLI pins the release images to its own version; a +// development build pins :latest. +func TestImageTagFollowsCLIVersion(t *testing.T) { + cases := map[string]string{ + "0.12.8": "0.12.8", "v0.12.8": "0.12.8", "1.0.0-rc.1": "1.0.0-rc.1", + "0.0.1": "latest", "": "latest", "dev": "latest", "abc123": "latest", + } + for in, want := range cases { + if got := ImageTag(in); got != want { + t.Errorf("ImageTag(%q) = %q, want %q", in, got, want) + } + } + compose := BackendFiles("0.12.8")[ComposeFile] + for _, img := range []string{ + "ghcr.io/agent-field/af-stack-runtime:${AF_STACK_VERSION:-0.12.8}", + "ghcr.io/agent-field/af-stack-dashboard:${AF_STACK_VERSION:-0.12.8}", + "ghcr.io/agent-field/af-stack-supportdesk-agent:${AF_STACK_VERSION:-0.12.8}", + } { + if !strings.Contains(compose, img) { + t.Errorf("compose does not pin %s", img) + } + } + if strings.Contains(compose, tagPlaceholder) { + t.Error("compose still contains the tag placeholder") + } +} + +// Contract: the compose file is valid YAML, publishes every port in Ports +// through its .env key, and mounts the support files EnsureBackend writes. +func TestComposeStackIsCoherent(t *testing.T) { + compose := BackendFiles("0.12.8")[ComposeFile] + var doc struct { + Services map[string]struct { + Image string `yaml:"image"` + Ports []string `yaml:"ports"` + Volumes []string `yaml:"volumes"` + } `yaml:"services"` + } + if err := yaml.Unmarshal([]byte(compose), &doc); err != nil { + t.Fatalf("compose is not valid YAML: %v", err) + } + for _, p := range Ports { + svc, ok := doc.Services[p.Service] + if !ok { + t.Fatalf("Ports names service %q which the compose file lacks", p.Service) + } + want := "${" + p.Env + ":-" + strconv.Itoa(p.Default) + "}:" + strconv.Itoa(p.Target) + found := false + for _, binding := range svc.Ports { + if binding == want { + found = true + } + } + if !found { + t.Errorf("service %s does not publish %s (have %v)", p.Service, want, svc.Ports) + } + } + for svc, s := range doc.Services { + if s.Image == "" { + t.Errorf("service %s has no image: the bundled stack must never need a source build", svc) + } + } + for _, mount := range []string{"./backend/postgres-init.sh:", "./backend/litellm-config.yaml:"} { + if !strings.Contains(compose, mount) { + t.Errorf("compose does not mount %s", mount) + } + } +} + +// Contract: EnsureBackend fills in what is missing and leaves edited files +// alone, so an app that customised its compose file survives `dev`. +func TestEnsureBackendIsIdempotentAndNonDestructive(t *testing.T) { + root := t.TempDir() + written, err := EnsureBackend(root, "0.12.8") + if err != nil { + t.Fatal(err) + } + if len(written) != 3 || !HasBackend(root) { + t.Fatalf("first EnsureBackend wrote %v", written) + } + custom := "services: {}\n# customised\n" + if err := os.WriteFile(filepath.Join(root, ComposeFile), []byte(custom), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, BackendDir, "postgres-init.sh")); err != nil { + t.Fatal(err) + } + written, err = EnsureBackend(root, "0.12.8") + if err != nil { + t.Fatal(err) + } + if len(written) != 1 || written[0] != BackendDir+"/postgres-init.sh" { + t.Fatalf("second EnsureBackend wrote %v, want only the removed file", written) + } + got, _ := os.ReadFile(filepath.Join(root, ComposeFile)) + if string(got) != custom { + t.Fatal("EnsureBackend overwrote a customised compose file") + } + info, _ := os.Stat(filepath.Join(root, BackendDir, "postgres-init.sh")) + if info.Mode()&0o111 == 0 { + t.Fatal("postgres-init.sh must be executable for the postgres entrypoint") + } +} + +// Contract: a busy default port moves to the next free one and lands in +// .env; a free port stays; a port bound by our own compose project stays +// even though it is busy; COMPOSE_PROJECT_NAME is derived from the dir. +func TestAllocatePortsMovesOnlyForeignConflicts(t *testing.T) { + root := filepath.Join(t.TempDir(), "My App!") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + // Pin every port to one this test controls so the machine's own + // services (a local Postgres on 5432, say) cannot skew the outcome, then + // occupy two of them: one "foreign", one "ours". + foreign := listen(t) + ours := listen(t) + var env strings.Builder + for _, p := range Ports { + switch p.Env { + case "AF_STACK_PORT": + fmt.Fprintf(&env, "%s=%d\n", p.Env, foreign) + case "AGENTFIELD_PORT": + fmt.Fprintf(&env, "%s=%d\n", p.Env, ours) + default: + fmt.Fprintf(&env, "%s=%d\n", p.Env, freePort(t)) + } + } + if err := os.WriteFile(filepath.Join(root, ".env"), []byte(env.String()), 0o644); err != nil { + t.Fatal(err) + } + owned := func(_ string, p Port, hostPort int) bool { + return p.Env == "AGENTFIELD_PORT" && hostPort == ours + } + alloc, err := AllocatePorts(root, owned) + if err != nil { + t.Fatal(err) + } + if alloc.Resolved["AF_STACK_PORT"] == foreign { + t.Fatalf("busy foreign port %d was kept", foreign) + } + if alloc.Resolved["AGENTFIELD_PORT"] != ours { + t.Fatalf("port bound by our own compose project was moved: %d -> %d", ours, alloc.Resolved["AGENTFIELD_PORT"]) + } + if len(alloc.Moved) != 1 || alloc.Moved[0].Port.Env != "AF_STACK_PORT" { + t.Fatalf("moved = %+v, want exactly AF_STACK_PORT", alloc.Moved) + } + if alloc.Project != "my-app" { + t.Fatalf("project = %q, want my-app", alloc.Project) + } + seen := map[int]bool{} + for k, v := range alloc.Resolved { + if seen[v] { + t.Fatalf("two services resolved to the same host port %d (%s)", v, k) + } + seen[v] = true + } + got, _ := ReadEnv(root) + if got["AF_STACK_PORT"] != strconv.Itoa(alloc.Resolved["AF_STACK_PORT"]) { + t.Fatalf(".env AF_STACK_PORT = %q, want %d", got["AF_STACK_PORT"], alloc.Resolved["AF_STACK_PORT"]) + } + if got["AGENTFIELD_PORT"] != strconv.Itoa(ours) || got["COMPOSE_PROJECT_NAME"] != "my-app" { + t.Fatalf(".env after allocation = %v", got) + } +} + +// Contract: SetEnv replaces existing keys in place, appends new ones once, +// and seeds a missing .env from .env.example so documented keys survive. +func TestSetEnvSeedsFromExampleAndReplacesInPlace(t *testing.T) { + root := t.TempDir() + example := "# url\nAF_STACK_URL=http://localhost:8080\nAF_STACK_API_KEY=\n" + if err := os.WriteFile(filepath.Join(root, ".env.example"), []byte(example), 0o644); err != nil { + t.Fatal(err) + } + if err := SetEnv(root, map[string]string{"AF_STACK_URL": "http://localhost:8082", "COMPOSE_PROJECT_NAME": "x"}, "# added"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(root, ".env")) + got := string(data) + want := "# url\nAF_STACK_URL=http://localhost:8082\nAF_STACK_API_KEY=\n\n# added\nCOMPOSE_PROJECT_NAME=x\n" + if got != want { + t.Fatalf(".env =\n%s\nwant\n%s", got, want) + } + // Second write: in place, no duplicate lines. + if err := SetEnv(root, map[string]string{"AF_STACK_URL": "http://localhost:8083", "COMPOSE_PROJECT_NAME": "y"}, "# added"); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(filepath.Join(root, ".env")) + if strings.Count(string(data), "AF_STACK_URL=") != 1 || strings.Count(string(data), "COMPOSE_PROJECT_NAME=") != 1 { + t.Fatalf("duplicate keys after second SetEnv:\n%s", data) + } + if !strings.Contains(string(data), "COMPOSE_PROJECT_NAME=y") { + t.Fatalf("value not replaced:\n%s", data) + } +} + +// Contract: WaitReady returns once /ready answers 200 and reports the last +// status when it never does; WaitAgent recognises the runtime's +// {"agents":[{"node_id":...}]} shape. +func TestWaitReadyAndWaitAgent(t *testing.T) { + var readyAfter, listAfter int + mux := http.NewServeMux() + mux.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) { + if readyAfter++; readyAfter < 2 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"booting"}`)) + return + } + _, _ = w.Write([]byte(`{"status":"ready"}`)) + }) + mux.HandleFunc("/api/v1/agents", func(w http.ResponseWriter, _ *http.Request) { + if listAfter++; listAfter < 2 { + _, _ = w.Write([]byte(`{"agents":[]}`)) + return + } + _, _ = w.Write([]byte(`{"agents":[{"node_id":"supportdesk","reasoners":["echo"]}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx := context.Background() + if err := WaitReady(ctx, srv.URL, 10*time.Second, nil); err != nil { + t.Fatalf("WaitReady: %v", err) + } + if !WaitAgent(ctx, srv.URL, "supportdesk", 10*time.Second, nil) { + t.Fatal("WaitAgent did not see the registered agent") + } + + never := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"db_unavailable"}`)) + })) + defer never.Close() + err := WaitReady(ctx, never.URL, 10*time.Millisecond, nil) + if err == nil || !strings.Contains(err.Error(), "503") || !strings.Contains(err.Error(), "db_unavailable") { + t.Fatalf("WaitReady timeout error should carry the last status, got: %v", err) + } + if WaitAgent(ctx, never.URL, "supportdesk", 10*time.Millisecond, nil) { + t.Fatal("WaitAgent should give up on a runtime that never lists the agent") + } +} + +// freePort returns a port that was free a moment ago. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +func listen(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = l.Close() }) + return l.Addr().(*net.TCPAddr).Port +} From e54ecb9abdad9db1b054fd319cf39340e0e13740 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 14:16:26 -0400 Subject: [PATCH 2/5] feat(cli): af-stack dev boots the bundled backend inside a scaffolded app When no checkout encloses the directory but it is an app written by `af-stack init `, dev no longer demands a clone: it writes any missing backend files, allocates free host ports into .env, runs docker compose up -d, waits for /ready and for the supportdesk agent to register, writes AF_STACK_URL (and VITE_AF_STACK_URL for the saas template) into .env, and prints where things are. Always detached, so npm's prestart hook can run it. A runtime that never becomes ready is reported with its last status and the log command; a missing docker says what to install. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- services/cli/internal/checkout/checkout.go | 13 +- services/cli/internal/project/appdev.go | 135 ++++++++++++ services/cli/internal/project/appdev_test.go | 204 +++++++++++++++++++ services/cli/internal/project/project.go | 6 + 4 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 services/cli/internal/project/appdev.go create mode 100644 services/cli/internal/project/appdev_test.go diff --git a/services/cli/internal/checkout/checkout.go b/services/cli/internal/checkout/checkout.go index 6316b7c1..566a4db9 100644 --- a/services/cli/internal/checkout/checkout.go +++ b/services/cli/internal/checkout/checkout.go @@ -1,11 +1,14 @@ // Package checkout locates the BackAI checkout — a clone of the repository — -// that in-tree commands (`init --name`, `dev`, `agent|module|plugin new`, -// `deploy`) operate on, and explains clearly when there is none. +// that in-tree commands (`init --name`, `agent|module|plugin new`, `deploy`, +// and `dev` when there is one) operate on, and explains clearly when there +// is none. // // The most common way to end up outside a checkout is to scaffold a // standalone app with `af-stack init `, cd into it, and then run a -// fork command there. That directory calls a running BackAI; it has no -// apps/ tree to brand or add agents to. The error says so. +// fork command there. That directory has no apps/ tree to brand or add +// agents to; the error says so. (`dev` is the exception: the app carries a +// bundled backend, and project.RunDev runs it when NotFoundError names a +// ScaffoldedApp.) package checkout import ( @@ -31,7 +34,7 @@ func (e *NotFoundError) Error() string { var b strings.Builder fmt.Fprintf(&b, "must run from inside a BackAI checkout — a clone of %s (a directory containing apps/dashboard and apps/customer-app); %s is not one.", RepoURL, e.Dir) if e.ScaffoldedApp != "" { - fmt.Fprintf(&b, "\n %s is a standalone app created by `af-stack init `: it calls a running BackAI and has no fork surfaces to brand or extend.", e.ScaffoldedApp) + fmt.Fprintf(&b, "\n %s is a standalone app created by `af-stack init `: it has a bundled backend (`af-stack dev` works there) but no fork surfaces to brand or extend.", e.ScaffoldedApp) } fmt.Fprintf(&b, "\n To brand a fork or add agents, modules, or plugins: git clone %s my-fork && cd my-fork", RepoURL) fmt.Fprintf(&b, "\n To scaffold a standalone app instead: af-stack init (works in any directory)") diff --git a/services/cli/internal/project/appdev.go b/services/cli/internal/project/appdev.go new file mode 100644 index 00000000..b1bfbddc --- /dev/null +++ b/services/cli/internal/project/appdev.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/backai/services/cli/internal/buildinfo" + "github.com/Agent-Field/backai/services/cli/internal/starter" +) + +// Timeouts for the bundled backend. Variables so tests can shorten them. +var ( + // appReadyTimeout bounds the wait for the runtime's /ready after + // `docker compose up -d` returns; the image pulls happen before that, + // so this only covers boot + migrations + the AgentField handshake. + appReadyTimeout = 5 * time.Minute + // appAgentTimeout bounds the wait for the demo agent to register. + appAgentTimeout = 90 * time.Second +) + +// demoAgentNodeID is the agent the bundled stack registers; the starter's +// first call is .echo. +const demoAgentNodeID = "supportdesk" + +// runAppDev is `af-stack dev` inside an app written by `af-stack init +// `: it boots the app's bundled backend from the published images and +// points the app at it. Unlike the checkout flavour it is always detached — +// it returns once the runtime is ready, which is what `npm start`'s prestart +// hook needs. +func runAppDev(ctx context.Context, root string, noPreflight bool, stdout, stderr io.Writer) error { + written, err := starter.EnsureBackend(root, buildinfo.Version) + if err != nil { + return fmt.Errorf("dev: %w", err) + } + if len(written) > 0 { + fmt.Fprintf(stdout, "Wrote the bundled backend: %s\n", strings.Join(written, ", ")) + } + + if _, err := exec.LookPath("docker"); err != nil { + return errors.New("dev: docker is required to run the bundled backend — install Docker Desktop (or Docker Engine with the compose plugin), start it, and run this again") + } + + ports := map[string]int{} + for _, p := range starter.Ports { + ports[p.Env] = p.Default + } + project := starter.ProjectName(root) + if noPreflight { + env, err := starter.ReadEnv(root) + if err != nil { + return fmt.Errorf("dev: read .env: %w", err) + } + for _, p := range starter.Ports { + if n, convErr := strconv.Atoi(strings.TrimSpace(env[p.Env])); convErr == nil && n > 0 { + ports[p.Env] = n + } + } + if v := strings.TrimSpace(env["COMPOSE_PROJECT_NAME"]); v != "" { + project = v + } + } else { + alloc, err := starter.AllocatePorts(root, starter.ComposeOwnsPort) + if err != nil { + return fmt.Errorf("dev: port preflight: %w", err) + } + ports = alloc.Resolved + project = alloc.Project + for _, m := range alloc.Moved { + fmt.Fprintf(stdout, "Port %d is busy; %s moves to %d (%s in .env).\n", m.From, m.Port.Label, m.To, m.Port.Env) + } + } + + apiURL := fmt.Sprintf("http://localhost:%d", ports["AF_STACK_PORT"]) + + // Point the app at the runtime before waiting, so even an interrupted + // first boot leaves .env correct for the next `npm start`. + urls := map[string]string{"AF_STACK_URL": apiURL} + if usesViteURL(root) { + urls["VITE_AF_STACK_URL"] = apiURL + } + if err := starter.SetEnv(root, urls, "# Written by `af-stack dev`: the bundled backend's base URL."); err != nil { + return fmt.Errorf("dev: write .env: %w", err) + } + + fmt.Fprintf(stdout, "Starting the bundled BackAI backend (compose project %q; the first run pulls the images)...\n", project) + if err := runCommand(ctx, root, "docker", []string{"compose", "up", "-d"}, stdout, stderr); err != nil { + return fmt.Errorf("dev: docker compose up: %w", err) + } + + fmt.Fprintf(stdout, "Waiting for the runtime at %s/ready ", apiURL) + if err := starter.WaitReady(ctx, apiURL, appReadyTimeout, stdout); err != nil { + fmt.Fprintln(stdout) + return fmt.Errorf("dev: %w\n Inspect with: docker compose logs runtime (in %s)", err, root) + } + fmt.Fprintln(stdout, " ready") + + fmt.Fprintf(stdout, "Waiting for the %s agent to register ", demoAgentNodeID) + if starter.WaitAgent(ctx, apiURL, demoAgentNodeID, appAgentTimeout, stdout) { + fmt.Fprintln(stdout, " registered") + } else { + fmt.Fprintf(stdout, " not yet — it keeps retrying in the background (docker compose logs %s-agent)\n", demoAgentNodeID) + } + + fmt.Fprintln(stdout, "") + fmt.Fprintln(stdout, "Bundled backend is up:") + fmt.Fprintf(stdout, " API runtime %s/api/v1\n", apiURL) + fmt.Fprintf(stdout, " Your app AF_STACK_URL=%s (written to .env)\n", apiURL) + fmt.Fprintf(stdout, " Operator dashboard http://localhost:%d (operator@af-stack.local / changeme123)\n", ports["AF_STACK_DASHBOARD_PORT"]) + fmt.Fprintf(stdout, " AgentField UI http://localhost:%d\n", ports["AGENTFIELD_PORT"]) + fmt.Fprintf(stdout, " Logs / stop docker compose logs -f · docker compose down (in %s)\n", root) + return nil +} + +// usesViteURL reports whether the app reads VITE_AF_STACK_URL (the saas +// template's Vite dev proxy) so dev keeps that key in step too. +func usesViteURL(root string) bool { + for _, name := range []string{".env", ".env.example"} { + // #nosec G304 -- files inside the app directory the user is running in. + data, err := os.ReadFile(filepath.Join(root, name)) + if err == nil && strings.Contains(string(data), "VITE_AF_STACK_URL=") { + return true + } + } + return false +} diff --git a/services/cli/internal/project/appdev_test.go b/services/cli/internal/project/appdev_test.go new file mode 100644 index 00000000..0042cb99 --- /dev/null +++ b/services/cli/internal/project/appdev_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/backai/services/cli/internal/starter" +) + +// scaffoldedApp writes the markers `af-stack init ` leaves (the +// checkout package keys on them) without the backend files, the way an app +// written by an older CLI looks. +func scaffoldedApp(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), "my-ai-product") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "package.json": `{"name":"my-ai-product","scripts":{"prestart":"af-stack dev"}}`, + "CLAUDE.md": "# app\n", + ".env.example": "AF_STACK_URL=http://localhost:8080\nAF_STACK_API_KEY=\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +// stubDocker puts a fake `docker` first on PATH that records its argv to +// a log file, answers `compose port runtime 8080` with the fake runtime's +// host port (so the preflight treats that busy port as ours), and does +// nothing for `compose up`. +func stubDocker(t *testing.T, runtimePort string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("stub docker script is POSIX") + } + bin := t.TempDir() + log := filepath.Join(bin, "docker.log") + script := "#!/bin/sh\necho \"$@\" >> " + log + "\n" + + "if [ \"$1 $2 $3 $4\" = \"compose port runtime 8080\" ]; then echo \"0.0.0.0:" + runtimePort + "\"; fi\n" + + "exit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + return log +} + +func fakeRuntime(t *testing.T, agentRegistered bool) (*httptest.Server, string) { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"status":"ready"}`)) + }) + mux.HandleFunc("/api/v1/agents", func(w http.ResponseWriter, _ *http.Request) { + if agentRegistered { + _, _ = w.Write([]byte(`{"agents":[{"node_id":"supportdesk"}]}`)) + return + } + _, _ = w.Write([]byte(`{"agents":[]}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + return srv, u.Port() +} + +// Contract: in an app written by `af-stack init `, `af-stack dev` +// does not demand a checkout. It writes the bundled backend when missing, +// runs `docker compose up -d` in the app, waits for the runtime, points +// the app at it through .env, and prints where things are. +func TestDevInScaffoldedAppBootsBundledBackend(t *testing.T) { + root := scaffoldedApp(t) + defer chdir(t, root)() + _, port := fakeRuntime(t, true) + log := stubDocker(t, port) + // Pin the runtime port to the fake so the readiness wait hits it. + if err := os.WriteFile(filepath.Join(root, ".env"), []byte("AF_STACK_PORT="+port+"\n"), 0o644); err != nil { + t.Fatal(err) + } + appAgentTimeout = 5 * time.Second + + var out, errOut bytes.Buffer + if err := RunDev(context.Background(), nil, &out, &errOut); err != nil { + t.Fatalf("dev in scaffolded app: %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String()) + } + + for _, rel := range []string{starter.ComposeFile, "backend/postgres-init.sh", "backend/litellm-config.yaml"} { + if _, err := os.Stat(filepath.Join(root, rel)); err != nil { + t.Errorf("dev did not write %s: %v", rel, err) + } + } + calls, _ := os.ReadFile(log) + if !strings.Contains(string(calls), "compose up -d") { + t.Errorf("dev did not run docker compose up -d; docker calls:\n%s", calls) + } + env, _ := starter.ReadEnv(root) + if env["AF_STACK_URL"] != "http://localhost:"+port { + t.Errorf("AF_STACK_URL in .env = %q, want the runtime's port %s", env["AF_STACK_URL"], port) + } + if env["AF_STACK_PORT"] != port { + t.Errorf("AF_STACK_PORT was moved off the port our own compose project holds: %q", env["AF_STACK_PORT"]) + } + if env["COMPOSE_PROJECT_NAME"] != "my-ai-product" { + t.Errorf("COMPOSE_PROJECT_NAME = %q", env["COMPOSE_PROJECT_NAME"]) + } + for _, want := range []string{"AF_STACK_URL=http://localhost:" + port, "ready", "registered", "Operator dashboard", "docker compose down"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} + +// Contract: a runtime that never becomes ready is reported with the last +// status and a pointer at the logs, and .env already carries the URL so +// the next attempt is consistent. +func TestDevInScaffoldedAppReportsUnreadyRuntime(t *testing.T) { + root := scaffoldedApp(t) + defer chdir(t, root)() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"db_unavailable"}`)) + })) + defer srv.Close() + u, _ := url.Parse(srv.URL) + stubDocker(t, u.Port()) + if err := os.WriteFile(filepath.Join(root, ".env"), []byte("AF_STACK_PORT="+u.Port()+"\n"), 0o644); err != nil { + t.Fatal(err) + } + appReadyTimeout = 50 * time.Millisecond + defer func() { appReadyTimeout = 5 * time.Minute }() + + var out, errOut bytes.Buffer + err := RunDev(context.Background(), nil, &out, &errOut) + if err == nil { + t.Fatal("expected an error when the runtime never becomes ready") + } + for _, want := range []string{"did not become ready", "db_unavailable", "docker compose logs runtime"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } + env, _ := starter.ReadEnv(root) + if env["AF_STACK_URL"] != "http://localhost:"+u.Port() { + t.Errorf("AF_STACK_URL should be written before the wait, got %q", env["AF_STACK_URL"]) + } +} + +// Contract: without docker on PATH the error says what to install rather +// than failing inside compose. +func TestDevInScaffoldedAppNeedsDocker(t *testing.T) { + root := scaffoldedApp(t) + defer chdir(t, root)() + t.Setenv("PATH", t.TempDir()) + + var out, errOut bytes.Buffer + err := RunDev(context.Background(), nil, &out, &errOut) + if err == nil || !strings.Contains(err.Error(), "docker is required") { + t.Fatalf("want a docker-is-required error, got: %v", err) + } +} + +// Contract: a directory that is neither a checkout nor a scaffolded app +// still gets the checkout explanation (covered in checkout_error_test.go); +// a scaffolded app with the saas template also gets VITE_AF_STACK_URL. +func TestDevInScaffoldedAppKeepsViteURL(t *testing.T) { + root := scaffoldedApp(t) + defer chdir(t, root)() + if err := os.WriteFile(filepath.Join(root, ".env.example"), []byte("VITE_AF_STACK_URL=http://localhost:8080\nPORT=34000\n"), 0o644); err != nil { + t.Fatal(err) + } + _, port := fakeRuntime(t, false) + stubDocker(t, port) + if err := os.WriteFile(filepath.Join(root, ".env"), []byte("AF_STACK_PORT="+port+"\n"), 0o644); err != nil { + t.Fatal(err) + } + appAgentTimeout = 10 * time.Millisecond + + var out, errOut bytes.Buffer + if err := RunDev(context.Background(), nil, &out, &errOut); err != nil { + t.Fatalf("dev: %v\n%s", err, errOut.String()) + } + env, _ := starter.ReadEnv(root) + if env["VITE_AF_STACK_URL"] != "http://localhost:"+port { + t.Errorf("VITE_AF_STACK_URL = %q", env["VITE_AF_STACK_URL"]) + } + if !strings.Contains(out.String(), "not yet") { + t.Errorf("an unregistered agent should be reported, not hidden:\n%s", out.String()) + } +} diff --git a/services/cli/internal/project/project.go b/services/cli/internal/project/project.go index 9a578223..cca52d0a 100644 --- a/services/cli/internal/project/project.go +++ b/services/cli/internal/project/project.go @@ -52,6 +52,12 @@ func RunDev(ctx context.Context, args []string, stdout, stderr io.Writer) error } root, err := checkout.Find() if err != nil { + // An app written by `af-stack init ` carries its own backend: + // run that instead of demanding a clone. + var nf *checkout.NotFoundError + if errors.As(err, &nf) && nf.ScaffoldedApp != "" { + return runAppDev(ctx, nf.ScaffoldedApp, *noPreflight, stdout, stderr) + } return err } From f8a9ffe106f859a49b4cb6bc049a2e2707b7b48d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 14:16:26 -0400 Subject: [PATCH 3/5] feat(init): scaffolded apps carry their backend and boot it from npm start Both templates get docker-compose.yml + backend/ from the starter package. The node app runs af-stack dev through a prestart hook (plus backend / backend:stop scripts) and its starter now calls supportdesk.echo and prints the reply; the saas app gets a predev hook. .env.example, README, CLAUDE.md, the failure text, and the printed next steps describe the self-contained path instead of a backend from a clone. The node starter tests use the runtime's {"agents":[...]} shape and an echo endpoint. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- services/cli/internal/initcmd/scaffold.go | 102 +++++++++++++----- .../internal/initcmd/scaffold_node_test.go | 24 ++++- .../cli/internal/initcmd/scaffold_saas.go | 25 +++-- .../internal/initcmd/scaffold_saas_test.go | 3 + .../cli/internal/initcmd/scaffold_test.go | 17 ++- 5 files changed, 134 insertions(+), 37 deletions(-) diff --git a/services/cli/internal/initcmd/scaffold.go b/services/cli/internal/initcmd/scaffold.go index 348847fc..bbcfa51f 100644 --- a/services/cli/internal/initcmd/scaffold.go +++ b/services/cli/internal/initcmd/scaffold.go @@ -11,7 +11,9 @@ import ( "sort" "strings" + "github.com/Agent-Field/backai/services/cli/internal/buildinfo" "github.com/Agent-Field/backai/services/cli/internal/output" + "github.com/Agent-Field/backai/services/cli/internal/starter" ) // runScaffold implements the npm-like `af-stack init `: it creates a @@ -57,6 +59,13 @@ func runScaffold(args []string, stdout, stderr io.Writer) error { return output.Usage("init: unknown template %q for a new project (available: node, saas); to scaffold the coding-agent hero template inside an AF Stack checkout, run `af-stack init --template coding-agent` without a project name", *template) } + // Every scaffold carries its own backend: the compose stack that boots + // BackAI from the release images matching this CLI. `af-stack dev` (and + // `npm start`, through its prestart hook) runs it in place. + for rel, contents := range starter.BackendFiles(buildinfo.Version) { + files[rel] = contents + } + projectSlug := slugify(name) target := filepath.Join(*dir, projectSlug) @@ -85,20 +94,19 @@ func runScaffold(args []string, stdout, stderr io.Writer) error { "files": written, } return output.Result(stdout, *asJSON, machine, func(w io.Writer) error { - fmt.Fprintf(w, "Created %s — a new %s app on the AF Stack backend (%d files).\n\n", target, tmpl, len(written)) - fmt.Fprintln(w, "Next steps:") + fmt.Fprintf(w, "Created %s — a new %s app with its own BackAI backend (%d files).\n\n", target, tmpl, len(written)) + fmt.Fprintln(w, "Next steps (Docker must be running):") fmt.Fprintf(w, " cd %s\n", target) if tmpl == "saas" { - fmt.Fprintln(w, " cp .env.example .env # set VITE_AF_STACK_URL") - fmt.Fprintln(w, " npm install && npm run dev") - fmt.Fprintln(w, " af-stack test # run the fork gates") + fmt.Fprintln(w, " npm install && npm run dev # boots the bundled backend, then the app on :34000") + fmt.Fprintln(w, " af-stack test # run the fork gates") } else { - fmt.Fprintln(w, " cp .env.example .env # set AF_STACK_URL to the \"API runtime\" URL af-stack dev prints") - fmt.Fprintln(w, " npm install && npm start") + fmt.Fprintln(w, " npm install && npm start # boots the bundled backend, then runs src/index.mjs") } fmt.Fprintln(w, "") - fmt.Fprintln(w, "No backend yet? Start one from your BackAI clone with: af-stack dev") - fmt.Fprintln(w, "(it prints the API runtime URL; when :8080 is busy it picks another port)") + fmt.Fprintln(w, "The backend is docker-compose.yml in that directory: `af-stack dev` starts it") + fmt.Fprintln(w, "(that is what the npm hook runs), `docker compose down` stops it. Ports are") + fmt.Fprintln(w, "allocated into .env when the defaults (8080, 8081, 5432, ...) are busy.") return nil }) } @@ -143,7 +151,10 @@ func nodeTemplate(displayName, slug string) map[string]string { "type": "module", "description": "An app built on the AF Stack backend.", "scripts": { - "start": "node src/index.mjs" + "prestart": "af-stack dev", + "start": "node src/index.mjs", + "backend": "af-stack dev", + "backend:stop": "docker compose down" } } ` @@ -197,10 +208,10 @@ function fail(what, detail) { console.error("\n" + what); if (detail) console.error("Details: " + detail); console.error(` + "`" + ` -Start a backend from your BackAI clone with 'af-stack dev'. It prints the -runtime's URL as "API runtime" — when :8080 is busy it picks another port — -so put that URL in .env: AF_STACK_URL=http://localhost: -(and AF_STACK_API_KEY if auth is on).` + "`" + `); +This app carries its own backend (docker-compose.yml). Start it with +'af-stack dev' in this directory — 'npm start' does that first — and it +writes the runtime's URL into .env as AF_STACK_URL (another port than 8080 +when 8080 is busy). Docker must be running.` + "`" + `); process.exit(1); } @@ -229,9 +240,23 @@ async function main() { console.log("Talking to BackAI at " + BASE_URL); await checkRuntime(); try { - // The simplest call that proves the wiring: list available agents. - const agents = await api("/agents"); - console.log("Available agents:", agents); + // The simplest call that proves the wiring: list the registered agents. + const listing = await api("/agents"); + const agents = Array.isArray(listing) ? listing : listing?.agents ?? []; + console.log("Registered agents:", agents.map((a) => a.node_id ?? a).join(", ") || "(none yet)"); + + // Call the bundled demo agent's no-key reasoner: it echoes its input back + // through the gateway -> AgentField -> agent round trip. + if (agents.some((a) => (a.node_id ?? a) === "supportdesk")) { + const reply = await api("/agents/supportdesk.echo", { + method: "POST", + body: { input: { payload: { message: "hello from " + BASE_URL } } }, + }); + const echoed = reply?.result?.echoed ?? reply?.output?.echoed ?? reply?.echoed ?? reply; + console.log("Echo agent replied: " + JSON.stringify(echoed)); + } else { + console.log("The supportdesk agent has not registered yet; run again in a few seconds."); + } // Example: ask the OpenAI-compatible LLM gateway for a one-liner. // const reply = await api("/llm/chat/completions", { method: "POST", body: { @@ -251,11 +276,18 @@ async function main() { main(); ` - env := `# BackAI runtime base URL: the "API runtime" URL that ` + "`af-stack dev`" + ` prints. -# The default is 8080, but af-stack dev picks another port when 8080 is busy. + env := `# BackAI runtime base URL. ` + "`af-stack dev`" + ` (run by ` + "`npm start`" + `) boots the bundled +# backend and writes the real value here — another port than 8080 when 8080 +# is busy — so you normally never edit this line. AF_STACK_URL=http://localhost:8080 # Bearer token — required when the runtime has auth enabled AF_STACK_API_KEY= + +# The bundled backend (docker-compose.yml) reads this file too: +# AF_STACK_VERSION= run another BackAI release +# OPENROUTER_API_KEY=... (or OPENAI/ANTHROPIC/...) turns demo mode off +# AF_STACK_MODE=personal no login, no paywall +# AF_STACK_PORT=..., AGENTFIELD_PORT=..., POSTGRES_PORT=... host ports ` gitignore := `node_modules/ @@ -270,16 +302,29 @@ first-class primitive. ## Quickstart -1. Start a backend from your BackAI clone: ` + "`af-stack dev`" + `. Note the URL it - prints as **API runtime** (8080 by default; another port if 8080 was busy). -2. Configure this app: ` + "`cp .env.example .env`" + `, set ` + "`AF_STACK_URL`" + ` to that URL - and, if auth is on, ` + "`AF_STACK_API_KEY`" + `. ` + "`src/index.mjs`" + ` reads ` + "`.env`" + ` itself. -3. Run it: ` + "`npm install && npm start`" + ` +Docker (with Compose) must be running. Then: + +` + fence + `sh +npm install && npm start +` + fence + ` + +` + "`npm start`" + ` first runs ` + "`af-stack dev`" + `, which boots the bundled backend in +` + "`docker-compose.yml`" + ` — Postgres, MinIO, LiteLLM, the AgentField control plane, +the BackAI runtime, the operator dashboard, and the ` + "`supportdesk`" + ` demo agent — +waits for the runtime, and writes its URL into ` + "`.env`" + ` as ` + "`AF_STACK_URL`" + `. +Then ` + "`src/index.mjs`" + ` lists the registered agents and calls ` + "`supportdesk.echo`" + `. +The first run pulls the images; later runs are a no-op when the backend is up. + +- Operator dashboard: http://localhost:33000 (` + "`operator@af-stack.local`" + ` / ` + "`changeme123`" + `) +- API: http://localhost:8080/api/v1 (` + "`af-stack dev`" + ` picks other ports when these are busy and records them in ` + "`.env`" + `) +- Stop: ` + "`npm run backend:stop`" + ` (` + "`docker compose down`" + `; add ` + "`-v`" + ` to drop the data) +- Live model calls: set ` + "`OPENROUTER_API_KEY`" + ` (or another provider key) in ` + "`.env`" + ` and restart ## What's here - ` + "`src/index.mjs`" + ` — talks to the backend with the built-in ` + "`fetch`" + ` (zero deps). -- ` + "`.env.example`" + ` — the two env vars the app needs. +- ` + "`docker-compose.yml`" + ` + ` + "`backend/`" + ` — the bundled backend, pinned to the BackAI release that scaffolded this app. +- ` + "`.env.example`" + ` — the app's env vars; the backend reads the same file. ## Upgrade to the typed SDK @@ -299,7 +344,10 @@ const reply = await suite.llm.chat({ claudeMd := "# " + displayName + ` — an app on the AF Stack backend This project was scaffolded by ` + "`af-stack init " + slug + "`" + `. It CONSUMES an -AF Stack backend over HTTP — it is not a fork of the stack itself. +AF Stack backend over HTTP — it is not a fork of the stack itself. The backend +it consumes is bundled: ` + "`docker-compose.yml`" + ` boots BackAI from the release +images, ` + "`af-stack dev`" + ` (run by ` + "`npm start`" + `) starts it and writes its URL +into ` + "`.env`" + `, ` + "`docker compose down`" + ` stops it. ## How to talk to the backend - One base URL (` + "`AF_STACK_URL`" + `), everything under ` + "`/api/v1`" + `, bearer auth @@ -326,7 +374,7 @@ Plans and Stripe keys are configured by the operator in the dashboard ## Ground rules - The backend owns auth, tenancy, billing, and secrets — call it, don't reimplement it here. -- No backend running? Start one from an AF Stack checkout with ` + "`af-stack dev`" + `. +- Backend not running? ` + "`af-stack dev`" + ` in this directory (needs Docker). - Keep real credentials in ` + "`.env`" + ` (gitignored), never in code. ` diff --git a/services/cli/internal/initcmd/scaffold_node_test.go b/services/cli/internal/initcmd/scaffold_node_test.go index fdd66123..b5a586e5 100644 --- a/services/cli/internal/initcmd/scaffold_node_test.go +++ b/services/cli/internal/initcmd/scaffold_node_test.go @@ -2,6 +2,7 @@ package initcmd import ( "bytes" + "encoding/json" "net" "net/http" "net/http/httptest" @@ -56,7 +57,20 @@ func backaiRuntime(t *testing.T) *httptest.Server { }) mux.HandleFunc("/api/v1/agents", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"node_id":"supportdesk"}]`)) + _, _ = w.Write([]byte(`{"agents":[{"node_id":"supportdesk","reasoners":["echo"]}]}`)) + }) + mux.HandleFunc("POST /api/v1/agents/supportdesk.echo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var body struct { + Input struct { + Payload map[string]any `json:"payload"` + } `json:"input"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", "status": "completed", + "result": map[string]any{"echoed": body.Input.Payload}, + }) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) @@ -68,9 +82,13 @@ func TestNodeStarterTalksToRuntime(t *testing.T) { dir := scaffoldNodeStarter(t) srv := backaiRuntime(t) out, errOut, code := runStarter(t, dir, "AF_STACK_URL="+srv.URL) - if code != 0 || !strings.Contains(out, "supportdesk") { + if code != 0 || !strings.Contains(out, "Registered agents: supportdesk") { t.Fatalf("code=%d\nstdout:\n%s\nstderr:\n%s", code, out, errOut) } + // The demo call round-trips through the gateway and comes back echoed. + if !strings.Contains(out, `Echo agent replied: {"message":"hello from `+srv.URL+`"}`) { + t.Fatalf("starter did not call supportdesk.echo:\n%s", out) + } } // Contract: `cp .env.example .env` then editing AF_STACK_URL must actually @@ -114,7 +132,7 @@ func TestNodeStarterExplainsForeignServer(t *testing.T) { if code != 1 { t.Fatalf("expected exit 1, got %d\n%s", code, errOut) } - for _, want := range []string{"not a BackAI runtime", "API runtime", "AF_STACK_URL=http://localhost:"} { + for _, want := range []string{"not a BackAI runtime", "af-stack dev", "AF_STACK_URL"} { if !strings.Contains(errOut, want) { t.Errorf("stderr missing %q:\n%s", want, errOut) } diff --git a/services/cli/internal/initcmd/scaffold_saas.go b/services/cli/internal/initcmd/scaffold_saas.go index b3a8b0fa..796f50b3 100644 --- a/services/cli/internal/initcmd/scaffold_saas.go +++ b/services/cli/internal/initcmd/scaffold_saas.go @@ -60,7 +60,10 @@ func saasPackageJSON(slug string) string { "type": "module", "description": "A SaaS app on the BackAI backend (customer app + notes module + agent).", "scripts": { + "predev": "af-stack dev", "dev": "vite", + "backend": "af-stack dev", + "backend:stop": "docker compose down", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", @@ -172,10 +175,17 @@ const saasTSConfigNode = `{ } ` -const saasEnvExample = `# BackAI runtime base URL the app proxies /api/v1 to. +const saasEnvExample = `# BackAI runtime base URL the app proxies /api/v1 to. ` + "`af-stack dev`" + ` (run by +# ` + "`npm run dev`" + `) boots the bundled backend and writes the real value here. VITE_AF_STACK_URL=http://localhost:8080 # Dev server port for the customer app. PORT=34000 + +# The bundled backend (docker-compose.yml) reads this file too: +# AF_STACK_VERSION= run another BackAI release +# OPENROUTER_API_KEY=... (or OPENAI/ANTHROPIC/...) turns demo mode off +# AF_STACK_MODE=personal no login, no paywall +# AF_STACK_PORT=..., AGENTFIELD_PORT=..., POSTGRES_PORT=... host ports ` const saasGitignore = `node_modules/ @@ -750,18 +760,21 @@ with auth, tenancy, and billing owned by the platform. ## Quickstart -1. Boot a backend from your BackAI checkout: ` + "`af-stack dev`" + ` -2. Configure this app: ` + "`cp .env.example .env`" + ` and set - ` + "`VITE_AF_STACK_URL`" + `. -3. Run it: +Docker (with Compose) must be running. Then: ` + f + `sh npm install -npm run dev # customer app on http://localhost:34000 +npm run dev # boots the bundled backend, then the customer app on http://localhost:34000 npm test # vitest: the API-client contract npm run typecheck # tsc --noEmit ` + f + ` +` + "`npm run dev`" + ` first runs ` + "`af-stack dev`" + `, which boots the backend in +` + "`docker-compose.yml`" + ` (Postgres, MinIO, LiteLLM, AgentField, the BackAI +runtime, the operator dashboard on http://localhost:33000, and the +` + "`supportdesk`" + ` demo agent), waits for it, and writes its URL into ` + "`.env`" + ` +as ` + "`VITE_AF_STACK_URL`" + `. Stop it with ` + "`npm run backend:stop`" + `. + ## Layout - ` + "`src/`" + ` — the customer app (` + "`api/client.ts`" + ` is the auth-aware, diff --git a/services/cli/internal/initcmd/scaffold_saas_test.go b/services/cli/internal/initcmd/scaffold_saas_test.go index bccd850d..8234d69c 100644 --- a/services/cli/internal/initcmd/scaffold_saas_test.go +++ b/services/cli/internal/initcmd/scaffold_saas_test.go @@ -42,6 +42,9 @@ var saasExpectedFiles = []string{ "agents/notes-assistant/requirements.txt", "agents/notes-assistant/Dockerfile", "docker-compose.reference.yml", + "docker-compose.yml", + "backend/postgres-init.sh", + "backend/litellm-config.yaml", "capabilities.json", "README.md", "AGENTS.md", diff --git a/services/cli/internal/initcmd/scaffold_test.go b/services/cli/internal/initcmd/scaffold_test.go index d6632fda..97044ebe 100644 --- a/services/cli/internal/initcmd/scaffold_test.go +++ b/services/cli/internal/initcmd/scaffold_test.go @@ -26,7 +26,8 @@ func TestScaffold_CreatesConsumingProject(t *testing.T) { } proj := filepath.Join(root, "docu-chat") - for _, rel := range []string{"package.json", "src/index.mjs", ".env.example", ".gitignore", "README.md", "CLAUDE.md"} { + for _, rel := range []string{"package.json", "src/index.mjs", ".env.example", ".gitignore", "README.md", "CLAUDE.md", + "docker-compose.yml", "backend/postgres-init.sh", "backend/litellm-config.yaml"} { if _, err := os.Stat(filepath.Join(proj, filepath.FromSlash(rel))); err != nil { t.Fatalf("expected scaffold file %s: %v", rel, err) } @@ -45,6 +46,20 @@ func TestScaffold_CreatesConsumingProject(t *testing.T) { if _, hasDeps := pkg["dependencies"]; hasDeps { t.Fatalf("starter must have zero runtime dependencies, got: %s", raw) } + // `npm start` must boot the bundled backend first: the scaffold is + // self-contained, no separately started control plane. + scripts, _ := pkg["scripts"].(map[string]any) + if scripts["prestart"] != "af-stack dev" || scripts["backend:stop"] != "docker compose down" { + t.Fatalf("package.json scripts must boot/stop the bundled backend, got: %v", scripts) + } + // The compose stack is pinned to release images (a dev build pins :latest). + compose := read(t, proj, "docker-compose.yml") + if !strings.Contains(compose, "ghcr.io/agent-field/af-stack-runtime:${AF_STACK_VERSION:-latest}") { + t.Fatalf("scaffolded compose is not pinned to the release runtime image:\n%s", compose) + } + if !strings.Contains(stdout.String(), "npm install && npm start") || strings.Contains(stdout.String(), "from your BackAI clone") { + t.Fatalf("next steps must be the self-contained path:\n%s", stdout.String()) + } // The starter consumes the backend abstraction (single base URL + /api/v1), // not per-service URLs. From a1e89a341f2d53ebb981f88e6acc4c02b76558c3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 14:16:26 -0400 Subject: [PATCH 4/5] ci(release): publish the supportdesk-agent image and smoke the scaffolded app The images matrix gains af-stack-supportdesk-agent (its Dockerfile needs its own directory as context). A second smoke builds the CLI at the release version, scaffolds an app, runs npm start, and refuses to publish unless the echo reply comes back. Before it, an anonymous manifest fetch asserts each image is pullable with no registry login, because GHCR packages are private by default and a scaffold from a released CLI pulls exactly that way; the runner logs out of GHCR so the smoke pulls like a user. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .github/workflows/release.yml | 99 ++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 419f4032..0ed1cd83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,13 @@ name: Release # Postgres so a broken build can't ship, then cut the GitHub Release with # cross-compiled CLI binaries and move the `latest` image tag. # +# The image set is runtime, dashboard, customer-app and the bundled +# supportdesk-agent — the last one is what `af-stack init` wires into a +# scaffolded app's own docker-compose.yml. +# The smoke job also scaffolds an app with the freshly built CLI and runs +# `npm start` in it, proving the published images boot a bundled backend the +# app can actually talk to before anything is tagged. +# # Manual runs are supported via workflow_dispatch (e.g. to re-cut after a # transient failure). Use dry_run to compute the version without publishing. on: @@ -112,10 +119,18 @@ jobs: include: - name: runtime dockerfile: services/runtime/Dockerfile + context: . - name: dashboard dockerfile: apps/dashboard/Dockerfile + context: . - name: customer-app dockerfile: apps/customer-app/Dockerfile + context: . + # The agent image's Dockerfile does `COPY requirements.txt ./`, so it + # needs its own directory as the build context, not the repo root. + - name: supportdesk-agent + dockerfile: apps/backend/agents/supportdesk/Dockerfile + context: apps/backend/agents/supportdesk steps: - uses: actions/checkout@v7 with: @@ -129,7 +144,7 @@ jobs: - name: Build and push ${{ matrix.name }} uses: docker/build-push-action@v6 with: - context: . + context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true tags: ghcr.io/agent-field/af-stack-${{ matrix.name }}:${{ needs.prepare.outputs.version }} @@ -175,6 +190,86 @@ jobs: exit 1 fi curl -fsS http://localhost:8080/health >/dev/null && echo "health OK" + # Free host port 8080 before the scaffold smoke. `af-stack dev` would + # auto-allocate around a busy port, but the scaffolded app is supposed to + # come up on the default one — so prove that path, don't fall back to it. + - name: Tear down the runtime smoke + run: docker compose -f docker-compose.release-smoke.yml down -v || true + + # Second smoke: the scaffolded-app path, end to end, against the images + # this release just pushed. `af-stack init` writes a docker-compose.yml + # pinned to ${{ needs.prepare.outputs.version }}, `npm start` runs + # `af-stack dev` via its prestart hook and then calls supportdesk.echo. + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Put the CLI build dir on PATH + # `npm start`'s prestart hook shells out to `af-stack` by name. + run: echo /tmp >> "$GITHUB_PATH" + - name: Build the CLI pinned to the release version + env: + AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + go build -ldflags "-X main.version=${AF_STACK_VERSION}" \ + -o /tmp/af-stack ./services/cli/cmd/af-stack + /tmp/af-stack version || true + - name: Assert the release images are publicly pullable + # `af-stack init` apps pull these with NO registry login, the way any + # user does. GHCR creates packages private by default, so a release + # whose images are private ships a CLI whose scaffolds cannot boot. + # Fix: github.com/orgs/Agent-Field/packages → the package → Package + # settings → Change visibility → Public, then re-run this job. + env: + AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + private="" + for svc in runtime dashboard customer-app supportdesk-agent; do + token="$(curl -fsS "https://ghcr.io/token?scope=repository:agent-field/af-stack-$svc:pull" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')" + code="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' "https://ghcr.io/v2/agent-field/af-stack-$svc/manifests/$AF_STACK_VERSION")" + echo "ghcr.io/agent-field/af-stack-$svc:$AF_STACK_VERSION anonymous pull: HTTP $code" + [ "$code" = "200" ] || private="$private af-stack-$svc" + done + if [ -n "$private" ]; then + echo "::error::these GHCR packages are not publicly pullable:$private — make each one Public under the org's package settings and re-run this job; until then apps from \`af-stack init\` cannot boot their bundled backend" + exit 1 + fi + - name: Log out of GHCR so the scaffold pulls anonymously, like a user + run: docker logout ghcr.io || true + - name: Scaffold a standalone app + run: | + set -euo pipefail + rm -rf /tmp/smoke-app + /tmp/af-stack init smoke-app --dir /tmp + echo "--- generated docker-compose.yml ---" + cat /tmp/smoke-app/docker-compose.yml + - name: npm start — scaffolded app boots its bundled backend + # The first `docker compose up -d` pulls ~6 images. + timeout-minutes: 15 + run: | + set -euo pipefail + export PATH=/tmp:$PATH + cd /tmp/smoke-app + # No dependencies — this just validates the generated package.json. + npm install + status=0 + npm start 2>&1 | tee /tmp/smoke-start.log || status=$? + if [ "$status" -ne 0 ] || ! grep -q '^Echo agent replied:' /tmp/smoke-start.log; then + echo "::error::the scaffolded app could not talk to its bundled backend — refusing to publish the release" + for svc in runtime agentfield supportdesk-agent; do + echo "--- docker compose logs $svc ---" + docker compose logs --tail 80 "$svc" || true + done + exit 1 + fi + echo "scaffolded app talked to its bundled backend OK" + - name: Tear down the scaffolded app + if: always() + run: docker compose -f /tmp/smoke-app/docker-compose.yml --project-directory /tmp/smoke-app down -v || true - name: Tear down if: always() run: docker compose -f docker-compose.release-smoke.yml down -v || true @@ -220,7 +315,7 @@ jobs: if: needs.prepare.outputs.prerelease != 'true' run: | set -euo pipefail - for svc in runtime dashboard customer-app; do + for svc in runtime dashboard customer-app supportdesk-agent; do docker buildx imagetools create \ --tag "ghcr.io/agent-field/af-stack-$svc:latest" \ "ghcr.io/agent-field/af-stack-$svc:${{ needs.prepare.outputs.version }}" From 19e9b9cc9ea562086718abe9af4e5c0867d7cdab Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 14:16:26 -0400 Subject: [PATCH 5/5] docs: af-stack init is a complete app with a bundled backend README, the dx hub and run.md, cli-distribution, cli-admin, and the skill describe the two init forms as they now behave: the positional form scaffolds an app that boots BackAI from the release images (no clone; Docker required), the flag form brands a fork inside a clone. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- README.md | 16 +++++++++---- docs/cli-admin.md | 4 ++++ docs/cli-distribution.md | 11 ++++++++- docs/dx/README.md | 6 +++-- docs/dx/run.md | 49 ++++++++++++++++++++++++++++++++++++---- skills/af-stack/SKILL.md | 24 +++++++++++++------- 6 files changed, 90 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index abbaba0b..1b41fdbc 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ BackAI is currently in beta and under active development. Expect rapid improveme ## Quickstart -Prerequisite: Docker with Compose. Node 18+ is optional: `af-stack dev` -uses it to auto-allocate conflict-free ports and falls back to the -defaults without it. +Prerequisite: Docker with Compose. Node 18+ is optional in this checkout: +`af-stack dev` uses it to auto-allocate conflict-free ports and falls back +to the defaults without it. ```bash git clone https://github.com/Agent-Field/backai.git @@ -139,8 +139,9 @@ approvals, sandbox limits, and audit records keep build and live operations inside explicit boundaries. ```bash -# A standalone app that calls a running BackAI. Works in any directory. -af-stack init my-ai-product +# A complete app with its own bundled backend. Works in any directory; needs Docker. +af-stack init my-ai-product && cd my-ai-product +npm install && npm start # boots the backend, then talks to it # Or brand a full fork and hand it to your coding agent. These run inside # a clone of this repo; that clone is where the four surfaces below live. @@ -150,6 +151,11 @@ af-stack init --name "Acme AI" --color "#2563EB" af-stack agent new researcher ``` +The first path needs no clone: the scaffold carries a `docker-compose.yml` +that boots Postgres, the LLM gateway, the runtime, the operator dashboard, +and a demo agent from the published release images, pinned to the CLI's +version. + Inside a fork, builders and agents usually edit only four surfaces: | Surface | Path | Purpose | diff --git a/docs/cli-admin.md b/docs/cli-admin.md index a7c782eb..dd020ed3 100644 --- a/docs/cli-admin.md +++ b/docs/cli-admin.md @@ -26,6 +26,10 @@ Postgres directly need: Requests go to `${AF_STACK_URL}/api/v1` with `Authorization: Bearer ${AF_STACK_API_KEY}`. +In an app scaffolded by `af-stack init `, `af-stack dev` writes the +resolved `AF_STACK_URL` into that app's `.env` — read it from there when the +default port was busy. + ### Minting an operator key Operator keys are minted directly against the database, so **both** bootstrap diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 857fada1..b79cb338 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -63,6 +63,11 @@ docker compose up Four commands. Works on any machine with git + docker. Browser opens dashboard, the dev is "in." +With the CLI installed there is a no-clone route to the same backend: +`af-stack init ` scaffolds an app that carries its own +`docker-compose.yml`, and `npm start` boots it from the published release +images. + ### Install CLI for power features One line — the install script from @@ -106,10 +111,14 @@ Every command below exists in the current binary (see [`services/cli/cmd/af-stack/main.go`](../services/cli/cmd/af-stack/main.go)). ```bash +# Standalone app + its own bundled backend (any directory, no clone) +af-stack init my-ai-product # app + docker-compose.yml + backend/ +cd my-ai-product && npm install && npm start # boots the backend, then the app + # Fork bootstrap + dev loop (run inside a clone of this repo) af-stack init --name "DocuChat" --color "#0A66C2" # optional: --logo ./your-logo.svg sets the light+dark mark in brand.yaml -af-stack dev --detach +af-stack dev --detach # in a scaffolded app, af-stack dev boots its bundled backend af-stack mode personal|saas # auth+billing off ⇄ multi-tenant SaaS af-stack upgrade [--check] # pull latest upstream into this fork diff --git a/docs/dx/README.md b/docs/dx/README.md index 2a3a0047..fb1f5e6f 100644 --- a/docs/dx/README.md +++ b/docs/dx/README.md @@ -16,8 +16,10 @@ af-stack deploy helm # ship it (helm | fly | railway | render) Four commands, one loop: **init → dev → edit → deploy**, all inside the clone. `af-stack init ` with a positional name is a different thing: -it scaffolds a small standalone app that *calls* a running BackAI, in any -directory, and has no surfaces to brand or extend. See +it scaffolds an app that *carries its own backend* — a `docker-compose.yml` +and a `backend/` directory pinned to the CLI's version — in any directory, +with no clone and no surfaces to brand or extend. `af-stack dev` inside that +app boots the backend from the published release images. See [run.md](run.md) for what `af-stack dev` actually brings up and [build-app.md](build-app.md) for the surfaces you edit. diff --git a/docs/dx/run.md b/docs/dx/run.md index 31589af8..6d8e493f 100644 --- a/docs/dx/run.md +++ b/docs/dx/run.md @@ -8,10 +8,7 @@ af-stack dev ``` From inside the clone, that's the whole thing — see the -[golden path](README.md) for the `git clone` line. Run it anywhere else and -it exits 1 with `must run from inside a BackAI checkout — a clone of -https://github.com/Agent-Field/backai …`, followed by the clone command and -the standalone-app alternative. `af-stack dev`: +[golden path](README.md) for the `git clone` line. `af-stack dev`: 1. Runs a **port preflight** (`scripts/preflight.mjs --fix`) — finds a free host port for each service, writes the overrides into `.env`, and @@ -25,6 +22,11 @@ the standalone-app alternative. `af-stack dev`: Prefer raw compose? `docker compose up` works too — but then you own port conflicts yourself. +`af-stack dev` also runs inside an app scaffolded by +`af-stack init ` — that app brings its own backend, so no clone is +involved. See [In a scaffolded app](#in-a-scaffolded-app) below. Anywhere +else (no checkout, no scaffold) it exits 1 and prints both ways in. + ## Local URLs After `af-stack dev`, the default endpoint map: @@ -45,6 +47,45 @@ next free one and records the override in `.env` (e.g. `AGENTFIELD_PORT`, `MINIO_CONSOLE_PORT`) — so read `.env` / the printed map if a URL above doesn't respond. +## In a scaffolded app + +`af-stack init ` writes an app that carries its own backend: a +`docker-compose.yml` plus a `backend/` directory +(`backend/postgres-init.sh`, `backend/litellm-config.yaml`). The compose +file pulls the published BackAI release images, pinned to the version of +the CLI that scaffolded it — Postgres (pgvector), MinIO, LiteLLM, the +AgentField control plane, the runtime, the operator dashboard, and the +`supportdesk` demo agent with its no-key `echo` reasoner. Docker with +Compose is the only prerequisite (plus Node 18+ for the app itself). + +Run `af-stack dev` from inside that app and it: + +1. Allocates conflict-free host ports, writing `AF_STACK_PORT`, + `AGENTFIELD_PORT`, `POSTGRES_PORT` and friends into the app's `.env` + when the defaults (8080 / 8081 / 5432 / …) are busy. +2. Runs `docker compose up -d` and waits for the runtime's `/ready`. +3. Writes `AF_STACK_URL=http://localhost:` into `.env` and prints the + URLs — API runtime, operator dashboard + (`http://localhost:33000`, `operator@af-stack.local` / `changeme123`), + AgentField UI. + +It is **detached**: it returns once the backend is ready. The first run +pulls the images, so give it a minute. + +The scaffold's `package.json` wires that up for you: + +| Command | Does | +| --- | --- | +| `npm start` | `prestart` runs `af-stack dev` (a no-op when the backend is already up), then the app lists the registered agents and calls `supportdesk.echo` | +| `npm run backend` | `af-stack dev` on its own | +| `npm run backend:stop` | `docker compose down` (add `-v` to drop the data volumes too) | + +`af-stack init --template saas` (a Vite/React starter) gets the same +bundled backend; there `npm run dev` boots it via a `predev` hook. + +There is no customer app in the scaffolded backend — the app you scaffolded +is the customer app. + ## `.env` `af-stack dev` reads and writes `.env`. Start from `.env.example`. Preflight diff --git a/skills/af-stack/SKILL.md b/skills/af-stack/SKILL.md index ff5f6f97..20205788 100644 --- a/skills/af-stack/SKILL.md +++ b/skills/af-stack/SKILL.md @@ -38,9 +38,15 @@ required in practice: without the flag init prompts on stdin, and in a non-interactive shell that read hits EOF and the command fails with `init: --name is required` — so always pass it. Everything after is editing the four surfaces. Prefer these commands over hand-copying files. -`af-stack init ` with a positional name is different: it scaffolds a -small standalone app that calls a running BackAI, in any directory, with no -surfaces to brand or extend. +`af-stack init ` with a positional name is different: it scaffolds an +app that **carries its own backend** — a `docker-compose.yml` plus a +`backend/` directory that boot the published BackAI release images, pinned +to the CLI's version. It works in any directory, needs no clone (only +Docker, plus Node 18+ for the app), and has no surfaces to brand or extend. +`npm start` runs `af-stack dev` first (a no-op when the backend is already +up), then the app, which lists the registered agents and calls +`supportdesk.echo`. `af-stack agent|module|plugin new` and `deploy` still +need a clone. ## Read these first @@ -228,10 +234,11 @@ Follow this sequence. Don't skip steps. `git clone https://github.com/Agent-Field/backai && cd `, then `af-stack init --name "" --template `. (`af-stack init ` with a positional name is a different command: it - scaffolds a small standalone `node`/`saas` app that calls a running BackAI - and has none of the four surfaces.) To add a surface to an EXISTING - checkout, use `af-stack agent|module|plugin new`, or copy the matching - template from `snippets/`: + scaffolds a `node`/`saas` app that ships its own backend — `af-stack dev` + inside it boots the release images, no clone — and has none of the four + surfaces.) To add a surface to an EXISTING checkout, use + `af-stack agent|module|plugin new`, or copy the matching template from + `snippets/`: - New agent → `snippets/agent.py` - New workload module (Python sidecar) → `snippets/workload-module/` - New dashboard plugin → `snippets/dashboard-plugin/` @@ -243,7 +250,8 @@ Follow this sequence. Don't skip steps. 5. **Wire with SDK only.** Connect surfaces using `suite.*` (runtime handlers, dashboard, customer-app) or `app.*` (inside agents). Never reach the DB / LiteLLM / AgentField directly from outside its layer. -6. **Run locally.** `af-stack dev` brings up the whole stack (falls back to +6. **Run locally.** `af-stack dev` brings up the whole stack — from source + in a checkout, from the release images in a scaffolded app (falls back to `docker compose up` in a raw fork). Use the runtime's `/api/v1/openapi.json` to verify your routes are registered. 7. **Deploy.** `deploy/` has Helm / Fly / Railway / Render / compose — pick a