diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b1e1..e4178a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index cc24fe8..0a65a27 100644 --- a/README.md +++ b/README.md @@ -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 --key-id --key AuthKey_.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: `; 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 diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index b7be08a..7fa3b98 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -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" ) @@ -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{ @@ -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_.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 } @@ -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") != "": @@ -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()) @@ -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)) @@ -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 @@ -176,11 +287,14 @@ 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.") @@ -188,7 +302,32 @@ func runAuthApple(cmd *cobra.Command, _ []string) error { 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)" diff --git a/cmd/builder/auth_agent_test.go b/cmd/builder/auth_agent_test.go new file mode 100644 index 0000000..c7c08f7 --- /dev/null +++ b/cmd/builder/auth_agent_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/auth" + "github.com/MobAI-App/ios-builder/internal/exitcode" +) + +// stubGitHubToken answers token checks with scopes and records saves. +func stubGitHubToken(t *testing.T, scopes []string) *[]string { + t.Helper() + var saved []string + prevCheck, prevSave := checkGitHubToken, saveGitHubToken + checkGitHubToken = func(_ context.Context, token string) (*auth.GitHubIdentity, error) { + return &auth.GitHubIdentity{Login: "octo", Scopes: scopes, ScopesKnown: scopes != nil}, nil + } + saveGitHubToken = func(token string) error { saved = append(saved, token); return nil } + t.Cleanup(func() { checkGitHubToken, saveGitHubToken = prevCheck, prevSave; rootCmd.SetIn(nil) }) + return &saved +} + +func TestAuthGitHubWithoutTerminal(t *testing.T) { + stubGitHubToken(t, nil) + _, _, err := runNoInput(t, "auth", "github") + wantUsage(t, err, "--token-stdin", "--device-flow") +} + +func TestAuthGitHubTokenStdin(t *testing.T) { + saved := stubGitHubToken(t, []string{"repo", "workflow", "gist", "read:org"}) + rootCmd.SetIn(strings.NewReader("ghp_secret\n")) + stdout, _, err := runNoInput(t, "auth", "github", "--token-stdin", "--json") + if err != nil { + t.Fatal(err) + } + var res githubAuthResult + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("not JSON: %q", stdout) + } + if res.Event != "authenticated" || res.Method != "token" || res.Login != "octo" || !res.ScopesChecked || len(res.MissingScopes) != 0 { + t.Errorf("result = %+v", res) + } + if !slices.Equal(*saved, []string{"ghp_secret"}) { + t.Errorf("saved = %v", *saved) + } +} + +func TestAuthGitHubTokenMissingScopes(t *testing.T) { + saved := stubGitHubToken(t, []string{"repo"}) + rootCmd.SetIn(strings.NewReader("ghp_secret")) + stdout, _, err := runNoInput(t, "auth", "github", "--token-stdin", "--json") + if exitcode.Code(err) != exitcode.Auth || !strings.Contains(err.Error(), "workflow, gist") { + t.Fatalf("err = %v, want an auth error naming workflow, gist", err) + } + var res githubAuthResult + if err := json.Unmarshal([]byte(stdout), &res); err != nil || !slices.Equal(res.MissingScopes, []string{"workflow", "gist"}) { + t.Errorf("JSON = %q (%v)", stdout, err) + } + if len(*saved) != 0 { + t.Error("a token without the scopes was saved") + } +} + +func TestAuthGitHubEmptyStdin(t *testing.T) { + stubGitHubToken(t, nil) + rootCmd.SetIn(strings.NewReader(" \n")) + _, _, err := runNoInput(t, "auth", "github", "--token-stdin") + wantUsage(t, err, "--token-stdin") +} + +func TestAuthOthersWithoutTerminal(t *testing.T) { + t.Setenv("ASC_ISSUER_ID", "") + _, _, err := runNoInput(t, "auth", "codemagic") + wantUsage(t, err, "--token-stdin", "CODEMAGIC_API_TOKEN") + _, _, err = runNoInput(t, "auth", "apple") + wantUsage(t, err, "--issuer-id") +} diff --git a/cmd/builder/devflags.go b/cmd/builder/devflags.go new file mode 100644 index 0000000..b35d2a8 --- /dev/null +++ b/cmd/builder/devflags.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "os" + "sync" + + "github.com/MobAI-App/ios-builder/internal/dev" + "github.com/spf13/cobra" +) + +// appleIDPasswordEnv carries the re-sign password, which has no flag so it +// stays out of shell history and process lists. +const appleIDPasswordEnv = "BUILDER_APPLE_ID_PASSWORD" + +// addDevAgentFlags adds the flags that answer a dev session's questions. +func addDevAgentFlags(c *cobra.Command) { + c.Flags().BoolP("yes", "y", false, "Take the default answers: first device, no re-sign, the IPA's bundle ID") + c.Flags().Bool("resign", false, "Re-sign the IPA on install (--resign=false: install as is) without asking; needs --apple-id and "+appleIDPasswordEnv+" off a terminal") + c.Flags().String("apple-id", "", "Apple ID to re-sign with (password from "+appleIDPasswordEnv+")") + c.Flags().Bool("json", false, "Print session events as JSON lines on stdout (all other output goes to stderr); implies --no-input") +} + +// devInput reads those flags into a dev.Input. +func devInput(cmd *cobra.Command) dev.Input { + in := dev.Input{Interactive: interactive(cmd), Password: os.Getenv(appleIDPasswordEnv)} + in.Yes, _ = cmd.Flags().GetBool("yes") + in.AppleID, _ = cmd.Flags().GetString("apple-id") + if cmd.Flags().Changed("resign") { + resign, _ := cmd.Flags().GetBool("resign") + in.Resign = &resign + } + return in +} + +// devEvents sets up --json for a dev session: events go to stdout as one +// JSON object per line, and everything the session and the tools it runs +// (flutter attach, Metro) print goes to stderr. The handlers print with fmt, +// so os.Stdout itself is pointed at stderr until restore. +func devEvents(cmd *cobra.Command) (emit func(dev.Event), restore func()) { + if !newOutput(cmd).json { + return nil, func() {} + } + enc := json.NewEncoder(cmd.OutOrStdout()) + var mu sync.Mutex + stdout := os.Stdout + os.Stdout = os.Stderr + return func(e dev.Event) { + mu.Lock() + defer mu.Unlock() + _ = enc.Encode(e) + }, func() { + os.Stdout = stdout + } +} + +// configureDevSession applies the flags and --json to session. +func configureDevSession(cmd *cobra.Command, session *dev.Session, emit func(dev.Event)) { + session.SetInput(devInput(cmd)) + if emit != nil { + session.SetEvents(emit) + } +} diff --git a/cmd/builder/distribute.go b/cmd/builder/distribute.go index dd06ddc..a60d787 100644 --- a/cmd/builder/distribute.go +++ b/cmd/builder/distribute.go @@ -129,7 +129,7 @@ func runDistribute(cmd *cobra.Command, cfg *config.Config, ipaPath string) error opts.QR = !noQR && (qr || isTerminal(cmd.OutOrStdout())) opts.Progress = uploadProgress(out.log) } - if opts.Stdin != nil && !isTerminal(opts.Stdin) { + if opts.Stdin != nil && (!isTerminal(opts.Stdin) || !interactive(cmd)) { opts.Stdin = nil } diff --git a/cmd/builder/exitcode_test.go b/cmd/builder/exitcode_test.go new file mode 100644 index 0000000..b98200a --- /dev/null +++ b/cmd/builder/exitcode_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/MobAI-App/ios-builder/internal/exitcode" + "github.com/MobAI-App/ios-builder/internal/github" + "github.com/spf13/cobra" +) + +// exitTree is a small command tree with the same usage wiring as rootCmd. +func exitTree(runErr error) *cobra.Command { + root := &cobra.Command{Use: "builder", SilenceUsage: true, SilenceErrors: true} + one := &cobra.Command{Use: "one ", Args: cobra.ExactArgs(1), RunE: func(*cobra.Command, []string) error { return runErr }} + one.Flags().Bool("flag", false, "") + req := &cobra.Command{Use: "req", RunE: func(*cobra.Command, []string) error { return nil }} + req.Flags().String("must", "", "") + _ = req.MarkFlagRequired("must") + group := &cobra.Command{Use: "group"} + group.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.AddCommand(one, req, group) + usageErrors(root) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + return root +} + +func TestExitCodeMapping(t *testing.T) { + tests := []struct { + name string + args []string + runErr error + want int + }{ + {"ok", []string{"one", "x"}, nil, exitcode.OK}, + {"generic failure", []string{"one", "x"}, errors.New("boom"), exitcode.Failure}, + {"unknown flag", []string{"one", "x", "--nope"}, nil, exitcode.Usage}, + {"wrong arg count", []string{"one"}, nil, exitcode.Usage}, + {"unknown command", []string{"nope"}, nil, exitcode.Usage}, + {"unknown subcommand", []string{"group", "nope"}, nil, exitcode.Usage}, + {"group alone shows help", []string{"group"}, nil, exitcode.OK}, + {"missing required flag", []string{"req"}, nil, exitcode.Usage}, + {"auth missing", []string{"one", "x"}, getGitHubClientError(), exitcode.Auth}, + {"build failed", []string{"one", "x"}, fmt.Errorf("build failed: %w", &github.RunFailedError{Conclusion: "failure"}), exitcode.BuildFailed}, + {"timeout", []string{"one", "x"}, fmt.Errorf("wait: %w", context.DeadlineExceeded), exitcode.Timeout}, + {"interrupted", []string{"one", "x"}, fmt.Errorf("upload: %w", context.Canceled), exitcode.Interrupted}, + {"needs input", []string{"one", "x"}, needInput("the project name", "--project"), exitcode.Usage}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := exitTree(tt.runErr) + root.SetArgs(tt.args) + if got := exitCode(root.Execute()); got != tt.want { + t.Errorf("exit code = %d, want %d", got, tt.want) + } + }) + } +} + +// getGitHubClientError is what a command sees without a GitHub login. +func getGitHubClientError() error { + return exitcode.With(exitcode.Auth, errors.New("not authenticated. Run: builder auth github")) +} + +// TestRootArgsAreUsageErrors checks the wiring on the real command tree: +// an argument error must exit 2 without running anything. +func TestRootArgsAreUsageErrors(t *testing.T) { + usageErrors(rootCmd) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + t.Cleanup(func() { rootCmd.SetArgs(nil); rootCmd.SetOut(nil); rootCmd.SetErr(nil) }) + for _, args := range [][]string{{"mobai", "install"}, {"auth", "apple", "extra"}, {"ios", "nope"}} { + rootCmd.SetArgs(args) + if got := exitCode(rootCmd.Execute()); got != exitcode.Usage { + t.Errorf("builder %v: exit code %d, want %d", args, got, exitcode.Usage) + } + } +} diff --git a/cmd/builder/flutter.go b/cmd/builder/flutter.go index 3afb8a9..678ce1d 100644 --- a/cmd/builder/flutter.go +++ b/cmd/builder/flutter.go @@ -8,6 +8,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/dev" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/spf13/cobra" ) @@ -35,7 +36,8 @@ func init() { devFlutterCmd.Flags().String("mobai-url", mobai.DefaultBaseURL, "MobAI API URL") devFlutterCmd.Flags().String("ipa", "", "Path to IPA (default: auto-detect from dist/)") devFlutterCmd.Flags().Bool("skip-install", false, "Skip app installation (app must already be installed)") - devFlutterCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install)") + devFlutterCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install; else used when MobAI does not report it)") + addDevAgentFlags(devFlutterCmd) devFlutterCmd.Flags().Bool("no-attach", false, "Print flutter attach command instead of running it") devFlutterCmd.Flags().Bool("no-watch", false, "Disable automatic hot reload on file changes") } @@ -80,14 +82,17 @@ func runDevFlutter(cmd *cobra.Command, args []string) error { } } + emit, restore := devEvents(cmd) + defer restore() + if skipInstall { if bundleID == "" { - return fmt.Errorf("--bundle-id is required when using --skip-install") + return exitcode.Usagef("--bundle-id is required when using --skip-install") } } else { if ipaPath == "" { var err error - ipaPath, err = dev.FindIPA("dist") + ipaPath, err = dev.FindIPA("dist", interactive(cmd)) if err != nil { return err } @@ -104,6 +109,7 @@ func runDevFlutter(cmd *cobra.Command, args []string) error { handler := dev.NewFlutterHandler(mobaiURL, noAttach, noWatch, watchCfg) session := dev.NewSession(mobaiURL, deviceID, ipaPath, handler) session.SetSkipInstall(skipInstall, bundleID) + configureDevSession(cmd, session, emit) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/builder/init_agent_test.go b/cmd/builder/init_agent_test.go new file mode 100644 index 0000000..c3491f8 --- /dev/null +++ b/cmd/builder/init_agent_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" +) + +// initRepo makes the current directory a git repository with a GitHub origin. +func initRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "https://github.com/octo/app.git"}} { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return dir +} + +func TestInitWithoutTerminalNamesTheFlags(t *testing.T) { + initRepo(t) + + _, _, err := runNoInput(t, "init") + wantUsage(t, err, "--project", "--yes") + + _, _, err = runNoInput(t, "init", "--project", "App") + wantUsage(t, err, "--ios-path") + + _, _, err = runNoInput(t, "init", "--project", "App", "--ios-path", "") + wantUsage(t, err, "--commit") + + _, _, err = runNoInput(t, "init", "--project", "App", "--ios-path", "", "--commit=false") + wantUsage(t, err, "--build") + + if _, err := os.Stat("builder.json"); err == nil { + t.Fatal("init wrote builder.json before every question was answered") + } + + if _, _, err = runNoInput(t, "init", "--project", "App", "--ios-path", "", "--commit=false", "--build=false"); err != nil { + t.Fatalf("init with every answer as a flag: %v", err) + } + if _, err := os.Stat("builder.json"); err != nil { + t.Fatal("builder.json was not written") + } +} + +func TestInitYesJSON(t *testing.T) { + dir := initRepo(t) + if err := os.MkdirAll(filepath.Join("ios", "App.xcodeproj"), 0o755); err != nil { + t.Fatal(err) + } + + // The detected path is a question too. + _, _, err := runNoInput(t, "init", "--project", "App") + wantUsage(t, err, "--ios-path", "--yes") + + stdout, _, err := runNoInput(t, "init", "--yes", "--json") + if err != nil { + t.Fatal(err) + } + var res initResult + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("stdout is not one JSON object: %q", stdout) + } + want := []string{filepath.Join(".github", "workflows", "ios-build.yml"), filepath.Join(".github", "workflows", "ios-share.yml"), "builder.json"} + if res.Project != filepath.Base(dir) || res.Repository != "octo/app" || res.IOSPath != "ios" || res.Framework != "React Native/Expo" || + !slices.Equal(res.Files, want) || res.Committed || res.Pushed || res.Build != nil { + t.Errorf("init --yes --json = %+v", res) + } +} diff --git a/cmd/builder/input.go b/cmd/builder/input.go new file mode 100644 index 0000000..473dd11 --- /dev/null +++ b/cmd/builder/input.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/MobAI-App/ios-builder/internal/exitcode" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// noInput is the global --no-input flag. +var noInput bool + +// stdinIsTerminal reports whether stdin is a terminal. Tests replace it. +var stdinIsTerminal = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// envTrue reads a boolean environment variable the way CI systems set them. +func envTrue(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// interactive reports whether cmd may ask questions: stdin is a terminal and +// nothing asked for non-interactive behaviour (--no-input, BUILDER_NO_INPUT, +// CI, or --json, whose stdout belongs to the result). Every prompt checks it +// first; without it a question either takes its default under --yes or fails +// with needInput naming the flag that answers it. +func interactive(cmd *cobra.Command) bool { + if noInput || envTrue("BUILDER_NO_INPUT") || envTrue("CI") { + return false + } + if cmd != nil { + if f := cmd.Flags().Lookup("json"); f != nil && f.Value.String() == "true" { + return false + } + } + return stdinIsTerminal() +} + +// needInput is the usage error (exit 2) for a question that cannot be asked. +// flags name what answers it, e.g. "--project" or "--yes". +func needInput(what string, flags ...string) error { + return exitcode.Usagef("%s is needed and there is no terminal to ask on (or --no-input/CI is set); pass %s", what, joinOr(flags)) +} + +func joinOr(items []string) string { + switch len(items) { + case 0: + return "the matching flag" + case 1: + return items[0] + } + return strings.Join(items[:len(items)-1], ", ") + " or " + items[len(items)-1] +} + +// asker answers the questions of one command: --yes takes each default, a +// terminal prompts, anything else is a needInput error naming the flag. +type asker struct { + interactive bool + yes bool +} + +func newAsker(cmd *cobra.Command) asker { + yes, _ := cmd.Flags().GetBool("yes") + return asker{interactive: interactive(cmd), yes: yes} +} + +// text asks for a line of text; def is what --yes and an empty answer take. +func (a asker) text(label, def, flag string) (string, error) { + if a.yes { + return def, nil + } + if !a.interactive { + return "", needInput(fmt.Sprintf("%s (default %q)", strings.ToLower(label[:1])+label[1:], def), flag, "--yes for the default") + } + return promptString(label, def) +} + +// required asks for a value that has no default, so --yes cannot answer it. +func (a asker) required(label, flag string) (string, error) { + if !a.interactive { + return "", needInput(strings.ToLower(label[:1])+label[1:], flag) + } + return promptString(label, "") +} + +// password asks for a password with masked input; only its flag answers it +// without a terminal. +func (a asker) password(label, flag string) (string, error) { + if !a.interactive { + return "", needInput(strings.ToLower(label[:1])+label[1:], flag) + } + return promptPassword(label) +} + +// confirm asks a yes/no question; def is what --yes takes. In a terminal an +// empty answer is no, as promptui's confirm has always had it. +func (a asker) confirm(label string, def bool, flag string) (bool, error) { + if a.yes { + return def, nil + } + if !a.interactive { + return false, needInput(fmt.Sprintf("an answer to %q", label), flag, "--yes") + } + _, err := (&promptui.Prompt{Label: label, IsConfirm: true}).Run() + return err == nil, nil +} diff --git a/cmd/builder/json_shape_test.go b/cmd/builder/json_shape_test.go new file mode 100644 index 0000000..ddb72b4 --- /dev/null +++ b/cmd/builder/json_shape_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/build" +) + +// jsonKeys marshals v and returns its top-level keys, sorted. +func jsonKeys(t *testing.T, v any) []string { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + return slices.Sorted(maps.Keys(m)) +} + +// The documented shapes (README, "Using Builder from agents and CI"). A +// change here is a change to what scripts parse. +func TestBuildJSONShape(t *testing.T) { + res := newBuildJSON(&build.BuildResult{BuildID: "ab12cd34", IPAPath: "dist/App-ab12cd34.ipa", IPASize: 42, WorkflowURL: "https://github.com/o/r/actions/runs/1", Duration: 61500 * time.Millisecond}, "github", "development") + want := []string{"build_id", "duration_seconds", "ipa", "ipa_size", "profile", "provider", "workflow_url"} + if got := jsonKeys(t, res); !slices.Equal(got, want) { + t.Errorf("ios build --json keys = %v, want %v", got, want) + } + if res.Duration != 62 { + t.Errorf("duration_seconds = %v, want 62", res.Duration) + } +} + +func TestShareJSONShape(t *testing.T) { + want := []string{"build_id", "cancel_command", "provider", "ready", "run_id", "submitted", "workflow_url"} + got := jsonKeys(t, shareJSON{BuildID: "b", Provider: "codemagic", WorkflowURL: "u", RunID: "r", Submitted: true, CancelCommand: "c"}) + if !slices.Equal(got, want) { + t.Errorf("ios share --json keys = %v, want %v", got, want) + } +} + +func TestMobaiPingJSON(t *testing.T) { + t.Chdir(t.TempDir()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + stdout, _, err := runNoInput(t, "mobai", "ping", "--url", srv.URL, "--json") + if err != nil || strings.TrimSpace(stdout) != "{\n \"ok\": true\n}" { + t.Fatalf("mobai ping --json = %q, %v", stdout, err) + } +} + +func TestInitJSONShape(t *testing.T) { + want := []string{"committed", "files", "ios_path", "project", "pushed", "repository"} + if got := jsonKeys(t, initResult{Files: []string{}}); !slices.Equal(got, want) { + t.Errorf("init --json keys = %v, want %v", got, want) + } +} diff --git a/cmd/builder/kmp.go b/cmd/builder/kmp.go index 56b8a78..4227b11 100644 --- a/cmd/builder/kmp.go +++ b/cmd/builder/kmp.go @@ -8,6 +8,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/dev" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/spf13/cobra" ) @@ -34,7 +35,8 @@ func init() { devKMPCmd.Flags().String("mobai-url", mobai.DefaultBaseURL, "MobAI API URL") devKMPCmd.Flags().String("ipa", "", "Path to IPA (default: auto-detect from dist/)") devKMPCmd.Flags().Bool("skip-install", false, "Skip app installation (app must already be installed)") - devKMPCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install)") + devKMPCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install; else used when MobAI does not report it)") + addDevAgentFlags(devKMPCmd) devKMPCmd.Flags().Bool("logs", false, "Show app logs") } @@ -55,14 +57,17 @@ func runDevKMP(cmd *cobra.Command, args []string) error { } } + emit, restore := devEvents(cmd) + defer restore() + if skipInstall { if bundleID == "" { - return fmt.Errorf("--bundle-id is required when using --skip-install") + return exitcode.Usagef("--bundle-id is required when using --skip-install") } } else { if ipaPath == "" { var err error - ipaPath, err = dev.FindIPA("dist") + ipaPath, err = dev.FindIPA("dist", interactive(cmd)) if err != nil { return err } @@ -79,6 +84,7 @@ func runDevKMP(cmd *cobra.Command, args []string) error { handler := dev.NewKMPHandler(showLogs) session := dev.NewSession(mobaiURL, deviceID, ipaPath, handler) session.SetSkipInstall(skipInstall, bundleID) + configureDevSession(cmd, session, emit) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/builder/main.go b/cmd/builder/main.go index 53dec92..c2a26df 100644 --- a/cmd/builder/main.go +++ b/cmd/builder/main.go @@ -2,13 +2,60 @@ package main import ( "os" + "strings" + + "github.com/MobAI-App/ios-builder/internal/exitcode" + "github.com/spf13/cobra" ) // version is set at build time via -ldflags "-X main.version=vX.Y.Z". var version = "dev" func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) + usageErrors(rootCmd) + os.Exit(exitCode(rootCmd.Execute())) +} + +// exitCode is exitcode.Code plus the usage errors cobra raises itself. Flag +// parsing and argument validators are tagged at the source (usageErrors); +// an unknown subcommand and a missing required flag are only recognisable by +// their message. +func exitCode(err error) int { + if err != nil && exitcode.Code(err) == exitcode.Failure { + msg := err.Error() + for _, prefix := range []string{"unknown command", "required flag(s)", "if any flags in the group", "at least one of the flags"} { + if strings.HasPrefix(msg, prefix) { + return exitcode.Usage + } + } + } + return exitcode.Code(err) +} + +// usageErrors makes cobra's flag and argument errors exit with Usage (2): +// the flag error hook covers parsing, and every command's Args validator is +// wrapped so "accepts 1 arg(s)" and the like are tagged too. +func usageErrors(root *cobra.Command) { + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return exitcode.With(exitcode.Usage, err) + }) + var walk func(*cobra.Command) + walk = func(c *cobra.Command) { + // cobra shows help and succeeds for `builder ios nope`, since a + // group without a Run is never validated; give groups a help Run + // so a mistyped subcommand is an unknown-command error instead. + if c.HasParent() && c.HasSubCommands() && !c.Runnable() { + c.Args = cobra.NoArgs + c.RunE = func(cmd *cobra.Command, _ []string) error { return cmd.Help() } + } + if args := c.Args; args != nil { + c.Args = func(cmd *cobra.Command, a []string) error { + return exitcode.With(exitcode.Usage, args(cmd, a)) + } + } + for _, sub := range c.Commands() { + walk(sub) + } } + walk(root) } diff --git a/cmd/builder/mobai.go b/cmd/builder/mobai.go index fd120d1..b5ebab9 100644 --- a/cmd/builder/mobai.go +++ b/cmd/builder/mobai.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "io" "net" @@ -57,6 +58,9 @@ func init() { mobaiCmd.PersistentFlags().String("url", mobai.DefaultBaseURL, "MobAI API URL") mobaiCmd.PersistentFlags().StringP("device", "d", "", "Device ID") + for _, c := range []*cobra.Command{mobaiPingCmd, mobaiInstallCmd, mobaiForwardCmd} { + c.Flags().Bool("json", false, "Print the result as JSON") + } } func getMobaiClient(cmd *cobra.Command) *mobai.Client { @@ -111,6 +115,9 @@ func runMobaiPing(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot connect to MobAI: %w", err) } + if newOutput(cmd).json { + return printJSON(cmd, map[string]any{"ok": true}) + } fmt.Printf("success\n") return nil } @@ -130,11 +137,14 @@ func runMobaiInstall(cmd *cobra.Command, args []string) error { } req := mobai.InstallAppRequest{Path: args[0]} - _, err = client.InstallApp(ctx, deviceID, req) + resp, err := client.InstallApp(ctx, deviceID, req) if err != nil { return fmt.Errorf("install failed: %w", err) } + if newOutput(cmd).json { + return printJSON(cmd, map[string]any{"device_id": deviceID, "bundle_id": resp.Data.BundleID, "installed": true}) + } fmt.Println("installed") return nil } @@ -221,7 +231,7 @@ func runMobaiForward(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "WSL proxy listen error: %v\n", err) } else { fmt.Fprintf(os.Stderr, "WSL proxy: 127.0.0.1:%d -> %s:%d\n", hostPort, windowsHost, hostPort) - fmt.Printf("forwarded %d -> %d\n", resp.DevicePort, resp.HostPort) + printForward(cmd, deviceID, resp) for { conn, err := listener.Accept() if err != nil { @@ -234,7 +244,7 @@ func runMobaiForward(cmd *cobra.Command, args []string) error { } } - fmt.Printf("forwarded %d -> %d\n", resp.DevicePort, resp.HostPort) + printForward(cmd, deviceID, resp) // Keep running to maintain the forward select {} @@ -286,3 +296,13 @@ func handleProxyConnection(clientConn net.Conn, remoteHost string, remotePort in // Wait for either direction to finish <-done } + +// printForward reports a port forward, as one JSON line with --json (the +// command keeps running to hold the forward). +func printForward(cmd *cobra.Command, deviceID string, resp *mobai.PortForwardResponse) { + if newOutput(cmd).json { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"device_id": deviceID, "device_port": resp.DevicePort, "host_port": resp.HostPort}) + return + } + fmt.Printf("forwarded %d -> %d\n", resp.DevicePort, resp.HostPort) +} diff --git a/cmd/builder/noninteractive_test.go b/cmd/builder/noninteractive_test.go new file mode 100644 index 0000000..79c4d90 --- /dev/null +++ b/cmd/builder/noninteractive_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" +) + +// runNoInput runs builder with stdin reported as not a terminal and fails the +// test if the command has not returned within a few seconds: a command that +// would prompt must fail fast or take its --yes default instead of blocking. +func runNoInput(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + return runWithTerminal(t, false, args...) +} + +func runWithTerminal(t *testing.T, terminal bool, args ...string) (stdout, stderr string, err error) { + t.Helper() + prev := stdinIsTerminal + stdinIsTerminal = func() bool { return terminal } + t.Cleanup(func() { stdinIsTerminal = prev; noInput = false }) + noInput = false + type result struct { + stdout, stderr string + err error + } + done := make(chan result, 1) + go func() { + o, e, err := run(t, args...) + done <- result{o, e, err} + }() + select { + case r := <-done: + return r.stdout, r.stderr, r.err + case <-time.After(10 * time.Second): + t.Fatalf("builder %v blocked instead of failing without a terminal", args) + return "", "", nil + } +} + +// wantUsage checks err is a usage error (exit 2) naming every flag in flags. +func wantUsage(t *testing.T, err error, flags ...string) { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } + if got := exitCode(err); got != exitcode.Usage { + t.Errorf("exit code = %d, want %d (%v)", got, exitcode.Usage, err) + } + for _, f := range flags { + if !strings.Contains(err.Error(), f) { + t.Errorf("error %q does not name %s", err, f) + } + } +} + +func TestInteractiveSwitches(t *testing.T) { + prev := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = prev; noInput = false }) + t.Setenv("CI", "") + t.Setenv("BUILDER_NO_INPUT", "") + + if !interactive(nil) { + t.Fatal("a terminal with nothing set should be interactive") + } + noInput = true + if interactive(nil) { + t.Error("--no-input should disable prompts") + } + noInput = false + for _, env := range []string{"CI", "BUILDER_NO_INPUT"} { + t.Setenv(env, "true") + if interactive(nil) { + t.Errorf("%s=true should disable prompts", env) + } + t.Setenv(env, "") + } + stdinIsTerminal = func() bool { return false } + if interactive(nil) { + t.Error("stdin that is not a terminal should disable prompts") + } +} + +func TestSigningCSRWithoutTerminal(t *testing.T) { + t.Chdir(t.TempDir()) + + _, _, err := runNoInput(t, "signing", "csr") + wantUsage(t, err, "--name") + + _, _, err = runNoInput(t, "signing", "csr", "--name", "A", "--email", "a@example.com") + if err != nil { + t.Fatalf("csr with flags: %v", err) + } + key, _ := os.ReadFile("ios-signing.key") + + // Replacing the key is destructive: --yes or a terminal. + _, _, err = runNoInput(t, "signing", "csr", "--name", "A", "--email", "a@example.com") + wantUsage(t, err, "--yes") + if again, _ := os.ReadFile("ios-signing.key"); !bytes.Equal(again, key) { + t.Fatal("the key was replaced without --yes") + } + + stdout, _, err := runNoInput(t, "signing", "csr", "--name", "A", "--email", "a@example.com", "--yes", "--json") + if err != nil { + t.Fatalf("csr --yes --json: %v", err) + } + var got map[string]string + if err := json.Unmarshal([]byte(stdout), &got); err != nil || got["key"] != "ios-signing.key" || got["csr"] != "ios-signing.csr" { + t.Fatalf("csr --json = %q (%v)", stdout, err) + } +} + +// TestNoInputOverridesATerminal: with a terminal, --no-input still must not +// prompt. +func TestNoInputOverridesATerminal(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("CI", "") + t.Setenv("BUILDER_NO_INPUT", "") + _, _, err := runWithTerminal(t, true, "--no-input", "signing", "csr") + wantUsage(t, err, "--name") + + t.Setenv("BUILDER_NO_INPUT", "1") + _, _, err = runWithTerminal(t, true, "signing", "p12", "--certificate", "x.cer") + wantUsage(t, err, "--key", "--yes") +} + +func TestSigningSetupManualWithoutTerminal(t *testing.T) { + t.Chdir(t.TempDir()) + cfg := &config.Config{Project: "App", Platform: "ios", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + prev := signingSecretStore + signingSecretStore = func() (secretStore, error) { return nil, errors.New("no login in tests") } + t.Cleanup(func() { signingSecretStore = prev }) + + _, _, err := runNoInput(t, "signing", "setup", "--profile", "app.mobileprovision") + wantUsage(t, err, "--certificate") +} + +func TestDevSkipInstallNeedsBundleID(t *testing.T) { + t.Chdir(t.TempDir()) + for _, sub := range []string{"flutter", "rn", "kmp"} { + _, _, err := runNoInput(t, "dev", sub, "--skip-install", "--json") + wantUsage(t, err, "--bundle-id") + } + if os.Stdout == os.Stderr { + t.Fatal("dev --json left os.Stdout pointed at stderr") + } +} + +func TestSigningP12WithoutTerminal(t *testing.T) { + t.Chdir(t.TempDir()) + _, _, err := runNoInput(t, "signing", "p12") + wantUsage(t, err, "--certificate") + _, _, err = runNoInput(t, "signing", "p12", "--certificate", "x.cer", "--yes") + wantUsage(t, err, "--password") +} diff --git a/cmd/builder/providers.go b/cmd/builder/providers.go index 113e282..8308617 100644 --- a/cmd/builder/providers.go +++ b/cmd/builder/providers.go @@ -8,6 +8,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/build" "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/workflow" "github.com/spf13/cobra" @@ -97,6 +98,13 @@ func runProviderInit(cmd *cobra.Command) error { if err := mgr.Save(cfg); err != nil { return err } + if newOutput(cmd).json { + defaultProvider, _ := cfg.ProviderName("") + if paths == nil { + paths = []string{} + } + return printJSON(cmd, map[string]any{"provider": name, "files": append(paths, "builder.json"), "default_provider": defaultProvider, "custom_workflows": customWorkflows}) + } if customWorkflows { fmt.Println("Preserved custom workflow names and CI files. Merge any runner/workflow updates manually (see docs/providers.md).") } @@ -126,7 +134,7 @@ func init() { name, _ := cmd.Flags().GetString("provider") id, _ := cmd.Flags().GetString("run-id") if id == "" { - return fmt.Errorf("--run-id is required") + return exitcode.Usagef("--run-id is required") } p, _, err := build.RemoteProvider(cfg, name) if err != nil { @@ -135,10 +143,14 @@ func init() { if err := build.CancelRemote(cmd.Context(), p, id); err != nil { return fmt.Errorf("cancellation could not be confirmed; check the provider dashboard: %w", err) } + if newOutput(cmd).json { + return printJSON(cmd, map[string]any{"provider": p.Name(), "run_id": id, "stopped": true}) + } fmt.Println("Run has stopped.") return nil }} cancelCmd.Flags().String("provider", "", "Provider holding the run (codemagic or bitrise)") cancelCmd.Flags().String("run-id", "", "Run ID from the provider workflow URL") + cancelCmd.Flags().Bool("json", false, "Print the result as JSON") iosCmd.AddCommand(cancelCmd) } diff --git a/cmd/builder/release.go b/cmd/builder/release.go index 16bd410..6ad83eb 100644 --- a/cmd/builder/release.go +++ b/cmd/builder/release.go @@ -9,6 +9,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/build" "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/release" "github.com/spf13/cobra" ) @@ -96,7 +97,7 @@ func runIOSRelease(cmd *cobra.Command, _ []string) error { // DeadlineExceeded as the App Store Connect wait and claims processing continues. func releaseError(res *release.Result, err error, timeout time.Duration) error { if errors.Is(err, context.DeadlineExceeded) && (res == nil || res.IPAPath == "") { - return fmt.Errorf("the build did not finish within %s; raise --timeout (%v)", timeout, err) + return exitcode.With(exitcode.Timeout, fmt.Errorf("the build did not finish within %s; raise --timeout (%v)", timeout, err)) } return err } diff --git a/cmd/builder/rn.go b/cmd/builder/rn.go index 2fd5150..3f32921 100644 --- a/cmd/builder/rn.go +++ b/cmd/builder/rn.go @@ -8,6 +8,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/dev" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/spf13/cobra" ) @@ -31,7 +32,8 @@ func init() { devReactNativeCmd.Flags().String("mobai-url", mobai.DefaultBaseURL, "MobAI API URL") devReactNativeCmd.Flags().String("ipa", "", "Path to IPA (default: auto-detect from dist/)") devReactNativeCmd.Flags().Bool("skip-install", false, "Skip app installation (app must already be installed)") - devReactNativeCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install)") + devReactNativeCmd.Flags().String("bundle-id", "", "Bundle ID (required with --skip-install; else used when MobAI does not report it)") + addDevAgentFlags(devReactNativeCmd) devReactNativeCmd.Flags().Int("metro-port", 8081, "Metro bundler port") devReactNativeCmd.Flags().Bool("logs", false, "Show app logs") } @@ -54,14 +56,17 @@ func runDevReactNative(cmd *cobra.Command, args []string) error { } } + emit, restore := devEvents(cmd) + defer restore() + if skipInstall { if bundleID == "" { - return fmt.Errorf("--bundle-id is required when using --skip-install") + return exitcode.Usagef("--bundle-id is required when using --skip-install") } } else { if ipaPath == "" { var err error - ipaPath, err = dev.FindIPA("dist") + ipaPath, err = dev.FindIPA("dist", interactive(cmd)) if err != nil { return err } @@ -78,6 +83,7 @@ func runDevReactNative(cmd *cobra.Command, args []string) error { handler := dev.NewReactNativeHandler(metroPort, showLogs, mobaiURL) session := dev.NewSession(mobaiURL, deviceID, ipaPath, handler) session.SetSkipInstall(skipInstall, bundleID) + configureDevSession(cmd, session, emit) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 0dcd66e..98a1bab 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -12,6 +12,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "syscall" "time" @@ -19,13 +20,13 @@ import ( "github.com/MobAI-App/ios-builder/internal/auth" "github.com/MobAI-App/ios-builder/internal/build" "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/otainstall" "github.com/MobAI-App/ios-builder/internal/release" "github.com/MobAI-App/ios-builder/internal/signing" "github.com/MobAI-App/ios-builder/internal/update" "github.com/MobAI-App/ios-builder/internal/workflow" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -53,7 +54,7 @@ func initConfig() { func getGitHubClient() (*github.Client, error) { token, err := auth.GetToken() if err != nil { - return nil, fmt.Errorf("not authenticated. Run: builder auth github") + return nil, exitcode.With(exitcode.Auth, fmt.Errorf("not authenticated. Run: builder auth github (or pipe a token: builder auth github --token-stdin)")) } return github.NewClient(token), nil } @@ -334,8 +335,12 @@ func runInit(cmd *cobra.Command, args []string) error { if provider != "" && provider != "github" { return runProviderInit(cmd) } - fmt.Println("Builder - iOS Build Setup") - fmt.Println() + out := newOutput(cmd) + w := out.log + ask := newAsker(cmd) + res := &initResult{Files: []string{}} + fmt.Fprintln(w, "Builder - iOS Build Setup") + fmt.Fprintln(w) // Get remote name from flag remoteName, _ := cmd.Flags().GetString("remote") @@ -346,15 +351,15 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to detect GitHub repository: %w\nMake sure you're in a git repository with a GitHub remote", err) } - fmt.Printf("Detected repository: %s/%s (from remote '%s')\n", githubOwner, repoName, remoteName) - fmt.Println() + fmt.Fprintf(w, "Detected repository: %s/%s (from remote '%s')\n", githubOwner, repoName, remoteName) + fmt.Fprintln(w) // Get project name projectName, _ := cmd.Flags().GetString("project") if projectName == "" { cwd, _ := os.Getwd() defaultProject := filepath.Base(cwd) - projectName, err = promptString("Project name", defaultProject) + projectName, err = ask.text("Project name", defaultProject, "--project") if err != nil { return err } @@ -364,76 +369,95 @@ func runInit(cmd *cobra.Command, args []string) error { iosPath, _ := cmd.Flags().GetString("ios-path") scheme, _ := cmd.Flags().GetString("scheme") - if iosPath == "" { + if !cmd.Flags().Changed("ios-path") { detectedPath, framework := detectIOSPath() + res.Framework = framework if detectedPath != "" { - fmt.Printf("Detected %s project (iOS at '%s')\n", framework, detectedPath) + fmt.Fprintf(w, "Detected %s project (iOS at '%s')\n", framework, detectedPath) if framework == expoManagedFramework { - fmt.Printf("There is no '%s' directory yet; the build generates it on the runner with 'expo prebuild'.\n", detectedPath) - fmt.Println("app.json / app.config.js must set ios.bundleIdentifier, or prebuild cannot run unattended.") + fmt.Fprintf(w, "There is no '%s' directory yet; the build generates it on the runner with 'expo prebuild'.\n", detectedPath) + fmt.Fprintln(w, "app.json / app.config.js must set ios.bundleIdentifier, or prebuild cannot run unattended.") } - confirmPrompt := promptui.Prompt{ - Label: "Use this path", - IsConfirm: true, + use, err := ask.confirm("Use this path", true, "--ios-path") + if err != nil { + return err } - _, err := confirmPrompt.Run() - if err == nil { + if use { iosPath = detectedPath } } else if framework != "" { - fmt.Printf("Detected %s project\n", framework) + fmt.Fprintf(w, "Detected %s project\n", framework) } if iosPath == "" && framework == "" { - fmt.Println("No iOS project detected in current directory.") - fmt.Println("If this is a hybrid app (React Native, Flutter, etc.),") - iosPath, _ = promptString("Path to iOS folder (leave empty for root)", "") + fmt.Fprintln(w, "No iOS project detected in current directory.") + fmt.Fprintln(w, "If this is a hybrid app (React Native, Flutter, etc.),") + if iosPath, err = ask.text("Path to iOS folder (leave empty for root)", "", "--ios-path"); err != nil { + return err + } } } // Detect Flutter and prompt for version - var flutterVersion string + flutterVersion, _ := cmd.Flags().GetString("flutter-version") if isFlutterProject() { - fmt.Println() - fmt.Println("Detected Flutter project") - localVersion := getLocalFlutterVersion() - if localVersion != "" { - fmt.Printf("Local Flutter version: %s\n", localVersion) - } - flutterVersion, err = promptString("Flutter version for builds (leave empty for latest)", localVersion) - if err != nil { - return err + fmt.Fprintln(w) + fmt.Fprintln(w, "Detected Flutter project") + if !cmd.Flags().Changed("flutter-version") { + localVersion := getLocalFlutterVersion() + if localVersion != "" { + fmt.Fprintf(w, "Local Flutter version: %s\n", localVersion) + } + flutterVersion, err = ask.text("Flutter version for builds (leave empty for latest)", localVersion, "--flutter-version") + if err != nil { + return err + } } } // Detect Kotlin Multiplatform and prompt for JDK version - var jdkVersion string + jdkVersion, _ := cmd.Flags().GetString("jdk-version") if isKMPProject() { - fmt.Println() - fmt.Println("Detected Kotlin Multiplatform project") - fmt.Println("Note: KMP has no hot reload on iOS - rebuild for code changes.") - jdkVersion, err = promptString("JDK version for Gradle builds", "17") - if err != nil { - return err + fmt.Fprintln(w) + fmt.Fprintln(w, "Detected Kotlin Multiplatform project") + fmt.Fprintln(w, "Note: KMP has no hot reload on iOS - rebuild for code changes.") + if !cmd.Flags().Changed("jdk-version") { + jdkVersion, err = ask.text("JDK version for Gradle builds", "17", "--jdk-version") + if err != nil { + return err + } + } + } + + // The commit and build questions come last in a terminal, but without + // one they are settled here, before any file is written. + commit, _ := cmd.Flags().GetBool("commit") + runFirstBuild, _ := cmd.Flags().GetBool("build") + if !ask.interactive && !ask.yes { + if !cmd.Flags().Changed("commit") { + return needInput(`an answer to "Commit and push workflow"`, "--commit", "--commit=false", "--yes (no)") + } + if !cmd.Flags().Changed("build") { + return needInput(`an answer to "Run build now"`, "--build", "--build=false", "--yes (no)") } } - fmt.Println() - fmt.Printf("Project: %s\n", projectName) - fmt.Printf("Repository: %s/%s\n", githubOwner, repoName) + fmt.Fprintln(w) + fmt.Fprintf(w, "Project: %s\n", projectName) + fmt.Fprintf(w, "Repository: %s/%s\n", githubOwner, repoName) if iosPath != "" { - fmt.Printf("iOS Path: %s\n", iosPath) + fmt.Fprintf(w, "iOS Path: %s\n", iosPath) } if flutterVersion != "" { - fmt.Printf("Flutter: %s\n", flutterVersion) + fmt.Fprintf(w, "Flutter: %s\n", flutterVersion) } if jdkVersion != "" { - fmt.Printf("JDK: %s\n", jdkVersion) + fmt.Fprintf(w, "JDK: %s\n", jdkVersion) } - fmt.Println() + fmt.Fprintln(w) // Create workflow file locally - fmt.Println("Creating workflow file...") + fmt.Fprintln(w, "Creating workflow file...") workflowDir := ".github/workflows" if err := os.MkdirAll(workflowDir, 0755); err != nil { return fmt.Errorf("failed to create workflow directory: %w", err) @@ -448,7 +472,8 @@ func runInit(cmd *cobra.Command, args []string) error { if err := os.WriteFile(workflowPath, workflowContent, 0644); err != nil { return fmt.Errorf("failed to write workflow file: %w", err) } - fmt.Printf(" Created: %s\n", workflowPath) + fmt.Fprintf(w, " Created: %s\n", workflowPath) + res.Files = append(res.Files, workflowPath) // Ship the share workflow next to the build one so `builder ios share` needs // no extra setup. Dispatch-only, so it costs nothing until used. @@ -460,7 +485,8 @@ func runInit(cmd *cobra.Command, args []string) error { if err := os.WriteFile(sharePath, shareContent, 0644); err != nil { return fmt.Errorf("failed to write simulator workflow file: %w", err) } - fmt.Printf(" Created: %s\n", sharePath) + fmt.Fprintf(w, " Created: %s\n", sharePath) + res.Files = append(res.Files, sharePath) // Save config cfg, err := config.NewManager().Load() @@ -476,7 +502,7 @@ func runInit(cmd *cobra.Command, args []string) error { if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = detectBundleID(iosPath) } - signing.SyncExtensions(cfg, os.Stdout) + signing.SyncExtensions(cfg, w) if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } @@ -489,84 +515,125 @@ func runInit(cmd *cobra.Command, args []string) error { cfg.Provider = "github" } - fmt.Println("Creating builder.json...") + fmt.Fprintln(w, "Creating builder.json...") mgr := config.NewManager() if err := mgr.Save(cfg); err != nil { return fmt.Errorf("failed to save config: %w", err) } - fmt.Println(" Created: builder.json") + fmt.Fprintln(w, " Created: builder.json") + res.Files = append(res.Files, "builder.json") + res.Project, res.Repository, res.IOSPath = projectName, githubOwner+"/"+repoName, iosPath + res.BundleID, res.FlutterVersion, res.JDKVersion = cfg.IOS.BundleID, flutterVersion, jdkVersion - fmt.Println() - fmt.Println("Setup complete!") - fmt.Println() + fmt.Fprintln(w) + fmt.Fprintln(w, "Setup complete!") + fmt.Fprintln(w) // Ask to commit and push - commitPrompt := promptui.Prompt{ - Label: "Commit and push workflow", - IsConfirm: true, - } - _, commitErr := commitPrompt.Run() - - if commitErr == nil { - fmt.Println() - fmt.Println("Committing and pushing...") - - // Git add - addCmd := exec.Command("git", "add", ".github/workflows/ios-build.yml", ".github/workflows/ios-share.yml", "builder.json") - if output, err := addCmd.CombinedOutput(); err != nil { - fmt.Printf(" Warning: git add failed: %s\n", strings.TrimSpace(string(output))) - } else { - fmt.Println(" Added files to staging") - } - - // Git commit - commitCmd := exec.Command("git", "commit", "-m", "Add iOS build workflow") - if output, err := commitCmd.CombinedOutput(); err != nil { - outputStr := strings.TrimSpace(string(output)) - if strings.Contains(outputStr, "nothing to commit") { - fmt.Println(" Nothing to commit (already committed)") - } else { - fmt.Printf(" Warning: git commit failed: %s\n", outputStr) - } - } else { - fmt.Println(" Committed changes") + if !cmd.Flags().Changed("commit") { + if commit, err = ask.confirm("Commit and push workflow", false, "--commit"); err != nil { + return err } + } - // Git push - pushCmd := exec.Command("git", "push") - if output, err := pushCmd.CombinedOutput(); err != nil { - fmt.Printf(" Warning: git push failed: %s\n", strings.TrimSpace(string(output))) - } else { - fmt.Println(" Pushed to remote") - } - fmt.Println() + if commit { + fmt.Fprintln(w) + fmt.Fprintln(w, "Committing and pushing...") + res.Committed, res.Pushed = commitAndPushInit(w) + fmt.Fprintln(w) } // Ask to run build - buildPrompt := promptui.Prompt{ - Label: "Run build now", - IsConfirm: true, + if !cmd.Flags().Changed("build") { + if runFirstBuild, err = ask.confirm("Run build now", false, "--build"); err != nil { + return err + } } - _, buildErr := buildPrompt.Run() - if buildErr == nil { - fmt.Println() - _, err := runBuild(context.Background(), cfg, &build.BuildOptions{ + if runFirstBuild { + fmt.Fprintln(w) + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + result, err := runBuild(ctx, cfg, &build.BuildOptions{ OutputDir: "dist", Timeout: build.DefaultTimeout, Remote: remoteName, - }) + }, w) + if result != nil { + res.Build = newBuildJSON(result, "github", "") + if !out.json { + fmt.Fprintf(w, "IPA: %s\n", result.IPAPath) + fmt.Fprintf(w, "Workflow: %s\n", result.WorkflowURL) + } + } + if out.json { + _ = printJSON(cmd, res) + } return err } - fmt.Println() - fmt.Println("To build later, run:") - fmt.Println(" builder ios build") - fmt.Println() + if out.json { + return printJSON(cmd, res) + } + fmt.Fprintln(w) + fmt.Fprintln(w, "To build later, run:") + fmt.Fprintln(w, " builder ios build") + fmt.Fprintln(w) return nil } +// initResult is the JSON of `init --json`. +type initResult struct { + Project string `json:"project"` + Repository string `json:"repository"` + IOSPath string `json:"ios_path"` + Framework string `json:"framework,omitempty"` + BundleID string `json:"bundle_id,omitempty"` + FlutterVersion string `json:"flutter_version,omitempty"` + JDKVersion string `json:"jdk_version,omitempty"` + Files []string `json:"files"` + Committed bool `json:"committed"` + Pushed bool `json:"pushed"` + Build *buildJSON `json:"build,omitempty"` +} + +// commitAndPushInit commits the files init wrote and pushes them. Failures +// are warnings: the files are in place either way. +func commitAndPushInit(w io.Writer) (committed, pushed bool) { + addCmd := exec.Command("git", "add", ".github/workflows/ios-build.yml", ".github/workflows/ios-share.yml", "builder.json") + if output, err := addCmd.CombinedOutput(); err != nil { + fmt.Fprintf(w, " Warning: git add failed: %s\n", strings.TrimSpace(string(output))) + } else { + fmt.Fprintln(w, " Added files to staging") + } + + commitCmd := exec.Command("git", "commit", "-m", "Add iOS build workflow") + if output, err := commitCmd.CombinedOutput(); err != nil { + outputStr := strings.TrimSpace(string(output)) + if strings.Contains(outputStr, "nothing to commit") { + fmt.Fprintln(w, " Nothing to commit (already committed)") + committed = true + } else { + fmt.Fprintf(w, " Warning: git commit failed: %s\n", outputStr) + } + } else { + fmt.Fprintln(w, " Committed changes") + committed = true + } + + pushCmd := exec.Command("git", "push") + if output, err := pushCmd.CombinedOutput(); err != nil { + fmt.Fprintf(w, " Warning: git push failed: %s\n", strings.TrimSpace(string(output))) + } else { + fmt.Fprintln(w, " Pushed to remote") + pushed = true + } + return committed, pushed +} + var iosCmd = &cobra.Command{ Use: "ios", Short: "iOS build commands", @@ -608,6 +675,7 @@ func init() { // Root command setup cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") + rootCmd.PersistentFlags().BoolVar(&noInput, "no-input", false, "Never prompt: take --yes defaults or fail naming the flag to pass (also BUILDER_NO_INPUT=1 or CI=true)") rootCmd.AddCommand(initCmd) rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(iosCmd) @@ -626,6 +694,12 @@ func init() { initCmd.Flags().String("app-id", "", "Codemagic app ID or Bitrise app slug") initCmd.Flags().String("branch", "", "Committed branch containing the provider workflow") initCmd.Flags().Bool("set-default", false, "Make this provider the project default") + initCmd.Flags().String("flutter-version", "", "Flutter version for builds (default: the local one; empty for latest)") + initCmd.Flags().String("jdk-version", "", "JDK version for Kotlin Multiplatform Gradle builds (default 17)") + initCmd.Flags().Bool("commit", false, "Commit and push the workflow and builder.json without asking (--commit=false: don't)") + initCmd.Flags().Bool("build", false, "Run the first build without asking (--build=false: don't)") + initCmd.Flags().BoolP("yes", "y", false, "Accept every detected value and default without asking; does not commit or build unless --commit/--build") + initCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr); implies --no-input") // iOS build command flags iosBuildCmd.Flags().StringP("output", "o", "dist", "Output directory for IPA") @@ -635,12 +709,14 @@ func init() { iosBuildCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)") iosBuildCmd.Flags().String("profile", "", "Build profile from builder.json (default: defaultProfile, else the top-level ios settings)") iosBuildCmd.Flags().Bool("submit", false, "Also upload to App Store Connect and process for TestFlight (short for: ios release)") + iosBuildCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr); with --submit or --distribute their JSON follows") iosCmd.AddCommand(iosBuildCmd) // iOS share command flags iosShareCmd.Flags().Duration("duration", 30*time.Minute, "How long the simulator stays available while unused") iosShareCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to") iosShareCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)") + iosShareCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") iosCmd.AddCommand(iosShareCmd) } @@ -711,13 +787,56 @@ func runIOSBuild(cmd *cobra.Command, args []string) error { ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() } - result, err := runBuild(ctx, cfg, &opts) - if err != nil || !distribute { + out := newOutput(cmd) + result, err := runBuild(ctx, cfg, &opts, out.log) + if err != nil { return err } + if out.json { + if err := printJSON(cmd, newBuildJSON(result, name, opts.Profile)); err != nil { + return err + } + } else { + fmt.Fprintf(out.log, "IPA: %s\n", result.IPAPath) + fmt.Fprintf(out.log, "Workflow: %s\n", result.WorkflowURL) + } + if !distribute { + return nil + } return runDistribute(cmd, cfg, result.IPAPath) } +// buildJSON is the JSON of `ios build --json` (and init's "build"). +type buildJSON struct { + BuildID string `json:"build_id"` + IPAPath string `json:"ipa"` + IPASize int64 `json:"ipa_size"` + WorkflowURL string `json:"workflow_url"` + Provider string `json:"provider"` + Profile string `json:"profile,omitempty"` + Duration float64 `json:"duration_seconds"` +} + +func newBuildJSON(r *build.BuildResult, provider, profile string) *buildJSON { + return &buildJSON{ + BuildID: r.BuildID, IPAPath: r.IPAPath, IPASize: r.IPASize, WorkflowURL: r.WorkflowURL, + Provider: provider, Profile: profile, Duration: r.Duration.Round(time.Second).Seconds(), + } +} + +// shareJSON is the JSON of `ios share --json`. Ready means the simulator is +// in MobAI now; Submitted means the provider accepted the run and it shows up +// once the build and bridge start. +type shareJSON struct { + BuildID string `json:"build_id"` + Provider string `json:"provider"` + WorkflowURL string `json:"workflow_url"` + RunID string `json:"run_id,omitempty"` + Ready bool `json:"ready"` + Submitted bool `json:"submitted"` + CancelCommand string `json:"cancel_command,omitempty"` +} + func runIOSShare(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -732,6 +851,7 @@ func runIOSShare(cmd *cobra.Command, args []string) error { // A simulator build takes no profile, so the provider is the flag, else // builder.json's. provider, _ := cmd.Flags().GetString("provider") + out := newOutput(cmd) ctx := cmd.Context() if ctx == nil { @@ -747,7 +867,7 @@ func runIOSShare(cmd *cobra.Command, args []string) error { if err != nil { return err } - result, err := build.NewCoordinator(cfg, ghClient).Share(ctx, build.ShareOptions{ + result, err := build.NewCoordinatorWithOutput(cfg, ghClient, out.log).Share(ctx, build.ShareOptions{ Provider: provider, Duration: duration, Remote: remote, @@ -756,24 +876,38 @@ func runIOSShare(cmd *cobra.Command, args []string) error { return err } - fmt.Println() + name, _ := cfg.ProviderName(provider) + if out.json { + res := shareJSON{BuildID: result.BuildID, Provider: name, WorkflowURL: result.WorkflowURL, Ready: !result.Submitted, Submitted: result.Submitted} + if result.Submitted { + res.RunID = result.ProviderRunID + res.CancelCommand = fmt.Sprintf("builder ios cancel --provider %s --run-id %s", name, result.ProviderRunID) + } else if result.RunID != 0 { + res.RunID = strconv.FormatInt(result.RunID, 10) + } + return printJSON(cmd, res) + } + + w := out.log + fmt.Fprintln(w) if result.Submitted { - name, _ := cfg.ProviderName(provider) - fmt.Println("Simulator session submitted. The build and bridge must start before it appears in MobAI under CI Devices.") - fmt.Println("The workflow is limited to 90 minutes including setup/build; idle duration is not a guaranteed session length.") - fmt.Printf("Workflow: %s\n", result.WorkflowURL) - fmt.Printf("Cancel: builder ios cancel --provider %s --run-id %s\n", name, result.ProviderRunID) - fmt.Printf("After the run finishes, remove its snapshot: git push %s --delete refs/ios-builder/jobs/%s\n", remote, result.BuildID) + fmt.Fprintln(w, "Simulator session submitted. The build and bridge must start before it appears in MobAI under CI Devices.") + fmt.Fprintln(w, "The workflow is limited to 90 minutes including setup/build; idle duration is not a guaranteed session length.") + fmt.Fprintf(w, "Workflow: %s\n", result.WorkflowURL) + fmt.Fprintf(w, "Cancel: builder ios cancel --provider %s --run-id %s\n", name, result.ProviderRunID) + fmt.Fprintf(w, "After the run finishes, remove its snapshot: git push %s --delete refs/ios-builder/jobs/%s\n", remote, result.BuildID) return nil } - fmt.Println("Simulator ready. Open MobAI and find it under CI Devices.") - fmt.Println("It closes when you stop its bridge there, or after being left unused.") - fmt.Printf("Workflow: %s\n", result.WorkflowURL) + fmt.Fprintln(w, "Simulator ready. Open MobAI and find it under CI Devices.") + fmt.Fprintln(w, "It closes when you stop its bridge there, or after being left unused.") + fmt.Fprintf(w, "Workflow: %s\n", result.WorkflowURL) return nil } -func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) (*build.BuildResult, error) { +// runBuild provisions a missing signing set (GitHub) and runs the build, +// with progress on log. +func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions, log io.Writer) (*build.BuildResult, error) { ghClient, err := clientForProvider(cfg, opts.Provider) if err != nil { return nil, err @@ -781,20 +915,9 @@ func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) // A GitHub build with a distribution needs its signing set in the // repository; ensureSigningSecrets leaves Codemagic and Bitrise alone. if ghClient != nil && !opts.Unsigned { - if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, opts.Provider, os.Stdout); err != nil { + if err := ensureSigningSecrets(ctx, cfg, ghClient, getASCClient, opts.Profile, opts.Provider, log); err != nil { return nil, err } } - - coordinator := build.NewCoordinator(cfg, ghClient) - - result, err := coordinator.Build(ctx, opts) - if err != nil { - return nil, err - } - - fmt.Printf("IPA: %s\n", result.IPAPath) - fmt.Printf("Workflow: %s\n", result.WorkflowURL) - - return result, nil + return build.NewCoordinatorWithOutput(cfg, ghClient, log).Build(ctx, opts) } diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index c457fb9..23fc9fe 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -102,11 +102,15 @@ func init() { signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") + signingCSRCmd.Flags().BoolP("yes", "y", false, "Replace an existing ios-signing.key without asking") + signingCSRCmd.Flags().Bool("json", false, "Print the written paths as JSON") signingP12Cmd.Flags().StringP("certificate", "c", "", "Path to the .cer downloaded from the Apple Developer portal") signingP12Cmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr'") signingP12Cmd.Flags().StringP("out", "o", "ios-signing.p12", "Path to write the .p12 to") signingP12Cmd.Flags().String("password", "", "Password to protect the .p12 (prompted if omitted)") + signingP12Cmd.Flags().BoolP("yes", "y", false, "Take the default key path (ios-signing.key) instead of asking") + signingP12Cmd.Flags().Bool("json", false, "Print the written path as JSON") } // addSigningSetupFlags registers the flags of `signing setup`; tests build @@ -131,15 +135,17 @@ func addSigningSetupFlags(cmd *cobra.Command) { func runSigningCSR(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") email, _ := cmd.Flags().GetString("email") + ask := newAsker(cmd) + out := newOutput(cmd) var err error if name == "" { - if name, err = promptString("Your name (as on the certificate)", ""); err != nil { + if name, err = ask.required("Your name (as on the certificate)", "--name"); err != nil { return err } } if email == "" { - if email, err = promptString("Apple Developer account email", ""); err != nil { + if email, err = ask.required("Apple Developer account email", "--email"); err != nil { return err } } @@ -150,10 +156,13 @@ func runSigningCSR(cmd *cobra.Command, args []string) error { keyPath := "ios-signing.key" csrPath := "ios-signing.csr" if _, err := os.Stat(keyPath); err == nil { - fmt.Printf("%s already exists. Regenerating it invalidates any\n", keyPath) - fmt.Println("certificate created from the previous CSR.") - confirm := promptui.Prompt{Label: "Generate a new key", IsConfirm: true} - if _, err := confirm.Run(); err != nil { + fmt.Fprintf(out.log, "%s already exists. Regenerating it invalidates any\n", keyPath) + fmt.Fprintln(out.log, "certificate created from the previous CSR.") + replace, err := ask.confirm("Generate a new key", true, "--yes") + if err != nil { + return err + } + if !replace { return fmt.Errorf("keeping the existing key") } } @@ -170,6 +179,9 @@ func runSigningCSR(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to write CSR: %w", err) } + if out.json { + return printJSON(cmd, map[string]string{"key": keyPath, "csr": csrPath}) + } fmt.Println() fmt.Printf("Private key: %s\n", keyPath) fmt.Printf("CSR: %s\n", csrPath) @@ -216,14 +228,15 @@ func runSigningP12(cmd *cobra.Command, args []string) error { outPath, _ := cmd.Flags().GetString("out") password, _ := cmd.Flags().GetString("password") + ask := newAsker(cmd) var err error if certPath == "" { - if certPath, err = promptString("Path to certificate (.cer from the Apple Developer portal)", ""); err != nil { + if certPath, err = ask.required("Path to certificate (.cer from the Apple Developer portal)", "--certificate"); err != nil { return err } } if keyPath == "" { - if keyPath, err = promptString("Path to private key", "ios-signing.key"); err != nil { + if keyPath, err = ask.text("Path to private key", "ios-signing.key", "--key"); err != nil { return err } } @@ -231,7 +244,7 @@ func runSigningP12(cmd *cobra.Command, args []string) error { return fmt.Errorf("certificate and key are required") } if password == "" { - if password, err = promptPassword("Password to protect the .p12"); err != nil { + if password, err = ask.password("Password to protect the .p12", "--password"); err != nil { return err } } @@ -244,6 +257,9 @@ func runSigningP12(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to write .p12: %w", err) } + if newOutput(cmd).json { + return printJSON(cmd, map[string]string{"p12": outPath}) + } fmt.Printf("Created %s (do not commit it)\n", outPath) return nil } @@ -275,12 +291,14 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { // A GitHub client that cannot be built is reported with the upload, after // the files are read: the values are printed either way. store, storeErr := signingSecretStore() - out := cmd.OutOrStdout() + o := newOutput(cmd) + out := o.log + ask := newAsker(cmd) // Get certificate path certPath, _ := cmd.Flags().GetString("certificate") if certPath == "" { - certPath, err = promptString("Path to .p12 certificate file", "") + certPath, err = ask.required("Path to .p12 certificate file", "--certificate") if err != nil { return err } @@ -297,7 +315,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { // Get provisioning profile path profilePath, _ := cmd.Flags().GetString("profile") if profilePath == "" { - profilePath, err = promptString("Path to .mobileprovision file", "") + profilePath, err = ask.required("Path to .mobileprovision file", "--profile") if err != nil { return err } @@ -354,7 +372,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { // from the private key that produced the CSR. keyPath, _ := cmd.Flags().GetString("key") if keyPath == "" { - keyPath, err = promptString("Path to private key file (from 'builder signing csr')", "ios-signing.key") + keyPath, err = ask.text("Path to private key file (from 'builder signing csr')", "ios-signing.key", "--key") if err != nil { return err } @@ -364,7 +382,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read private key %s: %w", keyPath, err) } if password == "" { - if password, err = promptPassword("Password to protect the .p12"); err != nil { + if password, err = ask.password("Password to protect the .p12", "--password"); err != nil { return err } } @@ -380,7 +398,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } fmt.Fprintf(out, "Assembled .p12: %s (do not commit it)\n", p12Path) } else if password == "" { - if password, err = promptPassword("Certificate password"); err != nil { + if password, err = ask.password("Certificate password", "--password"); err != nil { return err } } @@ -404,6 +422,19 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { fmt.Fprintln(out, profileWritten(profileName, typ, replaced)) names := config.SigningSecretNames(set) + if o.json { + res := signingManualResult{ + Distribution: typ, SigningSet: set, BuildProfile: profileName, Replaced: replaced, + P12: p12Path, Profile: profilePath, Extensions: extensionPathByID, + Secrets: names.Names(), GitHubUpload: "ok", + } + if uploadErr != nil { + res.GitHubUpload = uploadErr.Error() + _ = printJSON(cmd, res) + return signingUploadFailed(cfg) + } + return printJSON(cmd, res) + } fmt.Fprintln(out) fmt.Fprintln(out, signingUploadLine(cfg, names, uploadErr)) fmt.Fprintln(out) @@ -419,6 +450,19 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { return nil } +// signingManualResult is the JSON output of `signing setup --certificate`. +type signingManualResult struct { + Distribution signing.Type `json:"distribution"` + SigningSet string `json:"signing_set"` + BuildProfile string `json:"build_profile"` + Replaced string `json:"replaced_distribution,omitempty"` + P12 string `json:"p12"` + Profile string `json:"profile"` + Extensions map[string]string `json:"extension_profiles,omitempty"` + Secrets []string `json:"secrets"` + GitHubUpload string `json:"github_upload"` // "ok" or why it failed, as in automatic mode +} + // matchExtensionProfiles pairs every extension in ios.extensions with the // path of the --extension-profile (path → contents) whose app id covers it. // Each must be of the app profile's type; an extension without a profile, or diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 7fe613e..46e1800 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "maps" - "os" "path/filepath" "regexp" "slices" @@ -14,11 +13,11 @@ import ( "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/MobAI-App/ios-builder/internal/signing" "github.com/manifoldco/promptui" "github.com/spf13/cobra" - "golang.org/x/term" ) // providerSecretsDoc explains the dashboard steps for Codemagic and Bitrise. @@ -32,10 +31,6 @@ type signingAutoResult struct { GeneratedPassword string `json:"generated_password,omitempty"` } -func stdinIsTerminal() bool { - return term.IsTerminal(int(os.Stdin.Fd())) -} - // runSigningAuto is `signing setup` without --certificate/--profile: it // provisions everything through the App Store Connect API. The prompts, the // plan and the summary live here; the work is signing.Setup. @@ -111,8 +106,8 @@ func runSigningAuto(cmd *cobra.Command) error { } fmt.Fprintln(out.log) if !yes { - if !stdinIsTerminal() { - return errors.New("this creates resources in your Apple Developer account; confirm with --yes when not running in a terminal") + if !interactive(cmd) { + return exitcode.With(exitcode.Usage, errors.New("this creates resources in your Apple Developer account; confirm with --yes when not running in a terminal (or with --json/--no-input)")) } if _, err := (&promptui.Prompt{Label: "Continue", IsConfirm: true}).Run(); err != nil { return errors.New("canceled") @@ -123,7 +118,7 @@ func runSigningAuto(cmd *cobra.Command) error { var generated string switch { case password != "": - case yes || !stdinIsTerminal(): + case yes || !interactive(cmd): if generated, err = randomPassword(); err != nil { return err } @@ -231,8 +226,8 @@ func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) if id := configuredBundleID(cfg, out.log); id != "" { return id, nil } - if !stdinIsTerminal() || out.json { - return "", errors.New("bundle ID unknown: pass --bundle-id, set ios.bundleId in builder.json, or build once so ./dist has an IPA to read it from") + if !interactive(cmd) { + return "", exitcode.With(exitcode.Usage, errors.New("bundle ID unknown: pass --bundle-id, set ios.bundleId in builder.json, or build once so ./dist has an IPA to read it from")) } id, err := promptString("App bundle ID (e.g. com.example.app)", "") if err != nil { diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go index 74a6d2d..0198047 100644 --- a/cmd/builder/upload.go +++ b/cmd/builder/upload.go @@ -14,6 +14,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/distribute" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/spf13/cobra" ) @@ -51,7 +52,7 @@ var getASCClient = func() (*asc.Client, error) { creds, _, err := auth.GetAppleCredentials() if err != nil { if errors.Is(err, auth.ErrNotAuthenticated) { - return nil, fmt.Errorf("no App Store Connect API key configured. Run: builder auth apple (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH)") + return nil, exitcode.With(exitcode.Auth, fmt.Errorf("no App Store Connect API key configured. Run: builder auth apple (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH)")) } return nil, err } @@ -94,18 +95,24 @@ func newOutput(cmd *cobra.Command) output { return output{log: cmd.OutOrStdout()} } +// printJSON writes v to the command's stdout as indented JSON, the shape every +// --json result uses. +func printJSON(cmd *cobra.Command, v any) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(v) +} + // finish prints the result (JSON, or the human summary on success) and // returns err with a timeout translated into something actionable. A partial // result on failure is still printed as JSON so agents see how far it got. func finish[T any](o output, cmd *cobra.Command, result *T, err error, human func()) error { if o.json && result != nil { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - _ = enc.Encode(result) + _ = printJSON(cmd, result) } if err != nil { if errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("timed out waiting for App Store Connect; processing continues server-side, check later with builder ios submit --testflight or raise --timeout") + return exitcode.With(exitcode.Timeout, fmt.Errorf("timed out waiting for App Store Connect; processing continues server-side, check later with builder ios submit --testflight or raise --timeout")) } return err } diff --git a/internal/asc/client.go b/internal/asc/client.go index bd969e4..b4f9c8e 100644 --- a/internal/asc/client.go +++ b/internal/asc/client.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/MobAI-App/ios-builder/internal/exitcode" ) // DefaultBaseURL is the production App Store Connect API endpoint. @@ -96,6 +98,14 @@ type ErrorSource struct { } // Error renders the status and every ASC error on one line. +// ExitCode makes a rejected API key (401) exit with exitcode.Auth. +func (e *Error) ExitCode() int { + if e.StatusCode == 401 { + return exitcode.Auth + } + return exitcode.Failure +} + func (e *Error) Error() string { var b strings.Builder fmt.Fprintf(&b, "App Store Connect %s %s: HTTP %d", e.Method, e.Path, e.StatusCode) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 159c8a9..69fe811 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/zalando/go-keyring" ) @@ -47,22 +48,28 @@ type DeviceCode struct { // Login performs GitHub OAuth Device Code flow authentication. // It displays a URL and code for the user to authorize, then stores the token in the keychain. func Login(ctx context.Context) (*Token, error) { + return LoginWith(ctx, func(code *DeviceCode) { + fmt.Println() + fmt.Printf(" Open: %s\n", code.VerificationURI) + fmt.Printf(" Enter code: %s\n", code.UserCode) + fmt.Println() + fmt.Println("Waiting for authorization...") + }) +} + +// LoginWith is Login with the code shown by show, so a caller can print it +// as JSON or elsewhere. +func LoginWith(ctx context.Context, show func(*DeviceCode)) (*Token, error) { code, err := requestDeviceCode(ctx) if err != nil { return nil, err } - - fmt.Println() - fmt.Printf(" Open: %s\n", code.VerificationURI) - fmt.Printf(" Enter code: %s\n", code.UserCode) - fmt.Println() - fmt.Println("Waiting for authorization...") + show(code) token, err := pollForToken(ctx, code) if err != nil { return nil, err } - fmt.Println("Authorized. Saving token...") if err := storeToken(token.AccessToken); err != nil { return nil, fmt.Errorf("failed to store token: %w", err) @@ -247,7 +254,7 @@ func pollForToken(ctx context.Context, code *DeviceCode) (*Token, error) { } if time.Now().After(deadline) { - return nil, fmt.Errorf("authorization timed out") + return nil, exitcode.With(exitcode.Timeout, fmt.Errorf("authorization timed out")) } token, err := requestToken(ctx, code.DeviceCode) diff --git a/internal/auth/github_token.go b/internal/auth/github_token.go new file mode 100644 index 0000000..0654249 --- /dev/null +++ b/internal/auth/github_token.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/MobAI-App/ios-builder/internal/exitcode" +) + +// RequiredScopes are the OAuth scopes Builder asks for in the device flow: +// repo (snapshot refs, secrets, releases), workflow (the workflow files) and +// gist (ios distribute manifests). +var RequiredScopes = []string{"repo", "workflow", "gist"} + +// githubAPI is the REST API root; tests point it at a fake server. +var githubAPI = "https://api.github.com" + +// GitHubIdentity is what GitHub says about a token. +type GitHubIdentity struct { + Login string + // Scopes are the token's OAuth scopes. ScopesKnown is false when GitHub + // does not report them, as for fine-grained tokens, whose permissions + // cannot be read back. + Scopes []string + ScopesKnown bool +} + +// CheckGitHubToken asks GitHub who the token belongs to and which scopes it +// carries. A token GitHub rejects is an exitcode.Auth error. +func CheckGitHubToken(ctx context.Context, token string) (*GitHubIdentity, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI+"/user", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("check the GitHub token: %w", err) + } + defer resp.Body.Close() + switch { + case resp.StatusCode == http.StatusUnauthorized: + return nil, exitcode.With(exitcode.Auth, fmt.Errorf("GitHub rejected the token (HTTP 401): it is wrong, expired or revoked")) + case resp.StatusCode != http.StatusOK: + return nil, fmt.Errorf("check the GitHub token: HTTP %d", resp.StatusCode) + } + var user struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("check the GitHub token: %w", err) + } + id := &GitHubIdentity{Login: user.Login} + if values, ok := resp.Header[http.CanonicalHeaderKey("X-OAuth-Scopes")]; ok { + id.ScopesKnown = true + id.Scopes = ParseScopes(strings.Join(values, ",")) + } + return id, nil +} + +// ParseScopes splits a scope list as GitHub writes it, comma or space +// separated. +func ParseScopes(s string) []string { + return append([]string{}, strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })...) +} + +// MissingScopes returns the RequiredScopes absent from scopes. +func MissingScopes(scopes []string) []string { + missing := []string{} + for _, want := range RequiredScopes { + if !slices.Contains(scopes, want) { + missing = append(missing, want) + } + } + return missing +} + +// SaveToken stores a GitHub token where GetToken finds it. +func SaveToken(token string) error { + return storeToken(token) +} diff --git a/internal/auth/github_token_test.go b/internal/auth/github_token_test.go new file mode 100644 index 0000000..7137933 --- /dev/null +++ b/internal/auth/github_token_test.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/MobAI-App/ios-builder/internal/exitcode" +) + +func fakeGitHub(t *testing.T, h http.HandlerFunc) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + prev := githubAPI + githubAPI = srv.URL + t.Cleanup(func() { githubAPI = prev }) +} + +func TestCheckGitHubToken(t *testing.T) { + fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Header.Get("Authorization") { + case "Bearer classic": + w.Header().Set("X-OAuth-Scopes", "repo, workflow") + case "Bearer fine": + default: + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"login":"octo"}`)) + }) + + id, err := CheckGitHubToken(context.Background(), "classic") + if err != nil { + t.Fatal(err) + } + if id.Login != "octo" || !id.ScopesKnown || !slices.Equal(id.Scopes, []string{"repo", "workflow"}) { + t.Fatalf("classic token = %+v", id) + } + if got := MissingScopes(id.Scopes); !slices.Equal(got, []string{"gist"}) { + t.Errorf("missing = %v, want [gist]", got) + } + + id, err = CheckGitHubToken(context.Background(), "fine") + if err != nil || id.ScopesKnown { + t.Fatalf("fine-grained token = %+v, %v; scopes must be unknown", id, err) + } + + _, err = CheckGitHubToken(context.Background(), "wrong") + if exitcode.Code(err) != exitcode.Auth { + t.Fatalf("rejected token: err = %v, want an auth error", err) + } +} + +func TestParseScopes(t *testing.T) { + if got := ParseScopes("repo,workflow gist"); !slices.Equal(got, []string{"repo", "workflow", "gist"}) { + t.Errorf("ParseScopes = %v", got) + } + if got := MissingScopes(ParseScopes("")); !slices.Equal(got, RequiredScopes) { + t.Errorf("MissingScopes(none) = %v", got) + } +} diff --git a/internal/build/remote.go b/internal/build/remote.go index 4f48493..4ceaffb 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -13,6 +13,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/auth" "github.com/MobAI-App/ios-builder/internal/ci" "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/snapshot" "github.com/google/uuid" ) @@ -38,7 +39,7 @@ func RemoteProvider(cfg *config.Config, override string) (ci.Provider, config.CI key := strings.ToUpper(name) + "_API_TOKEN" token, err := auth.GetProviderToken(name) if err != nil { - return nil, cfgCI, fmt.Errorf("not authenticated with %s; run builder auth %s or set %s", name, name, key) + return nil, cfgCI, exitcode.With(exitcode.Auth, fmt.Errorf("not authenticated with %s; run builder auth %s or set %s", name, name, key)) } switch name { case "codemagic": @@ -175,7 +176,7 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *co } terminal = true if !status.Success { - return nil, fmt.Errorf("%s build ended: %s (logs: %s)", p.Name(), status.State, run.URL) + return nil, exitcode.With(exitcode.BuildFailed, fmt.Errorf("%s build ended: %s (logs: %s)", p.Name(), status.State, run.URL)) } if lister, ok := p.(ci.ArtifactLister); ok { status.Artifacts, err = lister.Artifacts(ctx, run) diff --git a/internal/dev/flutter.go b/internal/dev/flutter.go index 8519a4e..7c75a45 100644 --- a/internal/dev/flutter.go +++ b/internal/dev/flutter.go @@ -33,8 +33,12 @@ type FlutterHandler struct { watcher watcher stdinMux *StdinMux cancelWatch context.CancelFunc + emit func(Event) } +// SetEmitter reports the VM Service URL as a "vm_service" event. +func (h *FlutterHandler) SetEmitter(emit func(Event)) { h.emit = emit } + // NewFlutterHandler creates a new Flutter handler. func NewFlutterHandler(mobaiURL string, noAttach, noWatch bool, watchCfg *config.WatchConfig) *FlutterHandler { return &FlutterHandler{ @@ -110,6 +114,10 @@ func (h *FlutterHandler) runFlutterAttach(ctx context.Context, deviceID, debugUR fmt.Println() fmt.Printf("VM Service URL (on device): %s\n", debugURL) fmt.Println() + if h.emit != nil { + h.emit(Event{Event: "vm_service", DeviceID: deviceID, URL: debugURL, + Command: fmt.Sprintf("MOBAI_DEVICE_ID=%s flutter attach -d mobai-ios --debug-url=%s", deviceID, debugURL)}) + } go func() { for output := range outputChan { diff --git a/internal/dev/input_test.go b/internal/dev/input_test.go new file mode 100644 index 0000000..e837959 --- /dev/null +++ b/internal/dev/input_test.go @@ -0,0 +1,112 @@ +package dev + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/exitcode" + "github.com/MobAI-App/ios-builder/internal/mobai" +) + +// fakeMobAI lists two physical devices and has no claim endpoint (MobAI +// before device claims), so Claim succeeds without a lease. +func fakeMobAI(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/devices" { + _, _ = w.Write([]byte(`[{"id":"00008030-000A1B2C3D4E5F60","name":"iPhone A","platform":"ios"},{"id":"00008030-000A1B2C3D4E5F61","name":"iPhone B","platform":"ios"}]`)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestDeviceChoiceWithoutTerminal(t *testing.T) { + // Claim keeps a client ID in the user config dir; keep it out of $HOME. + home := t.TempDir() + for _, env := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "AppData"} { + t.Setenv(env, home) + } + url := fakeMobAI(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + s := NewSession(url, "", "", nil) + s.SetInput(Input{}) + err := s.connectDevice(ctx) + var inputErr *InputError + if !errors.As(err, &inputErr) || exitcode.Code(err) != exitcode.Usage || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "iPhone B") { + t.Fatalf("err = %v, want an InputError naming --device and the devices", err) + } + + var events []Event + s = NewSession(url, "", "", nil) + s.SetInput(Input{Yes: true}) + s.SetEvents(func(e Event) { events = append(events, e) }) + if err := s.connectDevice(ctx); err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Event != "device" || events[0].DeviceName != "iPhone A" { + t.Errorf("events = %+v, want the first device", events) + } +} + +func TestResignWithoutTerminal(t *testing.T) { + yes, no := true, false + tests := []struct { + name string + in Input + resign bool + wantErr string + }{ + {"default is no", Input{}, false, ""}, + {"explicit no", Input{Resign: &no}, false, ""}, + {"needs an Apple ID", Input{Resign: &yes}, false, "--apple-id"}, + {"needs a password", Input{Resign: &yes, AppleID: "a@example.com"}, false, "BUILDER_APPLE_ID_PASSWORD"}, + {"complete", Input{Resign: &yes, AppleID: "a@example.com", Password: "pw"}, true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewSession("http://unused", "", "", nil) + s.SetInput(tt.in) + var req mobai.InstallAppRequest + err := s.resignRequest(&req) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) || exitcode.Code(err) != exitcode.Usage { + t.Fatalf("err = %v, want a usage error naming %s", err, tt.wantErr) + } + return + } + if err != nil || req.Resign != tt.resign { + t.Fatalf("resign = %v, err = %v; want %v", req.Resign, err, tt.resign) + } + }) + } +} + +func TestFindIPAWithoutTerminalTakesNewest(t *testing.T) { + dir := t.TempDir() + old, newer := filepath.Join(dir, "a.ipa"), filepath.Join(dir, "b.ipa") + for _, p := range []string{old, newer} { + if err := os.WriteFile(p, nil, 0o644); err != nil { + t.Fatal(err) + } + } + past := time.Now().Add(-time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + got, err := FindIPA(dir, false) + if err != nil || got != newer { + t.Fatalf("FindIPA = %q, %v; want %q", got, err, newer) + } +} diff --git a/internal/dev/reactnative.go b/internal/dev/reactnative.go index ecbeb99..04ef7bb 100644 --- a/internal/dev/reactnative.go +++ b/internal/dev/reactnative.go @@ -26,8 +26,12 @@ type ReactNativeHandler struct { showLogs bool metroCmd *exec.Cmd windowsHost string // Windows host IP (extracted from mobaiURL for WSL) + emit func(Event) } +// SetEmitter reports the Metro URL as a "metro" event. +func (h *ReactNativeHandler) SetEmitter(emit func(Event)) { h.emit = emit } + // NewReactNativeHandler creates a new React Native handler. func NewReactNativeHandler(metroPort int, showLogs bool, mobaiURL string) *ReactNativeHandler { if metroPort == 0 { @@ -68,6 +72,9 @@ func (h *ReactNativeHandler) Attach(ctx context.Context, deviceID string, debugO fmt.Println() fmt.Printf("Metro bundler: http://%s:%d\n", ip, h.metroPort) fmt.Println() + if h.emit != nil { + h.emit(Event{Event: "metro", DeviceID: deviceID, URL: fmt.Sprintf("http://%s:%d", ip, h.metroPort)}) + } fmt.Println("React Native hot reload active!") fmt.Println(" - Shake device or press 'd' in Metro to open dev menu") fmt.Println(" - Press 'r' in Metro terminal to reload") diff --git a/internal/dev/session.go b/internal/dev/session.go index b3bf72d..0c7131d 100644 --- a/internal/dev/session.go +++ b/internal/dev/session.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/MobAI-App/ios-builder/internal/exitcode" "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/gorilla/websocket" @@ -37,9 +38,47 @@ type Session struct { debugConn *websocket.Conn skipInstall bool handler FrameworkHandler + input Input + emit func(Event) } -// NewSession creates a new development session. +// Input answers the questions a session asks: which device, whether to +// re-sign and with which Apple ID, and the bundle ID when MobAI does not +// report it. +type Input struct { + // Interactive allows prompts on the terminal. Without it every question + // takes its answer from here or fails naming the flag that gives it. + Interactive bool + // Yes takes the first device when several are connected. + Yes bool + // Resign re-signs the IPA on install; nil asks in a terminal, else no. + Resign *bool + // AppleID and Password are the account to re-sign with. + AppleID string + Password string +} + +// Event is one line of a session's NDJSON output (--json): "device", +// "installed", "launched", then what the handler adds ("vm_service" for +// Flutter, "metro" for React Native). +type Event struct { + Event string `json:"event"` + DeviceID string `json:"device_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + BundleID string `json:"bundle_id,omitempty"` + Resigned bool `json:"resigned,omitempty"` + Skipped bool `json:"skipped,omitempty"` // installed: --skip-install + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` +} + +// emitterSetter is implemented by handlers that report events of their own. +type emitterSetter interface { + SetEmitter(func(Event)) +} + +// NewSession creates a new development session. It prompts in the terminal +// until SetInput says otherwise. func NewSession(mobaiURL, deviceID, ipaPath string, h FrameworkHandler) *Session { return &Session{ mobai: mobai.NewClient(mobaiURL), @@ -47,9 +86,37 @@ func NewSession(mobaiURL, deviceID, ipaPath string, h FrameworkHandler) *Session deviceID: deviceID, ipaPath: ipaPath, handler: h, + input: Input{Interactive: true}, + emit: func(Event) {}, } } +// SetInput sets how the session's questions are answered. +func (s *Session) SetInput(in Input) { s.input = in } + +// SetEvents sends the session's events to emit, and the handler's when it +// reports any. +func (s *Session) SetEvents(emit func(Event)) { + s.emit = emit + if h, ok := s.handler.(emitterSetter); ok { + h.SetEmitter(emit) + } +} + +// InputError is a question a non-interactive session could not ask; Flags +// name what answers it. +type InputError struct { + What string + Flags []string +} + +func (e *InputError) Error() string { + return fmt.Sprintf("%s is needed and there is no terminal to ask on (or --no-input/CI is set); pass %s", e.What, strings.Join(e.Flags, " or ")) +} + +// ExitCode makes a missing answer a usage error. +func (e *InputError) ExitCode() int { return exitcode.Usage } + // SetSkipInstall configures the session to skip installation. func (s *Session) SetSkipInstall(skip bool, bundleID string) { s.skipInstall = skip @@ -58,8 +125,9 @@ func (s *Session) SetSkipInstall(skip bool, bundleID string) { } } -// FindIPA lists IPAs in distDir and lets user select if multiple. -func FindIPA(distDir string) (string, error) { +// FindIPA lists IPAs in distDir and lets user select if multiple; without a +// terminal it takes the newest, as ios upload and ios distribute do. +func FindIPA(distDir string, interactive bool) (string, error) { if distDir == "" { distDir = "dist" } @@ -75,6 +143,9 @@ func FindIPA(distDir string) (string, error) { if len(matches) == 1 { return matches[0], nil } + if !interactive { + return ipa.Newest(distDir) + } names := make([]string, len(matches)) for i, p := range matches { @@ -113,12 +184,14 @@ func (s *Session) Start(ctx context.Context) error { } } else { fmt.Printf("Skipping install, using bundle ID: %s\n", s.bundleID) + s.emit(Event{Event: "installed", DeviceID: s.deviceID, BundleID: s.bundleID, Skipped: true}) } debugOutput, err := s.launchApp(ctx) if err != nil { return err } + s.emit(Event{Event: "launched", DeviceID: s.deviceID, BundleID: s.bundleID}) if s.handler != nil { return s.handler.Attach(ctx, s.deviceID, debugOutput) @@ -169,8 +242,14 @@ func (s *Session) connectDevice(ctx context.Context) error { if device == nil { return fmt.Errorf("device %s not found", s.deviceID) } - } else if len(devices) == 1 { + } else if len(devices) == 1 || s.input.Yes { device = &devices[0] + } else if !s.input.Interactive { + ids := make([]string, len(devices)) + for i, d := range devices { + ids[i] = fmt.Sprintf("%s (%s)", d.ID, d.Name) + } + return &InputError{What: "a device (connected: " + strings.Join(ids, ", ") + ")", Flags: []string{"--device ", "--yes for the first"}} } else { names := make([]string, len(devices)) for i, d := range devices { @@ -190,7 +269,57 @@ func (s *Session) connectDevice(ctx context.Context) error { s.deviceID = device.ID fmt.Printf("Using device: %s\n", device.Name) - return s.mobai.Claim(ctx, s.deviceID) + if err := s.mobai.Claim(ctx, s.deviceID); err != nil { + return err + } + s.emit(Event{Event: "device", DeviceID: device.ID, DeviceName: device.Name}) + return nil +} + +// resignRequest settles the re-sign question: the Input answer, else a +// prompt in a terminal, else no. Missing credentials for a re-sign are +// prompted for or are an InputError. +func (s *Session) resignRequest(req *mobai.InstallAppRequest) error { + in := s.input + resign := false + switch { + case in.Resign != nil: + resign = *in.Resign + case in.Interactive && !in.Yes: + resignPrompt := promptui.Select{ + Label: "Resign app", + Items: []string{"No", "Yes"}, + } + idx, _, err := resignPrompt.Run() + if err != nil { + return err + } + resign = idx == 1 + } + if !resign { + return nil + } + req.Resign, req.AppleID, req.Password = true, in.AppleID, in.Password + var err error + if req.AppleID == "" { + if !in.Interactive { + return &InputError{What: "the Apple ID to re-sign with", Flags: []string{"--apple-id"}} + } + appleIDPrompt := promptui.Prompt{Label: "Apple ID"} + if req.AppleID, err = appleIDPrompt.Run(); err != nil { + return err + } + } + if req.Password == "" { + if !in.Interactive { + return &InputError{What: "the Apple ID password to re-sign with", Flags: []string{"BUILDER_APPLE_ID_PASSWORD in the environment"}} + } + passwordPrompt := promptui.Prompt{Label: "Password", Mask: '*'} + if req.Password, err = passwordPrompt.Run(); err != nil { + return err + } + } + return nil } func (s *Session) installApp(ctx context.Context) error { @@ -205,48 +334,40 @@ func (s *Session) installApp(ctx context.Context) error { absPath = toWindowsPathIfWSL(absPath) req := mobai.InstallAppRequest{Path: absPath} - - resignPrompt := promptui.Select{ - Label: "Resign app", - Items: []string{"No", "Yes"}, - } - idx, _, err := resignPrompt.Run() - if err != nil { + if err := s.resignRequest(&req); err != nil { return err } - if idx == 1 { - req.Resign = true - - appleIDPrompt := promptui.Prompt{Label: "Apple ID"} - req.AppleID, err = appleIDPrompt.Run() - if err != nil { - return err - } - - passwordPrompt := promptui.Prompt{Label: "Password", Mask: '*'} - req.Password, err = passwordPrompt.Run() - if err != nil { - return err - } - } - fmt.Println("Installing app...") resp, err := s.mobai.InstallApp(ctx, s.deviceID, req) if err != nil { return fmt.Errorf("install app: %w", err) } + // MobAI's answer, else --bundle-id, else a prompt (or the guess without + // a terminal). + given := s.bundleID s.bundleID = resp.Data.BundleID if s.bundleID == "" { - bundlePrompt := promptui.Prompt{Label: "Bundle ID", Default: guessBundleID(resp, ipaBundleID, req.Resign)} - s.bundleID, err = bundlePrompt.Run() - if err != nil { - return err + guess := guessBundleID(resp, ipaBundleID, req.Resign) + switch { + case given != "": + s.bundleID = given + case !s.input.Interactive || s.input.Yes: + if guess == "" { + return &InputError{What: "the installed app's bundle ID (MobAI did not report it)", Flags: []string{"--bundle-id"}} + } + s.bundleID = guess + default: + bundlePrompt := promptui.Prompt{Label: "Bundle ID", Default: guess} + if s.bundleID, err = bundlePrompt.Run(); err != nil { + return err + } } } fmt.Printf("Installed: %s\n", s.bundleID) + s.emit(Event{Event: "installed", DeviceID: s.deviceID, BundleID: s.bundleID, Resigned: req.Resign}) return nil } diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go new file mode 100644 index 0000000..e802c47 --- /dev/null +++ b/internal/exitcode/exitcode.go @@ -0,0 +1,80 @@ +// Package exitcode is the one table of process exit codes Builder uses, so +// scripts and agents can branch on why a command failed without parsing its +// message. +// +// 0 OK the command did what it was asked +// 1 Failure anything not listed below +// 2 Usage bad flags or arguments, or an answer is needed that +// only a flag can give without a terminal +// 3 Auth a login or API key is missing or was rejected +// 4 BuildFailed the CI run ended without success +// 5 Timeout a wait ran past its time limit (--timeout) +// 130 Interrupted Ctrl-C or SIGTERM +package exitcode + +import ( + "context" + "errors" + "fmt" +) + +// The exit codes. README "Using Builder from agents and CI" lists them too. +const ( + OK = 0 + Failure = 1 + Usage = 2 + Auth = 3 + BuildFailed = 4 + Timeout = 5 + Interrupted = 130 +) + +// Error carries an exit code with an error; its message is the wrapped one. +type Error struct { + Code int + Err error +} + +func (e *Error) Error() string { return e.Err.Error() } +func (e *Error) Unwrap() error { return e.Err } + +// With tags err with code; a nil err stays nil. +func With(code int, err error) error { + if err == nil { + return nil + } + return &Error{Code: code, Err: err} +} + +// Usagef is a usage error (exit 2) with a formatted message. +func Usagef(format string, args ...any) error { + return With(Usage, fmt.Errorf(format, args...)) +} + +// Code maps an error to its exit code: an explicit tag (With, or an error +// type with an ExitCode method) first, then an interrupted or expired +// context, then any error reporting Timeout() true, else Failure. +func Code(err error) int { + if err == nil { + return OK + } + var tagged *Error + if errors.As(err, &tagged) { + return tagged.Code + } + var coded interface{ ExitCode() int } + if errors.As(err, &coded) { + return coded.ExitCode() + } + if errors.Is(err, context.Canceled) { + return Interrupted + } + if errors.Is(err, context.DeadlineExceeded) { + return Timeout + } + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) && timeout.Timeout() { + return Timeout + } + return Failure +} diff --git a/internal/exitcode/exitcode_test.go b/internal/exitcode/exitcode_test.go new file mode 100644 index 0000000..3bd82cc --- /dev/null +++ b/internal/exitcode/exitcode_test.go @@ -0,0 +1,56 @@ +package exitcode + +import ( + "context" + "errors" + "fmt" + "net/url" + "testing" +) + +type coded struct{} + +func (coded) Error() string { return "coded" } +func (coded) ExitCode() int { return BuildFailed } + +type netTimeout struct{} + +func (netTimeout) Error() string { return "i/o timeout" } +func (netTimeout) Timeout() bool { return true } +func (netTimeout) Temporary() bool { return false } + +func TestCode(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {"nil", nil, OK}, + {"plain", errors.New("boom"), Failure}, + {"usage", Usagef("pass --%s", "yes"), Usage}, + {"tag survives wrapping", fmt.Errorf("outer: %w", With(Auth, errors.New("no login"))), Auth}, + {"ExitCode method", fmt.Errorf("build failed: %w", coded{}), BuildFailed}, + {"canceled", fmt.Errorf("upload: %w", context.Canceled), Interrupted}, + {"deadline", fmt.Errorf("wait: %w", context.DeadlineExceeded), Timeout}, + {"network timeout", &url.Error{Op: "Get", URL: "https://x", Err: netTimeout{}}, Timeout}, + {"outer tag wins", With(Usage, fmt.Errorf("x: %w", context.DeadlineExceeded)), Usage}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Code(tt.err); got != tt.want { + t.Errorf("Code(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} + +func TestWrapKeepsMessage(t *testing.T) { + inner := errors.New("not authenticated") + err := With(Auth, inner) + if err.Error() != inner.Error() || !errors.Is(err, inner) { + t.Fatalf("With changed the error: %v", err) + } + if With(Auth, nil) != nil { + t.Fatal("With(nil) is not nil") + } +} diff --git a/internal/github/releases.go b/internal/github/releases.go index 99b1c4c..bb01468 100644 --- a/internal/github/releases.go +++ b/internal/github/releases.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "strings" + + "github.com/MobAI-App/ios-builder/internal/exitcode" ) // Release is a repository release; drafts have no tag in git. @@ -148,7 +150,7 @@ func (c *Client) MintAssetURL(ctx context.Context, owner, repo string, assetID i } // ErrGistScope is returned when the token cannot create gists. -var ErrGistScope = errors.New("the saved GitHub login lacks the gist scope that builder ios distribute needs; run builder auth github again") +var ErrGistScope = exitcode.With(exitcode.Auth, errors.New("the saved GitHub login lacks the gist scope that builder ios distribute needs; run builder auth github again")) type createGistRequest struct { Description string `json:"description"` diff --git a/internal/github/repo.go b/internal/github/repo.go index b88e277..2bf728b 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/http" + + "github.com/MobAI-App/ios-builder/internal/exitcode" ) // GetRepository retrieves a repository by owner and name @@ -43,7 +45,7 @@ func (c *Client) ListSecretNames(ctx context.Context, owner, repo string) ([]str if err := c.do(ctx, path, &list); err != nil { var apiErr *APIError if errors.As(err, &apiErr) && (apiErr.Status == "403" || apiErr.Status == "404") { - return nil, fmt.Errorf("cannot list the secrets of %s/%s (%s): the GitHub token needs the repo scope and admin access to the repository; run builder auth github as an admin of it", owner, repo, apiErr.Message) + return nil, exitcode.With(exitcode.Auth, fmt.Errorf("cannot list the secrets of %s/%s (%s): the GitHub token needs the repo scope and admin access to the repository; run builder auth github as an admin of it", owner, repo, apiErr.Message)) } return nil, fmt.Errorf("failed to list the secrets of %s/%s: %w", owner, repo, err) } diff --git a/internal/github/types.go b/internal/github/types.go index b112334..6dccd54 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -1,6 +1,10 @@ package github -import "time" +import ( + "time" + + "github.com/MobAI-App/ios-builder/internal/exitcode" +) // Repository represents a GitHub repository type Repository struct { @@ -130,3 +134,11 @@ type APIError struct { func (e *APIError) Error() string { return e.Message } + +// ExitCode makes a rejected token (401) exit with exitcode.Auth. +func (e *APIError) ExitCode() int { + if e.Status == "401" { + return exitcode.Auth + } + return exitcode.Failure +} diff --git a/internal/github/workflow.go b/internal/github/workflow.go index 954c18d..d6a2283 100644 --- a/internal/github/workflow.go +++ b/internal/github/workflow.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "time" + + "github.com/MobAI-App/ios-builder/internal/exitcode" ) const ( @@ -116,7 +118,7 @@ func (c *Client) PollForWorkflowStart(ctx context.Context, owner, repo, workflow for { if time.Now().After(deadline) { - return nil, fmt.Errorf("timed out waiting for workflow to start") + return nil, exitcode.With(exitcode.Timeout, fmt.Errorf("timed out waiting for workflow to start")) } run, err := c.FindWorkflowRunByBuildID(ctx, owner, repo, workflowFile, buildID) @@ -224,6 +226,9 @@ type RunFailedError struct { Failure *RunFailure } +// ExitCode makes a failed run exit with exitcode.BuildFailed. +func (e *RunFailedError) ExitCode() int { return exitcode.BuildFailed } + func (e *RunFailedError) Error() string { var b strings.Builder fmt.Fprintf(&b, "workflow failed with conclusion: %s", e.Conclusion) @@ -364,7 +369,7 @@ func (c *Client) PollForArtifact(ctx context.Context, owner, repo string, runID for { if time.Now().After(deadline) { - return nil, fmt.Errorf("timed out waiting for artifact %q", artifactName) + return nil, exitcode.With(exitcode.Timeout, fmt.Errorf("timed out waiting for artifact %q", artifactName)) } // Check if artifact is available