diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f67061..d1fea73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ All notable changes are documented here, following ## [Unreleased] ### Added +- Build lifecycle and `anvil build`: runs deps, analyze, test, and an + unsigned/debug/simulator build for a detected project, with a live Bubble Tea + view and a plain non-TTY renderer. Flags `--path`, `--target`, `--flavor`, + `--release`, `--dry-run`, `--plain`. +- Driver contract (`internal/driver`) and drivers for Flutter, React Native, + native Android, native iOS, Swift (SPM), and Kotlin/JVM, with `--flavor` + threaded into build and test steps. +- Runner (`internal/pipeline`): executes steps, streams combined output, applies + the driver's classification, collects artifacts, and stops on first failure. +- Detection refinement: Gradle is not always Android and `Package.swift` is + Swift, adding Swift and Kotlin stacks. - Stack detection engine (`internal/detect`): marker-file detectors for Flutter (vs pure Dart; app/module/plugin subtypes), React Native (bare, Expo managed, Expo prebuild), native Android (app vs library, KMP flag), and native iOS diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..0e84c68 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/openforge-oss/anvil/internal/detect" + "github.com/openforge-oss/anvil/internal/driver" + "github.com/openforge-oss/anvil/internal/pipeline" + "github.com/openforge-oss/anvil/internal/tui" +) + +var ( + buildPath string + buildTarget string + buildFlavor string + buildRelease bool + buildDryRun bool + buildPlain bool +) + +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Run a detected project through deps, analyze, test, and build", + Args: cobra.NoArgs, + RunE: runBuild, +} + +func init() { + buildCmd.Flags().StringVar(&buildPath, "path", ".", "project directory") + buildCmd.Flags().StringVar(&buildTarget, "target", "", "build target (stack-specific, e.g. apk, appbundle, ios)") + buildCmd.Flags().StringVar(&buildFlavor, "flavor", "", "build flavor, product flavor, or scheme") + buildCmd.Flags().BoolVar(&buildRelease, "release", false, "release build where applicable") + buildCmd.Flags().BoolVar(&buildDryRun, "dry-run", false, "print the steps without running them") + buildCmd.Flags().BoolVar(&buildPlain, "plain", false, "plain line output instead of the interactive view") + rootCmd.AddCommand(buildCmd) +} + +func runBuild(cmd *cobra.Command, _ []string) error { + root, err := filepath.Abs(buildPath) + if err != nil { + return err + } + + projects, err := detect.Scan(root, detect.DefaultOptions()) + if err != nil { + return err + } + chosen, err := chooseProject(cmd, projects, root) + if err != nil { + return err + } + + d, ok := driver.For(chosen.Stack, chosen.Path) + if !ok { + return fmt.Errorf("anvil build does not support the %q stack yet", chosen.Stack) + } + + opts := driver.BuildOptions{Target: buildTarget, Flavor: buildFlavor, Release: buildRelease} + + if buildDryRun { + printPlan(cmd, chosen, d, opts) + return nil + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + events := make(chan pipeline.Event) + go pipeline.Run(ctx, chosen.Path, d, opts, events) + + var success bool + if usePlain() { + success = tui.Plain(events, cmd.OutOrStdout()) + } else { + success, err = tui.Run(events) + if err != nil { + return err + } + } + if !success { + return errors.New("build failed") + } + return nil +} + +func chooseProject(cmd *cobra.Command, projects []detect.Project, root string) (detect.Project, error) { + switch len(projects) { + case 0: + return detect.Project{}, errors.New("no supported project detected; pass --path to a project directory") + case 1: + return projects[0], nil + } + for _, p := range projects { + if p.Path == root { + return p, nil + } + } + out := cmd.ErrOrStderr() + fmt.Fprintln(out, "Multiple projects detected; pass --path to one of:") + for _, p := range projects { + fmt.Fprintf(out, " %s (%s)\n", p.Path, p.Stack) + } + return detect.Project{}, errors.New("multiple projects detected") +} + +func printPlan(cmd *cobra.Command, p detect.Project, d driver.Driver, opts driver.BuildOptions) { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "%s (%s) at %s\n\n", d.Name(), p.Subtype, p.Path) + for _, it := range pipeline.Plan(d, opts) { + if it.Step == nil { + fmt.Fprintf(out, "%s: skipped\n", it.Phase) + continue + } + dir := it.Step.Dir + if dir == "" { + dir = "." + } + fmt.Fprintf(out, "%s: %s\n dir: %s\n cmd: %s\n", it.Phase, it.Name, dir, strings.Join(it.Step.Argv, " ")) + } +} + +func usePlain() bool { + if buildPlain || os.Getenv("CI") != "" { + return true + } + return !term.IsTerminal(int(os.Stdout.Fd())) +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9709005..0ff8d99 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,11 +6,14 @@ High-level phases and status, for monitoring. Granular tasks live in | Phase | What | Status | |-------|------|--------| | 0. Repo and pipeline | Go skeleton, wrapper scripts, Claude hooks, CI, docs, working rules | done | -| 1. Detection | Marker-file detectors for Flutter, React Native, Android, iOS; `anvil detect` | in progress | -| 2. Guided build | Driver lifecycle (deps, analyze, test, build); Flutter first, then RN, Android, iOS; interactive TUI; unified error surfacing | planned | +| 1. Detection | Marker-file detectors for Flutter, React Native, Android, iOS, Swift, Kotlin; `anvil detect` | done | +| 2. Guided build | Driver lifecycle (deps, analyze, test, build) for all six stacks; `--flavor`; interactive TUI plus plain fallback; `anvil build` | in progress | | 3. Signing | Guided Android keystore and iOS provisioning/signing | planned | | 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | planned | -| 5. Breadth | More ecosystems (web, Go) via new drivers; `--explain` educational mode | future | +| 5. Breadth | More ecosystems (web, Go) via new drivers; flavor auto-detection; `--explain` educational mode | future | + +Swift (SPM) and Kotlin/JVM drivers and `--flavor` were pulled forward into +Milestone 2. ## Guiding decisions (locked) diff --git a/go.mod b/go.mod index fa41e71..73e96fd 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,36 @@ module github.com/openforge-oss/anvil go 1.26 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index ff4d6ec..8ba2644 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,65 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/detect/android.go b/internal/detect/android.go index 1a1e601..86df10f 100644 --- a/internal/detect/android.go +++ b/internal/detect/android.go @@ -3,21 +3,17 @@ package detect import "path/filepath" func detectAndroid(dir string) *Project { - hasSettings := fileExists(filepath.Join(dir, "settings.gradle")) || - fileExists(filepath.Join(dir, "settings.gradle.kts")) - hasBuild := fileExists(filepath.Join(dir, "build.gradle")) || - fileExists(filepath.Join(dir, "build.gradle.kts")) - if !hasSettings && !hasBuild { + if !hasGradleFiles(dir) { return nil } - - confidence := 0.6 - if hasSettings { - confidence = 0.9 + isApp := gradleContains(dir, "com.android.application") + isLibrary := gradleContains(dir, "com.android.library") + if !isApp && !isLibrary { + return nil } subtype := "library" - if gradleContains(dir, "com.android.application") { + if isApp { subtype = "app" } @@ -27,22 +23,30 @@ func detectAndroid(dir string) *Project { flags = append(flags, "kmp") } - return &Project{Path: dir, Stack: Android, Subtype: subtype, Confidence: confidence, Flags: flags} + return &Project{Path: dir, Stack: Android, Subtype: subtype, Confidence: 0.9, Flags: flags} +} + +func hasGradleFiles(dir string) bool { + for _, n := range []string{"settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts"} { + if fileExists(filepath.Join(dir, n)) { + return true + } + } + return false } // gradleContains scans the build files at dir and in its immediate submodules -// for a token, covering the common case where the application plugin lives in an -// app/ module rather than the root build file. +// for a token, covering the common case where a plugin lives in a submodule +// (for example an app/ or androidApp/ module) rather than the root build file. func gradleContains(dir, token string) bool { candidates := []string{ filepath.Join(dir, "build.gradle"), filepath.Join(dir, "build.gradle.kts"), } - if entries, err := filepath.Glob(filepath.Join(dir, "*", "build.gradle")); err == nil { - candidates = append(candidates, entries...) - } - if entries, err := filepath.Glob(filepath.Join(dir, "*", "build.gradle.kts")); err == nil { - candidates = append(candidates, entries...) + for _, pat := range []string{"*/build.gradle", "*/build.gradle.kts"} { + if entries, err := filepath.Glob(filepath.Join(dir, pat)); err == nil { + candidates = append(candidates, entries...) + } } for _, c := range candidates { if fileContains(c, token) { diff --git a/internal/detect/detect.go b/internal/detect/detect.go index ad2f1f9..cd10ee7 100644 --- a/internal/detect/detect.go +++ b/internal/detect/detect.go @@ -17,6 +17,8 @@ const ( ReactNative Stack = "react-native" Android Stack = "android" IOS Stack = "ios" + Kotlin Stack = "kotlin" + Swift Stack = "swift" ) type Project struct { @@ -98,9 +100,15 @@ func detectDir(dir string) *Project { if p := detectAndroid(dir); p != nil { return p } + if p := detectKotlin(dir); p != nil { + return p + } if p := detectIOS(dir); p != nil { return p } + if p := detectSwift(dir); p != nil { + return p + } return nil } diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go index 474dea6..a8b7ff5 100644 --- a/internal/detect/detect_test.go +++ b/internal/detect/detect_test.go @@ -149,7 +149,20 @@ func TestScan(t *testing.T) { { name: "swift package library", files: map[string]string{"Package.swift": "// swift-tools-version:5.9\nlet package = Package(name: \"X\", products: [.library(name: \"X\", targets: [\"X\"])])"}, - want: []string{".|ios|library"}, + want: []string{".|swift|library"}, + }, + { + name: "swift executable package", + files: map[string]string{"Package.swift": "// swift-tools-version:5.9\nlet package = Package(name: \"cli\", targets: [.executableTarget(name: \"cli\")])"}, + want: []string{".|swift|executable"}, + }, + { + name: "kotlin jvm gradle is not android", + files: map[string]string{ + "settings.gradle.kts": "rootProject.name = \"svc\"", + "build.gradle.kts": "plugins { kotlin(\"jvm\") version \"2.0.0\" }", + }, + want: []string{".|kotlin|jvm"}, }, { name: "node_modules yields no phantom react native projects", diff --git a/internal/detect/ios.go b/internal/detect/ios.go index 9a36ef3..c6c50a8 100644 --- a/internal/detect/ios.go +++ b/internal/detect/ios.go @@ -6,13 +6,6 @@ func detectIOS(dir string) *Project { if hasGlob(dir, "*.xcworkspace") || hasGlob(dir, "*.xcodeproj") { return &Project{Path: dir, Stack: IOS, Subtype: "app", Confidence: 0.85} } - if fileExists(filepath.Join(dir, "Package.swift")) { - subtype := "library" - if fileContains(filepath.Join(dir, "Package.swift"), ".executable") { - subtype = "app" - } - return &Project{Path: dir, Stack: IOS, Subtype: subtype, Confidence: 0.7} - } if fileExists(filepath.Join(dir, "Podfile")) { return &Project{Path: dir, Stack: IOS, Subtype: "app", Confidence: 0.5} } diff --git a/internal/detect/kotlin.go b/internal/detect/kotlin.go new file mode 100644 index 0000000..ffbf02c --- /dev/null +++ b/internal/detect/kotlin.go @@ -0,0 +1,29 @@ +package detect + +// detectKotlin claims a Gradle project that applies a Kotlin plugin and is not +// an Android project (Android is checked first). This covers Kotlin/JVM and +// non-Android Kotlin Multiplatform. +func detectKotlin(dir string) *Project { + if !hasGradleFiles(dir) { + return nil + } + if gradleContains(dir, "com.android.application") || gradleContains(dir, "com.android.library") { + return nil + } + + multiplatform := gradleContains(dir, "kotlin(\"multiplatform\")") || + gradleContains(dir, "org.jetbrains.kotlin.multiplatform") + jvm := gradleContains(dir, "kotlin(\"jvm\")") || + gradleContains(dir, "org.jetbrains.kotlin.jvm") + if !multiplatform && !jvm { + return nil + } + + subtype := "jvm" + var flags []string + if multiplatform { + subtype = "multiplatform" + flags = append(flags, "kmp") + } + return &Project{Path: dir, Stack: Kotlin, Subtype: subtype, Confidence: 0.85, Flags: flags} +} diff --git a/internal/detect/swift.go b/internal/detect/swift.go new file mode 100644 index 0000000..d5389be --- /dev/null +++ b/internal/detect/swift.go @@ -0,0 +1,15 @@ +package detect + +import "path/filepath" + +func detectSwift(dir string) *Project { + path := filepath.Join(dir, "Package.swift") + if !fileExists(path) { + return nil + } + subtype := "library" + if fileContains(path, ".executable") { + subtype = "executable" + } + return &Project{Path: dir, Stack: Swift, Subtype: subtype, Confidence: 0.8} +} diff --git a/internal/driver/android.go b/internal/driver/android.go new file mode 100644 index 0000000..36a8d6d --- /dev/null +++ b/internal/driver/android.go @@ -0,0 +1,32 @@ +package driver + +type Android struct{ base } + +func (Android) Name() string { return "android" } + +func (Android) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + return nil, false + case Analyze: + return []Step{{Name: "gradlew lint", Argv: []string{gradlew(), "lint"}}}, true + case Test: + return []Step{{Name: "gradlew test", Argv: []string{gradlew(), "test"}}}, true + case Build: + task := buildTask(opts) + return []Step{{Name: "gradlew " + task, Argv: []string{gradlew(), task}}}, true + } + return nil, false +} + +func buildTask(opts BuildOptions) string { + verb := "assemble" + if opts.Target == "aab" || opts.Target == "appbundle" || opts.Target == "bundle" { + verb = "bundle" + } + variant := "Debug" + if opts.Release { + variant = "Release" + } + return verb + title(opts.Flavor) + variant +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 35ed074..19ad100 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -1,4 +1,130 @@ -// Package driver defines the per-ecosystem lifecycle contract (install deps, -// analyze, test, build, sign, upload) each supported stack implements. -// See docs/ARCHITECTURE.md. Implemented from Milestone 2. +// Package driver defines the build lifecycle contract and the per-stack drivers. +// A driver only describes what to run per phase and how to interpret the result; +// the pipeline package owns execution, streaming, and status. package driver + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +type Phase int + +const ( + Deps Phase = iota + Analyze + Test + Build +) + +var Phases = []Phase{Deps, Analyze, Test, Build} + +func (p Phase) String() string { + switch p { + case Deps: + return "deps" + case Analyze: + return "analyze" + case Test: + return "test" + case Build: + return "build" + default: + return "unknown" + } +} + +type Status int + +const ( + Pending Status = iota + Running + OK + Failed + Skipped +) + +func (s Status) String() string { + switch s { + case Pending: + return "pending" + case Running: + return "running" + case OK: + return "ok" + case Failed: + return "failed" + case Skipped: + return "skipped" + default: + return "unknown" + } +} + +// Step is one command in a phase. Dir is relative to the project root; empty +// means the root itself. +type Step struct { + Name string + Argv []string + Dir string + Env []string +} + +// BuildOptions carries user choices for the Build phase. Target is +// stack-specific (for example apk, appbundle, ios); empty selects the driver +// default. Flavor selects a build flavor, product flavor, or scheme where the +// stack supports it. Release requests a release build where applicable. +type BuildOptions struct { + Target string + Flavor string + Release bool +} + +type Driver interface { + Name() string + Steps(phase Phase, opts BuildOptions) (steps []Step, applicable bool) + Classify(phase Phase, exitCode int, output []byte) Status + Artifacts(phase Phase, output []byte) []string +} + +// base provides the default Classify and Artifacts so drivers only override the +// exceptions. +type base struct{ root string } + +func (base) Classify(_ Phase, exitCode int, _ []byte) Status { + if exitCode == 0 { + return OK + } + return Failed +} + +func (base) Artifacts(_ Phase, _ []byte) []string { return nil } + +func gradlew() string { + if runtime.GOOS == "windows" { + return "gradlew.bat" + } + return "./gradlew" +} + +func title(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func firstGlob(dir, pattern string) string { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil || len(matches) == 0 { + return "" + } + return filepath.Base(matches[0]) +} diff --git a/internal/driver/driver_test.go b/internal/driver/driver_test.go new file mode 100644 index 0000000..836ff22 --- /dev/null +++ b/internal/driver/driver_test.go @@ -0,0 +1,135 @@ +package driver + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func cmds(steps []Step) []string { + out := make([]string, len(steps)) + for i, s := range steps { + out[i] = strings.Join(s.Argv, " ") + } + return out +} + +func mustSteps(t *testing.T, d Driver, p Phase, opts BuildOptions) []Step { + t.Helper() + steps, ok := d.Steps(p, opts) + if !ok { + t.Fatalf("%s %s: not applicable, want applicable", d.Name(), p) + } + return steps +} + +func wantCmds(t *testing.T, got []Step, want ...string) { + t.Helper() + if g := cmds(got); !reflect.DeepEqual(g, want) { + t.Errorf("got %v, want %v", g, want) + } +} + +func TestFlutterSteps(t *testing.T) { + d := Flutter{} + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "flutter pub get") + wantCmds(t, mustSteps(t, d, Analyze, BuildOptions{}), "flutter analyze") + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), "flutter test") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), "flutter build apk --release") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Target: "appbundle"}), "flutter build appbundle --release") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Target: "ios"}), "flutter build ios --release --no-codesign") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Flavor: "prod"}), "flutter build apk --release --flavor prod") +} + +func TestFlutterArtifacts(t *testing.T) { + out := []byte("Running Gradle task...\n✓ Built build/app/outputs/flutter-apk/app-release.apk (21.2MB).\n") + got := Flutter{}.Artifacts(Build, out) + want := []string{"build/app/outputs/flutter-apk/app-release.apk"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestAndroidSteps(t *testing.T) { + d := Android{} + if _, ok := d.Steps(Deps, BuildOptions{}); ok { + t.Error("android deps should be skipped") + } + wantCmds(t, mustSteps(t, d, Analyze, BuildOptions{}), gradlew()+" lint") + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), gradlew()+" test") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), gradlew()+" assembleDebug") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Release: true}), gradlew()+" assembleRelease") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Target: "aab", Release: true}), gradlew()+" bundleRelease") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Flavor: "prod"}), gradlew()+" assembleProdDebug") +} + +func TestKotlinSteps(t *testing.T) { + d := Kotlin{} + if _, ok := d.Steps(Analyze, BuildOptions{}); ok { + t.Error("kotlin analyze should be skipped") + } + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), gradlew()+" test") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), gradlew()+" build") +} + +func TestSwiftSteps(t *testing.T) { + d := Swift{} + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "swift package resolve") + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), "swift test") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), "swift build") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Release: true}), "swift build -c release") +} + +func TestReactNativeSteps(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "yarn.lock": "", + "ios/Podfile": "platform :ios", + ".eslintrc.js": "module.exports = {}", + "package.json": `{"name":"rn","jest":{},"dependencies":{"react-native":"0.74.0"}}`, + }) + d := ReactNative{base{root}} + + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "yarn install --frozen-lockfile", "pod install") + wantCmds(t, mustSteps(t, d, Analyze, BuildOptions{}), "npx eslint .") + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), "npx jest --ci") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), gradlew()+" assembleDebug") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{Flavor: "prod", Release: true}), gradlew()+" assembleProdRelease") +} + +func TestIOSSteps(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "App.xcworkspace"), 0o755); err != nil { + t.Fatal(err) + } + writeFiles(t, root, map[string]string{"Podfile": "platform :ios"}) + d := IOS{base{root}} + + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "pod install") + if _, ok := d.Steps(Analyze, BuildOptions{}); ok { + t.Error("ios analyze should be skipped") + } + build := mustSteps(t, d, Build, BuildOptions{}) + if got := strings.Join(build[0].Argv, " "); !strings.Contains(got, "-workspace App.xcworkspace") || !strings.Contains(got, "-scheme App") || !strings.Contains(got, "CODE_SIGNING_ALLOWED=NO") { + t.Errorf("unexpected ios build: %s", got) + } + flavored := mustSteps(t, d, Build, BuildOptions{Flavor: "Staging"}) + if got := strings.Join(flavored[0].Argv, " "); !strings.Contains(got, "-scheme Staging") { + t.Errorf("flavor should override scheme: %s", got) + } +} + +func writeFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for rel, content := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/driver/flutter.go b/internal/driver/flutter.go new file mode 100644 index 0000000..fa28453 --- /dev/null +++ b/internal/driver/flutter.go @@ -0,0 +1,63 @@ +package driver + +import "strings" + +type Flutter struct{ base } + +func (Flutter) Name() string { return "flutter" } + +func (Flutter) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + return []Step{{Name: "flutter pub get", Argv: []string{"flutter", "pub", "get"}}}, true + case Analyze: + return []Step{{Name: "flutter analyze", Argv: []string{"flutter", "analyze"}}}, true + case Test: + return []Step{{Name: "flutter test", Argv: []string{"flutter", "test"}}}, true + case Build: + var args []string + switch target(opts, "apk") { + case "apk": + args = []string{"build", "apk", "--release"} + case "appbundle", "aab": + args = []string{"build", "appbundle", "--release"} + case "ios": + args = []string{"build", "ios", "--release", "--no-codesign"} + default: + return nil, false + } + if opts.Flavor != "" { + args = append(args, "--flavor", opts.Flavor) + } + return []Step{{Name: "flutter " + strings.Join(args, " "), Argv: append([]string{"flutter"}, args...)}}, true + } + return nil, false +} + +func (Flutter) Artifacts(phase Phase, output []byte) []string { + if phase != Build { + return nil + } + var paths []string + for _, line := range strings.Split(string(output), "\n") { + i := strings.Index(line, "Built ") + if i < 0 { + continue + } + p := strings.TrimSpace(line[i+len("Built "):]) + if j := strings.Index(p, " ("); j >= 0 { + p = p[:j] + } + if p != "" { + paths = append(paths, strings.TrimSuffix(p, ".")) + } + } + return paths +} + +func target(opts BuildOptions, fallback string) string { + if opts.Target == "" { + return fallback + } + return opts.Target +} diff --git a/internal/driver/ios.go b/internal/driver/ios.go new file mode 100644 index 0000000..dc54c6d --- /dev/null +++ b/internal/driver/ios.go @@ -0,0 +1,59 @@ +package driver + +import ( + "path/filepath" + "strings" +) + +type IOS struct{ base } + +func (IOS) Name() string { return "ios" } + +func (d IOS) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + if fileExists(filepath.Join(d.root, "Podfile")) { + return []Step{{Name: "pod install", Argv: []string{"pod", "install"}}}, true + } + return nil, false + case Analyze: + return nil, false + case Test: + flag, value, scheme := d.container(opts) + if scheme == "" { + return nil, false + } + return []Step{{ + Name: "xcodebuild test", + Argv: []string{"xcodebuild", "test", flag, value, "-scheme", scheme, + "-destination", "platform=iOS Simulator,name=iPhone 15"}, + }}, true + case Build: + flag, value, scheme := d.container(opts) + if scheme == "" { + return nil, false + } + return []Step{{ + Name: "xcodebuild build", + Argv: []string{"xcodebuild", "build", flag, value, "-scheme", scheme, + "-destination", "generic/platform=iOS Simulator", "CODE_SIGNING_ALLOWED=NO"}, + }}, true + } + return nil, false +} + +// container prefers a CocoaPods workspace over a bare project and derives the +// scheme from its name, the common single-scheme convention. +func (d IOS) container(opts BuildOptions) (flag, value, scheme string) { + if ws := firstGlob(d.root, "*.xcworkspace"); ws != "" { + flag, value, scheme = "-workspace", ws, strings.TrimSuffix(ws, ".xcworkspace") + } else if pj := firstGlob(d.root, "*.xcodeproj"); pj != "" { + flag, value, scheme = "-project", pj, strings.TrimSuffix(pj, ".xcodeproj") + } else { + return "", "", "" + } + if opts.Flavor != "" { + scheme = opts.Flavor + } + return flag, value, scheme +} diff --git a/internal/driver/kotlin.go b/internal/driver/kotlin.go new file mode 100644 index 0000000..5417d9e --- /dev/null +++ b/internal/driver/kotlin.go @@ -0,0 +1,15 @@ +package driver + +type Kotlin struct{ base } + +func (Kotlin) Name() string { return "kotlin" } + +func (Kotlin) Steps(phase Phase, _ BuildOptions) ([]Step, bool) { + switch phase { + case Test: + return []Step{{Name: "gradlew test", Argv: []string{gradlew(), "test"}}}, true + case Build: + return []Step{{Name: "gradlew build", Argv: []string{gradlew(), "build"}}}, true + } + return nil, false +} diff --git a/internal/driver/reactnative.go b/internal/driver/reactnative.go new file mode 100644 index 0000000..83b65d7 --- /dev/null +++ b/internal/driver/reactnative.go @@ -0,0 +1,92 @@ +package driver + +import ( + "os" + "path/filepath" + "strings" +) + +type ReactNative struct{ base } + +func (ReactNative) Name() string { return "react-native" } + +func (r ReactNative) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + steps := []Step{jsInstall(r.root)} + if fileExists(filepath.Join(r.root, "ios", "Podfile")) { + steps = append(steps, Step{Name: "pod install", Argv: []string{"pod", "install"}, Dir: "ios"}) + } + return steps, true + case Analyze: + if !hasEslint(r.root) { + return nil, false + } + return []Step{{Name: "eslint", Argv: []string{"npx", "eslint", "."}}}, true + case Test: + if !hasJest(r.root) { + return nil, false + } + return []Step{{Name: "jest", Argv: []string{"npx", "jest", "--ci"}}}, true + case Build: + if target(opts, "android") == "ios" { + ws := firstGlob(filepath.Join(r.root, "ios"), "*.xcworkspace") + if ws == "" { + return nil, false + } + scheme := strings.TrimSuffix(ws, ".xcworkspace") + if opts.Flavor != "" { + scheme = opts.Flavor + } + return []Step{{ + Name: "xcodebuild build", + Dir: "ios", + Argv: []string{"xcodebuild", "build", "-workspace", ws, "-scheme", scheme, + "-destination", "generic/platform=iOS Simulator", "CODE_SIGNING_ALLOWED=NO"}, + }}, true + } + variant := "Debug" + if opts.Release { + variant = "Release" + } + task := "assemble" + title(opts.Flavor) + variant + return []Step{{Name: "gradlew " + task, Dir: "android", Argv: []string{gradlew(), task}}}, true + } + return nil, false +} + +func jsInstall(root string) Step { + switch { + case fileExists(filepath.Join(root, "yarn.lock")): + return Step{Name: "yarn install", Argv: []string{"yarn", "install", "--frozen-lockfile"}} + case fileExists(filepath.Join(root, "pnpm-lock.yaml")): + return Step{Name: "pnpm install", Argv: []string{"pnpm", "install", "--frozen-lockfile"}} + case fileExists(filepath.Join(root, "package-lock.json")): + return Step{Name: "npm ci", Argv: []string{"npm", "ci"}} + default: + return Step{Name: "npm install", Argv: []string{"npm", "install"}} + } +} + +func hasEslint(root string) bool { + for _, n := range []string{ + ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", + ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", + "eslint.config.mjs", "eslint.config.cjs", + } { + if fileExists(filepath.Join(root, n)) { + return true + } + } + return false +} + +func hasJest(root string) bool { + for _, n := range []string{"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs", "jest.config.json"} { + if fileExists(filepath.Join(root, n)) { + return true + } + } + data, err := os.ReadFile(filepath.Join(root, "package.json")) + return err == nil && strings.Contains(string(data), "\"jest\"") +} diff --git a/internal/driver/registry.go b/internal/driver/registry.go new file mode 100644 index 0000000..b60d03f --- /dev/null +++ b/internal/driver/registry.go @@ -0,0 +1,24 @@ +package driver + +import "github.com/openforge-oss/anvil/internal/detect" + +// For returns the driver for a detected stack. Dart is detectable but not +// buildable as an app, so it has no driver. +func For(stack detect.Stack, root string) (Driver, bool) { + switch stack { + case detect.Flutter: + return Flutter{base{root}}, true + case detect.ReactNative: + return ReactNative{base{root}}, true + case detect.Android: + return Android{base{root}}, true + case detect.IOS: + return IOS{base{root}}, true + case detect.Kotlin: + return Kotlin{base{root}}, true + case detect.Swift: + return Swift{base{root}}, true + default: + return nil, false + } +} diff --git a/internal/driver/swift.go b/internal/driver/swift.go new file mode 100644 index 0000000..8828649 --- /dev/null +++ b/internal/driver/swift.go @@ -0,0 +1,25 @@ +package driver + +import "strings" + +type Swift struct{ base } + +func (Swift) Name() string { return "swift" } + +func (Swift) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + return []Step{{Name: "swift package resolve", Argv: []string{"swift", "package", "resolve"}}}, true + case Analyze: + return nil, false + case Test: + return []Step{{Name: "swift test", Argv: []string{"swift", "test"}}}, true + case Build: + args := []string{"build"} + if opts.Release { + args = append(args, "-c", "release") + } + return []Step{{Name: "swift " + strings.Join(args, " "), Argv: append([]string{"swift"}, args...)}}, true + } + return nil, false +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index c654d5e..98ff9ce 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -1,4 +1,179 @@ -// Package pipeline is the guided orchestrator that runs a detected project -// through its lifecycle phases. See docs/ARCHITECTURE.md. Implemented from -// Milestone 2. +// Package pipeline runs a driver's steps: it executes each phase in order, +// streams combined output line by line, captures exit codes, applies the +// driver's classification, collects artifacts, and stops on the first failure. package pipeline + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + + "github.com/openforge-oss/anvil/internal/driver" +) + +// Item is one entry in the resolved plan: a concrete step, or a skipped phase +// (Step is nil). +type Item struct { + Phase driver.Phase + Name string + Step *driver.Step +} + +// Plan resolves the full ordered list of steps for a driver, inserting a skipped +// entry for any phase the driver reports as not applicable. +func Plan(d driver.Driver, opts driver.BuildOptions) []Item { + var items []Item + for _, ph := range driver.Phases { + steps, ok := d.Steps(ph, opts) + if !ok || len(steps) == 0 { + items = append(items, Item{Phase: ph, Name: ph.String()}) + continue + } + for i := range steps { + s := steps[i] + items = append(items, Item{Phase: ph, Name: s.Name, Step: &s}) + } + } + return items +} + +type Kind int + +const ( + KindPlan Kind = iota + KindStepStart + KindLine + KindStepDone + KindDone +) + +type Event struct { + Kind Kind + Items []Item + Index int + Line string + Status driver.Status + ExitCode int + Artifacts []string + Success bool +} + +type Result struct { + Success bool + Artifacts []string +} + +// Run executes the plan for driver d rooted at root, emitting events on the +// channel and closing it when finished. Callers read the channel concurrently. +func Run(ctx context.Context, root string, d driver.Driver, opts driver.BuildOptions, events chan<- Event) Result { + defer close(events) + + items := Plan(d, opts) + events <- Event{Kind: KindPlan, Items: items} + + var artifacts []string + success := true + + for idx, it := range items { + if it.Step == nil { + events <- Event{Kind: KindStepDone, Index: idx, Status: driver.Skipped} + continue + } + events <- Event{Kind: KindStepStart, Index: idx} + + code, out := runStep(ctx, root, *it.Step, func(line string) { + events <- Event{Kind: KindLine, Index: idx, Line: line} + }) + + status := d.Classify(it.Phase, code, out) + var arts []string + if status == driver.OK { + arts = d.Artifacts(it.Phase, out) + artifacts = append(artifacts, arts...) + } + events <- Event{Kind: KindStepDone, Index: idx, Status: status, ExitCode: code, Artifacts: arts} + + if status == driver.Failed { + success = false + break + } + } + + res := Result{Success: success, Artifacts: artifacts} + events <- Event{Kind: KindDone, Success: success, Artifacts: artifacts} + return res +} + +func runStep(ctx context.Context, root string, step driver.Step, emit func(string)) (int, []byte) { + workdir := root + if step.Dir != "" { + workdir = filepath.Join(root, step.Dir) + } + + name := step.Argv[0] + if !filepath.IsAbs(name) { + if cand := filepath.Join(workdir, name); fileExists(cand) { + name = cand + } + } + + var full bytes.Buffer + w := &lineWriter{full: &full, emit: emit} + + cmd := exec.CommandContext(ctx, name, step.Argv[1:]...) + cmd.Dir = workdir + if len(step.Env) > 0 { + cmd.Env = append(os.Environ(), step.Env...) + } + cmd.Stdout = w + cmd.Stderr = w + + err := cmd.Run() + w.flush() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode(), full.Bytes() + } + emit("anvil: " + err.Error()) + return -1, full.Bytes() + } + return 0, full.Bytes() +} + +// lineWriter splits combined output into lines. exec serializes writes when the +// same writer is used for Stdout and Stderr, so no locking is needed. +type lineWriter struct { + buf []byte + full *bytes.Buffer + emit func(string) +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.full.Write(p) + w.buf = append(w.buf, p...) + for { + i := bytes.IndexByte(w.buf, '\n') + if i < 0 { + break + } + w.emit(string(bytes.TrimRight(w.buf[:i], "\r"))) + w.buf = w.buf[i+1:] + } + return len(p), nil +} + +func (w *lineWriter) flush() { + if len(w.buf) > 0 { + w.emit(string(w.buf)) + w.buf = nil + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..9dbd2bf --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -0,0 +1,108 @@ +package pipeline + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/openforge-oss/anvil/internal/driver" +) + +// TestHelperProcess is not a real test; it is re-executed as the subprocess for +// each pipeline Step so the runner can be exercised with deterministic output +// and exit codes. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + if out := os.Getenv("HELPER_OUT"); out != "" { + fmt.Fprintln(os.Stdout, out) + } + code, _ := strconv.Atoi(os.Getenv("HELPER_EXIT")) + os.Exit(code) +} + +func helperStep(name, out string, exit int) driver.Step { + return driver.Step{ + Name: name, + Argv: []string{os.Args[0], "-test.run=TestHelperProcess"}, + Env: []string{"GO_WANT_HELPER_PROCESS=1", "HELPER_OUT=" + out, "HELPER_EXIT=" + strconv.Itoa(exit)}, + } +} + +type fakeDriver struct { + steps map[driver.Phase][]driver.Step +} + +func (fakeDriver) Name() string { return "fake" } + +func (f fakeDriver) Steps(p driver.Phase, _ driver.BuildOptions) ([]driver.Step, bool) { + s := f.steps[p] + return s, len(s) > 0 +} + +func (fakeDriver) Classify(_ driver.Phase, code int, _ []byte) driver.Status { + if code == 0 { + return driver.OK + } + return driver.Failed +} + +func (fakeDriver) Artifacts(_ driver.Phase, _ []byte) []string { return nil } + +func TestRunFailFastAndSkip(t *testing.T) { + d := fakeDriver{steps: map[driver.Phase][]driver.Step{ + driver.Deps: {helperStep("deps", "deps-line", 0)}, + driver.Test: {helperStep("test", "boom", 1)}, + driver.Build: {helperStep("build", "should-not-run", 0)}, + }} + + events := make(chan Event, 128) + res := Run(context.Background(), t.TempDir(), d, driver.BuildOptions{}, events) + + done := map[int]driver.Status{} + started := map[int]bool{} + var lines []string + for e := range events { + switch e.Kind { + case KindStepStart: + started[e.Index] = true + case KindStepDone: + done[e.Index] = e.Status + case KindLine: + lines = append(lines, e.Line) + } + } + + if res.Success { + t.Error("expected failure") + } + // Plan order: 0 deps, 1 analyze (skipped), 2 test, 3 build. + if done[0] != driver.OK { + t.Errorf("deps status = %v, want ok", done[0]) + } + if done[1] != driver.Skipped { + t.Errorf("analyze status = %v, want skipped", done[1]) + } + if done[2] != driver.Failed { + t.Errorf("test status = %v, want failed", done[2]) + } + if started[3] { + t.Error("build should not start after a failure (fail-fast)") + } + if !contains(lines, "deps-line") || !contains(lines, "boom") { + t.Errorf("missing streamed lines, got %v", lines) + } +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if strings.TrimSpace(s) == want { + return true + } + } + return false +} diff --git a/internal/tui/plain.go b/internal/tui/plain.go new file mode 100644 index 0000000..9a3e854 --- /dev/null +++ b/internal/tui/plain.go @@ -0,0 +1,44 @@ +// Package tui renders pipeline events, either as an interactive Bubble Tea view +// or as plain line-oriented output for non-TTY and CI use. +package tui + +import ( + "fmt" + "io" + + "github.com/openforge-oss/anvil/internal/driver" + "github.com/openforge-oss/anvil/internal/pipeline" +) + +// Plain consumes pipeline events and writes line-oriented output. It returns +// whether the run succeeded. +func Plain(events <-chan pipeline.Event, out io.Writer) bool { + var items []pipeline.Item + success := true + for e := range events { + switch e.Kind { + case pipeline.KindPlan: + items = e.Items + case pipeline.KindStepStart: + fmt.Fprintf(out, "==> %s: %s\n", items[e.Index].Phase, items[e.Index].Name) + case pipeline.KindLine: + fmt.Fprintf(out, " %s\n", e.Line) + case pipeline.KindStepDone: + switch e.Status { + case driver.Skipped: + fmt.Fprintf(out, "--- %s: skipped\n", items[e.Index].Phase) + case driver.OK: + fmt.Fprintln(out, " ok") + for _, a := range e.Artifacts { + fmt.Fprintf(out, " artifact: %s\n", a) + } + case driver.Failed: + fmt.Fprintf(out, " failed (exit %d)\n", e.ExitCode) + success = false + } + case pipeline.KindDone: + success = e.Success + } + } + return success +} diff --git a/internal/tui/plain_test.go b/internal/tui/plain_test.go new file mode 100644 index 0000000..39c2e28 --- /dev/null +++ b/internal/tui/plain_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "bytes" + "strings" + "testing" + + "github.com/openforge-oss/anvil/internal/driver" + "github.com/openforge-oss/anvil/internal/pipeline" +) + +func TestPlainSuccess(t *testing.T) { + items := []pipeline.Item{ + {Phase: driver.Deps, Name: "install", Step: &driver.Step{}}, + {Phase: driver.Analyze, Name: "analyze"}, + } + events := make(chan pipeline.Event, 16) + events <- pipeline.Event{Kind: pipeline.KindPlan, Items: items} + events <- pipeline.Event{Kind: pipeline.KindStepStart, Index: 0} + events <- pipeline.Event{Kind: pipeline.KindLine, Index: 0, Line: "hello"} + events <- pipeline.Event{Kind: pipeline.KindStepDone, Index: 0, Status: driver.OK, Artifacts: []string{"out.apk"}} + events <- pipeline.Event{Kind: pipeline.KindStepDone, Index: 1, Status: driver.Skipped} + events <- pipeline.Event{Kind: pipeline.KindDone, Success: true} + close(events) + + var buf bytes.Buffer + if !Plain(events, &buf) { + t.Fatal("want success") + } + for _, want := range []string{"==> deps: install", "hello", "ok", "artifact: out.apk", "analyze: skipped"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("missing %q in:\n%s", want, buf.String()) + } + } +} + +func TestPlainFailure(t *testing.T) { + items := []pipeline.Item{{Phase: driver.Test, Name: "test", Step: &driver.Step{}}} + events := make(chan pipeline.Event, 8) + events <- pipeline.Event{Kind: pipeline.KindPlan, Items: items} + events <- pipeline.Event{Kind: pipeline.KindStepStart, Index: 0} + events <- pipeline.Event{Kind: pipeline.KindStepDone, Index: 0, Status: driver.Failed, ExitCode: 1} + events <- pipeline.Event{Kind: pipeline.KindDone, Success: false} + close(events) + + var buf bytes.Buffer + if Plain(events, &buf) { + t.Fatal("want failure") + } + if !strings.Contains(buf.String(), "failed (exit 1)") { + t.Errorf("missing failure line in:\n%s", buf.String()) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..f929d31 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,141 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/openforge-oss/anvil/internal/driver" + "github.com/openforge-oss/anvil/internal/pipeline" +) + +const tailLines = 8 + +type eventMsg pipeline.Event +type closedMsg struct{} + +func waitEvent(ch <-chan pipeline.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return closedMsg{} + } + return eventMsg(e) + } +} + +type model struct { + events <-chan pipeline.Event + items []pipeline.Item + status []driver.Status + spinner spinner.Model + tail []string + success bool +} + +func newModel(ch <-chan pipeline.Event) model { + s := spinner.New() + s.Spinner = spinner.Dot + return model{events: ch, spinner: s} +} + +func (m model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, waitEvent(m.events)) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case closedMsg: + return m, tea.Quit + case eventMsg: + m.apply(pipeline.Event(msg)) + return m, waitEvent(m.events) + } + return m, nil +} + +func (m *model) apply(e pipeline.Event) { + switch e.Kind { + case pipeline.KindPlan: + m.items = e.Items + m.status = make([]driver.Status, len(e.Items)) + case pipeline.KindStepStart: + if e.Index < len(m.status) { + m.status[e.Index] = driver.Running + } + m.tail = nil + case pipeline.KindLine: + m.tail = append(m.tail, e.Line) + if len(m.tail) > tailLines { + m.tail = m.tail[len(m.tail)-tailLines:] + } + case pipeline.KindStepDone: + if e.Index < len(m.status) { + m.status[e.Index] = e.Status + } + case pipeline.KindDone: + m.success = e.Success + } +} + +var ( + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + failStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + runStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + dimStyle = lipgloss.NewStyle().Faint(true) +) + +func (m model) View() string { + var b strings.Builder + for i, it := range m.items { + st := driver.Pending + if i < len(m.status) { + st = m.status[i] + } + marker, label := m.row(st, it) + fmt.Fprintf(&b, "%s %s\n", marker, label) + } + if len(m.tail) > 0 { + b.WriteString("\n") + for _, line := range m.tail { + b.WriteString(dimStyle.Render(" "+line) + "\n") + } + } + return b.String() +} + +func (m model) row(st driver.Status, it pipeline.Item) (marker, label string) { + name := fmt.Sprintf("%s: %s", it.Phase, it.Name) + switch st { + case driver.Running: + return runStyle.Render(m.spinner.View()), name + case driver.OK: + return okStyle.Render("[ok]"), name + case driver.Failed: + return failStyle.Render("[!!]"), name + case driver.Skipped: + return dimStyle.Render("[--]"), dimStyle.Render(name + " (skipped)") + default: + return dimStyle.Render("[ ]"), dimStyle.Render(name) + } +} + +// Run renders pipeline events with an interactive TUI and returns success. +func Run(events <-chan pipeline.Event) (bool, error) { + final, err := tea.NewProgram(newModel(events)).Run() + if err != nil { + return false, err + } + return final.(model).success, nil +} diff --git a/tasks/todo.md b/tasks/todo.md index 376cc69..594b862 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -36,11 +36,25 @@ Known limitation: a project nested inside a detected root is not separately surfaced (add-to-app, plugin example), a consequence of prune-on-detect; revisit if needed. Next: Milestone 2 (guided build lifecycle). -## Milestone 2: guided build lifecycle +## Milestone 2: guided build lifecycle (in progress) + +- [x] `driver` lifecycle contract (Phase, Status, Step, BuildOptions, Driver) + registry +- [x] Drivers: Flutter, React Native, Android, iOS, plus Swift and Kotlin/JVM (pulled forward) +- [x] `--flavor` threaded through build and test steps +- [x] Detection refinement: Gradle is not always Android, `Package.swift` is Swift, plus Swift and Kotlin stacks +- [x] `internal/pipeline` runner: exec streaming, exit codes, classify, artifacts, fail-fast +- [x] `internal/tui`: Bubble Tea view + plain non-TTY renderer +- [x] `anvil build` command (`--path`, `--target`, `--flavor`, `--release`, `--dry-run`, `--plain`) +- [x] Tests: driver Steps, runner via subprocess helper, plain renderer; `./check` green +- [ ] PR into develop, CI green -- [ ] `driver` lifecycle contract; Flutter driver first (deps, analyze, test, build) -- [ ] Interactive TUI (Charm/Bubble Tea); unified error surfacing -- [ ] Android, iOS, React Native drivers +### Review, Milestone 2 +Build lifecycle works end to end. `anvil build` detects the project, picks the +driver, and runs deps, analyze, test, build with a live TUI or a plain CI +renderer. Verified: dry-run and flavor wiring on ca-mobile, the runner via a real +subprocess (streaming, exit codes, fail-fast), and the no-project error path. +Signing and store upload remain for Milestones 3 and 4. iOS scheme is derived by +convention (or `--flavor`); auto-detecting flavors and schemes is a later step. ## Milestone 3: signing and upload