Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions templates/cli/internal/apns/fastlane/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
180 changes: 179 additions & 1 deletion templates/cli/internal/apns/fastlane/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
Loading