From ae2187d9d1b12d131f3493a41ef7f3780ec8b0a9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:16:50 -0400 Subject: [PATCH 1/2] fix(init): make the node starter find the runtime and say why it can't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The starter `af-stack init ` writes assumed the runtime was at localhost:8080 and, on any failure, printed "Could not reach the backend" plus a raw response body. Two things made that wrong in practice: `af-stack dev` moves the API off :8080 whenever the port is busy (the reporter's :8080 was an AgentField control plane, whose 404 body the starter dumped verbatim), and `npm start` ran plain `node src/index.mjs`, which never read .env — so the documented `cp .env.example .env` and "set AF_STACK_URL" did nothing. The starter now loads .env itself (no dependency; real env wins), tolerates a pasted ".../api/v1" suffix, and probes /health before the first call so it can tell the three cases apart: nothing listening, something listening that is not a BackAI runtime, and an auth rejection — each with the fix, including where the real URL comes from ("API runtime" in af-stack dev's output). The scaffold's next steps, README, and .env.example say the same. Tests drive the real file with node against fake backends for each case. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- services/cli/internal/initcmd/scaffold.go | 82 +++++++-- .../internal/initcmd/scaffold_node_test.go | 163 ++++++++++++++++++ 2 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 services/cli/internal/initcmd/scaffold_node_test.go diff --git a/services/cli/internal/initcmd/scaffold.go b/services/cli/internal/initcmd/scaffold.go index e38859a..1fc12cd 100644 --- a/services/cli/internal/initcmd/scaffold.go +++ b/services/cli/internal/initcmd/scaffold.go @@ -93,11 +93,12 @@ func runScaffold(args []string, stdout, stderr io.Writer) error { fmt.Fprintln(w, " npm install && npm run dev") fmt.Fprintln(w, " af-stack test # run the fork gates") } else { - fmt.Fprintln(w, " cp .env.example .env # set AF_STACK_URL / AF_STACK_API_KEY") + 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, "") - fmt.Fprintln(w, "No backend yet? Start one from your AF Stack checkout with: af-stack dev") + 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)") return nil }) } @@ -155,7 +156,24 @@ func nodeTemplate(displayName, slug string) map[string]string { // dependencies. For a typed client, install @af-stack/sdk and swap the api() // helper for ` + "`suite.agents.call(...)`" + `, ` + "`suite.llm.chat(...)`" + `, etc. -const BASE_URL = process.env.AF_STACK_URL ?? "http://localhost:8080"; +import { readFileSync } from "node:fs"; + +// Load ./.env (create it with ` + "`cp .env.example .env`" + `) without a dependency. +// Real environment variables win over the file. +try { + for (const line of readFileSync(new URL("../.env", import.meta.url), "utf8").split(/\r?\n/)) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); + if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, "$2"); + } +} catch { + // no .env yet — defaults below apply +} + +// The runtime's base URL: what ` + "`af-stack dev`" + ` prints as "API runtime". A pasted +// ".../api/v1" suffix is tolerated. +const BASE_URL = (process.env.AF_STACK_URL ?? "http://localhost:8080") + .replace(/\/+$/, "") + .replace(/\/api\/v1$/, ""); const API_KEY = process.env.AF_STACK_API_KEY ?? ""; async function api(path, { method = "GET", body } = {}) { @@ -168,13 +186,48 @@ async function api(path, { method = "GET", body } = {}) { body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { - throw new Error(method + " " + path + " -> " + res.status + " " + (await res.text())); + const err = new Error(method + " " + path + " -> " + res.status + " " + (await res.text())); + err.status = res.status; + throw err; } return res.status === 204 ? null : res.json(); } +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).` + "`" + `); + process.exit(1); +} + +// Is there a BackAI runtime at BASE_URL? Its /health answers {"status":"alive"}. +// Anything else on that port (an AgentField control plane, another dev server) +// answers differently, and that is the usual failure when :8080 was busy. +async function checkRuntime() { + let res; + try { + res = await fetch(BASE_URL + "/health"); + } catch (err) { + const cause = err.cause; + fail("Nothing is listening at " + BASE_URL + ".", + cause?.code ?? cause?.errors?.[0]?.code ?? cause?.message ?? err.message); + } + const text = await res.text(); + let body = null; + try { body = JSON.parse(text); } catch { /* not JSON */ } + if (!res.ok || !body || (body.status !== "alive" && body.status !== "ready")) { + fail("Something is listening at " + BASE_URL + ", but it is not a BackAI runtime.", + "GET /health -> " + res.status + " " + text.slice(0, 160)); + } +} + async function main() { - console.log("Talking to AF Stack at " + BASE_URL); + 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"); @@ -187,17 +240,19 @@ async function main() { // }}); // console.log(reply.choices?.[0]?.message?.content); } catch (err) { - console.error("\nCould not reach the backend. Start it with 'af-stack dev',"); - console.error("then set AF_STACK_URL (and AF_STACK_API_KEY if auth is on).\n"); - console.error("Details:", err.message); - process.exitCode = 1; + if (err.status === 401 || err.status === 403) { + fail("The runtime has auth on and rejected this app's key.", + err.message + "\nMint one with 'af-stack keys create' (operator key needed) and set AF_STACK_API_KEY in .env."); + } + fail("The runtime answered, but the call failed.", err.message); } } main(); ` - env := `# AF Stack runtime base URL + 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. AF_STACK_URL=http://localhost:8080 # Bearer token — required when the runtime has auth enabled AF_STACK_API_KEY= @@ -215,9 +270,10 @@ first-class primitive. ## Quickstart -1. Start a backend (from your AF Stack checkout): ` + "`af-stack dev`" + ` -2. Configure this app: ` + "`cp .env.example .env`" + ` and set ` + "`AF_STACK_URL`" + ` / - ` + "`AF_STACK_API_KEY`" + `. +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`" + ` ## What's here diff --git a/services/cli/internal/initcmd/scaffold_node_test.go b/services/cli/internal/initcmd/scaffold_node_test.go new file mode 100644 index 0000000..fdd6612 --- /dev/null +++ b/services/cli/internal/initcmd/scaffold_node_test.go @@ -0,0 +1,163 @@ +package initcmd + +import ( + "bytes" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The node starter is what a new user runs first, so drive the real file with +// node against fake backends instead of grepping the template. + +func scaffoldNodeStarter(t *testing.T) string { + t.Helper() + parent := t.TempDir() + defer chdir(t, parent)() + var out, errOut bytes.Buffer + if err := Run([]string{"starter"}, strings.NewReader(""), &out, &errOut); err != nil { + t.Fatalf("scaffold: %v", err) + } + return filepath.Join(parent, "starter") +} + +func runStarter(t *testing.T, dir string, env ...string) (string, string, int) { + t.Helper() + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not on PATH") + } + cmd := exec.Command("node", "src/index.mjs") + cmd.Dir = dir + // Only what the starter needs; in particular no inherited AF_STACK_URL. + cmd.Env = append([]string{"PATH=" + os.Getenv("PATH")}, env...) + var out, errOut bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errOut + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("run node: %v", err) + } + return out.String(), errOut.String(), code +} + +func backaiRuntime(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"alive","uptime_s":1}`)) + }) + mux.HandleFunc("/api/v1/agents", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"node_id":"supportdesk"}]`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// Contract: against a BackAI runtime the starter lists agents and exits 0. +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") { + t.Fatalf("code=%d\nstdout:\n%s\nstderr:\n%s", code, out, errOut) + } +} + +// Contract: `cp .env.example .env` then editing AF_STACK_URL must actually +// take effect — the starter reads .env itself, with no dependency. +func TestNodeStarterReadsDotEnv(t *testing.T) { + dir := scaffoldNodeStarter(t) + srv := backaiRuntime(t) + write(t, dir, ".env", "# local\nAF_STACK_URL="+srv.URL+"\nAF_STACK_API_KEY=\n") + out, errOut, code := runStarter(t, dir) + if code != 0 || !strings.Contains(out, "Talking to BackAI at "+srv.URL) { + t.Fatalf("code=%d\nstdout:\n%s\nstderr:\n%s", code, out, errOut) + } +} + +// Contract: pasting the "API runtime" URL as printed (with /api/v1) works. +func TestNodeStarterToleratesApiV1Suffix(t *testing.T) { + dir := scaffoldNodeStarter(t) + srv := backaiRuntime(t) + out, errOut, code := runStarter(t, dir, "AF_STACK_URL="+srv.URL+"/api/v1/") + if code != 0 || !strings.Contains(out, "supportdesk") { + t.Fatalf("code=%d\nstdout:\n%s\nstderr:\n%s", code, out, errOut) + } +} + +// Contract: when the port is held by something that is not a BackAI runtime +// (an AgentField control plane, say — the reason af-stack dev moved the API +// off :8080), the starter says so and tells the user where the URL comes from. +func TestNodeStarterExplainsForeignServer(t *testing.T) { + dir := scaffoldNodeStarter(t) + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"status":"healthy","checks":{}}`)) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"endpoint_not_found"}`, http.StatusNotFound) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + _, errOut, code := runStarter(t, dir, "AF_STACK_URL="+srv.URL) + 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:"} { + if !strings.Contains(errOut, want) { + t.Errorf("stderr missing %q:\n%s", want, errOut) + } + } +} + +// Contract: with nothing listening at all, the starter says that and points +// at af-stack dev. +func TestNodeStarterExplainsNothingListening(t *testing.T) { + dir := scaffoldNodeStarter(t) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closed := "http://" + l.Addr().String() + _ = l.Close() + + _, errOut, code := runStarter(t, dir, "AF_STACK_URL="+closed) + if code != 1 { + t.Fatalf("expected exit 1, got %d\n%s", code, errOut) + } + for _, want := range []string{"Nothing is listening at " + closed, "af-stack dev"} { + if !strings.Contains(errOut, want) { + t.Errorf("stderr missing %q:\n%s", want, errOut) + } + } +} + +// Contract: an auth rejection is named as such, with the fix. +func TestNodeStarterExplainsAuthRejection(t *testing.T) { + dir := scaffoldNodeStarter(t) + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"status":"alive"}`)) + }) + mux.HandleFunc("/api/v1/agents", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"code":"UNAUTHORIZED"}`, http.StatusUnauthorized) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + _, errOut, code := runStarter(t, dir, "AF_STACK_URL="+srv.URL) + if code != 1 || !strings.Contains(errOut, "AF_STACK_API_KEY") { + t.Fatalf("code=%d\n%s", code, errOut) + } +} From 0d032291a1e55700717deb63cbb33ff2b171badb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 12:16:50 -0400 Subject: [PATCH 2/2] feat(dev): print the AF_STACK_URL line apps should copy Both "what runs where" banners list the API as ".../api/v1", which is not what an app's AF_STACK_URL wants, and neither said which line to copy when preflight had moved the port. Add "Your apps AF_STACK_URL=http://localhost:" to both. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- scripts/preflight.mjs | 1 + services/cli/internal/project/project.go | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index 0899c11..ca07e6f 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -198,6 +198,7 @@ function printEndpointMap(items, project) { console.log(` Admin console http://localhost:${P("AF_STACK_DASHBOARD_PORT")}`) console.log(` API runtime http://localhost:${P("AF_STACK_PORT")}/api/v1`) console.log(` Runtime health http://localhost:${P("AF_STACK_PORT")}/health`) + console.log(` Your apps AF_STACK_URL=http://localhost:${P("AF_STACK_PORT")}`) console.log(` AgentField UI http://localhost:${P("AGENTFIELD_PORT")}`) console.log(` Metrics http://localhost:${P("AF_STACK_METRICS_PORT")}/metrics`) console.log(` LiteLLM http://localhost:${P("LITELLM_PORT")}`) diff --git a/services/cli/internal/project/project.go b/services/cli/internal/project/project.go index 231aefb..c129ffe 100644 --- a/services/cli/internal/project/project.go +++ b/services/cli/internal/project/project.go @@ -83,6 +83,7 @@ func RunDev(ctx context.Context, args []string, stdout, stderr io.Writer) error fmt.Fprintf(stdout, " Customer app %s (open this first)\n", customerURL) fmt.Fprintf(stdout, " Dashboard http://localhost:%s\n", dashPort) fmt.Fprintf(stdout, " API http://localhost:%s\n", apiPort) + fmt.Fprintf(stdout, " Your apps AF_STACK_URL=http://localhost:%s\n", apiPort) // Only auto-open in detached mode; in the foreground `docker compose up` // holds the terminal and the URLs above are already printed.