Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`)
Expand Down
82 changes: 69 additions & 13 deletions services/cli/internal/initcmd/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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 } = {}) {
Expand All @@ -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:<port>
(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");
Expand All @@ -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=
Expand All @@ -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
Expand Down
163 changes: 163 additions & 0 deletions services/cli/internal/initcmd/scaffold_node_test.go
Original file line number Diff line number Diff line change
@@ -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:<port>"} {
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)
}
}
1 change: 1 addition & 0 deletions services/cli/internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading