Skip to content
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ go install ./cmd/builder

# Run
./builder auth github # Authenticate with GitHub (OAuth device flow)
printenv GH_TOKEN | ./builder auth github --token-stdin # Non-interactive (scopes checked)
./builder init --yes --json # Non-interactive init: detected values, no commit/build
./builder init # Set up workflow in current repo
./builder ios build # Trigger build and download IPA to ./dist/
./builder ios build --profile production # Build with a builder.json profile
Expand Down Expand Up @@ -428,6 +430,22 @@ internal/
- **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per
line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light);
`TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`.
- **Exit Codes** (`internal/exitcode`): 0 ok, 1 failure, 2 usage, 3 auth, 4 CI run failed, 5 timeout,
130 interrupted. Tag at the source with `exitcode.With`/`Usagef`, or give an error type an `ExitCode()`
method (`github.RunFailedError`, `github.APIError` and `asc.Error` on 401); `Code` then falls back to
context.Canceled/DeadlineExceeded and `Timeout()`. `main.usageErrors` wraps every cobra `Args` and the
flag error hook, and gives groups a help `RunE` so `builder ios nope` is exit 2, not help + 0.
- **No Hidden Prompts** (`cmd/builder/input.go`): every question goes through `interactive(cmd)` (stdin is
a terminal and none of `--no-input`, `BUILDER_NO_INPUT`, `CI`, `--json`) and the `asker`: `--yes` takes
the default, a terminal prompts, anything else is `needInput` (exit 2 naming the flag). New prompts must
use it; tests run commands with `runNoInput`, which fails on a command that blocks.
- **init Without A Terminal**: `--commit`/`--build` are checked (via `Changed`) before any file is
written; `--yes` accepts detected values but never commits or builds.
- **Dev Session Input**: `dev.Input` answers device/re-sign/bundle-ID questions (`dev.InputError` is exit 2);
`--json` emits `dev.Event` NDJSON and points `os.Stdout` at stderr for the session, because the handlers
and the tools they run print with fmt.
- **auth github --token-stdin**: `auth.CheckGitHubToken` reads `X-OAuth-Scopes` from `GET /user`; a classic
token missing `repo`/`workflow`/`gist` is refused (exit 3), fine-grained tokens (no header) are saved unchecked.
- **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets`
(internal/signing/sets.go) hold the non-interactive core of `signing setup`
and on-demand provisioning; cmd/builder keeps the prompts, the plan and the
Expand Down
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,75 @@ builder asc users # Team members and whether they can test internall
builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee
```

