diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 419f403..0ed1cd8 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 }}" diff --git a/README.md b/README.md index abbaba0..1b41fdb 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 a7c782e..dd020ed 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 857fada..b79cb33 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 2a3a004..fb1f5e6 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 31589af..6d8e493 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/services/cli/cmd/af-stack/main.go b/services/cli/cmd/af-stack/main.go index 2e9d52d..7d1e983 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 0000000..898bf42 --- /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/checkout/checkout.go b/services/cli/internal/checkout/checkout.go index 6316b7c..566a4db 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/initcmd/scaffold.go b/services/cli/internal/initcmd/scaffold.go index 348847f..bbcfa51 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 fdd6612..b5a586e 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 b3a8b0f..796f50b 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 bccd850..8234d69 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 d6632fd..97044eb 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. diff --git a/services/cli/internal/project/appdev.go b/services/cli/internal/project/appdev.go new file mode 100644 index 0000000..b1bfbdd --- /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 0000000..0042cb9 --- /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 9a57822..cca52d0 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 } 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 0000000..58971ca --- /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 0000000..76d9b07 --- /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 0000000..dd9fdfd --- /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 0000000..333b1d2 --- /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 0000000..290b94e --- /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 +} diff --git a/skills/af-stack/SKILL.md b/skills/af-stack/SKILL.md index ff5f6f9..2020578 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