From bc203c884fded0a2eca27bb04bc7f5124773fad7 Mon Sep 17 00:00:00 2001 From: David-paul O Folorunsho-roberts Date: Thu, 23 Jul 2026 10:33:17 +0100 Subject: [PATCH 1/8] feat(detect): stack detection engine and anvil detect command Add internal/detect with marker-file detectors for Flutter (vs pure Dart; app/module/plugin), React Native (bare, Expo managed, Expo prebuild), native Android (app vs library, KMP flag), and native iOS (Xcode/SPM/Podfile). A depth-limited prune-on-detect scanner with skip lists and a containment sweep attributes android/ios folders to their Flutter or RN parent and surfaces each monorepo project once. Add the Cobra-based anvil detect command (table and --json, with --path and --depth). 16 fixture-tree tests cover the edge cases (node_modules exclusion, monorepo, KMP, plugin example pruning). Also apply the no-emojis, no-dash-connectors, minimal-comments style across docs and record it in CLAUDE.md. --- CHANGELOG.md | 26 ++-- CLAUDE.md | 101 +++++++++------- README.md | 56 ++++----- cmd/detect.go | 68 +++++++++++ cmd/root.go | 51 +++----- cmd/version.go | 21 +++- docs/ARCHITECTURE.md | 83 ++++++------- docs/ROADMAP.md | 26 ++-- go.mod | 8 ++ go.sum | 12 ++ internal/detect/android.go | 53 +++++++++ internal/detect/detect.go | 179 ++++++++++++++++++++++++++-- internal/detect/detect_test.go | 211 +++++++++++++++++++++++++++++++++ internal/detect/flutter.go | 51 ++++++++ internal/detect/ios.go | 20 ++++ internal/detect/reactnative.go | 49 ++++++++ internal/driver/driver.go | 15 +-- internal/pipeline/pipeline.go | 11 +- tasks/lessons.md | 17 +-- tasks/todo.md | 70 ++++++----- 20 files changed, 892 insertions(+), 236 deletions(-) create mode 100644 cmd/detect.go create mode 100644 go.sum create mode 100644 internal/detect/android.go create mode 100644 internal/detect/detect_test.go create mode 100644 internal/detect/flutter.go create mode 100644 internal/detect/ios.go create mode 100644 internal/detect/reactnative.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d80c5..2f67061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,25 @@ All notable changes are documented here, following ## [Unreleased] ### Added -- Initial repository scaffold: Go module + CLI skeleton (`version`/`help`) and - `internal/{detect,driver,pipeline}` package stubs. -- Working pipeline: `./check`/`./lint`/`./test`/`./build` wrapper scripts - (Go-only), `Taskfile.yml`, and `CLAUDE.md` collaboration rules. -- Claude Code hooks: fingerprint-gated Stop-hook (`./check` on finish) and a +- 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 + (Xcode project/workspace, SPM, Podfile). Depth-limited prune-on-detect scanner + with skip lists and a containment sweep, so `android/`/`ios/` folders are + attributed to their Flutter or React Native parent and monorepos surface each + project once. +- `anvil detect` command: table and `--json` output, `--path` and `--depth` flags. +- Adopted Cobra for the CLI. +- Style rules in `CLAUDE.md` (no emojis, no dash connectors, minimal comments). +- Initial repository scaffold: Go module plus CLI skeleton (`version`, `help`) + and `internal/{detect,driver,pipeline}` package stubs. +- Working pipeline: `./check`, `./lint`, `./test`, `./build` wrapper scripts + (Go only), `Taskfile.yml`, and `CLAUDE.md` collaboration rules. +- Claude Code hooks: fingerprint-gated Stop hook (`./check` on finish) and a dangerous-command guard, wired in `.claude/settings.json`. -- CI: cross-platform matrix (build/vet/test) + staticcheck + govulncheck, with - concurrency cancellation. +- CI: cross-platform matrix (build, vet, test) plus staticcheck and govulncheck, + with concurrency cancellation. - Docs: `README.md`, `docs/ROADMAP.md`, `docs/ARCHITECTURE.md`, and the - `tasks/todo.md` / `tasks/lessons.md` progress + lessons files. + `tasks/todo.md` and `tasks/lessons.md` progress and lessons files. [Unreleased]: https://github.com/openforge-oss/anvil/commits/main diff --git a/CLAUDE.md b/CLAUDE.md index cbf0737..982d461 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,74 +1,85 @@ -# CLAUDE.md — how to work in `anvil` +# CLAUDE.md, how to work in anvil -`anvil` is a guided, zero-config **build & release pipeline CLI** for mobile and -app projects (Go, single binary). This file is the contract for any AI agent -working here. Read it, and `tasks/lessons.md`, at the start of every session. +anvil is a guided, zero-config build and release pipeline CLI for mobile and app +projects (Go, single binary). This file is the contract for any AI agent working +here. Read it, and `tasks/lessons.md`, at the start of every session. ## Golden rules (non-negotiable) -1. **Plan first.** For anything non-trivial (3+ steps or a design choice), write - the plan to `tasks/todo.md` and get confirmation before executing. If it goes +1. Plan first. For anything non-trivial (3+ steps or a design choice), write the + plan to `tasks/todo.md` and get confirmation before executing. If it goes sideways, stop and re-plan. -2. **Confirm, don't assume.** Never make a consequential decision (naming, scope, - architecture, dependencies, anything outward-facing) on your own — ask first. -3. **Branch → PR, never push `main`.** Cut `feat/…` / `fix/…` from `develop`; - open a PR into `develop`. `main` is protected and released. -4. **Verify before "done".** Run `./check` and prove it passes. "Would a staff - engineer approve this?" No feature is complete without evidence. -5. **Simplicity first, no laziness, minimal impact.** Find root causes; touch - only what's necessary; no temporary hacks. -6. **Commits are the user's.** Author commits as the user's git identity. Never - add a `Co-Authored-By: Claude` trailer or otherwise credit the agent. +2. Confirm, do not assume. Never make a consequential decision (naming, scope, + architecture, dependencies, anything outward-facing) on your own. Ask first. +3. Branch then PR, never push `main`. Cut `feat/...` or `fix/...` from `develop` + and open a PR into `develop`. `main` is protected and released. +4. Verify before "done". Run `./check` and prove it passes. No feature is + complete without evidence. +5. Simplicity first, no laziness, minimal impact. Find root causes, touch only + what is necessary, no temporary hacks. +6. Commits are the user's. Author commits as the user's git identity. Never add a + `Co-Authored-By: Claude` trailer or otherwise credit the agent. + +## Style + +- No emojis in code, docs, commit messages, or output. +- No em-dash ("—") and no " - " dash connectors in prose. Use commas, periods, + parentheses, or colons. Normal hyphens in compound words (zero-config) and CLI + flags (`--json`) are fine. +- No unnecessary comments. Names must be self-explanatory. Comment only a + non-obvious constraint that cannot be expressed in code. A short package doc + comment is fine. ## The loop 1. Session start: skim `tasks/todo.md` (where we are) and `tasks/lessons.md` (mistakes not to repeat). -2. Plan → confirm → implement in small steps → **`./check`** → open a PR. -3. After **any** correction from the user, append a `mistake → rule` line to +2. Plan, confirm, implement in small steps, `./check`, open a PR. +3. After any correction from the user, append a `mistake -> rule` line to `tasks/lessons.md`. Iterate until the mistake rate drops. ## Commands (use the wrappers, not raw tools) -- `./check` — lint + test. **Run before every push.** -- `./lint` — gofmt check, `go vet`, staticcheck/govulncheck (if installed). -- `./test` — `go test ./... -race -cover` (args pass through). -- `./build` — build `bin/anvil` (`GORELEASE=vX.Y.Z ./build` stamps the version). +- `./check`: lint plus test. Run before every push. +- `./lint`: gofmt check, `go vet`, staticcheck/govulncheck (if installed). +- `./test`: `go test ./... -race -cover` (args pass through). +- `./build`: build `bin/anvil` (`GORELEASE=vX.Y.Z ./build` stamps the version). -(`task ` also works if [go-task](https://taskfile.dev) is installed.) +`task ` also works if [go-task](https://taskfile.dev) is installed. -## Token & context economy (treat tokens as money) +## Token and context economy (treat tokens as money) -- **Locate, then read narrowly** (grep/glob → read the needed range). Never read a - whole large file to find one thing. Never re-read a file you just wrote. -- **Filter output at the source** — pipe through `grep`/`head`/`tail`; never dump - whole logs or JSON. -- **Batch & parallelize** independent reads/searches; don't re-run expensive commands. -- **Offload heavy research to subagents** and take back only the conclusion — keep - the main context clean. -- **Subagent test discipline:** subagents lint-only; the orchestrator runs the full - `./test` **once** at the end — never N× per subagent. -- **Be concise & surgical.** Reference `path:line`; change the few lines that matter; - no rewrites, no filler. No rabbit holes — if it isn't converging, stop and re-plan. +- Locate, then read narrowly (grep or glob, then read the needed range). Never + read a whole large file to find one thing. Never re-read a file you just wrote. +- Filter output at the source (pipe through grep, head, tail). Never dump whole + logs or JSON. +- Batch and parallelize independent reads and searches. Do not re-run expensive + commands. +- Offload heavy research to subagents and take back only the conclusion. +- Subagent test discipline: subagents lint only; the orchestrator runs the full + `./test` once at the end, never N times per subagent. +- Be concise and surgical. Reference `path:line`, change the few lines that + matter, no rewrites, no filler. No rabbit holes: if it is not converging, stop + and re-plan. ## Review loop -- CodeRabbit / `@claude` are **opt-in** (comment-triggered), not on every commit. -- Cap automated fix iterations at **3**; escalate security or ambiguous items to the +- CodeRabbit and `@claude` are opt-in (comment-triggered), not on every commit. +- Cap automated fix iterations at 3, escalate security or ambiguous items to the user rather than looping. ## Automation in this repo -- **Stop hook** (`scripts/hooks/check-on-stop.sh`): auto-runs `./check` when you - finish — but only if the working tree changed since the last check (fingerprint +- Stop hook (`scripts/hooks/check-on-stop.sh`): auto-runs `./check` when you + finish, but only if the working tree changed since the last check (fingerprint cached in `.cache/`). If it fails, fix before finishing. -- **Command guard** (`scripts/hooks/forbidden-commands.py`): blocks force-push, - `rm -rf` on dangerous paths, `git reset --hard`, `git clean -fd…`. +- Command guard (`scripts/hooks/forbidden-commands.py`): blocks force-push, + `rm -rf` on dangerous paths, `git reset --hard`, `git clean -fd`. - Both are wired in `.claude/settings.json`. ## Where things are -- `cmd/` — CLI entry (stdlib skeleton today; Cobra/Charm arrive with the tool build). -- `internal/detect`, `internal/driver`, `internal/pipeline` — the tool's core - (detector + per-ecosystem driver + orchestrator). See `docs/ARCHITECTURE.md`. -- `docs/ROADMAP.md` — phases and status. `tasks/todo.md` — live progress. +- `cmd/`: CLI entry (Cobra: root, version, detect). +- `internal/detect`, `internal/driver`, `internal/pipeline`: the tool's core + (detector, per-ecosystem driver, orchestrator). See `docs/ARCHITECTURE.md`. +- `docs/ROADMAP.md`: phases and status. `tasks/todo.md`: live progress. diff --git a/README.md b/README.md index 3c6ef43..2cab18c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@
-# ⚒️ anvil +# anvil -**A guided, zero-config build & release pipeline for mobile and app projects.** +**A guided, zero-config build and release pipeline for mobile and app projects.** -One command — detect the stack, fetch deps, analyze, surface errors, test, build -(→ sign → upload) — without memorizing each framework's CLI. +One command detects the stack, fetches dependencies, analyzes, surfaces errors, +tests, and builds (then signs and uploads), without memorizing each framework's CLI. [![CI](https://github.com/openforge-oss/anvil/actions/workflows/ci.yml/badge.svg)](https://github.com/openforge-oss/anvil/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -15,54 +15,54 @@ Part of [OpenForge](https://github.com/openforge-oss).
---- - ## Status -🚧 **Early development — Milestone 0** (repo + working pipeline). The CLI is a -skeleton; the detection/build engine is next. Track progress in +Early development, Milestone 1 (detection engine). The CLI can detect a project's +stack; the build lifecycle comes next. Track progress in [`tasks/todo.md`](tasks/todo.md) and [`docs/ROADMAP.md`](docs/ROADMAP.md). ## The problem -Shipping a mobile/app build is a fiddly, error-prone grind — Gradle/AGP version -matrices, the CocoaPods→SPM migration, iOS provisioning & code signing, per-stack -build commands nobody remembers. Existing tools either need config and a cloud -account (fastlane, Codemagic), only output server containers (Nixpacks, -buildpacks), or are heavy monorepo build systems (Nx, Bazel). None is a **local, -zero-config, auto-detecting, guided CLI that produces mobile artifacts**. +Shipping a mobile or app build is a fiddly, error-prone grind: Gradle and AGP +version matrices, the CocoaPods to Swift Package Manager migration, iOS +provisioning and code signing, and per-stack build commands nobody remembers. +Existing tools either need config and a cloud account (fastlane, Codemagic), only +output server containers (Nixpacks, buildpacks), or are heavy monorepo build +systems (Nx, Bazel). None is a local, zero-config, auto-detecting, guided CLI +that produces mobile artifacts. ## The idea ```console $ anvil ship # (planned) -✔ Detected: Flutter app (android, ios) -✔ flutter pub get -✔ flutter analyze 0 issues -✔ flutter test 42 passed -▶ Build target? › Android App Bundle (.aab) iOS Archive (.ipa) Both -… +Detected: Flutter app (android, ios) +flutter pub get ok +flutter analyze 0 issues +flutter test 42 passed +Build target? Android App Bundle (.aab) / iOS Archive (.ipa) / Both +... ``` Auto-detect the stack, run the right lifecycle, explain failures in plain language, and (later) walk you through signing and store upload. It graduates beginners by printing the exact commands it runs. -- **v1 stacks:** Flutter, React Native, native Android, native iOS. -- **Built in Go** (single static binary; nothing to install but the binary). -- **Pluggable:** adding a framework = adding one *driver* — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +- v1 stacks: Flutter, React Native, native Android, native iOS. +- Built in Go (single static binary; nothing to install but the binary). +- Pluggable: adding a framework means adding one driver. See + [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). -## Contributing / working here +## Contributing This repo runs a disciplined, low-token workflow. Read [`CLAUDE.md`](CLAUDE.md) -first. TL;DR: branch → PR (never push `main`), and run **`./check`** (lint + +first. In short: branch then PR (never push `main`), and run `./check` (lint plus test) before every push. ```bash -./check # gofmt + go vet + staticcheck/govulncheck + go test -race -./build # → bin/anvil +./check # gofmt, go vet, staticcheck/govulncheck, go test -race +./build # produces bin/anvil ``` ## License -[MIT](LICENSE) © 2026 OpenForge. +[MIT](LICENSE), OpenForge, 2026. diff --git a/cmd/detect.go b/cmd/detect.go new file mode 100644 index 0000000..4e2711b --- /dev/null +++ b/cmd/detect.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "github.com/openforge-oss/anvil/internal/detect" + "github.com/spf13/cobra" +) + +var ( + detectPath string + detectJSON bool + detectDepth int +) + +var detectCmd = &cobra.Command{ + Use: "detect", + Short: "Detect the stack(s) in a project directory", + Args: cobra.NoArgs, + RunE: runDetect, +} + +func init() { + detectCmd.Flags().StringVar(&detectPath, "path", ".", "directory to scan") + detectCmd.Flags().BoolVar(&detectJSON, "json", false, "output JSON") + detectCmd.Flags().IntVar(&detectDepth, "depth", 4, "maximum directory depth to scan") + rootCmd.AddCommand(detectCmd) +} + +func runDetect(cmd *cobra.Command, _ []string) error { + opts := detect.DefaultOptions() + opts.MaxDepth = detectDepth + + projects, err := detect.Scan(detectPath, opts) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + + if detectJSON { + if projects == nil { + projects = []detect.Project{} + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(projects) + } + + if len(projects) == 0 { + fmt.Fprintln(out, "No supported stack detected.") + return nil + } + + w := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "PATH\tSTACK\tSUBTYPE\tCONFIDENCE") + for _, p := range projects { + subtype := p.Subtype + if len(p.Flags) > 0 { + subtype += " [" + strings.Join(p.Flags, ",") + "]" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%.2f\n", p.Path, p.Stack, subtype, p.Confidence) + } + return w.Flush() +} diff --git a/cmd/root.go b/cmd/root.go index 78d9b5a..e0e0bdf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,48 +1,25 @@ -// Package cmd wires up the anvil command-line interface. -// -// This is the minimal skeleton: it dispatches a couple of built-in commands so -// the binary builds and runs. The guided build/release pipeline (detect → deps -// → analyze → test → build → sign → upload) arrives in the tool-build phase; -// see docs/ROADMAP.md. +// Package cmd implements the anvil command line. package cmd import ( - "flag" - "fmt" "os" -) - -const usage = `anvil — a guided, zero-config build & release pipeline for mobile and app projects. -Usage: - anvil [flags] + "github.com/spf13/cobra" +) -Commands: - version Print the anvil version - help Show this help +var rootCmd = &cobra.Command{ + Use: "anvil", + Short: "Guided, zero-config build and release pipeline for mobile and app projects", + Long: `anvil detects a project's stack and runs the right build and release +lifecycle with one guided command, without memorizing each framework's CLI. -anvil is under active development. See docs/ROADMAP.md for what's coming. -` +anvil is under active development. See docs/ROADMAP.md.`, + SilenceUsage: true, +} -// Execute is the entry point for the anvil CLI. +// Execute runs the root command and exits non-zero on error. func Execute() { - flag.Usage = func() { fmt.Fprint(os.Stderr, usage) } - flag.Parse() - - args := flag.Args() - if len(args) == 0 { - fmt.Fprint(os.Stderr, usage) - os.Exit(2) - } - - switch args[0] { - case "version": - fmt.Println(versionString()) - case "help", "-h", "--help": - fmt.Print(usage) - default: - fmt.Fprintf(os.Stderr, "anvil: unknown command %q\n\n", args[0]) - fmt.Fprint(os.Stderr, usage) - os.Exit(2) + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } } diff --git a/cmd/version.go b/cmd/version.go index 8b2176f..0eb83b8 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,11 +1,28 @@ package cmd -import "runtime/debug" +import ( + "fmt" + "runtime/debug" + + "github.com/spf13/cobra" +) // version is injected at release time via -ldflags "-X ...cmd.version=vX.Y.Z". -// For `go install`ed or `go run` builds it falls back to the module build info. var version = "dev" +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the anvil version", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + fmt.Fprintln(cmd.OutOrStdout(), versionString()) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + func versionString() string { if version != "dev" { return "anvil " + version diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 583497a..16a0678 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,41 +1,43 @@ # Architecture -The design goal: **the core knows the *phases*; drivers know the *frameworks*.** -Adding support for a new stack = implementing one detector + one driver and -registering it. (Pattern borrowed from Nx plugins / Buck2's language-agnostic -core / Cloud Native Buildpacks' detect phase.) +Design goal: the core knows the phases; drivers know the frameworks. Adding +support for a new stack means implementing one detector plus one driver and +registering it. The pattern is borrowed from Nx plugins, Buck2's +language-agnostic core, and Cloud Native Buildpacks' detect phase. ``` - ┌──────────────────────────────────────────────┐ - │ pipeline │ - │ detect → confirm → deps → analyze → test → │ - │ build → sign → upload (guided TUI, unified │ - │ error surfacing) │ - └───────────────┬───────────────┬──────────────┘ - │ │ - internal/detect internal/driver (registry) - │ │ - markers → confidence ┌───────┴────────┬───────────┐ - Flutter ReactNative Android iOS … + core: pipeline + detect, confirm, deps, analyze, test, build, sign, upload + (guided TUI, unified error surfacing) + | | + internal/detect internal/driver (registry) + | | + markers -> confidence Flutter ReactNative Android iOS ... ``` ## Components -### `internal/detect` -Each detector inspects a directory and returns a **confidence score** from marker -files: +### internal/detect + +Each detector inspects a directory and returns a confidence score from marker +files. | Marker | Stack | |--------|-------| -| `pubspec.yaml` | Flutter / Dart | -| `package.json` with a `react-native` dep | React Native | -| `build.gradle` / `settings.gradle` | native Android | -| `*.xcodeproj` / `*.xcworkspace` / `Podfile` | native iOS | +| `pubspec.yaml` with a `flutter:` block or `sdk: flutter` | Flutter (else pure Dart) | +| `package.json` with a `react-native` or `expo` dependency | React Native (bare or Expo) | +| `settings.gradle(.kts)` | native Android build root | +| `*.xcodeproj`, `*.xcworkspace`, `Podfile`, `Package.swift` | native iOS | + +Detectors run over a depth-limited walk. The scanner prunes on detect (it does +not descend into a detected root), so a Gradle multi-module build is one project +and the `android/` and `ios/` folders inside a Flutter or React Native app are +attributed to that parent, never reported as separate stacks. A containment +sweep is the safety net. Directories like `node_modules`, `Pods`, `build`, and +`.dart_tool` are skipped. -Detectors run independently; the pipeline resolves multiple hits (e.g. an RN app -with `android/` + `ios/` folders) by confidence and nesting. +### internal/driver -### `internal/driver` Every ecosystem implements one lifecycle contract (sketch): ```go @@ -47,25 +49,26 @@ type Driver interface { Build(ctx Context) (Artifacts, error) Sign(ctx Context) Result // phase 3 Upload(ctx Context) Result // phase 4 - Capabilities() Capabilities // artifact types, supportsSigning, … + Capabilities() Capabilities } ``` -A driver just shells out to that ecosystem's real toolchain (`flutter`, `gradle`, -`xcodebuild`, `npm`) and returns **normalized, structured results**. That -normalization — turning raw tool output into one consistent, plain-language error -model — is the actual product. +A driver shells out to that ecosystem's real toolchain (flutter, gradle, +xcodebuild, npm) and returns normalized, structured results so the pipeline can +surface errors uniformly. That normalization, turning raw tool output into one +consistent plain-language error model, is the actual product. -### `internal/pipeline` -Language-agnostic orchestrator: pick driver(s) via the registry, query -`Capabilities()` to decide which phases/prompts apply (skip signing for a web +### internal/pipeline + +Language-agnostic orchestrator: pick the driver(s) via the registry, query +`Capabilities()` to decide which phases and prompts apply (skip signing for a web build), run phases in order, stop cleanly on failure, and drive the interactive -UI. Knows the phases, nothing about any framework. +UI. It knows the phases, nothing about any framework. ## Implementation notes -- **Go**, single static binary. CLI via Cobra and the guided TUI via the Charm - stack (Bubble Tea + Huh) land with the tool build (Milestone 2); the current - skeleton is stdlib-only so it builds offline. -- Drivers are compiled-in first; can be externalized as plugins later without - touching the core. -- Distribution (later): GoReleaser → Homebrew tap + Scoop + `curl | sh`. + +- Go, single static binary. CLI via Cobra. The guided TUI (Charm stack: Bubble + Tea plus Huh) lands with the build lifecycle in Milestone 2. +- Drivers are compiled in first; they can be externalized as plugins later + without touching the core. +- Distribution (later): GoReleaser to a Homebrew tap, Scoop, and `curl | sh`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eb6eddb..9709005 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -5,21 +5,23 @@ High-level phases and status, for monitoring. Granular tasks live in | Phase | What | Status | |-------|------|--------| -| **0. Repo + pipeline** | Go skeleton, wrapper scripts, Claude hooks, CI, docs, working rules | 🟡 in progress | -| **1. Detection** | Marker-file detectors for Flutter / React Native / Android / iOS; `anvil detect` | ⚪ next | -| **2. Guided build** | Driver lifecycle (deps→analyze→test→build); Flutter first, then RN/Android/iOS; interactive TUI; unified error surfacing | ⚪ planned | -| **3. Signing** | Guided Android keystore + 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 | +| 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 | +| 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 | ## Guiding decisions (locked) -- **Language:** Go — single static binary, neutral (not tied to any framework), best guided-CLI toolkit, trivial cross-platform distribution. -- **Shape:** local-first, zero-config, auto-detecting, interactive. Not a cloud CI, not a config-heavy monorepo tool. -- **Scope:** mobile-first in v1; the driver architecture keeps new stacks cheap. +- Language: Go. Single static binary, neutral (not tied to any framework), strong + guided-CLI toolkit, trivial cross-platform distribution. +- Shape: local-first, zero-config, auto-detecting, interactive. Not a cloud CI, + not a config-heavy monorepo tool. +- Scope: mobile-first in v1. The driver architecture keeps new stacks cheap. ## Non-goals (for now) -- Replacing CI platforms (GitHub Actions/Codemagic) — `anvil` is the local - shortcut; it can *emit* CI config later. -- Being a monorepo task graph (that's Nx/Bazel/moon). +- Replacing CI platforms (GitHub Actions, Codemagic). anvil is the local shortcut; + it can emit CI config later. +- Being a monorepo task graph (that is Nx, Bazel, moon). diff --git a/go.mod b/go.mod index d2f760a..fa41e71 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/openforge-oss/anvil go 1.26 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff4d6ec --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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 new file mode 100644 index 0000000..1a1e601 --- /dev/null +++ b/internal/detect/android.go @@ -0,0 +1,53 @@ +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 { + return nil + } + + confidence := 0.6 + if hasSettings { + confidence = 0.9 + } + + subtype := "library" + if gradleContains(dir, "com.android.application") { + subtype = "app" + } + + var flags []string + if gradleContains(dir, "kotlin(\"multiplatform\")") || + gradleContains(dir, "org.jetbrains.kotlin.multiplatform") { + flags = append(flags, "kmp") + } + + return &Project{Path: dir, Stack: Android, Subtype: subtype, Confidence: confidence, Flags: flags} +} + +// 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. +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 _, c := range candidates { + if fileContains(c, token) { + return true + } + } + return false +} diff --git a/internal/detect/detect.go b/internal/detect/detect.go index 224b746..ad2f1f9 100644 --- a/internal/detect/detect.go +++ b/internal/detect/detect.go @@ -1,9 +1,172 @@ -// Package detect identifies a project's ecosystem(s) from marker files: -// pubspec.yaml → Flutter; package.json with a react-native dep → React Native; -// build.gradle / settings.gradle → native Android; *.xcodeproj / *.xcworkspace -// / Podfile → native iOS; and more as drivers are added. -// -// Detectors run independently and return a confidence score; the pipeline -// resolves multiple hits (e.g. an RN app with native folders) by confidence and -// nesting. Implemented in the tool-build phase — see docs/ARCHITECTURE.md. +// Package detect identifies the stack(s) of a project tree from marker files. package detect + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +type Stack string + +const ( + Flutter Stack = "flutter" + Dart Stack = "dart" + ReactNative Stack = "react-native" + Android Stack = "android" + IOS Stack = "ios" +) + +type Project struct { + Path string `json:"path"` + Stack Stack `json:"stack"` + Subtype string `json:"subtype,omitempty"` + Confidence float64 `json:"confidence"` + Flags []string `json:"flags,omitempty"` +} + +type Options struct { + MaxDepth int + SkipDirs map[string]bool +} + +func DefaultSkipDirs() map[string]bool { + names := []string{ + "node_modules", ".git", "build", ".gradle", ".dart_tool", "Pods", + "DerivedData", ".expo", ".idea", ".fvm", ".symlinks", "Carthage", + ".build", "out", "dist", "vendor", ".cxx", + } + m := make(map[string]bool, len(names)) + for _, n := range names { + m[n] = true + } + return m +} + +func DefaultOptions() Options { + return Options{MaxDepth: 4, SkipDirs: DefaultSkipDirs()} +} + +// Scan walks root and returns one Project per detected stack root. It prunes on +// detect (it does not descend into a detected root), skips DefaultSkipDirs, and +// absorbs android/ios folders that belong to a Flutter or React Native parent. +func Scan(root string, opts Options) ([]Project, error) { + if opts.MaxDepth <= 0 { + opts.MaxDepth = 4 + } + if opts.SkipDirs == nil { + opts.SkipDirs = DefaultSkipDirs() + } + root = filepath.Clean(root) + + var found []Project + walk := func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if path != root && opts.SkipDirs[d.Name()] { + return filepath.SkipDir + } + depth := depthOf(root, path) + if depth > opts.MaxDepth { + return filepath.SkipDir + } + if p := detectDir(path); p != nil { + found = append(found, *p) + return filepath.SkipDir + } + if depth == opts.MaxDepth { + return filepath.SkipDir + } + return nil + } + if err := filepath.WalkDir(root, walk); err != nil { + return nil, err + } + return containmentSweep(found), nil +} + +func detectDir(dir string) *Project { + if p := detectFlutterOrDart(dir); p != nil { + return p + } + if p := detectReactNative(dir); p != nil { + return p + } + if p := detectAndroid(dir); p != nil { + return p + } + if p := detectIOS(dir); p != nil { + return p + } + return nil +} + +var ownedChildren = map[string]bool{ + "android": true, "ios": true, "macos": true, "windows": true, + "linux": true, "web": true, ".android": true, ".ios": true, +} + +func containmentSweep(ps []Project) []Project { + sort.SliceStable(ps, func(i, j int) bool { return len(ps[i].Path) < len(ps[j].Path) }) + var out []Project + for _, p := range ps { + if p.Stack == Android || p.Stack == IOS { + if absorbedByParent(p, out) { + continue + } + } + out = append(out, p) + } + return out +} + +func absorbedByParent(child Project, kept []Project) bool { + for _, anc := range kept { + if anc.Stack != Flutter && anc.Stack != ReactNative { + continue + } + if isDescendant(anc.Path, child.Path) && ownedChildren[filepath.Base(child.Path)] { + return true + } + } + return false +} + +func depthOf(root, path string) int { + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." { + return 0 + } + return strings.Count(rel, string(filepath.Separator)) + 1 +} + +func isDescendant(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel != "." && !strings.HasPrefix(rel, "..") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func hasGlob(dir, pattern string) bool { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + return err == nil && len(matches) > 0 +} + +func fileContains(path, substr string) bool { + data, err := os.ReadFile(path) + return err == nil && strings.Contains(string(data), substr) +} diff --git a/internal/detect/detect_test.go b/internal/detect/detect_test.go new file mode 100644 index 0000000..474dea6 --- /dev/null +++ b/internal/detect/detect_test.go @@ -0,0 +1,211 @@ +package detect + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + 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) + } + } + return root +} + +func summarize(root string, ps []Project) []string { + out := make([]string, 0, len(ps)) + for _, p := range ps { + rel, _ := filepath.Rel(root, p.Path) + s := filepath.ToSlash(rel) + "|" + string(p.Stack) + "|" + p.Subtype + if len(p.Flags) > 0 { + f := append([]string(nil), p.Flags...) + sort.Strings(f) + s += "|" + strings.Join(f, ",") + } + out = append(out, s) + } + sort.Strings(out) + return out +} + +const flutterAppPubspec = `name: demo +environment: + sdk: ">=3.0.0 <4.0.0" +dependencies: + flutter: + sdk: flutter +` + +func TestScan(t *testing.T) { + cases := []struct { + name string + files map[string]string + want []string + }{ + { + name: "flutter app absorbs native folders", + files: map[string]string{ + "pubspec.yaml": flutterAppPubspec, + "lib/main.dart": "void main() {}", + "android/.gitkeep": "", + "ios/.gitkeep": "", + "ios/Podfile": "platform :ios", + "android/build.gradle": "", + }, + want: []string{".|flutter|app"}, + }, + { + name: "pure dart package is not flutter", + files: map[string]string{"pubspec.yaml": "name: mylib\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\ndependencies:\n meta: ^1.0.0\n"}, + want: []string{".|dart|package"}, + }, + { + name: "flutter module", + files: map[string]string{ + "pubspec.yaml": "name: mod\ndependencies:\n flutter:\n sdk: flutter\nflutter:\n module:\n androidPackage: com.example.mod\n", + }, + want: []string{".|flutter|module"}, + }, + { + name: "flutter plugin prunes its example app", + files: map[string]string{ + "pubspec.yaml": "name: plug\ndependencies:\n flutter:\n sdk: flutter\nflutter:\n plugin:\n platforms:\n android:\n package: com.example.plug\n", + "android/.gitkeep": "", + "ios/.gitkeep": "", + "example/pubspec.yaml": flutterAppPubspec, + "example/lib/main.dart": "void main() {}", + }, + want: []string{".|flutter|plugin"}, + }, + { + name: "bare react native", + files: map[string]string{ + "package.json": `{"name":"rn","dependencies":{"react-native":"0.74.0"}}`, + "android/.gitkeep": "", + "ios/.gitkeep": "", + }, + want: []string{".|react-native|bare-rn"}, + }, + { + name: "expo managed without native folders", + files: map[string]string{"package.json": `{"name":"exp","dependencies":{"expo":"51.0.0","react-native":"0.74.0"}}`}, + want: []string{".|react-native|expo-managed"}, + }, + { + name: "expo prebuild with native folders", + files: map[string]string{ + "package.json": `{"name":"exp","dependencies":{"expo":"51.0.0","react-native":"0.74.0"}}`, + "android/.gitkeep": "", + "ios/.gitkeep": "", + }, + want: []string{".|react-native|expo-bare"}, + }, + { + name: "android app with application module", + files: map[string]string{ + "settings.gradle": "include ':app'", + "app/build.gradle": "plugins { id 'com.android.application' }", + }, + want: []string{".|android|app"}, + }, + { + name: "android library only", + files: map[string]string{ + "settings.gradle": "include ':lib'", + "build.gradle": "plugins { id 'com.android.library' }", + }, + want: []string{".|android|library"}, + }, + { + name: "gradle multi module is one project", + files: map[string]string{ + "settings.gradle": "include ':app', ':core'", + "app/build.gradle": "plugins { id 'com.android.application' }", + "core/build.gradle": "plugins { id 'com.android.library' }", + }, + want: []string{".|android|app"}, + }, + { + name: "standalone ios workspace and project count once", + files: map[string]string{ + "App.xcodeproj/project.pbxproj": "", + "App.xcworkspace/contents.xcworkspacedata": "", + "Podfile": "platform :ios, '13.0'", + }, + want: []string{".|ios|app"}, + }, + { + 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"}, + }, + { + name: "node_modules yields no phantom react native projects", + files: map[string]string{ + "package.json": `{"name":"rn","dependencies":{"react-native":"0.74.0"}}`, + "android/.gitkeep": "", + "ios/.gitkeep": "", + "node_modules/react-native/package.json": `{"name":"react-native","version":"0.74.0"}`, + "node_modules/some-lib/package.json": `{"name":"some-lib","dependencies":{"react-native":"0.74.0"}}`, + }, + want: []string{".|react-native|bare-rn"}, + }, + { + name: "mixed monorepo surfaces each project", + files: map[string]string{ + "package.json": `{"name":"mono","private":true,"workspaces":["apps/*","packages/*"]}`, + "apps/fl/pubspec.yaml": flutterAppPubspec, + "apps/fl/lib/main.dart": "void main() {}", + "apps/rn/package.json": `{"name":"rn","dependencies":{"react-native":"0.74.0"}}`, + "apps/rn/android/.gitkeep": "", + "apps/rn/ios/.gitkeep": "", + "packages/andlib/settings.gradle": "include ':lib'", + "packages/andlib/build.gradle": "plugins { id 'com.android.library' }", + }, + want: []string{ + "apps/fl|flutter|app", + "apps/rn|react-native|bare-rn", + "packages/andlib|android|library", + }, + }, + { + name: "kotlin multiplatform flagged, ios app pruned", + files: map[string]string{ + "settings.gradle.kts": "include(\":androidApp\", \":shared\")", + "build.gradle.kts": "plugins { kotlin(\"multiplatform\") }", + "androidApp/build.gradle.kts": "plugins { id(\"com.android.application\") }", + "shared/build.gradle.kts": "plugins { kotlin(\"multiplatform\") }", + "iosApp/iosApp.xcodeproj/project.pbxproj": "", + }, + want: []string{".|android|app|kmp"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := writeTree(t, tc.files) + got, err := Scan(root, DefaultOptions()) + if err != nil { + t.Fatal(err) + } + gotS := summarize(root, got) + want := append([]string(nil), tc.want...) + sort.Strings(want) + if !reflect.DeepEqual(gotS, want) { + t.Errorf("Scan()\n got: %v\nwant: %v", gotS, want) + } + }) + } +} diff --git a/internal/detect/flutter.go b/internal/detect/flutter.go new file mode 100644 index 0000000..d3dede2 --- /dev/null +++ b/internal/detect/flutter.go @@ -0,0 +1,51 @@ +package detect + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type pubspecDoc struct { + Dependencies map[string]any `yaml:"dependencies"` + Flutter map[string]any `yaml:"flutter"` +} + +func detectFlutterOrDart(dir string) *Project { + path := filepath.Join(dir, "pubspec.yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var doc pubspecDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + return &Project{Path: dir, Stack: Dart, Subtype: "package", Confidence: 0.5} + } + + _, hasFlutterDep := doc.Dependencies["flutter"] + isFlutter := hasFlutterDep || doc.Flutter != nil + if !isFlutter { + return &Project{Path: dir, Stack: Dart, Subtype: "package", Confidence: 0.9} + } + + subtype := "app" + if doc.Flutter != nil { + if _, ok := doc.Flutter["module"]; ok { + subtype = "module" + } else if _, ok := doc.Flutter["plugin"]; ok { + subtype = "plugin" + } + } + if subtype == "app" && !isRunnableFlutterApp(dir) { + subtype = "package" + } + return &Project{Path: dir, Stack: Flutter, Subtype: subtype, Confidence: 0.95} +} + +func isRunnableFlutterApp(dir string) bool { + return dirExists(filepath.Join(dir, "android")) || + dirExists(filepath.Join(dir, "ios")) || + fileExists(filepath.Join(dir, "lib", "main.dart")) +} diff --git a/internal/detect/ios.go b/internal/detect/ios.go new file mode 100644 index 0000000..9a36ef3 --- /dev/null +++ b/internal/detect/ios.go @@ -0,0 +1,20 @@ +package detect + +import "path/filepath" + +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} + } + return nil +} diff --git a/internal/detect/reactnative.go b/internal/detect/reactnative.go new file mode 100644 index 0000000..7d9841c --- /dev/null +++ b/internal/detect/reactnative.go @@ -0,0 +1,49 @@ +package detect + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type packageJSON struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func detectReactNative(dir string) *Project { + path := filepath.Join(dir, "package.json") + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + + hasRN := pkg.has("react-native") + hasExpo := pkg.has("expo") + if !hasRN && !hasExpo { + return nil + } + + native := dirExists(filepath.Join(dir, "ios")) && dirExists(filepath.Join(dir, "android")) + subtype := "bare-rn" + switch { + case hasExpo && !native: + subtype = "expo-managed" + case hasExpo && native: + subtype = "expo-bare" + } + return &Project{Path: dir, Stack: ReactNative, Subtype: subtype, Confidence: 0.9} +} + +func (p packageJSON) has(dep string) bool { + if _, ok := p.Dependencies[dep]; ok { + return true + } + _, ok := p.DevDependencies[dep] + return ok +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go index be114fe..35ed074 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -1,13 +1,4 @@ -// Package driver defines the per-ecosystem lifecycle contract that every -// supported stack implements: -// -// Detect(dir) -> confidence -// InstallDeps(ctx) / Analyze(ctx) / Test(ctx) / Build(ctx) -> Result -// Sign(ctx) / Upload(ctx) -> Result // later phases -// Capabilities() -> what this driver supports -// -// Each driver shells out to that ecosystem's real toolchain (flutter, gradle, -// xcodebuild, npm, …) and returns normalized results so the pipeline can -// surface errors uniformly. Adding a framework = adding one driver. -// Implemented in the tool-build phase — see docs/ARCHITECTURE.md. +// 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 diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 2816c56..c654d5e 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -1,9 +1,4 @@ -// Package pipeline is the guided orchestrator: detect the stack, confirm with -// the user, then run each lifecycle phase in order (deps → analyze → surface -// errors → test → build → sign → upload), stopping cleanly on failure and -// reporting in plain language rather than dumping raw tool output. -// -// The core knows the phases but nothing about any specific framework — all -// ecosystem knowledge lives in drivers. Implemented in the tool-build phase; -// see docs/ARCHITECTURE.md. +// 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 diff --git a/tasks/lessons.md b/tasks/lessons.md index 79bae02..690640e 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,12 +1,13 @@ -# Lessons — mistake → prevention rule +# Lessons: mistake -> prevention rule -Append a line after **every** correction from the user. Format: -`YYYY-MM-DD: `. Review at session start. +Append a line after every correction from the user. Format: +`YYYY-MM-DD: -> `. Review at session start. Seeded from the OpenForge collaboration so far: -- 2026-07-23: Credited Claude as commit co-author → **Never** add `Co-Authored-By: Claude` or any agent attribution; commits are authored solely by the user's git identity. -- 2026-07-23: Made naming/scope decisions unilaterally → **Confirm every consequential decision** (names, scope, architecture, dependencies, outward-facing actions) before acting. -- 2026-07-23: Assumed a Flutter-only framing → OpenForge is **framework-agnostic**; design tools to span many stacks unless told otherwise. -- 2026-07-23: Confused an org's display **Name** with the **Rename organization** (login/URL) action → they are different settings; verify the actual `login` via API, not the display name. -- 2026-07-23: Reached for a tool version that lags a brand-new toolchain (prebuilt binary vs bleeding-edge Go) → prefer `go run …@latest` / source-built tools that track the installed toolchain to avoid red CI. +- 2026-07-23: Credited Claude as commit co-author -> never add `Co-Authored-By: Claude` or any agent attribution; commits are authored solely by the user's git identity. +- 2026-07-23: Made naming and scope decisions unilaterally -> confirm every consequential decision (names, scope, architecture, dependencies, outward-facing actions) before acting. +- 2026-07-23: Assumed a Flutter-only framing -> OpenForge is framework-agnostic; design tools to span many stacks unless told otherwise. +- 2026-07-23: Confused an org's display Name with the Rename organization (login/URL) action -> they are different settings; verify the actual login via API, not the display name. +- 2026-07-23: Reached for a tool version that lags a brand-new toolchain (prebuilt binary vs bleeding-edge Go) -> prefer `go run ...@latest` or source-built tools that track the installed toolchain to avoid red CI. +- 2026-07-23: Used emojis and em-dash/dash connectors in text and verbose comments in code -> no emojis, no "—" or " - " connectors in prose (use commas, periods, parentheses), and no unnecessary comments in code. diff --git a/tasks/todo.md b/tasks/todo.md index d40ab7e..376cc69 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,35 +1,49 @@ -# anvil — todo & progress +# anvil todo and progress -Live progress board. Check items off as they land; add a **Review** note per -milestone. This is the file to read first to see where we are. +Live progress board. Check items off as they land, add a Review note per +milestone. Read this first to see where we are. -## Milestone 0 — repo + working pipeline (in progress) +## Milestone 0: repo and working pipeline (done) -- [x] Go skeleton: module, `cmd` (version/help), `internal/` placeholders, builds & tests clean -- [x] Wrapper scripts: `./check`, `./lint`, `./test`, `./build` (+ `Taskfile.yml`) -- [x] Claude hooks: smart Stop-hook (fingerprint-gated `./check`) + command guard, wired in `.claude/settings.json` -- [x] `CLAUDE.md` (how we work), `tasks/todo.md`, `tasks/lessons.md` -- [x] CI: matrix build/vet/test + staticcheck + govulncheck; concurrency-cancel +- [x] Go skeleton: module, cmd (version/help), internal placeholders, builds and tests clean +- [x] Wrapper scripts: `./check`, `./lint`, `./test`, `./build` (plus `Taskfile.yml`) +- [x] Claude hooks: fingerprint-gated Stop hook plus command guard, wired in `.claude/settings.json` +- [x] `CLAUDE.md`, `tasks/todo.md`, `tasks/lessons.md` +- [x] CI: matrix build/vet/test plus staticcheck and govulncheck, concurrency-cancel - [x] Docs: `README.md`, `docs/ROADMAP.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md` -- [ ] Repo created on GitHub, pushed, branch protection on `main`+`develop` -- [ ] CI green on first push - -### Review — Milestone 0 -_(fill in once pushed: what shipped, what to watch, any follow-ups)_ - -## Milestone 1 — detection engine (next phase, separate plan) - -- [ ] `detect` interface + marker-file detectors: Flutter, React Native, Android, iOS -- [ ] `anvil detect` command prints the resolved stack(s) + confidence - -## Milestone 2 — guided build lifecycle - -- [ ] `driver` lifecycle contract; Flutter driver first (deps → analyze → test → build) +- [x] Repo created on GitHub, pushed, branch protection on main and develop +- [x] CI green on first push + +### Review, Milestone 0 +Shipped the repo and the full working pipeline. CI is green on all jobs. Next: +build the detection engine. + +## Milestone 1: detection engine (in progress) + +- [x] Style cleanup (no emojis, no dash connectors, minimal comments); Style section in CLAUDE.md +- [x] Adopt Cobra; cmd restructured (root, version, detect) +- [x] `internal/detect`: types, scanner (prune-on-detect, skip lists, containment sweep) +- [x] Detectors: Flutter, React Native, Android, iOS +- [x] `anvil detect` command (table plus `--json`, `--path`, `--depth`) +- [x] Tests over fixture trees; `node_modules` exclusion and android/ios absorption covered +- [ ] PR into develop, CI green + +### Review, Milestone 1 +Detection works end to end. `anvil detect` returns one row per real project and +attributes android/ios folders to their Flutter/RN parent (verified on ca-mobile +and motobites). 16 fixture-tree tests pass; detect package coverage 89.6%. +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 + +- [ ] `driver` lifecycle contract; Flutter driver first (deps, analyze, test, build) - [ ] Interactive TUI (Charm/Bubble Tea); unified error surfacing -- [ ] Android + iOS + React Native drivers +- [ ] Android, iOS, React Native drivers -## Milestone 3 — signing & upload +## Milestone 3: signing and upload -- [ ] Guided Android keystore + iOS provisioning/signing -- [ ] Store/registry upload (TestFlight / Play / npm) -- [ ] GoReleaser → Homebrew/Scoop/curl distribution +- [ ] Guided Android keystore and iOS provisioning/signing +- [ ] Store/registry upload (TestFlight, Play, npm) +- [ ] GoReleaser to Homebrew/Scoop/curl distribution From 97054527df1ebac898907e32a18144b83ea212d6 Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Thu, 23 Jul 2026 16:10:32 +0100 Subject: [PATCH 2/8] feat(build): guided build lifecycle and anvil build (#3) * feat(build): guided build lifecycle and anvil build Add the 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. Add the runner (internal/pipeline) that streams combined output, captures exit codes, classifies results, collects artifacts, and fail-fasts. Add internal/tui with a Bubble Tea view and a plain non-TTY renderer, and the anvil build command (--path/--target/--flavor/--release/--dry-run/--plain). Refine detection so Gradle is not always Android and Package.swift is Swift, adding Swift and Kotlin stacks. Tests cover driver steps, the runner via a real subprocess, and the plain renderer. * fix: remove unused dirExists helper (staticcheck U1000) --- CHANGELOG.md | 11 ++ cmd/build.go | 136 ++++++++++++++++++++++ docs/ROADMAP.md | 9 +- go.mod | 30 ++++- go.sum | 53 +++++++++ internal/detect/android.go | 40 ++++--- internal/detect/detect.go | 8 ++ internal/detect/detect_test.go | 15 ++- internal/detect/ios.go | 7 -- internal/detect/kotlin.go | 29 +++++ internal/detect/swift.go | 15 +++ internal/driver/android.go | 32 +++++ internal/driver/driver.go | 132 ++++++++++++++++++++- internal/driver/driver_test.go | 135 +++++++++++++++++++++ internal/driver/flutter.go | 63 ++++++++++ internal/driver/ios.go | 59 ++++++++++ internal/driver/kotlin.go | 15 +++ internal/driver/reactnative.go | 92 +++++++++++++++ internal/driver/registry.go | 24 ++++ internal/driver/swift.go | 25 ++++ internal/pipeline/pipeline.go | 181 ++++++++++++++++++++++++++++- internal/pipeline/pipeline_test.go | 108 +++++++++++++++++ internal/tui/plain.go | 44 +++++++ internal/tui/plain_test.go | 53 +++++++++ internal/tui/tui.go | 141 ++++++++++++++++++++++ tasks/todo.md | 22 +++- 26 files changed, 1438 insertions(+), 41 deletions(-) create mode 100644 cmd/build.go create mode 100644 internal/detect/kotlin.go create mode 100644 internal/detect/swift.go create mode 100644 internal/driver/android.go create mode 100644 internal/driver/driver_test.go create mode 100644 internal/driver/flutter.go create mode 100644 internal/driver/ios.go create mode 100644 internal/driver/kotlin.go create mode 100644 internal/driver/reactnative.go create mode 100644 internal/driver/registry.go create mode 100644 internal/driver/swift.go create mode 100644 internal/pipeline/pipeline_test.go create mode 100644 internal/tui/plain.go create mode 100644 internal/tui/plain_test.go create mode 100644 internal/tui/tui.go 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 From 366e55006b33c8398429f3888420e6937f0deaf9 Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Thu, 23 Jul 2026 20:41:19 +0100 Subject: [PATCH 3/8] feat(sign): guided release signing (#11) Add a Sign phase and the anvil sign command plus anvil build --sign. internal/sign generates a PKCS12 keystore with keytool, writes key.properties, wires Gradle signingConfigs, writes an iOS ExportOptions.plist, and gitignores the secrets. iOS sign steps live on the Flutter (flutter build ipa), React Native, and native iOS (xcodebuild archive + exportArchive) drivers; Android signs at build time via the wired Gradle config. Passwords come from prompts (huh) or environment, never the repo, and --dry-run makes no changes. Tests cover live keystore generation (keytool), Gradle wiring idempotence, gitignore, ExportOptions, and step argv. --- CHANGELOG.md | 7 ++ cmd/build.go | 84 ++++++++------ cmd/sign.go | 204 +++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 2 +- go.mod | 8 +- go.sum | 32 +++++- internal/driver/android.go | 2 - internal/driver/driver.go | 15 +++ internal/driver/driver_test.go | 29 +++++ internal/driver/flutter.go | 9 ++ internal/driver/ios.go | 20 ++++ internal/driver/reactnative.go | 23 ++++ internal/pipeline/pipeline.go | 15 ++- internal/sign/sign.go | 179 +++++++++++++++++++++++++++++ internal/sign/sign_test.go | 131 +++++++++++++++++++++ tasks/todo.md | 20 +++- 16 files changed, 738 insertions(+), 42 deletions(-) create mode 100644 cmd/sign.go create mode 100644 internal/sign/sign.go create mode 100644 internal/sign/sign_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fea73..b9856a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes are documented here, following ## [Unreleased] ### Added +- Guided release signing: `anvil sign` and `anvil build --sign`, a Sign phase + that runs after Build. Android setup generates a PKCS12 keystore with keytool, + writes key.properties, wires Gradle signingConfigs, and gitignores the secrets, + so a release build comes out signed. iOS writes an ExportOptions.plist and then + archives and exports a development or ad-hoc signed ipa (Flutter via + `flutter build ipa`, React Native and native iOS via xcodebuild). Passwords + come from prompts or environment, never the repo; `--dry-run` changes nothing. - 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`, diff --git a/cmd/build.go b/cmd/build.go index 0e84c68..74a7466 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -23,6 +23,7 @@ var ( buildTarget string buildFlavor string buildRelease bool + buildSign bool buildDryRun bool buildPlain bool ) @@ -39,57 +40,51 @@ func init() { 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(&buildSign, "sign", false, "set up signing and produce a signed artifact") 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) + chosen, err := resolveProject(cmd, 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} + phases := driver.Phases + + if buildSign { + signing, extra, err := setupSigning(cmd, chosen, "", buildDryRun) + if err != nil { + return err + } + opts.Signing = signing + phases = append(append([]driver.Phase{}, driver.Phases...), extra...) + } if buildDryRun { - printPlan(cmd, chosen, d, opts) + printPlan(cmd, chosen, d, opts, phases) return nil } + return runPipeline(cmd, chosen.Path, d, opts, phases) +} - 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 - } +func resolveProject(cmd *cobra.Command, path string) (detect.Project, error) { + root, err := filepath.Abs(path) + if err != nil { + return detect.Project{}, err } - if !success { - return errors.New("build failed") + projects, err := detect.Scan(root, detect.DefaultOptions()) + if err != nil { + return detect.Project{}, err } - return nil + return chooseProject(cmd, projects, root) } func chooseProject(cmd *cobra.Command, projects []detect.Project, root string) (detect.Project, error) { @@ -112,10 +107,35 @@ func chooseProject(cmd *cobra.Command, projects []detect.Project, root string) ( return detect.Project{}, errors.New("multiple projects detected") } -func printPlan(cmd *cobra.Command, p detect.Project, d driver.Driver, opts driver.BuildOptions) { +func runPipeline(cmd *cobra.Command, root string, d driver.Driver, opts driver.BuildOptions, phases []driver.Phase) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + events := make(chan pipeline.Event) + go pipeline.RunPhases(ctx, root, d, opts, phases, events) + + var ( + success bool + err error + ) + if usePlain() { + success = tui.Plain(events, cmd.OutOrStdout()) + } else { + success, err = tui.Run(events) + if err != nil { + return err + } + } + if !success { + return errors.New("one or more steps failed") + } + return nil +} + +func printPlan(cmd *cobra.Command, p detect.Project, d driver.Driver, opts driver.BuildOptions, phases []driver.Phase) { 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) { + for _, it := range pipeline.PlanPhases(d, opts, phases) { if it.Step == nil { fmt.Fprintf(out, "%s: skipped\n", it.Phase) continue @@ -129,7 +149,7 @@ func printPlan(cmd *cobra.Command, p detect.Project, d driver.Driver, opts drive } func usePlain() bool { - if buildPlain || os.Getenv("CI") != "" { + if buildPlain || signPlain || os.Getenv("CI") != "" { return true } return !term.IsTerminal(int(os.Stdout.Fd())) diff --git a/cmd/sign.go b/cmd/sign.go new file mode 100644 index 0000000..9bc4e56 --- /dev/null +++ b/cmd/sign.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/huh" + "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/sign" +) + +var ( + signPath string + signKeystore string + signKeyAlias string + signTeamID string + signMethod string + signPlatform string + signDryRun bool + signPlain bool +) + +var signCmd = &cobra.Command{ + Use: "sign", + Short: "Set up release signing for a detected project", + Args: cobra.NoArgs, + RunE: runSign, +} + +func init() { + signCmd.Flags().StringVar(&signPath, "path", ".", "project directory") + signCmd.Flags().StringVar(&signKeystore, "keystore", "", "keystore path (Android; generated if missing)") + signCmd.Flags().StringVar(&signKeyAlias, "key-alias", "upload", "keystore key alias (Android)") + signCmd.Flags().StringVar(&signTeamID, "team-id", "", "Apple Developer Team ID (iOS)") + signCmd.Flags().StringVar(&signMethod, "export-method", "development", "iOS export method: development or ad-hoc") + signCmd.Flags().StringVar(&signPlatform, "platform", "", "android or ios (default from the detected stack)") + signCmd.Flags().BoolVar(&signDryRun, "dry-run", false, "print what would happen without changing anything") + signCmd.Flags().BoolVar(&signPlain, "plain", false, "plain line output instead of the interactive view") + rootCmd.AddCommand(signCmd) +} + +func runSign(cmd *cobra.Command, _ []string) error { + chosen, err := resolveProject(cmd, signPath) + if err != nil { + return err + } + signing, extra, err := setupSigning(cmd, chosen, signPlatform, signDryRun) + if err != nil { + return err + } + d, ok := driver.For(chosen.Stack, chosen.Path) + if !ok { + return fmt.Errorf("signing not supported for the %q stack", chosen.Stack) + } + opts := driver.BuildOptions{Signing: signing} + + if len(extra) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Signing configured. Run 'anvil build --release' to produce a signed artifact.") + return nil + } + if signDryRun { + printPlan(cmd, chosen, d, opts, extra) + return nil + } + return runPipeline(cmd, chosen.Path, d, opts, extra) +} + +// setupSigning resolves signing config and performs the guided setup. When +// dryRun is true it makes no changes and generates no secrets. +func setupSigning(cmd *cobra.Command, p detect.Project, platformFlag string, dryRun bool) (driver.Signing, []driver.Phase, error) { + switch resolvePlatform(platformFlag, p.Stack) { + case "android": + return setupAndroidSigning(cmd, p, dryRun) + case "ios": + return setupIOSSigning(cmd, p, dryRun) + default: + return driver.Signing{}, nil, fmt.Errorf("signing not supported for the %q stack", p.Stack) + } +} + +func resolvePlatform(flag string, stack detect.Stack) string { + if flag != "" { + return flag + } + switch stack { + case detect.IOS: + return "ios" + case detect.Flutter, detect.ReactNative, detect.Android: + return "android" + default: + return "" + } +} + +func setupAndroidSigning(cmd *cobra.Command, p detect.Project, dryRun bool) (driver.Signing, []driver.Phase, error) { + gradleRoot, appBuildFile, kotlinDSL, ok := sign.AndroidLayout(p.Path) + if !ok { + return driver.Signing{}, nil, errors.New("could not find an Android app module (android/app or app)") + } + keystore := signKeystore + if keystore == "" { + keystore = filepath.Join(gradleRoot, "upload-keystore.jks") + } + out := cmd.OutOrStdout() + + if dryRun { + fmt.Fprintf(out, "Would set up Android signing:\n") + fmt.Fprintf(out, " generate keystore: %s\n", keystore) + fmt.Fprintf(out, " write: %s\n", filepath.Join(gradleRoot, "key.properties")) + if kotlinDSL { + fmt.Fprintf(out, " wire (manual, Kotlin DSL): %s\n", appBuildFile) + } else { + fmt.Fprintf(out, " wire signingConfigs: %s\n", appBuildFile) + } + fmt.Fprintf(out, " gitignore: %v\n", sign.SecretPatterns) + return driver.Signing{}, nil, nil + } + + storePass, err := resolveSecret("ANVIL_STORE_PASS", "Keystore password") + if err != nil { + return driver.Signing{}, nil, err + } + ks := sign.Keystore{Path: keystore, Alias: signKeyAlias, StorePass: storePass, KeyPass: storePass, DName: "CN=anvil, O=anvil, C=US"} + if err := sign.EnsureGitignore(p.Path, sign.SecretPatterns); err != nil { + return driver.Signing{}, nil, err + } + if err := sign.GenerateKeystore(ks); err != nil { + return driver.Signing{}, nil, err + } + if _, err := sign.WriteKeyProperties(gradleRoot, ks); err != nil { + return driver.Signing{}, nil, err + } + wired, err := sign.WireAndroidGradle(appBuildFile, kotlinDSL) + if err != nil { + return driver.Signing{}, nil, err + } + if !wired { + fmt.Fprintf(out, "Kotlin DSL not auto-wired. Add a signingConfig reading key.properties to %s\n", appBuildFile) + } + return driver.Signing{}, nil, nil +} + +func setupIOSSigning(cmd *cobra.Command, p detect.Project, dryRun bool) (driver.Signing, []driver.Phase, error) { + teamID := signTeamID + if teamID == "" { + teamID = os.Getenv("ANVIL_TEAM_ID") + } + plist := filepath.Join(p.Path, "ExportOptions.plist") + + if dryRun { + if teamID == "" { + teamID = "" + } + fmt.Fprintf(cmd.OutOrStdout(), "Would write %s (method=%s, teamID=%s) and gitignore it\n", plist, signMethod, teamID) + return driver.Signing{TeamID: teamID, ExportMethod: signMethod, ExportPlist: plist}, []driver.Phase{driver.Sign}, nil + } + + if teamID == "" { + if !interactive() { + return driver.Signing{}, nil, errors.New("set --team-id or ANVIL_TEAM_ID") + } + if err := huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Apple Developer Team ID").Value(&teamID), + )).Run(); err != nil { + return driver.Signing{}, nil, err + } + } + if _, err := sign.WriteExportOptions(p.Path, teamID, signMethod); err != nil { + return driver.Signing{}, nil, err + } + if err := sign.EnsureGitignore(p.Path, sign.SecretPatterns); err != nil { + return driver.Signing{}, nil, err + } + return driver.Signing{TeamID: teamID, ExportMethod: signMethod, ExportPlist: plist}, []driver.Phase{driver.Sign}, nil +} + +func resolveSecret(env, title string) (string, error) { + if v := os.Getenv(env); v != "" { + return v, nil + } + if !interactive() { + return "", fmt.Errorf("set %s or run interactively to provide the %s", env, title) + } + var v string + if err := huh.NewForm(huh.NewGroup( + huh.NewInput().Title(title).EchoMode(huh.EchoModePassword).Value(&v), + )).Run(); err != nil { + return "", err + } + if v == "" { + return "", fmt.Errorf("%s is required", title) + } + return v, nil +} + +func interactive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0ff8d99..756e761 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ High-level phases and status, for monitoring. Granular tasks live in | 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, 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 | +| 3. Signing | Guided Android keystore and iOS provisioning/signing | in progress | | 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | planned | | 5. Breadth | More ecosystems (web, Go) via new drivers; flavor auto-detection; `--explain` educational mode | future | diff --git a/go.mod b/go.mod index 73e96fd..ef1fd2d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.45.0 @@ -12,20 +13,25 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // 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/dustin/go-humanize v1.0.1 // 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/mitchellh/hashstructure/v2 v2.0.2 // 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 @@ -33,5 +39,5 @@ require ( github.com/spf13/pflag v1.0.9 // 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 + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 8ba2644..5691f9b 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,41 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 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= @@ -21,6 +43,10 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= @@ -33,6 +59,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J 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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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= @@ -57,8 +85,8 @@ 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= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 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= diff --git a/internal/driver/android.go b/internal/driver/android.go index 36a8d6d..a58fee3 100644 --- a/internal/driver/android.go +++ b/internal/driver/android.go @@ -6,8 +6,6 @@ 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: diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 19ad100..c7aeb08 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -17,8 +17,11 @@ const ( Analyze Test Build + Sign ) +// Phases is the default build lifecycle. Sign is intentionally excluded; it runs +// only when explicitly requested. var Phases = []Phase{Deps, Analyze, Test, Build} func (p Phase) String() string { @@ -31,6 +34,8 @@ func (p Phase) String() string { return "test" case Build: return "build" + case Sign: + return "sign" default: return "unknown" } @@ -80,6 +85,16 @@ type BuildOptions struct { Target string Flavor string Release bool + Signing Signing +} + +// Signing holds resolved iOS signing inputs used by the Sign phase. Android +// signing is arranged before Build by wiring Gradle (see internal/sign), so it +// needs nothing here. +type Signing struct { + TeamID string + ExportMethod string + ExportPlist string } type Driver interface { diff --git a/internal/driver/driver_test.go b/internal/driver/driver_test.go index 836ff22..65b3973 100644 --- a/internal/driver/driver_test.go +++ b/internal/driver/driver_test.go @@ -121,6 +121,35 @@ func TestIOSSteps(t *testing.T) { } } +func TestFlutterSignSteps(t *testing.T) { + d := Flutter{} + if _, ok := d.Steps(Sign, BuildOptions{}); ok { + t.Error("flutter sign without an export plist should be skipped") + } + wantCmds(t, mustSteps(t, d, Sign, BuildOptions{Signing: Signing{ExportPlist: "ExportOptions.plist"}}), + "flutter build ipa --export-options-plist ExportOptions.plist") +} + +func TestIOSSignSteps(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "App.xcworkspace"), 0o755); err != nil { + t.Fatal(err) + } + d := IOS{base{root}} + steps := mustSteps(t, d, Sign, BuildOptions{Signing: Signing{TeamID: "T123", ExportPlist: "ExportOptions.plist"}}) + if len(steps) != 2 { + t.Fatalf("want 2 steps, got %d", len(steps)) + } + arch := strings.Join(steps[0].Argv, " ") + if !strings.Contains(arch, "archive") || !strings.Contains(arch, "DEVELOPMENT_TEAM=T123") || !strings.Contains(arch, "-scheme App") { + t.Errorf("archive step: %s", arch) + } + exp := strings.Join(steps[1].Argv, " ") + if !strings.Contains(exp, "-exportArchive") || !strings.Contains(exp, "-exportOptionsPlist ExportOptions.plist") { + t.Errorf("export step: %s", exp) + } +} + func writeFiles(t *testing.T, root string, files map[string]string) { t.Helper() for rel, content := range files { diff --git a/internal/driver/flutter.go b/internal/driver/flutter.go index fa28453..76b52be 100644 --- a/internal/driver/flutter.go +++ b/internal/driver/flutter.go @@ -30,6 +30,15 @@ func (Flutter) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { args = append(args, "--flavor", opts.Flavor) } return []Step{{Name: "flutter " + strings.Join(args, " "), Argv: append([]string{"flutter"}, args...)}}, true + case Sign: + if opts.Signing.ExportPlist == "" { + return nil, false + } + args := []string{"build", "ipa", "--export-options-plist", opts.Signing.ExportPlist} + 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 } diff --git a/internal/driver/ios.go b/internal/driver/ios.go index dc54c6d..e8f822e 100644 --- a/internal/driver/ios.go +++ b/internal/driver/ios.go @@ -38,6 +38,26 @@ func (d IOS) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { Argv: []string{"xcodebuild", "build", flag, value, "-scheme", scheme, "-destination", "generic/platform=iOS Simulator", "CODE_SIGNING_ALLOWED=NO"}, }}, true + case Sign: + flag, value, scheme := d.container(opts) + if scheme == "" { + return nil, false + } + archive := "build/anvil/" + scheme + ".xcarchive" + plist := opts.Signing.ExportPlist + if plist == "" { + plist = "ExportOptions.plist" + } + archiveArgs := []string{"xcodebuild", flag, value, "-scheme", scheme, + "-configuration", "Release", "-archivePath", archive, "archive", "-allowProvisioningUpdates"} + if opts.Signing.TeamID != "" { + archiveArgs = append(archiveArgs, "DEVELOPMENT_TEAM="+opts.Signing.TeamID) + } + return []Step{ + {Name: "xcodebuild archive", Argv: archiveArgs}, + {Name: "xcodebuild -exportArchive", Argv: []string{"xcodebuild", "-exportArchive", + "-archivePath", archive, "-exportPath", "build/anvil/ipa", "-exportOptionsPlist", plist}}, + }, true } return nil, false } diff --git a/internal/driver/reactnative.go b/internal/driver/reactnative.go index 83b65d7..cafd388 100644 --- a/internal/driver/reactnative.go +++ b/internal/driver/reactnative.go @@ -51,6 +51,29 @@ func (r ReactNative) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { } task := "assemble" + title(opts.Flavor) + variant return []Step{{Name: "gradlew " + task, Dir: "android", Argv: []string{gradlew(), task}}}, true + case Sign: + if opts.Signing.ExportPlist == "" { + return nil, false + } + ws := firstGlob(filepath.Join(r.root, "ios"), "*.xcworkspace") + if ws == "" { + return nil, false + } + scheme := strings.TrimSuffix(ws, ".xcworkspace") + if opts.Flavor != "" { + scheme = opts.Flavor + } + archive := "build/anvil/" + scheme + ".xcarchive" + archiveArgs := []string{"xcodebuild", "-workspace", ws, "-scheme", scheme, + "-configuration", "Release", "-archivePath", archive, "archive", "-allowProvisioningUpdates"} + if opts.Signing.TeamID != "" { + archiveArgs = append(archiveArgs, "DEVELOPMENT_TEAM="+opts.Signing.TeamID) + } + return []Step{ + {Name: "xcodebuild archive", Dir: "ios", Argv: archiveArgs}, + {Name: "xcodebuild -exportArchive", Dir: "ios", Argv: []string{"xcodebuild", "-exportArchive", + "-archivePath", archive, "-exportPath", "build/anvil/ipa", "-exportOptionsPlist", opts.Signing.ExportPlist}}, + }, true } return nil, false } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 98ff9ce..aa42485 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -25,8 +25,13 @@ type Item struct { // 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 { + return PlanPhases(d, opts, driver.Phases) +} + +// PlanPhases resolves the plan for a specific set of phases. +func PlanPhases(d driver.Driver, opts driver.BuildOptions, phases []driver.Phase) []Item { var items []Item - for _, ph := range driver.Phases { + for _, ph := range phases { steps, ok := d.Steps(ph, opts) if !ok || len(steps) == 0 { items = append(items, Item{Phase: ph, Name: ph.String()}) @@ -69,9 +74,15 @@ type Result struct { // 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 { + return RunPhases(ctx, root, d, opts, driver.Phases, events) +} + +// RunPhases executes a specific set of phases, emitting events and closing the +// channel when finished. +func RunPhases(ctx context.Context, root string, d driver.Driver, opts driver.BuildOptions, phases []driver.Phase, events chan<- Event) Result { defer close(events) - items := Plan(d, opts) + items := PlanPhases(d, opts, phases) events <- Event{Kind: KindPlan, Items: items} var artifacts []string diff --git a/internal/sign/sign.go b/internal/sign/sign.go new file mode 100644 index 0000000..1340a5c --- /dev/null +++ b/internal/sign/sign.go @@ -0,0 +1,179 @@ +// Package sign provides guided release-signing setup: it generates and wires +// signing material without committing secrets. Interactive prompting lives in +// the cmd layer; these functions take resolved values so they can be tested. +package sign + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Keystore describes an Android signing keystore. +type Keystore struct { + Path string + Alias string + StorePass string + KeyPass string + DName string +} + +// GenerateKeystore creates a PKCS12 keystore with keytool if Path does not +// already exist. It is idempotent. +func GenerateKeystore(ks Keystore) error { + if fileExists(ks.Path) { + return nil + } + if err := os.MkdirAll(filepath.Dir(ks.Path), 0o755); err != nil { + return err + } + args := []string{ + "-genkeypair", "-noprompt", + "-keystore", ks.Path, "-alias", ks.Alias, + "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000", + "-storetype", "PKCS12", + "-storepass", ks.StorePass, "-keypass", ks.KeyPass, + "-dname", ks.DName, + } + if out, err := exec.Command("keytool", args...).CombinedOutput(); err != nil { + return fmt.Errorf("keytool genkeypair: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// WriteKeyProperties writes key.properties into the Gradle root and returns its +// path. Gradle reads it via rootProject.file('key.properties'). +func WriteKeyProperties(gradleRoot string, ks Keystore) (string, error) { + path := filepath.Join(gradleRoot, "key.properties") + content := fmt.Sprintf("storeFile=%s\nstorePassword=%s\nkeyAlias=%s\nkeyPassword=%s\n", + ks.Path, ks.StorePass, ks.Alias, ks.KeyPass) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + return "", err + } + return path, nil +} + +// AndroidLayout locates the app-module build file and the Gradle root for the +// common project shapes: Flutter/RN under android/, and native at the repo root. +func AndroidLayout(root string) (gradleRoot, appBuildFile string, kotlinDSL, ok bool) { + candidates := []struct { + gradleRoot, app string + }{ + {filepath.Join(root, "android"), filepath.Join(root, "android", "app")}, + {root, filepath.Join(root, "app")}, + {root, root}, + } + for _, c := range candidates { + if f := filepath.Join(c.app, "build.gradle"); fileExists(f) { + return c.gradleRoot, f, false, true + } + if f := filepath.Join(c.app, "build.gradle.kts"); fileExists(f) { + return c.gradleRoot, f, true, true + } + } + return "", "", false, false +} + +const gradleMarker = "anvil-signing" + +// WireAndroidGradle appends a Groovy signingConfigs block that reads +// key.properties, unless it is already present. Kotlin DSL build files are left +// untouched (wired reports false) so the caller can print manual instructions. +func WireAndroidGradle(appBuildFile string, kotlinDSL bool) (bool, error) { + data, err := os.ReadFile(appBuildFile) + if err != nil { + return false, err + } + if strings.Contains(string(data), gradleMarker) { + return true, nil + } + if kotlinDSL { + return false, nil + } + block := "\n// " + gradleMarker + " (managed by anvil; reads key.properties)\n" + + "def anvilProps = new Properties()\n" + + "def anvilPropsFile = rootProject.file('key.properties')\n" + + "if (anvilPropsFile.exists()) {\n" + + " anvilPropsFile.withInputStream { anvilProps.load(it) }\n" + + " android {\n" + + " signingConfigs {\n" + + " release {\n" + + " storeFile file(anvilProps['storeFile'])\n" + + " storePassword anvilProps['storePassword']\n" + + " keyAlias anvilProps['keyAlias']\n" + + " keyPassword anvilProps['keyPassword']\n" + + " }\n" + + " }\n" + + " buildTypes { release { signingConfig signingConfigs.release } }\n" + + " }\n" + + "}\n// end " + gradleMarker + "\n" + f, err := os.OpenFile(appBuildFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return false, err + } + defer f.Close() + if _, err := f.WriteString(block); err != nil { + return false, err + } + return true, nil +} + +// WriteExportOptions writes an iOS ExportOptions.plist for automatic signing and +// returns its path. +func WriteExportOptions(root, teamID, method string) (string, error) { + path := filepath.Join(root, "ExportOptions.plist") + content := fmt.Sprintf(` + + + + method + %s + teamID + %s + signingStyle + automatic + + +`, method, teamID) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", err + } + return path, nil +} + +// EnsureGitignore appends any missing patterns to the project .gitignore under +// an anvil header. It is idempotent. +func EnsureGitignore(root string, patterns []string) error { + path := filepath.Join(root, ".gitignore") + existing, _ := os.ReadFile(path) + lines := map[string]bool{} + for _, l := range strings.Split(string(existing), "\n") { + lines[strings.TrimSpace(l)] = true + } + var add []string + for _, p := range patterns { + if !lines[p] { + add = append(add, p) + } + } + if len(add) == 0 { + return nil + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString("\n# anvil signing secrets\n" + strings.Join(add, "\n") + "\n") + return err +} + +// SecretPatterns are the signing files that must never be committed. +var SecretPatterns = []string{"key.properties", "*.jks", "*.keystore", "*.p12", "*.p8", "ExportOptions.plist"} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/internal/sign/sign_test.go b/internal/sign/sign_test.go new file mode 100644 index 0000000..1d41a5f --- /dev/null +++ b/internal/sign/sign_test.go @@ -0,0 +1,131 @@ +package sign + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func writeF(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestGenerateKeystoreAndKeyProperties(t *testing.T) { + if _, err := exec.LookPath("keytool"); err != nil { + t.Skip("keytool not available") + } + dir := t.TempDir() + ks := Keystore{ + Path: filepath.Join(dir, "ks.jks"), Alias: "upload", + StorePass: "testpass123", KeyPass: "testpass123", DName: "CN=test, O=test, C=US", + } + if err := GenerateKeystore(ks); err != nil { + t.Fatal(err) + } + if !fileExists(ks.Path) { + t.Fatal("keystore not created") + } + out, err := exec.Command("keytool", "-list", "-keystore", ks.Path, "-storepass", ks.StorePass, "-storetype", "PKCS12").CombinedOutput() + if err != nil { + t.Fatalf("keytool -list failed: %v: %s", err, out) + } + if !strings.Contains(string(out), "upload") { + t.Errorf("alias not in keystore listing: %s", out) + } + if err := GenerateKeystore(ks); err != nil { + t.Fatalf("second GenerateKeystore (should be idempotent): %v", err) + } + path, err := WriteKeyProperties(dir, ks) + if err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + for _, want := range []string{"storeFile=", "keyAlias=upload", "storePassword=testpass123"} { + if !strings.Contains(string(data), want) { + t.Errorf("key.properties missing %q", want) + } + } +} + +func TestAndroidLayout(t *testing.T) { + dir := t.TempDir() + writeF(t, filepath.Join(dir, "android/app/build.gradle"), "") + gr, app, kts, ok := AndroidLayout(dir) + if !ok || kts || gr != filepath.Join(dir, "android") || app != filepath.Join(dir, "android/app/build.gradle") { + t.Errorf("flutter layout: gr=%s app=%s kts=%v ok=%v", gr, app, kts, ok) + } + + dir2 := t.TempDir() + writeF(t, filepath.Join(dir2, "app/build.gradle.kts"), "") + gr2, _, kts2, ok2 := AndroidLayout(dir2) + if !ok2 || !kts2 || gr2 != dir2 { + t.Errorf("native kts layout: gr=%s kts=%v ok=%v", gr2, kts2, ok2) + } +} + +func TestWireAndroidGradleGroovyIdempotent(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "build.gradle") + writeF(t, f, "plugins { id 'com.android.application' }\nandroid { namespace 'com.x' }\n") + + wired, err := WireAndroidGradle(f, false) + if err != nil || !wired { + t.Fatalf("wire: wired=%v err=%v", wired, err) + } + data, _ := os.ReadFile(f) + if !strings.Contains(string(data), "signingConfigs") || !strings.Contains(string(data), gradleMarker) { + t.Errorf("gradle not wired:\n%s", data) + } + + if _, err := WireAndroidGradle(f, false); err != nil { + t.Fatal(err) + } + data2, _ := os.ReadFile(f) + if string(data) != string(data2) { + t.Errorf("not idempotent: file changed on the second wire") + } +} + +func TestWireAndroidGradleKotlinSkipped(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "build.gradle.kts") + writeF(t, f, "plugins { id(\"com.android.application\") }\n") + if wired, err := WireAndroidGradle(f, true); err != nil || wired { + t.Errorf("kotlin DSL should not auto-wire: wired=%v err=%v", wired, err) + } +} + +func TestEnsureGitignoreIdempotent(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 2; i++ { + if err := EnsureGitignore(dir, []string{"key.properties", "*.jks"}); err != nil { + t.Fatal(err) + } + } + data, _ := os.ReadFile(filepath.Join(dir, ".gitignore")) + if n := strings.Count(string(data), "key.properties"); n != 1 { + t.Errorf("duplicate gitignore entries (%d):\n%s", n, data) + } +} + +func TestWriteExportOptions(t *testing.T) { + dir := t.TempDir() + path, err := WriteExportOptions(dir, "TEAM123", "ad-hoc") + if err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + for _, want := range []string{"ad-hoc", "TEAM123", "automatic"} { + if !strings.Contains(string(data), want) { + t.Errorf("ExportOptions missing %q", want) + } + } +} diff --git a/tasks/todo.md b/tasks/todo.md index 594b862..f83d71d 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -56,8 +56,24 @@ 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 +## Milestone 3: signing (in progress) + +- [x] Sign phase in the driver contract; iOS sign steps on Flutter, RN, and native iOS +- [x] `internal/sign`: keytool keystore generation, key.properties, Gradle wiring, ExportOptions.plist, gitignore +- [x] `anvil sign` and `anvil build --sign`; `--dry-run` makes no changes +- [x] Secrets via prompt or env (huh), never committed +- [x] Tests: live keystore gen (keytool), gradle wiring idempotence, gitignore, ExportOptions, Step argv +- [ ] PR into develop, CI green + +### Review, Milestone 3 +Android signs at build time via wired Gradle signingConfigs; iOS archives and +exports a signed ipa. Guided setup is side-effect-free under --dry-run (a bug +caught in review after it briefly wrote into a real project, now fixed and the +project restored). Deferred: App Store export, App Store Connect API and +fastlane match, OS keychain, Play enrollment, and Android apksigner for a loose +prebuilt APK. + +## Milestone 4: upload -- [ ] Guided Android keystore and iOS provisioning/signing - [ ] Store/registry upload (TestFlight, Play, npm) - [ ] GoReleaser to Homebrew/Scoop/curl distribution From c17f5c10addf2a8581674c253216be21d73cf375 Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Thu, 23 Jul 2026 21:58:05 +0100 Subject: [PATCH 4/8] feat(upload): store upload and GoReleaser self-distribution (#13) Add internal/upload with an Uploader interface and three targets: iOS via xcrun altool (App Store Connect/TestFlight), Android via the Google Play Publisher API (insert edit, upload bundle, assign track, commit) using a service-account JWT, and npm via npm publish. Credentials resolve from flag, env, or a base64 env decoded to a 0600 temp file, and are refused if they live inside the repo. anvil upload is a dry run unless --yes. Add GoReleaser (.goreleaser.yaml) and a tag-triggered release workflow that build cross-platform binaries and publish a GitHub release plus Homebrew cask and Scoop manifests. Tests cover credential resolution, the in-repo refusal, and each uploader's Validate/Describe. --- .github/workflows/release.yml | 30 ++++++++ .goreleaser.yaml | 48 ++++++++++++ CHANGELOG.md | 9 +++ cmd/upload.go | 135 +++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 2 +- go.mod | 25 +++++- go.sum | 82 +++++++++++++++++++- internal/upload/android.go | 90 ++++++++++++++++++++++ internal/upload/creds.go | 58 ++++++++++++++ internal/upload/ios.go | 59 ++++++++++++++ internal/upload/npm.go | 45 +++++++++++ internal/upload/upload.go | 24 ++++++ internal/upload/upload_test.go | 86 +++++++++++++++++++++ tasks/todo.md | 20 ++++- 14 files changed, 705 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml create mode 100644 cmd/upload.go create mode 100644 internal/upload/android.go create mode 100644 internal/upload/creds.go create mode 100644 internal/upload/ios.go create mode 100644 internal/upload/npm.go create mode 100644 internal/upload/upload.go create mode 100644 internal/upload/upload_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a72ab29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +# Builds cross-platform binaries and publishes a GitHub release plus Homebrew and +# Scoop manifests when a version tag is pushed. Requires a HOMEBREW_TAP_TOKEN +# secret (a PAT that can push to openforge-oss/homebrew-tap and scoop-bucket); +# the default GITHUB_TOKEN cannot push to other repositories. +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: goreleaser/goreleaser-action@v7 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..b0fe0d8 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,48 @@ +version: 2 +project_name: anvil + +before: + hooks: + - go mod tidy + +builds: + - main: . + binary: anvil + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X github.com/openforge-oss/anvil/cmd.version={{ .Version }} + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +homebrew_casks: + - name: anvil + repository: + owner: openforge-oss + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: https://github.com/openforge-oss/anvil + description: Guided, zero-config build and release pipeline for mobile and app projects + +scoops: + - repository: + owner: openforge-oss + name: scoop-bucket + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: https://github.com/openforge-oss/anvil + description: Guided, zero-config build and release pipeline for mobile and app projects + +release: + github: + owner: openforge-oss + name: anvil diff --git a/CHANGELOG.md b/CHANGELOG.md index b9856a8..17d9661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes are documented here, following ## [Unreleased] ### Added +- Upload: `anvil upload` pushes a signed artifact to its store. iOS via + `xcrun altool` to App Store Connect/TestFlight, Android via the Google Play + Publisher API (insert edit, upload bundle, assign track, commit) using a + service account, and npm via `npm publish`. Credentials come from flags, env, + or a base64 env decoded to a temp file; a credential inside the repo is + refused; uploads are a dry run unless `--yes`. +- Self-distribution: a GoReleaser config and a tag-triggered release workflow + that build cross-platform binaries and publish a GitHub release plus Homebrew + (cask) and Scoop manifests. - Guided release signing: `anvil sign` and `anvil build --sign`, a Sign phase that runs after Build. Android setup generates a PKCS12 keystore with keytool, writes key.properties, wires Gradle signingConfigs, and gitignores the secrets, diff --git a/cmd/upload.go b/cmd/upload.go new file mode 100644 index 0000000..87efb8b --- /dev/null +++ b/cmd/upload.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/openforge-oss/anvil/internal/detect" + "github.com/openforge-oss/anvil/internal/upload" +) + +var ( + uploadPath string + uploadArtifact string + uploadPlatform string + uploadTrack string + uploadPackage string + uploadKeyID string + uploadIssuer string + uploadKeyPath string + uploadSA string + uploadYes bool +) + +var uploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload a signed artifact to its store or registry", + Args: cobra.NoArgs, + RunE: runUpload, +} + +func init() { + f := uploadCmd.Flags() + f.StringVar(&uploadPath, "path", ".", "project directory") + f.StringVar(&uploadArtifact, "artifact", "", "path to the signed artifact (ipa or aab)") + f.StringVar(&uploadPlatform, "platform", "", "ios, android, or npm (default from the detected stack)") + f.StringVar(&uploadTrack, "track", "internal", "Play track (android)") + f.StringVar(&uploadPackage, "package", "", "applicationId / package name (android)") + f.StringVar(&uploadKeyID, "api-key-id", "", "App Store Connect key id (ios)") + f.StringVar(&uploadIssuer, "api-issuer-id", "", "App Store Connect issuer id (ios)") + f.StringVar(&uploadKeyPath, "api-key-path", "", "App Store Connect .p8 path (ios)") + f.StringVar(&uploadSA, "service-account", "", "Google Play service account JSON path (android)") + f.BoolVar(&uploadYes, "yes", false, "perform the upload (default is a dry run)") + rootCmd.AddCommand(uploadCmd) +} + +func runUpload(cmd *cobra.Command, _ []string) error { + root, err := filepath.Abs(uploadPath) + if err != nil { + return err + } + platform := upload.Platform(uploadPlatform) + if platform == "" { + chosen, err := resolveProject(cmd, uploadPath) + if err != nil { + return err + } + platform = platformForStack(chosen.Stack) + } + + u, cleanup, err := buildUploader(root, platform, uploadYes) + if err != nil { + return err + } + defer cleanup() + + out := cmd.OutOrStdout() + if !uploadYes { + fmt.Fprintf(out, "Dry run (%s). Would:\n", platform) + for _, line := range u.Describe() { + fmt.Fprintf(out, " %s\n", line) + } + fmt.Fprintln(out, "Re-run with --yes to upload.") + return nil + } + + if err := u.Validate(); err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + return u.Run(ctx) +} + +func platformForStack(stack detect.Stack) upload.Platform { + switch stack { + case detect.IOS: + return upload.PlatformIOS + case detect.Flutter, detect.ReactNative, detect.Android: + return upload.PlatformAndroid + default: + return upload.Platform(stack) + } +} + +func buildUploader(root string, platform upload.Platform, stage bool) (upload.Uploader, func(), error) { + cleanup := func() {} + switch platform { + case upload.PlatformIOS: + keyPath, cu := orEnv(uploadKeyPath, "ASC_KEY_PATH"), cleanup + if stage { + p, c, err := upload.FileCred(root, uploadKeyPath, "ASC_KEY_PATH", "ASC_KEY_P8_BASE64", "AuthKey.p8") + if err != nil { + return nil, cleanup, err + } + keyPath, cu = p, c + } + return upload.IOS{KeyID: orEnv(uploadKeyID, "ASC_KEY_ID"), IssuerID: orEnv(uploadIssuer, "ASC_ISSUER_ID"), KeyPath: keyPath, Artifact: uploadArtifact}, cu, nil + case upload.PlatformAndroid: + saPath, cu := orEnv(uploadSA, "GOOGLE_APPLICATION_CREDENTIALS"), cleanup + if stage { + p, c, err := upload.FileCred(root, uploadSA, "GOOGLE_APPLICATION_CREDENTIALS", "PLAY_SERVICE_ACCOUNT_BASE64", "play-sa.json") + if err != nil { + return nil, cleanup, err + } + saPath, cu = p, c + } + return upload.Android{ServiceAccount: saPath, Package: uploadPackage, Track: uploadTrack, Artifact: uploadArtifact}, cu, nil + case upload.PlatformNPM: + return upload.NPM{Dir: root}, cleanup, nil + default: + return nil, cleanup, fmt.Errorf("unsupported upload platform %q", platform) + } +} + +func orEnv(flag, env string) string { + if flag != "" { + return flag + } + return os.Getenv(env) +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 756e761..35bea58 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ High-level phases and status, for monitoring. Granular tasks live in | 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 | in progress | -| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | planned | +| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | in progress | | 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 diff --git a/go.mod b/go.mod index ef1fd2d..4c2626a 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,20 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.45.0 + google.golang.org/api v0.290.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // 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 @@ -26,6 +32,13 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // 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 @@ -38,6 +51,16 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.82.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 5691f9b..be05c4e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -8,6 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= @@ -45,12 +53,37 @@ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -67,27 +100,70 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 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/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/upload/android.go b/internal/upload/android.go new file mode 100644 index 0000000..51c914a --- /dev/null +++ b/internal/upload/android.go @@ -0,0 +1,90 @@ +package upload + +import ( + "context" + "fmt" + "os" + + "golang.org/x/oauth2/google" + androidpublisher "google.golang.org/api/androidpublisher/v3" + "google.golang.org/api/option" +) + +type Android struct { + ServiceAccount string + Package string + Track string + Artifact string +} + +func (Android) Platform() Platform { return PlatformAndroid } + +func (u Android) Validate() error { + switch { + case u.ServiceAccount == "": + return fmt.Errorf("Android upload needs a service account JSON (--service-account, GOOGLE_APPLICATION_CREDENTIALS, or PLAY_SERVICE_ACCOUNT_BASE64)") + case u.Package == "": + return fmt.Errorf("Android upload needs --package (the applicationId)") + case u.Artifact == "": + return fmt.Errorf("Android upload needs --artifact ") + } + return nil +} + +func (u Android) Describe() []string { + return []string{ + fmt.Sprintf("Play edit: insert for %s", u.Package), + fmt.Sprintf("upload bundle: %s", u.Artifact), + fmt.Sprintf("assign track %q (status draft)", u.track()), + "commit edit", + } +} + +func (u Android) Run(ctx context.Context) error { + data, err := os.ReadFile(u.ServiceAccount) + if err != nil { + return err + } + conf, err := google.JWTConfigFromJSON(data, androidpublisher.AndroidpublisherScope) + if err != nil { + return fmt.Errorf("service account: %w", err) + } + svc, err := androidpublisher.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx))) + if err != nil { + return err + } + edit, err := svc.Edits.Insert(u.Package, &androidpublisher.AppEdit{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("edits.insert: %w", err) + } + f, err := os.Open(u.Artifact) + if err != nil { + return err + } + defer f.Close() + bundle, err := svc.Edits.Bundles.Upload(u.Package, edit.Id).Media(f).Context(ctx).Do() + if err != nil { + return fmt.Errorf("bundles.upload: %w", err) + } + _, err = svc.Edits.Tracks.Update(u.Package, edit.Id, u.track(), &androidpublisher.Track{ + Track: u.track(), + Releases: []*androidpublisher.TrackRelease{{ + Status: "draft", + VersionCodes: []int64{bundle.VersionCode}, + }}, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("tracks.update: %w", err) + } + if _, err := svc.Edits.Commit(u.Package, edit.Id).Context(ctx).Do(); err != nil { + return fmt.Errorf("edits.commit: %w", err) + } + return nil +} + +func (u Android) track() string { + if u.Track == "" { + return "internal" + } + return u.Track +} diff --git a/internal/upload/creds.go b/internal/upload/creds.go new file mode 100644 index 0000000..8218479 --- /dev/null +++ b/internal/upload/creds.go @@ -0,0 +1,58 @@ +package upload + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FileCred resolves a file-based credential: an explicit path, else the path in +// pathEnv, else the base64 contents in base64Env decoded to a 0600 temp file +// (the returned cleanup removes it). It refuses a path inside repoRoot so +// secrets are never read from the working tree. +func FileCred(repoRoot, explicitPath, pathEnv, base64Env, tmpName string) (path string, cleanup func(), err error) { + cleanup = func() {} + switch { + case explicitPath != "": + path = explicitPath + case os.Getenv(pathEnv) != "": + path = os.Getenv(pathEnv) + case os.Getenv(base64Env) != "": + raw, derr := base64.StdEncoding.DecodeString(strings.TrimSpace(os.Getenv(base64Env))) + if derr != nil { + return "", cleanup, fmt.Errorf("%s: %w", base64Env, derr) + } + dir, derr := os.MkdirTemp("", "anvil-cred") + if derr != nil { + return "", cleanup, derr + } + path = filepath.Join(dir, tmpName) + if werr := os.WriteFile(path, raw, 0o600); werr != nil { + os.RemoveAll(dir) + return "", cleanup, werr + } + return path, func() { os.RemoveAll(dir) }, nil + default: + return "", cleanup, fmt.Errorf("missing credential: set %s, %s, or provide a path", pathEnv, base64Env) + } + + if inside(repoRoot, path) { + return "", cleanup, fmt.Errorf("refusing to read a credential inside the repo: %s (keep it outside the working tree)", path) + } + if _, e := os.Stat(path); e != nil { + return "", cleanup, fmt.Errorf("credential not found: %s", path) + } + return path, cleanup, nil +} + +func inside(root, path string) bool { + ra, err1 := filepath.Abs(root) + pa, err2 := filepath.Abs(path) + if err1 != nil || err2 != nil { + return false + } + rel, err := filepath.Rel(ra, pa) + return err == nil && !strings.HasPrefix(rel, "..") +} diff --git a/internal/upload/ios.go b/internal/upload/ios.go new file mode 100644 index 0000000..21f2b2c --- /dev/null +++ b/internal/upload/ios.go @@ -0,0 +1,59 @@ +package upload + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type IOS struct { + KeyID string + IssuerID string + KeyPath string + Artifact string +} + +func (IOS) Platform() Platform { return PlatformIOS } + +func (u IOS) Validate() error { + switch { + case u.KeyID == "" || u.IssuerID == "": + return fmt.Errorf("iOS upload needs the App Store Connect key id and issuer id (--api-key-id/--api-issuer-id or ASC_KEY_ID/ASC_ISSUER_ID)") + case u.KeyPath == "": + return fmt.Errorf("iOS upload needs the .p8 key (--api-key-path or ASC_KEY_P8_BASE64)") + case u.Artifact == "": + return fmt.Errorf("iOS upload needs --artifact ") + } + return nil +} + +func (u IOS) Describe() []string { + return []string{ + fmt.Sprintf("stage AuthKey_%s.p8 into a temporary private_keys dir (0600)", u.KeyID), + fmt.Sprintf("xcrun altool --upload-app -f %s -t ios --apiKey %s --apiIssuer %s", u.Artifact, u.KeyID, u.IssuerID), + } +} + +func (u IOS) Run(ctx context.Context) error { + dir, err := os.MkdirTemp("", "anvil-asc") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + key, err := os.ReadFile(u.KeyPath) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "AuthKey_"+u.KeyID+".p8"), key, 0o600); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "xcrun", "altool", "--upload-app", + "-f", u.Artifact, "-t", "ios", "--apiKey", u.KeyID, "--apiIssuer", u.IssuerID) + cmd.Env = append(os.Environ(), "API_PRIVATE_KEYS_DIR="+dir) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} diff --git a/internal/upload/npm.go b/internal/upload/npm.go new file mode 100644 index 0000000..400eeef --- /dev/null +++ b/internal/upload/npm.go @@ -0,0 +1,45 @@ +package upload + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type NPM struct { + Dir string +} + +func (NPM) Platform() Platform { return PlatformNPM } + +func (u NPM) Validate() error { + if os.Getenv("NPM_TOKEN") != "" { + return nil + } + for _, p := range []string{filepath.Join(u.dir(), ".npmrc"), filepath.Join(os.Getenv("HOME"), ".npmrc")} { + if _, err := os.Stat(p); err == nil { + return nil + } + } + return fmt.Errorf("npm publish needs NPM_TOKEN or an .npmrc with an auth token") +} + +func (u NPM) Describe() []string { + return []string{fmt.Sprintf("npm publish (in %s)", u.dir())} +} + +func (u NPM) Run(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "npm", "publish") + cmd.Dir = u.dir() + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + +func (u NPM) dir() string { + if u.Dir == "" { + return "." + } + return u.Dir +} diff --git a/internal/upload/upload.go b/internal/upload/upload.go new file mode 100644 index 0000000..1ade6e8 --- /dev/null +++ b/internal/upload/upload.go @@ -0,0 +1,24 @@ +// Package upload pushes a signed artifact to its store or registry. It is a +// standalone subsystem (Android upload is an in-process API call, not a shell +// step), used by the anvil upload command. +package upload + +import "context" + +type Platform string + +const ( + PlatformIOS Platform = "ios" + PlatformAndroid Platform = "android" + PlatformNPM Platform = "npm" +) + +// Uploader pushes one artifact to one destination. Validate checks required +// inputs and credentials; Describe returns a human summary for dry-run; Run +// performs the upload. +type Uploader interface { + Platform() Platform + Validate() error + Describe() []string + Run(ctx context.Context) error +} diff --git a/internal/upload/upload_test.go b/internal/upload/upload_test.go new file mode 100644 index 0000000..3830507 --- /dev/null +++ b/internal/upload/upload_test.go @@ -0,0 +1,86 @@ +package upload + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileCredExplicitOutsideRepo(t *testing.T) { + repo := t.TempDir() + cred := filepath.Join(t.TempDir(), "key.p8") + if err := os.WriteFile(cred, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + got, cleanup, err := FileCred(repo, cred, "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err != nil || got != cred { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestFileCredRefusesInsideRepo(t *testing.T) { + repo := t.TempDir() + cred := filepath.Join(repo, "key.p8") + if err := os.WriteFile(cred, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + _, cleanup, err := FileCred(repo, cred, "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err == nil || !strings.Contains(err.Error(), "inside the repo") { + t.Fatalf("expected in-repo refusal, got %v", err) + } +} + +func TestFileCredBase64(t *testing.T) { + repo := t.TempDir() + t.Setenv("TEST_CRED_B64", base64.StdEncoding.EncodeToString([]byte("secret"))) + got, cleanup, err := FileCred(repo, "", "TEST_CRED_PATH", "TEST_CRED_B64", "k.p8") + defer cleanup() + if err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(got) + if string(data) != "secret" { + t.Errorf("decoded %q, want secret", data) + } + if inside(repo, got) { + t.Error("temp credential should be outside the repo") + } +} + +func TestFileCredMissing(t *testing.T) { + _, cleanup, err := FileCred(t.TempDir(), "", "NOPE_PATH", "NOPE_B64", "k.p8") + defer cleanup() + if err == nil { + t.Fatal("expected an error for a missing credential") + } +} + +func TestValidate(t *testing.T) { + if err := (IOS{}).Validate(); err == nil { + t.Error("empty iOS should not validate") + } + if err := (IOS{KeyID: "k", IssuerID: "i", KeyPath: "p", Artifact: "a.ipa"}).Validate(); err != nil { + t.Errorf("valid iOS: %v", err) + } + if err := (Android{}).Validate(); err == nil { + t.Error("empty Android should not validate") + } + if err := (Android{ServiceAccount: "sa.json", Package: "com.x", Artifact: "a.aab"}).Validate(); err != nil { + t.Errorf("valid Android: %v", err) + } +} + +func TestDescribe(t *testing.T) { + a := strings.Join(Android{ServiceAccount: "sa", Package: "com.x", Track: "beta", Artifact: "a.aab"}.Describe(), "\n") + if !strings.Contains(a, "com.x") || !strings.Contains(a, "beta") { + t.Errorf("android describe: %s", a) + } + i := strings.Join(IOS{KeyID: "K", IssuerID: "I", KeyPath: "p", Artifact: "a.ipa"}.Describe(), "\n") + if !strings.Contains(i, "altool") { + t.Errorf("ios describe: %s", i) + } +} diff --git a/tasks/todo.md b/tasks/todo.md index f83d71d..34a4e8c 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -73,7 +73,21 @@ project restored). Deferred: App Store export, App Store Connect API and fastlane match, OS keychain, Play enrollment, and Android apksigner for a loose prebuilt APK. -## Milestone 4: upload +## Milestone 4: upload (in progress) -- [ ] Store/registry upload (TestFlight, Play, npm) -- [ ] GoReleaser to Homebrew/Scoop/curl distribution +- [x] `internal/upload`: Uploader interface + iOS (altool), Android (Play API), npm +- [x] Credential resolution (flag/env/base64-to-temp) that refuses in-repo secrets +- [x] `anvil upload` with dry-run default (`--yes` to perform) +- [x] GoReleaser self-distribution: `.goreleaser.yaml` + tag-triggered `release.yml` +- [x] Tests (cred resolution, in-repo refusal, Validate, Describe); ./check + staticcheck green +- [ ] PR into develop, CI green + +### Review, Milestone 4 +Upload works end to end in dry-run for all three targets (verified). Live pushes +need real store accounts, so they are deferred; unit tests cover credential +resolution, the in-repo refusal, and each uploader's validation and plan. +GoReleaser was pulled into this milestone; it needs `openforge-oss/homebrew-tap` +and `scoop-bucket` repos plus a `HOMEBREW_TAP_TOKEN` secret before the first +release tag. Deferred: App Store submission metadata, Play staged rollout, +fastlane back-ends, `anvil build --upload`, npm OIDC. This completes the core +detect -> build -> sign -> upload pipeline. From cefe43b5b005585f2c6e369d8227b8543816f34c Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Thu, 23 Jul 2026 21:59:12 +0100 Subject: [PATCH 5/8] feat(detect,driver): Go and web/Node stacks (#14) Add Go and web/Node as detectors and drivers, reusing the existing contract with no new dependencies. Go: go.mod detection (app vs library via a main-package scan); go mod download, go vet + gofmt -l (Analyze classified as failed on non-empty output), go test, go build. Web/Node: framework detection (Next, Nuxt, SvelteKit, Angular, Vite, CRA, Vue, Svelte, Astro, Remix, Gatsby); deps by lockfile (with Yarn Berry --immutable), lint, test (jest/vitest/script with CI=true), build via the package.json script. Monorepo roots (workspaces, pnpm-workspace.yaml, lerna, nx) are descended into rather than claimed, and a single-package Turbo repo is a leaf. Detector order appends Go then Web (Web last, most permissive). --- CHANGELOG.md | 16 +++--- docs/ROADMAP.md | 4 +- internal/detect/detect.go | 9 ++++ internal/detect/go.go | 32 +++++++++++ internal/detect/goweb_test.go | 61 +++++++++++++++++++++ internal/detect/reactnative.go | 2 + internal/detect/web.go | 98 ++++++++++++++++++++++++++++++++++ internal/driver/go.go | 39 ++++++++++++++ internal/driver/goweb_test.go | 55 +++++++++++++++++++ internal/driver/reactnative.go | 15 ++++++ internal/driver/registry.go | 4 ++ internal/driver/web.go | 79 +++++++++++++++++++++++++++ tasks/todo.md | 17 +++++- 13 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 internal/detect/go.go create mode 100644 internal/detect/goweb_test.go create mode 100644 internal/detect/web.go create mode 100644 internal/driver/go.go create mode 100644 internal/driver/goweb_test.go create mode 100644 internal/driver/web.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d9661..1a47600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,13 @@ All notable changes are documented here, following ## [Unreleased] ### Added -- Upload: `anvil upload` pushes a signed artifact to its store. iOS via - `xcrun altool` to App Store Connect/TestFlight, Android via the Google Play - Publisher API (insert edit, upload bundle, assign track, commit) using a - service account, and npm via `npm publish`. Credentials come from flags, env, - or a base64 env decoded to a temp file; a credential inside the repo is - refused; uploads are a dry run unless `--yes`. -- Self-distribution: a GoReleaser config and a tag-triggered release workflow - that build cross-platform binaries and publish a GitHub release plus Homebrew - (cask) and Scoop manifests. +- Go and web/Node stacks (detector + driver, no new dependencies). Go: `go.mod` + detection (app vs library), running `go mod download`, `go vet` + `gofmt -l`, + `go test`, `go build`. Web/Node: framework detection (Next, Nuxt, SvelteKit, + Angular, Vite, CRA, Vue, Svelte, Astro, Remix, Gatsby), deps by lockfile, lint, + test (jest/vitest/script), and build via the package.json build script. + Monorepo roots (workspaces, pnpm, lerna, nx) are descended into so members + still surface, and a single-package Turbo repo is treated as a leaf. - Guided release signing: `anvil sign` and `anvil build --sign`, a Sign phase that runs after Build. Android setup generates a PKCS12 keystore with keytool, writes key.properties, wires Gradle signingConfigs, and gitignores the secrets, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 35bea58..eb1eb7f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,8 +9,8 @@ High-level phases and status, for monitoring. Granular tasks live in | 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 | in progress | -| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | in progress | -| 5. Breadth | More ecosystems (web, Go) via new drivers; flavor auto-detection; `--explain` educational mode | future | +| 4. Upload | TestFlight, Play, npm upload; GoReleaser distribution (Homebrew, Scoop, curl) | planned | +| 5. Breadth | More ecosystems (Go, web) via new drivers; flavor auto-detection; `--explain` educational mode | in progress | Swift (SPM) and Kotlin/JVM drivers and `--flavor` were pulled forward into Milestone 2. diff --git a/internal/detect/detect.go b/internal/detect/detect.go index cd10ee7..032f069 100644 --- a/internal/detect/detect.go +++ b/internal/detect/detect.go @@ -19,6 +19,8 @@ const ( IOS Stack = "ios" Kotlin Stack = "kotlin" Swift Stack = "swift" + Go Stack = "go" + Web Stack = "web" ) type Project struct { @@ -39,6 +41,7 @@ func DefaultSkipDirs() map[string]bool { "node_modules", ".git", "build", ".gradle", ".dart_tool", "Pods", "DerivedData", ".expo", ".idea", ".fvm", ".symlinks", "Carthage", ".build", "out", "dist", "vendor", ".cxx", + ".next", ".output", ".svelte-kit", ".nuxt", ".angular", ".astro", ".turbo", } m := make(map[string]bool, len(names)) for _, n := range names { @@ -109,6 +112,12 @@ func detectDir(dir string) *Project { if p := detectSwift(dir); p != nil { return p } + if p := detectGo(dir); p != nil { + return p + } + if p := detectWeb(dir); p != nil { + return p + } return nil } diff --git a/internal/detect/go.go b/internal/detect/go.go new file mode 100644 index 0000000..c0a26ac --- /dev/null +++ b/internal/detect/go.go @@ -0,0 +1,32 @@ +package detect + +import "path/filepath" + +func detectGo(dir string) *Project { + if !fileExists(filepath.Join(dir, "go.mod")) { + return nil + } + subtype := "library" + if hasGoMain(dir) { + subtype = "app" + } + return &Project{Path: dir, Stack: Go, Subtype: subtype, Confidence: 0.95} +} + +// hasGoMain reports whether a main package exists in the module root or under +// cmd/*, without invoking the toolchain. +func hasGoMain(dir string) bool { + patterns := []string{ + filepath.Join(dir, "*.go"), + filepath.Join(dir, "cmd", "*", "*.go"), + } + for _, pat := range patterns { + files, _ := filepath.Glob(pat) + for _, f := range files { + if fileContains(f, "package main") { + return true + } + } + } + return false +} diff --git a/internal/detect/goweb_test.go b/internal/detect/goweb_test.go new file mode 100644 index 0000000..f286081 --- /dev/null +++ b/internal/detect/goweb_test.go @@ -0,0 +1,61 @@ +package detect + +import ( + "reflect" + "sort" + "testing" +) + +func TestScanGoWeb(t *testing.T) { + cases := []struct { + name string + files map[string]string + want []string + }{ + {"go app", map[string]string{"go.mod": "module x\n", "main.go": "package main\nfunc main() {}"}, []string{".|go|app"}}, + {"go library", map[string]string{"go.mod": "module x\n", "lib.go": "package lib\n"}, []string{".|go|library"}}, + {"go cmd main", map[string]string{"go.mod": "module x\n", "cmd/srv/main.go": "package main\nfunc main() {}"}, []string{".|go|app"}}, + {"go.work descends to member", map[string]string{"go.work": "go 1.22\n", "a/go.mod": "module a\n", "a/main.go": "package main\nfunc main() {}"}, []string{"a|go|app"}}, + {"next app", map[string]string{"package.json": `{"dependencies":{"next":"14"},"scripts":{"build":"next build"}}`}, []string{".|web|next"}}, + {"vite app", map[string]string{"package.json": `{"devDependencies":{"vite":"5"},"scripts":{"build":"vite build"}}`}, []string{".|web|vite"}}, + {"cra app", map[string]string{"package.json": `{"dependencies":{"react-scripts":"5"},"scripts":{"build":"react-scripts build"}}`}, []string{".|web|cra"}}, + {"web library (test only)", map[string]string{"package.json": `{"scripts":{"test":"vitest run"}}`}, []string{".|web|library"}}, + {"pnpm workspace descends to web member", map[string]string{ + "package.json": `{"name":"root"}`, + "pnpm-workspace.yaml": "packages:\n - app\n", + "app/package.json": `{"dependencies":{"vite":"5"},"scripts":{"build":"vite build"}}`, + }, []string{"app|web|vite"}}, + {"rn app with build script stays rn", map[string]string{ + "package.json": `{"dependencies":{"react-native":"0.74.0"},"scripts":{"build":"x"}}`, + "android/.gitkeep": "", + "ios/.gitkeep": "", + }, []string{".|react-native|bare-rn"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := writeTree(t, tc.files) + got, err := Scan(root, DefaultOptions()) + if err != nil { + t.Fatal(err) + } + gotS := summarize(root, got) + want := append([]string(nil), tc.want...) + sort.Strings(want) + if !reflect.DeepEqual(gotS, want) { + t.Errorf("Scan()\n got: %v\nwant: %v", gotS, want) + } + }) + } +} + +func TestScanBareAndTurbo(t *testing.T) { + bare := writeTree(t, map[string]string{"package.json": `{"name":"x","dependencies":{"lodash":"4"}}`}) + if got, _ := Scan(bare, DefaultOptions()); len(got) != 0 { + t.Errorf("bare package.json should not be claimed, got %v", summarize(bare, got)) + } + turbo := writeTree(t, map[string]string{"turbo.json": "{}", "package.json": `{"dependencies":{"vite":"5"},"scripts":{"build":"vite build"}}`}) + got, _ := Scan(turbo, DefaultOptions()) + if s := summarize(turbo, got); len(s) != 1 || s[0] != ".|web|vite" { + t.Errorf("single-package turbo app should be claimed as web, got %v", s) + } +} diff --git a/internal/detect/reactnative.go b/internal/detect/reactnative.go index 7d9841c..aa2153e 100644 --- a/internal/detect/reactnative.go +++ b/internal/detect/reactnative.go @@ -9,6 +9,8 @@ import ( type packageJSON struct { Dependencies map[string]string `json:"dependencies"` DevDependencies map[string]string `json:"devDependencies"` + Scripts map[string]string `json:"scripts"` + Workspaces json.RawMessage `json:"workspaces"` } func detectReactNative(dir string) *Project { diff --git a/internal/detect/web.go b/internal/detect/web.go new file mode 100644 index 0000000..2e6ecc4 --- /dev/null +++ b/internal/detect/web.go @@ -0,0 +1,98 @@ +package detect + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// webFrameworks maps a dependency to a subtype, most specific first so that, for +// example, Next wins over a bare React and SvelteKit over plain Svelte. +var webFrameworks = []struct{ dep, subtype string }{ + {"next", "next"}, + {"nuxt", "nuxt"}, + {"@sveltejs/kit", "sveltekit"}, + {"@angular/core", "angular"}, + {"@angular/cli", "angular"}, + {"astro", "astro"}, + {"@remix-run/dev", "remix"}, + {"remix", "remix"}, + {"gatsby", "gatsby"}, + {"react-scripts", "cra"}, + {"@vue/cli-service", "vue"}, + {"vue", "vue"}, + {"svelte", "svelte"}, + {"vite", "vite"}, +} + +func detectWeb(dir string) *Project { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return nil + } + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + if pkg.has("react-native") || pkg.has("expo") { + return nil + } + if isWorkspaceRoot(dir, pkg) { + return nil + } + + if fw := webFramework(pkg); fw != "" { + return &Project{Path: dir, Stack: Web, Subtype: fw, Confidence: 0.9} + } + if _, ok := pkg.Scripts["build"]; ok { + return &Project{Path: dir, Stack: Web, Subtype: "web-app", Confidence: 0.8} + } + if hasWebTooling(dir, pkg) { + return &Project{Path: dir, Stack: Web, Subtype: "library", Confidence: 0.65} + } + return nil +} + +// isWorkspaceRoot reports whether the package.json marks a monorepo root, which +// should be descended into rather than claimed. Checked before the build-script +// test because orchestrator roots often have a root build script. turbo.json +// alone is not a trigger (Turbo supports single-package repos). +func isWorkspaceRoot(dir string, pkg packageJSON) bool { + if ws := strings.TrimSpace(string(pkg.Workspaces)); ws != "" && ws != "null" && ws != "[]" && ws != "{}" { + return true + } + for _, f := range []string{"pnpm-workspace.yaml", "lerna.json", "nx.json"} { + if fileExists(filepath.Join(dir, f)) { + return true + } + } + return false +} + +func webFramework(pkg packageJSON) string { + for _, fw := range webFrameworks { + if pkg.has(fw.dep) { + return fw.subtype + } + } + return "" +} + +func hasWebTooling(dir string, pkg packageJSON) bool { + if _, ok := pkg.Scripts["test"]; ok { + return true + } + if _, ok := pkg.Scripts["lint"]; ok { + return true + } + for _, n := range []string{ + ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", "eslint.config.js", + "jest.config.js", "jest.config.ts", "vitest.config.js", "vitest.config.ts", + } { + if fileExists(filepath.Join(dir, n)) { + return true + } + } + return false +} diff --git a/internal/driver/go.go b/internal/driver/go.go new file mode 100644 index 0000000..868d8e6 --- /dev/null +++ b/internal/driver/go.go @@ -0,0 +1,39 @@ +package driver + +import "bytes" + +type Go struct{ base } + +func (Go) Name() string { return "go" } + +func (Go) Steps(phase Phase, _ BuildOptions) ([]Step, bool) { + switch phase { + case Deps: + return []Step{{Name: "go mod download", Argv: []string{"go", "mod", "download"}}}, true + case Analyze: + return []Step{ + {Name: "go vet", Argv: []string{"go", "vet", "./..."}}, + {Name: "gofmt -l", Argv: []string{"gofmt", "-l", "."}}, + }, true + case Test: + return []Step{{Name: "go test", Argv: []string{"go", "test", "./..."}}}, true + case Build: + return []Step{{Name: "go build", Argv: []string{"go", "build", "./..."}}}, true + } + return nil, false +} + +// Classify treats a non-empty Analyze output as a failure: gofmt -l exits 0 even +// when it lists unformatted files, so exit code alone is not enough. +func (Go) Classify(phase Phase, exitCode int, output []byte) Status { + if phase == Analyze { + if exitCode != 0 || len(bytes.TrimSpace(output)) > 0 { + return Failed + } + return OK + } + if exitCode == 0 { + return OK + } + return Failed +} diff --git a/internal/driver/goweb_test.go b/internal/driver/goweb_test.go new file mode 100644 index 0000000..1d316f8 --- /dev/null +++ b/internal/driver/goweb_test.go @@ -0,0 +1,55 @@ +package driver + +import ( + "strings" + "testing" +) + +func TestGoSteps(t *testing.T) { + d := Go{} + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "go mod download") + wantCmds(t, mustSteps(t, d, Analyze, BuildOptions{}), "go vet ./...", "gofmt -l .") + wantCmds(t, mustSteps(t, d, Test, BuildOptions{}), "go test ./...") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), "go build ./...") +} + +func TestGoClassifyAnalyze(t *testing.T) { + d := Go{} + if d.Classify(Analyze, 0, []byte("")) != OK { + t.Error("clean analyze should be OK") + } + if d.Classify(Analyze, 0, []byte("main.go\n")) != Failed { + t.Error("gofmt listing files (exit 0) should be Failed") + } + if d.Classify(Test, 0, nil) != OK || d.Classify(Test, 1, nil) != Failed { + t.Error("non-analyze phases classify by exit code") + } +} + +func TestWebSteps(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "package-lock.json": "", + "package.json": `{"scripts":{"build":"vite build","test":"vitest run","lint":"eslint ."}}`, + }) + d := Web{base{root}} + wantCmds(t, mustSteps(t, d, Deps, BuildOptions{}), "npm ci") + wantCmds(t, mustSteps(t, d, Analyze, BuildOptions{}), "npm run lint") + wantCmds(t, mustSteps(t, d, Build, BuildOptions{}), "npm run build") + steps := mustSteps(t, d, Test, BuildOptions{}) + if len(steps) != 1 || strings.Join(steps[0].Argv, " ") != "npm run test" { + t.Errorf("web test: %v", cmds(steps)) + } +} + +func TestWebSkipsWhenNoScripts(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"package.json": `{"name":"x"}`}) + d := Web{base{root}} + if _, ok := d.Steps(Build, BuildOptions{}); ok { + t.Error("build should be skipped with no build script") + } + if _, ok := d.Steps(Analyze, BuildOptions{}); ok { + t.Error("analyze should be skipped with no lint script or eslint config") + } +} diff --git a/internal/driver/reactnative.go b/internal/driver/reactnative.go index cafd388..e069a72 100644 --- a/internal/driver/reactnative.go +++ b/internal/driver/reactnative.go @@ -81,6 +81,9 @@ func (r ReactNative) Steps(phase Phase, opts BuildOptions) ([]Step, bool) { func jsInstall(root string) Step { switch { case fileExists(filepath.Join(root, "yarn.lock")): + if isYarnBerry(root) { + return Step{Name: "yarn install", Argv: []string{"yarn", "install", "--immutable"}} + } 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"}} @@ -91,6 +94,18 @@ func jsInstall(root string) Step { } } +func isYarnBerry(root string) bool { + if fileExists(filepath.Join(root, ".yarnrc.yml")) { + return true + } + data, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return false + } + s := string(data) + return strings.Contains(s, "\"packageManager\"") && strings.Contains(s, "yarn@") && !strings.Contains(s, "yarn@1") +} + func hasEslint(root string) bool { for _, n := range []string{ ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", diff --git a/internal/driver/registry.go b/internal/driver/registry.go index b60d03f..50a406d 100644 --- a/internal/driver/registry.go +++ b/internal/driver/registry.go @@ -18,6 +18,10 @@ func For(stack detect.Stack, root string) (Driver, bool) { return Kotlin{base{root}}, true case detect.Swift: return Swift{base{root}}, true + case detect.Go: + return Go{base{root}}, true + case detect.Web: + return Web{base{root}}, true default: return nil, false } diff --git a/internal/driver/web.go b/internal/driver/web.go new file mode 100644 index 0000000..71ce77c --- /dev/null +++ b/internal/driver/web.go @@ -0,0 +1,79 @@ +package driver + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +type Web struct{ base } + +func (Web) Name() string { return "web" } + +func (w Web) Steps(phase Phase, _ BuildOptions) ([]Step, bool) { + scripts := readScripts(w.root) + switch phase { + case Deps: + return []Step{jsInstall(w.root)}, true + case Analyze: + if _, ok := scripts["lint"]; ok { + return []Step{pmRun(w.root, "lint")}, true + } + if hasEslint(w.root) { + return []Step{{Name: "npx eslint .", Argv: []string{"npx", "eslint", "."}}}, true + } + return nil, false + case Test: + if hasJest(w.root) { + return []Step{{Name: "npx jest --ci", Argv: []string{"npx", "jest", "--ci"}}}, true + } + if hasVitest(w.root) { + return []Step{{Name: "npx vitest run", Argv: []string{"npx", "vitest", "run"}}}, true + } + if t, ok := scripts["test"]; ok && !strings.Contains(t, "no test specified") { + s := pmRun(w.root, "test") + s.Env = []string{"CI=true"} + return []Step{s}, true + } + return nil, false + case Build: + if _, ok := scripts["build"]; ok { + return []Step{pmRun(w.root, "build")}, true + } + return nil, false + } + return nil, false +} + +func readScripts(root string) map[string]string { + data, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return nil + } + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + _ = json.Unmarshal(data, &pkg) + return pkg.Scripts +} + +func pmRun(root, script string) Step { + switch { + case fileExists(filepath.Join(root, "yarn.lock")): + return Step{Name: "yarn " + script, Argv: []string{"yarn", script}} + case fileExists(filepath.Join(root, "pnpm-lock.yaml")): + return Step{Name: "pnpm run " + script, Argv: []string{"pnpm", "run", script}} + default: + return Step{Name: "npm run " + script, Argv: []string{"npm", "run", script}} + } +} + +func hasVitest(root string) bool { + for _, n := range []string{"vitest.config.js", "vitest.config.ts", "vitest.config.mjs", "vitest.config.cjs"} { + if fileExists(filepath.Join(root, n)) { + return true + } + } + return false +} diff --git a/tasks/todo.md b/tasks/todo.md index 34a4e8c..b093db1 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -73,7 +73,22 @@ project restored). Deferred: App Store export, App Store Connect API and fastlane match, OS keychain, Play enrollment, and Android apksigner for a loose prebuilt APK. -## Milestone 4: upload (in progress) +## Milestone 5: breadth, Go and web/Node (in progress) + +- [x] detect: Go (go.mod, app/library) and Web (framework/app/library; workspace roots descend) +- [x] drivers: Go (deps, vet + gofmt, test, build; Analyze classify for gofmt) and Web (install, lint, test, build via package-manager scripts) +- [x] jsInstall Yarn Berry (`--immutable`); `pmRun` helper; framework output dirs added to skip list +- [x] Tests (Go app/library/cmd/go.work; web frameworks; workspace-root and bare not claimed); ./check + staticcheck green +- [x] Verified: anvil self-detects as go/app; build --dry-run shows the Go pipeline +- [ ] PR into develop, CI green + +### Review, Milestone 5 +anvil now spans eight stacks. Go and web reuse the detector+driver contract with +no new dependencies. Web detection descends into monorepo roots so members still +surface, and treats a single-package Turbo repo as a leaf. Remaining polish +issues: #4, #5, #6, #9, #10. + +## Milestone 4: upload - [x] `internal/upload`: Uploader interface + iOS (altool), Android (Play API), npm - [x] Credential resolution (flag/env/base64-to-temp) that refuses in-repo secrets From cf7e675d5eee3472694f0c8d7babf88b4740cb31 Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Fri, 24 Jul 2026 01:43:46 +0100 Subject: [PATCH 6/8] chore(release): prepare v0.1.0 (#16) Rewrite the README for the released tool (eight stacks, full detect/build/sign/upload pipeline, install via binary or go install, usage and flags). Make Homebrew and Scoop upload skip gracefully when HOMEBREW_TAP_TOKEN is absent, so the release ships binaries and the GitHub release without a token and auto-enables tap publishing once the secret is set. --- .goreleaser.yaml | 2 + README.md | 96 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b0fe0d8..1e9c28f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,6 +31,7 @@ homebrew_casks: owner: openforge-oss name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + skip_upload: "{{ if .Env.HOMEBREW_TAP_TOKEN }}false{{ else }}true{{ end }}" homepage: https://github.com/openforge-oss/anvil description: Guided, zero-config build and release pipeline for mobile and app projects @@ -39,6 +40,7 @@ scoops: owner: openforge-oss name: scoop-bucket token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + skip_upload: "{{ if .Env.HOMEBREW_TAP_TOKEN }}false{{ else }}true{{ end }}" homepage: https://github.com/openforge-oss/anvil description: Guided, zero-config build and release pipeline for mobile and app projects diff --git a/README.md b/README.md index 2cab18c..aed25e6 100644 --- a/README.md +++ b/README.md @@ -5,52 +5,87 @@ **A guided, zero-config build and release pipeline for mobile and app projects.** One command detects the stack, fetches dependencies, analyzes, surfaces errors, -tests, and builds (then signs and uploads), without memorizing each framework's CLI. +tests, builds, signs, and uploads, without memorizing each framework's CLI. [![CI](https://github.com/openforge-oss/anvil/actions/workflows/ci.yml/badge.svg)](https://github.com/openforge-oss/anvil/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/openforge-oss/anvil?sort=semver)](https://github.com/openforge-oss/anvil/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![status](https://img.shields.io/badge/status-early%20development-orange) Part of [OpenForge](https://github.com/openforge-oss). -## Status +## What it does -Early development, Milestone 1 (detection engine). The CLI can detect a project's -stack; the build lifecycle comes next. Track progress in -[`tasks/todo.md`](tasks/todo.md) and [`docs/ROADMAP.md`](docs/ROADMAP.md). - -## The problem - -Shipping a mobile or app build is a fiddly, error-prone grind: Gradle and AGP -version matrices, the CocoaPods to Swift Package Manager migration, iOS -provisioning and code signing, and per-stack build commands nobody remembers. -Existing tools either need config and a cloud account (fastlane, Codemagic), only -output server containers (Nixpacks, buildpacks), or are heavy monorepo build -systems (Nx, Bazel). None is a local, zero-config, auto-detecting, guided CLI -that produces mobile artifacts. - -## The idea +Point anvil at a project and it works out the stack, then runs the right +lifecycle: dependencies, static analysis, tests, build, and (where set up) +signing and store upload. It prints every command it runs, so it is also a way to +learn the underlying tools instead of hiding them. ```console -$ anvil ship # (planned) -Detected: Flutter app (android, ios) +$ anvil detect +PATH STACK SUBTYPE CONFIDENCE +. flutter app 0.98 + +$ anvil build flutter pub get ok flutter analyze 0 issues flutter test 42 passed -Build target? Android App Bundle (.aab) / iOS Archive (.ipa) / Both -... +flutter build appbundle +``` + +### Supported stacks + +Flutter, React Native, native Android, native iOS, Swift, Kotlin/JVM, Go, and +web/Node (Next, Nuxt, SvelteKit, Angular, Vite, CRA, Vue, Svelte, Astro, Remix, +Gatsby). Adding another is one driver. See +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +## Install + +anvil is a single static binary. + +### Prebuilt binary + +Download the archive for your OS and architecture from the +[latest release](https://github.com/openforge-oss/anvil/releases/latest), extract +it, and put `anvil` on your `PATH`. + +### Go + +```bash +go install github.com/openforge-oss/anvil@latest +``` + +Homebrew and Scoop distribution is wired and lands in the next release. + +## Usage + +```bash +anvil detect # identify the project and its stack +anvil build # deps, analyze, test, build (guided) +anvil build --release --flavor prod +anvil sign # set up Android or iOS signing +anvil build --sign # build and sign +anvil upload # dry-run by default, pass --yes to perform ``` -Auto-detect the stack, run the right lifecycle, explain failures in plain -language, and (later) walk you through signing and store upload. It graduates -beginners by printing the exact commands it runs. +Useful flags: `--path` (project directory), `--target` (android or ios), +`--flavor`, `--release`, `--dry-run` (print the plan without running it), and +`--plain` (no TUI, for CI). Every command has `--help`. + +anvil never puts secrets on the command line or in the repo. Keystore and store +credentials come from a prompt or an environment variable, and a credential +located inside the working tree is refused. -- v1 stacks: Flutter, React Native, native Android, native iOS. -- Built in Go (single static binary; nothing to install but the binary). -- Pluggable: adding a framework means adding one driver. See - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +## The problem + +Shipping a build is a fiddly, error-prone grind: Gradle and AGP version matrices, +the CocoaPods to Swift Package Manager migration, iOS provisioning and code +signing, and per-stack commands nobody remembers. Existing tools either need +config and a cloud account (fastlane, Codemagic), only output server containers +(Nixpacks, buildpacks), or are heavy monorepo build systems (Nx, Bazel). anvil is +a local, zero-config, auto-detecting, guided CLI that produces real artifacts. ## Contributing @@ -63,6 +98,9 @@ test) before every push. ./build # produces bin/anvil ``` +Progress lives in [`tasks/todo.md`](tasks/todo.md) and +[`docs/ROADMAP.md`](docs/ROADMAP.md). + ## License [MIT](LICENSE), OpenForge, 2026. From 7269446160ae0400412bf8fc161784ed72ebd898 Mon Sep 17 00:00:00 2001 From: "David F. Roberts" Date: Fri, 24 Jul 2026 12:11:10 +0100 Subject: [PATCH 7/8] docs: real install instructions, and bump actions off Node 20 (#18) README now shows the working Homebrew cask and Scoop bucket commands published by the v0.1.0 release, and notes that go install lands in GOPATH/bin. Mark 0.1.0 as released in the changelog instead of leaving shipped work under Unreleased. Bump actions/checkout to v7 and actions/setup-go to v7 across CI and release, clearing the Node 20 deprecation warning on the release run. --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 4 ++-- CHANGELOG.md | 5 +++++ README.md | 15 ++++++++++++++- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173a257..6cd1551 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,8 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version: stable cache: true @@ -41,8 +41,8 @@ jobs: name: staticcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version: stable cache: true @@ -53,8 +53,8 @@ jobs: name: govulncheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version: stable cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a72ab29..eb9b799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,10 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v7 with: go-version: stable - uses: goreleaser/goreleaser-action@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a47600..16488de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes are documented here, following ## [Unreleased] +## [0.1.0] - 2026-07-24 + +First tagged release. Distributed as prebuilt binaries, a Homebrew cask, a Scoop +manifest, and `go install`. + ### Added - Go and web/Node stacks (detector + driver, no new dependencies). Go: `go.mod` detection (app vs library), running `go mod download`, `go vet` + `gofmt -l`, diff --git a/README.md b/README.md index aed25e6..3492d91 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,19 @@ Gatsby). Adding another is one driver. See anvil is a single static binary. +### Homebrew (macOS and Linux) + +```bash +brew install --cask openforge-oss/tap/anvil +``` + +### Scoop (Windows) + +```powershell +scoop bucket add openforge-oss https://github.com/openforge-oss/scoop-bucket +scoop install anvil +``` + ### Prebuilt binary Download the archive for your OS and architecture from the @@ -57,7 +70,7 @@ it, and put `anvil` on your `PATH`. go install github.com/openforge-oss/anvil@latest ``` -Homebrew and Scoop distribution is wired and lands in the next release. +Installs to `$(go env GOPATH)/bin`, which must be on your `PATH`. ## Usage From eac6e7aab5ebb6c34b816774c0d34053ac50c3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Huy?= Date: Wed, 26 Aug 2026 04:30:53 +0700 Subject: [PATCH 8/8] feat(build): add lifecycle phase selection (#20) * feat(build): add lifecycle phase selection * docs: record phase selection verification --- README.md | 8 +++++-- cmd/build.go | 42 ++++++++++++++++++++++++++++++++++ cmd/build_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++ tasks/todo.md | 14 ++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 cmd/build_test.go diff --git a/README.md b/README.md index 3492d91..088e2ca 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,18 @@ Installs to `$(go env GOPATH)/bin`, which must be on your `PATH`. anvil detect # identify the project and its stack anvil build # deps, analyze, test, build (guided) anvil build --release --flavor prod +anvil build --until analyze # deps, then analyze +anvil build --only test # test phase only anvil sign # set up Android or iOS signing anvil build --sign # build and sign anvil upload # dry-run by default, pass --yes to perform ``` Useful flags: `--path` (project directory), `--target` (android or ios), -`--flavor`, `--release`, `--dry-run` (print the plan without running it), and -`--plain` (no TUI, for CI). Every command has `--help`. +`--flavor`, `--release`, `--until` (inclusive lifecycle prefix), `--only` (one +lifecycle phase), `--dry-run` (print the plan without running it), and `--plain` +(no TUI, for CI). `--until` and `--only` are mutually exclusive. Every command +has `--help`. anvil never puts secrets on the command line or in the repo. Keystore and store credentials come from a prompt or an environment variable, and a credential diff --git a/cmd/build.go b/cmd/build.go index 74a7466..798bdc6 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -26,6 +26,8 @@ var ( buildSign bool buildDryRun bool buildPlain bool + buildUntil string + buildOnly string ) var buildCmd = &cobra.Command{ @@ -43,10 +45,16 @@ func init() { buildCmd.Flags().BoolVar(&buildSign, "sign", false, "set up signing and produce a signed artifact") 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") + buildCmd.Flags().StringVar(&buildUntil, "until", "", "run through this lifecycle phase") + buildCmd.Flags().StringVar(&buildOnly, "only", "", "run only this lifecycle phase") rootCmd.AddCommand(buildCmd) } func runBuild(cmd *cobra.Command, _ []string) error { + if buildUntil != "" && buildOnly != "" { + return errors.New("--until and --only cannot be used together") + } + chosen, err := resolveProject(cmd, buildPath) if err != nil { return err @@ -68,6 +76,11 @@ func runBuild(cmd *cobra.Command, _ []string) error { phases = append(append([]driver.Phase{}, driver.Phases...), extra...) } + phases, err = selectBuildPhases(phases, buildUntil, buildOnly) + if err != nil { + return err + } + if buildDryRun { printPlan(cmd, chosen, d, opts, phases) return nil @@ -75,6 +88,35 @@ func runBuild(cmd *cobra.Command, _ []string) error { return runPipeline(cmd, chosen.Path, d, opts, phases) } +func selectBuildPhases(available []driver.Phase, until, only string) ([]driver.Phase, error) { + if until != "" && only != "" { + return nil, errors.New("--until and --only cannot be used together") + } + if until == "" && only == "" { + return append([]driver.Phase{}, available...), nil + } + + selected := only + if selected == "" { + selected = until + } + for idx, phase := range available { + if phase.String() != selected { + continue + } + if only != "" { + return []driver.Phase{phase}, nil + } + return append([]driver.Phase{}, available[:idx+1]...), nil + } + + names := make([]string, len(available)) + for idx, phase := range available { + names[idx] = phase.String() + } + return nil, fmt.Errorf("unknown phase %q; available phases: %s", selected, strings.Join(names, ", ")) +} + func resolveProject(cmd *cobra.Command, path string) (detect.Project, error) { root, err := filepath.Abs(path) if err != nil { diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..f962585 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "reflect" + "strings" + "testing" + + "github.com/openforge-oss/anvil/internal/driver" +) + +func TestSelectBuildPhases(t *testing.T) { + available := []driver.Phase{driver.Deps, driver.Analyze, driver.Test, driver.Build} + + tests := []struct { + name string + until string + only string + want []driver.Phase + wantErr string + }{ + {name: "all phases by default", want: available}, + {name: "inclusive prefix", until: "analyze", want: []driver.Phase{driver.Deps, driver.Analyze}}, + {name: "one phase", only: "test", want: []driver.Phase{driver.Test}}, + {name: "conflicting selectors", until: "test", only: "analyze", wantErr: "cannot be used together"}, + {name: "unknown phase", only: "sign", wantErr: "available phases: deps, analyze, test, build"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := selectBuildPhases(available, tt.until, tt.only) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("selectBuildPhases() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("phases = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSelectBuildPhasesIncludesOptionalSign(t *testing.T) { + available := append(append([]driver.Phase{}, driver.Phases...), driver.Sign) + + got, err := selectBuildPhases(available, "", "sign") + if err != nil { + t.Fatalf("selectBuildPhases() error = %v", err) + } + if !reflect.DeepEqual(got, []driver.Phase{driver.Sign}) { + t.Fatalf("phases = %v, want [sign]", got) + } +} diff --git a/tasks/todo.md b/tasks/todo.md index b093db1..53cab49 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -106,3 +106,17 @@ and `scoop-bucket` repos plus a `HOMEBREW_TAP_TOKEN` secret before the first release tag. Deferred: App Store submission metadata, Play staged rollout, fastlane back-ends, `anvil build --upload`, npm OIDC. This completes the core detect -> build -> sign -> upload pipeline. + +## Issue 9: lifecycle phase selection + +- [x] Add mutually exclusive `anvil build --until ` and `--only ` flags. +- [x] Validate phase names and filter the selected driver's plan before dry-run or execution. +- [x] Cover inclusive prefix selection, single-phase selection, invalid values, and conflicting flags. +- [x] Run `./check`, review the focused diff, and open a pull request into `develop`. + +### Review, Issue 9 + +Phase selection is validated once at the command boundary and feeds both dry-run output and real +execution. Focused tests cover prefixes, single phases, invalid values, conflicts, and optional +signing. `./check`, the binary build, staticcheck, and direct CLI dry runs pass. Pull request #20 +targets `develop`.