Every `release`/`upload`/`submit`/`distribute`/`asc` command takes `--json` for
machine-readable output and never prompts, so agents and CI jobs can drive them.
Every command takes `--json` for machine-readable output where it has a result,
and none of them waits for input without a terminal; see
[Using Builder from agents and CI](#using-builder-from-agents-and-ci).

## Using Builder from agents and CI

Builder is meant to be driven by coding agents and CI jobs as much as by
people. Three rules hold for every command:

- **No hidden prompts.** Builder only asks questions when stdin is a terminal.
`--no-input` (global), `BUILDER_NO_INPUT=1`, `CI=true` and `--json` turn
prompts off even in a terminal. A question then takes its default when
`--yes` is given, or the command stops at once with exit code 2 and an
error naming the flag that answers it, e.g.
`the project name ... is needed ...; pass --project or --yes for the default`.
- **`--json` puts the result on stdout**, as one JSON object (long-running
commands: one object per line), and all progress on stderr.
- **Exit codes say why it failed** (table below), so a script does not have to
parse messages.

### Non-interactive setup

```bash
printenv GH_TOKEN | builder auth github --token-stdin # classic token: repo, workflow, gist
printenv CODEMAGIC_API_TOKEN | builder auth codemagic --token-stdin
builder auth apple --issuer-id <id> --key-id <id> --key AuthKey_<id>.p8 # or ASC_* environment variables
builder init --yes --json # detected values; no commit, no build
builder init --project App --ios-path ios --commit --build=false
builder signing setup --distribution store --yes --json
```

| Command | Questions and the flags that answer them |
| --- | --- |
| `auth github` | the login: `--token-stdin` (scopes checked: a token without `repo`, `workflow` and `gist` is refused with exit 3), or `--device-flow` to print the code and wait for approval in a browser |
| `auth codemagic` / `bitrise` | `--token-stdin`, or `CODEMAGIC_API_TOKEN` / `BITRISE_API_TOKEN` |
| `auth apple` | `--issuer-id`, `--key-id`, `--key` (or `ASC_*`) |
| `init` | `--project`, `--ios-path`, `--flutter-version`, `--jdk-version`, `--commit`, `--build`; `--yes` takes the detected values and does **not** commit or build |
| `signing setup` | `--yes` to create resources, `--bundle-id`, `--password` (generated with `--yes`); manual mode `--certificate`, `--profile`, `--key` |
| `signing csr` / `p12` | `--name`, `--email`, `--yes` to replace an existing key; `--certificate`, `--key`, `--password` |
| `dev flutter` / `rn` / `kmp` | `--device` (or `--yes` for the first), `--ipa` (default: newest in `dist/`), `--resign` with `--apple-id` and `BUILDER_APPLE_ID_PASSWORD`, `--bundle-id` |
| `asc ... delete/remove/expire` | `--yes` |

### JSON output

| Command | stdout |
| --- | --- |
| `auth github --json` | `{"event":"authenticated","method":"token"\|"device_flow","login","scopes":[],"scopes_checked","missing_scopes":[]}`; with `--device-flow` a `{"event":"device_code","verification_uri","user_code","expires_in"}` line comes first |
| `auth status --json` | `{"github":{"logged_in"},"codemagic":{...},"bitrise":{...},"apple":{"logged_in","source","key_id"}}` |
| `init --json` | `{"project","repository","ios_path","framework","bundle_id","flutter_version","jdk_version","files":[],"committed","pushed","build":{...}}` |
| `ios build --json` | `{"build_id","ipa","ipa_size","workflow_url","provider","profile","duration_seconds"}`; `--distribute` adds one object per install link, `--submit` prints the `ios release` result instead |
| `ios share --json` | `{"build_id","provider","workflow_url","run_id","ready","submitted","cancel_command"}` |
| `ios release` / `upload` / `submit` / `distribute`, `signing setup`, `asc *` | their result objects (on failure too, when there is a partial result) |
| `dev flutter\|rn\|kmp --json` | one event per line while the session runs: `{"event":"device","device_id","device_name"}`, `{"event":"installed","bundle_id","resigned"}`, `{"event":"launched","bundle_id"}`, then `{"event":"vm_service","url","command"}` (Flutter) or `{"event":"metro","url"}` (React Native). flutter attach and Metro output goes to stderr |
| `mobai ping\|install\|forward --json` | `{"ok":true}`, `{"device_id","bundle_id","installed":true}`, `{"device_id","device_port","host_port"}` |

Errors are printed to stderr as `Error: <message>`; the exit code carries the
category.

### Exit codes

| Code | Meaning |
| --- | --- |
| 0 | Success |
| 1 | Any other failure |
| 2 | Usage: unknown command or flag, wrong arguments, or an answer only a flag can give without a terminal |
| 3 | Authentication: no login or API key, or GitHub / App Store Connect / the CI provider rejected it (including missing token scopes) |
| 4 | The CI run finished without success (failed, cancelled, timed out on the provider) |
| 5 | A wait ran out (`--timeout`, App Store Connect processing, the device-flow code) |
| 130 | Interrupted (Ctrl-C or SIGTERM) |

## Configuration

Expand Down
169 changes: 154 additions & 15 deletions cmd/builder/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/MobAI-App/ios-builder/internal/asc"
"github.com/MobAI-App/ios-builder/internal/auth"
"github.com/MobAI-App/ios-builder/internal/ci"
"github.com/MobAI-App/ios-builder/internal/exitcode"
"github.com/spf13/cobra"
"golang.org/x/term"
)
Expand All @@ -23,8 +25,17 @@ var authCmd = &cobra.Command{
var authGitHubCmd = &cobra.Command{
Use: "github",
Short: "Authenticate with GitHub",
Long: `Authenticates with GitHub using OAuth Device Flow and stores the token securely in your system keychain.`,
RunE: runAuthGitHub,
Long: `Authenticates with GitHub using OAuth Device Flow and stores the token securely in your system keychain.

Without a terminal (agents, CI) pipe a token in instead:

printenv GH_TOKEN | builder auth github --token-stdin

A classic token needs the repo, workflow and gist scopes; missing ones are
named and the token is not saved (exit 3). Fine-grained tokens do not report
their permissions, so they are saved unchecked. --device-flow runs the
browser flow without a terminal: it prints the code and waits for approval.`,
RunE: runAuthGitHub,
}

var authAppleCmd = &cobra.Command{
Expand All @@ -51,35 +62,126 @@ var authLogoutCmd = &cobra.Command{
}

func init() {
authGitHubCmd.Flags().Bool("token-stdin", false, "Read a GitHub token from stdin (classic token with repo, workflow and gist) instead of the browser flow")
authGitHubCmd.Flags().Bool("device-flow", false, "Run the browser flow without a terminal: print the code, wait for approval")
authGitHubCmd.Flags().Bool("json", false, "Print the result as JSON lines (progress goes to stderr)")
authCmd.AddCommand(authGitHubCmd)
authLogoutCmd.Flags().Bool("json", false, "Print the result as JSON")
authCmd.AddCommand(authLogoutCmd)
for _, name := range []string{"codemagic", "bitrise"} {
cmd := &cobra.Command{Use: name, Short: "Authenticate with " + name, Args: cobra.NoArgs, RunE: runAuthProvider}
cmd.Flags().Bool("token-stdin", false, "Read API token from stdin instead of a hidden-input prompt")
cmd.Flags().Bool("json", false, "Print the result as JSON")
authCmd.AddCommand(cmd)
}
authAppleCmd.Flags().String("issuer-id", "", "Issuer ID from App Store Connect → Users and Access → Integrations")
authAppleCmd.Flags().String("key-id", "", "Key ID of the API key")
authAppleCmd.Flags().String("key", "", "Path to the AuthKey_<KEYID>.p8 private key")
authAppleCmd.Flags().Bool("json", false, "Print the result as JSON")
authCmd.AddCommand(authAppleCmd)
authCmd.AddCommand(&cobra.Command{Use: "status", Short: "Show login availability for all providers", Args: cobra.NoArgs, RunE: runAuthStatus})
statusCmd := &cobra.Command{Use: "status", Short: "Show login availability for all providers", Args: cobra.NoArgs, RunE: runAuthStatus}
statusCmd.Flags().Bool("json", false, "Print the result as JSON")
authCmd.AddCommand(statusCmd)
}

func runAuthGitHub(cmd *cobra.Command, args []string) error {
fmt.Println("Authenticating with GitHub...")
// checkGitHubToken and saveGitHubToken are vars so tests need neither GitHub
// nor the keychain.
var (
checkGitHubToken = auth.CheckGitHubToken
saveGitHubToken = auth.SaveToken
)

// githubAuthResult is the JSON of `auth github`. With --device-flow a
// {"event":"device_code",...} line comes first; both are one line each.
type githubAuthResult struct {
Event string `json:"event"` // "authenticated"
Method string `json:"method"`
Login string `json:"login,omitempty"`
Scopes []string `json:"scopes"`
ScopesChecked bool `json:"scopes_checked"`
MissingScopes []string `json:"missing_scopes"`
}

func runAuthGitHub(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
out := newOutput(cmd)
emit := func(v any) { _ = json.NewEncoder(cmd.OutOrStdout()).Encode(v) }

token, err := auth.Login(ctx)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
if fromStdin, _ := cmd.Flags().GetBool("token-stdin"); fromStdin {
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 64*1024))
if err != nil {
return err
}
token := strings.TrimSpace(string(data))
if token == "" {
return exitcode.Usagef("no token on stdin; pipe one in, e.g. printenv GH_TOKEN | builder auth github --token-stdin")
}
id, err := checkGitHubToken(ctx, token)
if err != nil {
return err
}
res := githubAuthResult{Event: "authenticated", Method: "token", Login: id.Login, Scopes: id.Scopes, ScopesChecked: id.ScopesKnown, MissingScopes: []string{}}
if id.Scopes == nil {
res.Scopes = []string{}
}
if id.ScopesKnown {
res.MissingScopes = auth.MissingScopes(id.Scopes)
}
if len(res.MissingScopes) > 0 {
if out.json {
emit(res)
}
return exitcode.With(exitcode.Auth, fmt.Errorf("the token lacks the %s scope(s) Builder needs (it has: %s); create a classic token with %s, or run builder auth github in a terminal",
strings.Join(res.MissingScopes, ", "), strings.Join(id.Scopes, ", "), strings.Join(auth.RequiredScopes, ", ")))
}
if err := saveGitHubToken(token); err != nil {
return err
}
if out.json {
emit(res)
return nil
}
fmt.Fprintf(out.log, "Saved the GitHub token of %s.\n", id.Login)
if !id.ScopesKnown {
fmt.Fprintf(out.log, "GitHub does not report the permissions of fine-grained tokens; it needs contents, actions, secrets and workflows read/write on the repository, and gists for ios distribute.\n")
}
return nil
}

if deviceFlow, _ := cmd.Flags().GetBool("device-flow"); !deviceFlow && !interactive(cmd) {
return needInput("a GitHub login", "--token-stdin (a token with "+strings.Join(auth.RequiredScopes, ", ")+")", "--device-flow (prints a code to approve in a browser and waits for it)")
}

fmt.Println()
fmt.Printf("Authenticated successfully (scope: %s)\n", token.Scope)
fmt.Fprintln(out.log, "Authenticating with GitHub...")
token, err := auth.LoginWith(ctx, func(code *auth.DeviceCode) {
if out.json {
emit(map[string]any{"event": "device_code", "verification_uri": code.VerificationURI, "user_code": code.UserCode, "expires_in": code.ExpiresIn})
return
}
fmt.Fprintln(out.log)
fmt.Fprintf(out.log, " Open: %s\n", code.VerificationURI)
fmt.Fprintf(out.log, " Enter code: %s\n", code.UserCode)
fmt.Fprintln(out.log)
fmt.Fprintln(out.log, "Waiting for authorization...")
})
if err != nil {
// Denied or expired codes are auth failures; a timeout or Ctrl-C keeps its own code.
code := exitcode.Code(err)
if code == exitcode.Failure {
code = exitcode.Auth
}
return exitcode.With(code, fmt.Errorf("authentication failed: %w", err))
}
scopes := auth.ParseScopes(token.Scope)
if out.json {
emit(githubAuthResult{Event: "authenticated", Method: "device_flow", Scopes: scopes, ScopesChecked: true, MissingScopes: auth.MissingScopes(scopes)})
return nil
}
fmt.Fprintln(out.log)
fmt.Fprintf(out.log, "Authenticated successfully (scope: %s)\n", token.Scope)
return nil
}

Expand All @@ -91,6 +193,9 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
if err := auth.LogoutProvider(provider); err != nil {
return err
}
if newOutput(cmd).json {
return printJSON(cmd, map[string]any{"provider": provider, "removed": true})
}
fmt.Printf("Removed saved %s login\n", provider)
switch {
case provider == "apple" && os.Getenv("ASC_ISSUER_ID") != "":
Expand All @@ -112,6 +217,9 @@ func runAuthProvider(cmd *cobra.Command, _ []string) error {
}
token = strings.TrimSpace(string(data))
} else {
if !interactive(cmd) {
return needInput("a "+name+" API token", "--token-stdin", "set "+strings.ToUpper(name)+"_API_TOKEN")
}
fmt.Printf("Create a personal API token in your %s account settings.\n", name)
var err error
token, err = readProviderToken(cmd.Context(), cmd.InOrStdin(), cmd.ErrOrStderr())
Expand All @@ -124,11 +232,14 @@ func runAuthProvider(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("API token is empty")
}
if err := ci.ValidateToken(cmd.Context(), name, token); err != nil {
return fmt.Errorf("%s authentication failed: %w", name, err)
return exitcode.With(exitcode.Auth, fmt.Errorf("%s authentication failed: %w", name, err))
}
if err := auth.StoreProviderToken(name, token); err != nil {
return err
}
if newOutput(cmd).json {
return printJSON(cmd, map[string]any{"provider": name, "saved": true, "env_override": os.Getenv(strings.ToUpper(name)+"_API_TOKEN") != ""})
}
fmt.Printf("Saved %s login. Other provider logins are unchanged.\n", name)
if os.Getenv(strings.ToUpper(name)+"_API_TOKEN") != "" {
fmt.Printf("%s_API_TOKEN is set and takes precedence over this saved login.\n", strings.ToUpper(name))
Expand All @@ -141,8 +252,8 @@ func runAuthApple(cmd *cobra.Command, _ []string) error {
keyID, _ := cmd.Flags().GetString("key-id")
keyPath, _ := cmd.Flags().GetString("key")
if issuerID == "" || keyID == "" || keyPath == "" {
if stdin, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(stdin.Fd())) {
return fmt.Errorf("--issuer-id, --key-id and --key are required without a terminal (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_KEY_PATH)")
if stdin, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(stdin.Fd())) || !interactive(cmd) {
return exitcode.Usagef("--issuer-id, --key-id and --key are required without a terminal (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_KEY_PATH)")
}
fmt.Println("App Store Connect → Users and Access → Integrations → App Store Connect API")
var err error
Expand Down Expand Up @@ -176,19 +287,47 @@ func runAuthApple(cmd *cobra.Command, _ []string) error {
ctx = context.Background()
}
if err := client.CheckAccess(ctx); err != nil {
return fmt.Errorf("the key was rejected by App Store Connect: %w", err)
return exitcode.With(exitcode.Auth, fmt.Errorf("the key was rejected by App Store Connect: %w", err))
}
if err := auth.StoreAppleCredentials(creds); err != nil {
return err
}
if newOutput(cmd).json {
return printJSON(cmd, map[string]any{"provider": "apple", "saved": true, "issuer_id": creds.IssuerID, "key_id": creds.KeyID, "env_override": os.Getenv("ASC_ISSUER_ID") != ""})
}
fmt.Printf("Verified and saved App Store Connect API key %s.\n", creds.KeyID)
if os.Getenv("ASC_ISSUER_ID") != "" {
fmt.Println("ASC_* environment variables are set and take precedence over this saved login.")
}
return nil
}

func runAuthStatus(_ *cobra.Command, _ []string) error {
// loginStatus is one provider in `auth status --json`.
type loginStatus struct {
LoggedIn bool `json:"logged_in"`
Source string `json:"source,omitempty"` // apple: "environment" or "stored"
KeyID string `json:"key_id,omitempty"`
Error string `json:"error,omitempty"`
}

func runAuthStatus(cmd *cobra.Command, _ []string) error {
if newOutput(cmd).json {
status := map[string]loginStatus{}
for _, name := range []string{"github", "codemagic", "bitrise"} {
_, err := auth.GetProviderToken(name)
status[name] = loginStatus{LoggedIn: err == nil}
}
creds, source, err := auth.GetAppleCredentials()
switch {
case errors.Is(err, auth.ErrNotAuthenticated):
status["apple"] = loginStatus{}
case err != nil:
status["apple"] = loginStatus{Error: err.Error()}
default:
status["apple"] = loginStatus{LoggedIn: true, Source: string(source), KeyID: creds.KeyID}
}
return printJSON(cmd, status)
}
for _, name := range []string{"github", "codemagic", "bitrise"} {
_, err := auth.GetProviderToken(name)
state := "login available (not checked remotely)"
Expand Down
Loading
Loading