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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,12 @@ approvals, sandbox limits, and audit records keep build and live operations
inside explicit boundaries.

```bash
# Scaffold a small app that consumes a BackAI deployment.
# A standalone app that calls a running BackAI. Works in any directory.
af-stack init my-ai-product

# Or customize a full fork and give it to your coding agent.
# 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.
git clone https://github.com/Agent-Field/backai acme-ai && cd acme-ai
af-stack init --name "Acme AI" --color "#2563EB" --logo ./logo.png
af-stack agent new researcher
```
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Every command below exists in the current binary (see
[`services/cli/cmd/af-stack/main.go`](../services/cli/cmd/af-stack/main.go)).

```bash
# Fork bootstrap + dev loop
# Fork bootstrap + dev loop (run inside a clone of this repo)
af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png
af-stack dev --detach
af-stack mode personal|saas # auth+billing off ⇄ multi-tenant SaaS
Expand Down
8 changes: 6 additions & 2 deletions docs/dx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ source — where this hub and older prose disagree, this hub wins.
## The golden path (CLI-first)

```bash
af-stack init my-app # scaffold a fork / new project
git clone https://github.com/Agent-Field/backai my-app && cd my-app
af-stack init --name "My App" # brand the fork: brand.yaml, logos, default agent
af-stack dev # preflight ports + docker compose up
# … edit one of the four surfaces (below) …
af-stack deploy helm # ship it (helm | fly | railway | render)
```

Four commands, one loop: **init → dev → edit → deploy**. See
Four commands, one loop: **init → dev → edit → deploy**, all inside the
clone. `af-stack init <name>` 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
[run.md](run.md) for what `af-stack dev` actually brings up and
[build-app.md](build-app.md) for the surfaces you edit.

Expand Down
2 changes: 1 addition & 1 deletion docs/theming.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Theming and Branding

BackAI branding starts in root [`brand.yaml`](../brand.yaml). For a
new fork, prefer the CLI:
new fork, prefer the CLI, run inside your clone of the repo:

```bash
af-stack init --name "DocuChat" --color "#0A66C2" --logo ./logo.png
Expand Down
2 changes: 1 addition & 1 deletion services/cli/cmd/af-stack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Currently shipped subcommands:
//
// af-stack init my-app Scaffold a new app on the stack
// af-stack init --brand --name "DocuChat" Re-theme a fork (power-user path)
// af-stack init --name "DocuChat" Re-theme a fork (run inside a clone)
// af-stack dev Start local compose dev loop
// af-stack agent new <name> Scaffold an AgentField agent
// af-stack module new <id> Scaffold a workload module
Expand Down
103 changes: 103 additions & 0 deletions services/cli/internal/checkout/checkout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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.
//
// The most common way to end up outside a checkout is to scaffold a
// standalone app with `af-stack init <name>`, 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.
package checkout

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// RepoURL is the upstream the error message tells users to clone.
const RepoURL = "https://github.com/Agent-Field/backai"

