diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index ee72b6d0d2..5dbb2b0c75 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -19,6 +19,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" @@ -40,6 +41,9 @@ type Adapter struct { Fastlane string // Bundle is Bundler's command. Empty means "bundle" from PATH. Bundle string + // Brew is Homebrew's command, offered to install a missing fastlane. + // Empty means "brew" from PATH. + Brew string // WorkDir is the project folder whose Gemfile may list fastlane. Empty // means the current directory. WorkDir string @@ -81,7 +85,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if err != nil { return apns.Key{}, err } - if err := runner.check(ctx); err != nil { + err = runner.check(ctx) + // Installing is only offered for fastlane from PATH: a configured command + // is never replaced. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" { + if err = a.offerInstall(ctx, request); err == nil { + err = runner.check(ctx) + } + } + if err != nil { return apns.Key{}, err } if runner.gemfile != "" && request.Log != nil { @@ -254,22 +266,93 @@ func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { return command } +// errNotInstalled means there is no fastlane on PATH, which installing one +// can fix. +var errNotInstalled = errors.New("fastlane is not installed") + func (r runner) check(ctx context.Context) error { + if r.gemfile == "" && r.name == "fastlane" { + if _, err := exec.LookPath(r.name); err != nil { + return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + } + } output, err := r.command(ctx, "--version").Output() if err != nil { if r.gemfile != "" { return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) } - return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) + return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err) + } + upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" + if r.gemfile != "" { + upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS) + } + + return supportedVersion(string(output), upgrade) +} + +// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix +// it is one command to paste, with the folder quoted for a POSIX shell. On +// Windows no quoting is safe in every shell (PowerShell expands $ inside +// double quotes, cmd expands %, and Windows PowerShell has no &&), so the +// folder is named rather than put in a command. +func bundlerUpgrade(folder, goos string) string { + if goos == "windows" { + return "bundle update fastlane, run from the folder " + folder + } + + return "cd " + shellQuote(folder) + " && bundle update fastlane" +} + +// shellQuote quotes path for a POSIX shell: in single quotes, with any single +// quote closed, escaped and reopened. A path of only safe characters is left +// as it is. +func shellQuote(path string) string { + if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" { + return path } - return supportedVersion(string(output)) + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} + +const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + + "Or use --provider appwrite, which needs nothing installed" + +// offerInstall asks to install fastlane with Homebrew, when there is one, and +// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns +// how to install fastlane instead. +func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error { + notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + brew := a.Brew + if brew == "" { + brew = "brew" + } + if _, err := exec.LookPath(brew); err != nil || request.Asker == nil { + return notInstalled + } + choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?", + []string{"Yes, run brew install fastlane", "No"}) + if err != nil || choice != 0 { + return notInstalled + } + + if request.Log != nil { + request.Log("Running brew install fastlane ...") + } + command := exec.CommandContext(ctx, brew, "install", "fastlane") + command.Stdin, command.Stdout, command.Stderr = a.terminal() + if err := command.Run(); err != nil { + return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint) + } + + return nil } var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) -func supportedVersion(output string) error { +func supportedVersion(output, upgrade string) error { match := versionPattern.FindStringSubmatch(output) if match == nil { return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) @@ -281,7 +364,7 @@ func supportedVersion(output string) error { for index := range version { if version[index] != minimumVersion[index] { if version[index] < minimumVersion[index] { - return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Run fastlane update_fastlane", apns.ErrUnavailable, versionString(minimumVersion), versionString(version)) + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Upgrade it with %s", apns.ErrUnavailable, versionString(minimumVersion), versionString(version), upgrade) } break diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 8a95dafa4a..f6c45457a2 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -22,7 +23,7 @@ func TestSupportedVersion(t *testing.T) { "fastlane 1.300.0": false, "something else": false, } { - err := supportedVersion(output) + err := supportedVersion(output, "brew upgrade fastlane") if (err == nil) != supported { t.Errorf("supportedVersion(%q) = %v", output, err) } @@ -251,3 +252,180 @@ func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { t.Fatalf("err = %v", err) } } + +// choosingAsker answers every choice with choice and records the questions. +type choosingAsker struct { + choice int + asked []string +} + +func (c *choosingAsker) Ask(question string, secret bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosingAsker) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +// withoutFastlane leaves PATH with only bin, so fastlane is not found unless +// something puts it there. +func withoutFastlane(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + + return bin +} + +// fakeBrew writes a brew stand-in into bin that logs its arguments and, for +// install fastlane, copies fastlane into bin. +func fakeBrew(t *testing.T, bin, fastlane string) string { + t.Helper() + log := filepath.Join(t.TempDir(), "brew.log") + script := `#!/bin/sh +echo "brew $*" >> "` + log + `" +[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane" +` + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return log +} + +func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) { + withoutFastlane(t) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}} + + _, err := (&Adapter{}).CreateKey(context.Background(), request) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("message is missing %q: %v", part, err) + } + } +} + +func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" { + t.Errorf("asked %v", asker.asked) + } + if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" { + t.Errorf("brew calls = %q", calls) + } + if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + + _, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew after the offer was declined") + } +} + +func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { + if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + + _, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker}) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 { + t.Errorf("offered to install: %v", asker.asked) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew for a configured command") + } +} + +func TestTheBundlerUpgradeCommandRuns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil { + t.Fatal(err) + } + + _, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestBundlerUpgrade(t *testing.T) { + for _, test := range []struct{ folder, goos, want string }{ + {"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"}, + {"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"}, + {"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`}, + {`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`}, + {`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`}, + {`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`}, + } { + if got := bundlerUpgrade(test.folder, test.goos); got != test.want { + t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want) + } + } +} + +// The quoted command must work when pasted into a shell, for a folder whose +// name has spaces and a quote. +func TestTheQuotedCdRunsInAShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell") + } + folder := filepath.Join(t.TempDir(), "My Project's App") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output() + resolved, _ := filepath.EvalSymlinks(folder) + if err != nil || strings.TrimSpace(string(output)) != resolved { + t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) + } +}