// NotFoundError is returned when no checkout encloses the start directory.
type NotFoundError struct {
// Dir is the directory the search started from.
Dir string
// ScaffoldedApp is Dir or the nearest ancestor that looks like an app
// written by `af-stack init <name>`, or "" when there is none.
ScaffoldedApp string
}

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 <name>`: it calls a running BackAI and has 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 <name> (works in any directory)")
return b.String()
}

// Find walks up from the working directory to the nearest checkout root.
func Find() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
return FindFrom(wd)
}

// FindFrom is Find starting at dir instead of the working directory.
func FindFrom(dir string) (string, error) {
for d := dir; ; {
if IsRoot(d) {
return d, nil
}
next := filepath.Dir(d)
if next == d {
break
}
d = next
}
return "", &NotFoundError{Dir: dir, ScaffoldedApp: scaffoldedAppAbove(dir)}
}

// IsRoot reports whether dir is the root of a checkout: the workspace
// package.json plus both apps the platform ships.
func IsRoot(dir string) bool {
return exists(filepath.Join(dir, "package.json")) &&
exists(filepath.Join(dir, "apps", "dashboard")) &&
exists(filepath.Join(dir, "apps", "customer-app"))
}

// scaffoldedAppAbove returns dir or the nearest ancestor that looks like an
// app written by `af-stack init <name>`, or "" when there is none.
func scaffoldedAppAbove(dir string) string {
for d := dir; ; {
if looksScaffolded(d) {
return d
}
next := filepath.Dir(d)
if next == d {
return ""
}
d = next
}
}

// looksScaffolded matches what initcmd's scaffolds write: package.json,
// CLAUDE.md, and an .env.example that points the app at AF_STACK_URL (or
// VITE_AF_STACK_URL for the saas template).
func looksScaffolded(dir string) bool {
if !exists(filepath.Join(dir, "package.json")) || !exists(filepath.Join(dir, "CLAUDE.md")) {
return false
}
// #nosec G304 -- a marker file under a directory the user is already running in.
env, err := os.ReadFile(filepath.Join(dir, ".env.example"))
return err == nil && strings.Contains(string(env), "AF_STACK_URL=")
}

func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
141 changes: 141 additions & 0 deletions services/cli/internal/checkout/checkout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package checkout

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

func write(t *testing.T, root, rel, contents string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}

func fakeCheckout(t *testing.T) string {
t.Helper()
root := t.TempDir()
write(t, root, "package.json", "{}")
write(t, root, "apps/dashboard/.keep", "")
write(t, root, "apps/customer-app/.keep", "")
return root
}

func fakeScaffoldedApp(t *testing.T, env string) string {
t.Helper()
app := filepath.Join(t.TempDir(), "my-ai-product")
write(t, app, "package.json", `{"name":"my-ai-product"}`)
write(t, app, "CLAUDE.md", "# my-ai-product — an app on the AF Stack backend")
write(t, app, ".env.example", env)
write(t, app, "src/index.mjs", "")
return app
}

// Contract: inside a clone, or any subdirectory of it, the root is found
// and commands behave exactly as before.
func TestFindFromInsideCheckoutSubdir(t *testing.T) {
root := fakeCheckout(t)
deep := filepath.Join(root, "apps", "backend", "agents")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
got, err := FindFrom(deep)
if err != nil {
t.Fatalf("FindFrom(subdir) error: %v", err)
}
if got != root {
t.Fatalf("FindFrom(subdir) = %q, want %q", got, root)
}
}

// Contract: outside a checkout the error names the directory, says what a
// checkout is, gives the clone command, and offers the standalone scaffold.
func TestFindFromOutsideCheckoutExplains(t *testing.T) {
dir := t.TempDir()
_, err := FindFrom(dir)
var nf *NotFoundError
if !errors.As(err, &nf) {
t.Fatalf("want *NotFoundError, got %T: %v", err, err)
}
if nf.ScaffoldedApp != "" {
t.Fatalf("empty dir should not look scaffolded, got %q", nf.ScaffoldedApp)
}
msg := err.Error()
for _, want := range []string{
dir,
"apps/dashboard and apps/customer-app",
"git clone " + RepoURL,
"af-stack init <name>",
} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q:\n%s", want, msg)
}
}
if strings.Contains(msg, "standalone app created by") {
t.Errorf("empty dir must not be described as a scaffolded app:\n%s", msg)
}
}

// Contract: inside an app written by `af-stack init <name>` the error says
// this is a standalone app, not a fork — for both scaffold templates.
func TestFindFromInsideScaffoldedAppSaysSo(t *testing.T) {
for name, env := range map[string]string{
"node": "AF_STACK_URL=http://localhost:8080\n",
"saas": "VITE_AF_STACK_URL=http://localhost:8080\n",
} {
t.Run(name, func(t *testing.T) {
app := fakeScaffoldedApp(t, env)
_, err := FindFrom(filepath.Join(app, "src"))
var nf *NotFoundError
if !errors.As(err, &nf) {
t.Fatalf("want *NotFoundError, got %T: %v", err, err)
}
if nf.ScaffoldedApp != app {
t.Fatalf("ScaffoldedApp = %q, want %q", nf.ScaffoldedApp, app)
}
msg := err.Error()
if !strings.Contains(msg, app+" is a standalone app created by `af-stack init <name>`") {
t.Errorf("error message should identify the scaffolded app:\n%s", msg)
}
if !strings.Contains(msg, "git clone "+RepoURL) {
t.Errorf("error message should still give the clone command:\n%s", msg)
}
})
}
}

// Contract: a checkout that happens to carry the scaffold markers too is
// still a checkout.
func TestCheckoutWinsOverScaffoldMarkers(t *testing.T) {
root := fakeCheckout(t)
write(t, root, "CLAUDE.md", "@AGENTS.md")
write(t, root, ".env.example", "AF_STACK_URL=http://localhost:8080\n")
got, err := FindFrom(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != root {
t.Fatalf("FindFrom = %q, want %q", got, root)
}
}

// package.json alone (any Node project) is not a scaffolded app.
func TestPlainNodeProjectIsNotScaffolded(t *testing.T) {
dir := t.TempDir()
write(t, dir, "package.json", "{}")
_, err := FindFrom(dir)
var nf *NotFoundError
if !errors.As(err, &nf) {
t.Fatalf("want *NotFoundError, got %T", err)
}
if nf.ScaffoldedApp != "" {
t.Fatalf("plain node project reported as scaffolded: %q", nf.ScaffoldedApp)
}
}
86 changes: 86 additions & 0 deletions services/cli/internal/initcmd/brand_generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package initcmd

import (
"bytes"
"errors"
"strings"
"testing"
)

func minimalFork(t *testing.T) string {
t.Helper()
root := t.TempDir()
write(t, root, "package.json", "{}")
write(t, root, "apps/dashboard/.keep", "")
write(t, root, "apps/customer-app/.keep", "")
write(t, root, "brand.yaml", "schema_version: 1\nname: af-stack\ncodename: af-stack\ndisplay_name: AF Stack\n")
return root
}

func stubPnpm(t *testing.T, present bool) {
t.Helper()
old := pnpmOnPath
pnpmOnPath = func() bool { return present }
t.Cleanup(func() { pnpmOnPath = old })
}

// Contract: on a fresh clone (no node_modules yet) `init --name` must not
// run the brand generator at all — running it prints a Node stack trace as
// the first thing the user sees — and must say in one line what to run.
func TestRunSkipsBrandGeneratorWithoutNodeDeps(t *testing.T) {
root := minimalFork(t)
defer chdir(t, root)()
stubPnpm(t, true)
defer stubGenerator(t, func(string) error {
t.Fatal("generator must not run when node_modules is missing")
return nil
})()

var out, errOut bytes.Buffer
if err := Run([]string{"--name", "Acme AI"}, strings.NewReader(""), &out, &errOut); err != nil {
t.Fatalf("init: %v", err)
}
if got := errOut.String(); !strings.Contains(got, "Node deps are not installed yet") || !strings.Contains(got, "pnpm install && pnpm run generate:brand") {
t.Errorf("stderr should carry a one-line hint, got:\n%s", got)
}
if got := errOut.String(); strings.Contains(got, "generate brand:") || strings.Count(got, "\n") > 1 {
t.Errorf("stderr should be a single line, got:\n%s", got)
}
if !strings.Contains(out.String(), "brand CSS/modules NOT regenerated") {
t.Errorf("stdout should say the assets were not regenerated, got:\n%s", out.String())
}
}

// Contract: with deps present, a genuine generator failure is still
// reported — trimmed to its tail, which is where the actual error is.
func TestRunReportsTrimmedGeneratorFailure(t *testing.T) {
root := minimalFork(t)
write(t, root, "node_modules/.keep", "")
defer chdir(t, root)()
stubPnpm(t, true)
defer stubGenerator(t, func(string) error {
return errors.New("init: generate brand: exit status 1\n" + lastLines(strings.Repeat("noise\n", 30)+"Error: real cause\n", 8))
})()

var out, errOut bytes.Buffer
if err := Run([]string{"--name", "Acme AI"}, strings.NewReader(""), &out, &errOut); err != nil {
t.Fatalf("init: %v", err)
}
got := errOut.String()
if !strings.Contains(got, "Error: real cause") {
t.Errorf("stderr should carry the generator's actual error, got:\n%s", got)
}
if strings.Count(got, "noise") > 8 {
t.Errorf("stderr should be trimmed to the tail, got %d noise lines", strings.Count(got, "noise"))
}
}

func TestLastLinesKeepsTailOnly(t *testing.T) {
in := "a\nb\nc\nd\ne\n"
if got := lastLines(in, 2); got != "…\nd\ne" {
t.Fatalf("lastLines = %q", got)
}
if got := lastLines(in, 10); got != "a\nb\nc\nd\ne" {
t.Fatalf("lastLines (no trim) = %q", got)
}
}
Loading
Loading