Skip to content

Commit 89ea7ee

Browse files
Merge branch 'messaging-provider-cli-apns-fastlane-install' into messaging-provider-cli-apns
2 parents f46478f + 941fda7 commit 89ea7ee

2 files changed

Lines changed: 267 additions & 6 deletions

File tree

‎templates/cli/internal/apns/fastlane/adapter.go‎

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"os/exec"
2020
"path/filepath"
2121
"regexp"
22+
"runtime"
2223
"strconv"
2324
"strings"
2425

@@ -40,6 +41,9 @@ type Adapter struct {
4041
Fastlane string
4142
// Bundle is Bundler's command. Empty means "bundle" from PATH.
4243
Bundle string
44+
// Brew is Homebrew's command, offered to install a missing fastlane.
45+
// Empty means "brew" from PATH.
46+
Brew string
4347
// WorkDir is the project folder whose Gemfile may list fastlane. Empty
4448
// means the current directory.
4549
WorkDir string
@@ -81,7 +85,15 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key
8185
if err != nil {
8286
return apns.Key{}, err
8387
}
84-
if err := runner.check(ctx); err != nil {
88+
err = runner.check(ctx)
89+
// Installing is only offered for fastlane from PATH: a configured command
90+
// is never replaced.
91+
if errors.Is(err, errNotInstalled) && a.Fastlane == "" {
92+
if err = a.offerInstall(ctx, request); err == nil {
93+
err = runner.check(ctx)
94+
}
95+
}
96+
if err != nil {
8597
return apns.Key{}, err
8698
}
8799
if runner.gemfile != "" && request.Log != nil {
@@ -254,22 +266,93 @@ func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd {
254266
return command
255267
}
256268

269+
// errNotInstalled means there is no fastlane on PATH, which installing one
270+
// can fix.
271+
var errNotInstalled = errors.New("fastlane is not installed")
272+
257273
func (r runner) check(ctx context.Context) error {
274+
if r.gemfile == "" && r.name == "fastlane" {
275+
if _, err := exec.LookPath(r.name); err != nil {
276+
return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint)
277+
}
278+
}
258279
output, err := r.command(ctx, "--version").Output()
259280
if err != nil {
260281
if r.gemfile != "" {
261282
return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile)
262283
}
263284

264-
return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion))
285+
return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err)
286+
}
287+
upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed"
288+
if r.gemfile != "" {
289+
upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS)
290+
}
291+
292+
return supportedVersion(string(output), upgrade)
293+
}
294+
295+
// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix
296+
// it is one command to paste, with the folder quoted for a POSIX shell. On
297+
// Windows no quoting is safe in every shell (PowerShell expands $ inside
298+
// double quotes, cmd expands %, and Windows PowerShell has no &&), so the
299+
// folder is named rather than put in a command.
300+
func bundlerUpgrade(folder, goos string) string {
301+
if goos == "windows" {
302+
return "bundle update fastlane, run from the folder " + folder
303+
}
304+
305+
return "cd " + shellQuote(folder) + " && bundle update fastlane"
306+
}
307+
308+
// shellQuote quotes path for a POSIX shell: in single quotes, with any single
309+
// quote closed, escaped and reopened. A path of only safe characters is left
310+
// as it is.
311+
func shellQuote(path string) string {
312+
if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" {
313+
return path
265314
}
266315

267-
return supportedVersion(string(output))
316+
return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'"
317+
}
318+
319+
const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " +
320+
"or add gem \"fastlane\" to your project's Gemfile and run bundle install. " +
321+
"Or use --provider appwrite, which needs nothing installed"
322+
323+
// offerInstall asks to install fastlane with Homebrew, when there is one, and
324+
// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns
325+
// how to install fastlane instead.
326+
func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error {
327+
notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint)
328+
brew := a.Brew
329+
if brew == "" {
330+
brew = "brew"
331+
}
332+
if _, err := exec.LookPath(brew); err != nil || request.Asker == nil {
333+
return notInstalled
334+
}
335+
choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?",
336+
[]string{"Yes, run brew install fastlane", "No"})
337+
if err != nil || choice != 0 {
338+
return notInstalled
339+
}
340+
341+
if request.Log != nil {
342+
request.Log("Running brew install fastlane ...")
343+
}
344+
command := exec.CommandContext(ctx, brew, "install", "fastlane")
345+
command.Stdin, command.Stdout, command.Stderr = a.terminal()
346+
if err := command.Run(); err != nil {
347+
return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint)
348+
}
349+
350+
return nil
268351
}
269352

270353
var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`)
271354

272-
func supportedVersion(output string) error {
355+
func supportedVersion(output, upgrade string) error {
273356
match := versionPattern.FindStringSubmatch(output)
274357
if match == nil {
275358
return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable)
@@ -281,7 +364,7 @@ func supportedVersion(output string) error {
281364
for index := range version {
282365
if version[index] != minimumVersion[index] {
283366
if version[index] < minimumVersion[index] {
284-
return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Run fastlane update_fastlane", apns.ErrUnavailable, versionString(minimumVersion), versionString(version))
367+
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)
285368
}
286369

287370
break

‎templates/cli/internal/apns/fastlane/adapter_test.go‎

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"os/exec"
89
"path/filepath"
910
"runtime"
1011
"strings"
@@ -22,7 +23,7 @@ func TestSupportedVersion(t *testing.T) {
2223
"fastlane 1.300.0": false,
2324
"something else": false,
2425
} {
25-
err := supportedVersion(output)
26+
err := supportedVersion(output, "brew upgrade fastlane")
2627
if (err == nil) != supported {
2728
t.Errorf("supportedVersion(%q) = %v", output, err)
2829
}
@@ -251,3 +252,180 @@ func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) {
251252
t.Fatalf("err = %v", err)
252253
}
253254
}
255+
256+
// choosingAsker answers every choice with choice and records the questions.
257+
type choosingAsker struct {
258+
choice int
259+
asked []string
260+
}
261+
262+
func (c *choosingAsker) Ask(question string, secret bool) (string, error) {
263+
return "", errors.New("not expected: " + question)
264+
}
265+
266+
func (c *choosingAsker) Choose(question string, options []string) (int, error) {
267+
c.asked = append(c.asked, question)
268+
269+
return c.choice, nil
270+
}
271+
272+
// withoutFastlane leaves PATH with only bin, so fastlane is not found unless
273+
// something puts it there.
274+
func withoutFastlane(t *testing.T) string {
275+
t.Helper()
276+
if runtime.GOOS == "windows" {
277+
t.Skip("shell stand-ins")
278+
}
279+
bin := t.TempDir()
280+
t.Setenv("PATH", bin)
281+
282+
return bin
283+
}
284+
285+
// fakeBrew writes a brew stand-in into bin that logs its arguments and, for
286+
// install fastlane, copies fastlane into bin.
287+
func fakeBrew(t *testing.T, bin, fastlane string) string {
288+
t.Helper()
289+
log := filepath.Join(t.TempDir(), "brew.log")
290+
script := `#!/bin/sh
291+
echo "brew $*" >> "` + log + `"
292+
[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane"
293+
`
294+
if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil {
295+
t.Fatal(err)
296+
}
297+
298+
return log
299+
}
300+
301+
func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) {
302+
withoutFastlane(t)
303+
request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}}
304+
305+
_, err := (&Adapter{}).CreateKey(context.Background(), request)
306+
if !errors.Is(err, apns.ErrUnavailable) {
307+
t.Fatalf("err = %v", err)
308+
}
309+
for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} {
310+
if !strings.Contains(err.Error(), part) {
311+
t.Errorf("message is missing %q: %v", part, err)
312+
}
313+
}
314+
}
315+
316+
func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) {
317+
fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`)
318+
bin := withoutFastlane(t)
319+
log := fakeBrew(t, bin, fastlane)
320+
asker := &choosingAsker{choice: 0}
321+
var logged []string
322+
request := apns.Request{
323+
Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker,
324+
Log: func(format string, args ...any) { logged = append(logged, format) },
325+
}
326+
327+
key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request)
328+
if err != nil || key.KeyID != "KEY1234567" {
329+
t.Fatalf("key = %+v, %v", key, err)
330+
}
331+
if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" {
332+
t.Errorf("asked %v", asker.asked)
333+
}
334+
if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" {
335+
t.Errorf("brew calls = %q", calls)
336+
}
337+
if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." {
338+
t.Errorf("logged %v", logged)
339+
}
340+
}
341+
342+
func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) {
343+
fastlane, _ := fakeFastlane(t, "2.228.0", `{}`)
344+
bin := withoutFastlane(t)
345+
log := fakeBrew(t, bin, fastlane)
346+
347+
_, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}})
348+
if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") {
349+
t.Fatalf("err = %v", err)
350+
}
351+
if _, err := os.Stat(log); err == nil {
352+
t.Error("ran brew after the offer was declined")
353+
}
354+
}
355+
356+
func TestOldFastlaneSaysHowToUpgrade(t *testing.T) {
357+
if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil ||
358+
!strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") {
359+
t.Errorf("err = %v", err)
360+
}
361+
}
362+
363+
func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) {
364+
fastlane, _ := fakeFastlane(t, "2.228.0", `{}`)
365+
bin := withoutFastlane(t)
366+
log := fakeBrew(t, bin, fastlane)
367+
asker := &choosingAsker{choice: 0}
368+
369+
_, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker})
370+
if !errors.Is(err, apns.ErrUnavailable) {
371+
t.Fatalf("err = %v", err)
372+
}
373+
if len(asker.asked) != 0 {
374+
t.Errorf("offered to install: %v", asker.asked)
375+
}
376+
if _, err := os.Stat(log); err == nil {
377+
t.Error("ran brew for a configured command")
378+
}
379+
}
380+
381+
func TestTheBundlerUpgradeCommandRuns(t *testing.T) {
382+
if runtime.GOOS == "windows" {
383+
t.Skip("shell stand-in")
384+
}
385+
project := t.TempDir()
386+
gemfile := filepath.Join(project, "Gemfile")
387+
if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil {
388+
t.Fatal(err)
389+
}
390+
bundle := filepath.Join(t.TempDir(), "bundle")
391+
if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil {
392+
t.Fatal(err)
393+
}
394+
395+
_, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"})
396+
if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") {
397+
t.Errorf("err = %v", err)
398+
}
399+
}
400+
401+
func TestBundlerUpgrade(t *testing.T) {
402+
for _, test := range []struct{ folder, goos, want string }{
403+
{"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"},
404+
{"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"},
405+
{"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`},
406+
{`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`},
407+
{`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`},
408+
{`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`},
409+
} {
410+
if got := bundlerUpgrade(test.folder, test.goos); got != test.want {
411+
t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want)
412+
}
413+
}
414+
}
415+
416+
// The quoted command must work when pasted into a shell, for a folder whose
417+
// name has spaces and a quote.
418+
func TestTheQuotedCdRunsInAShell(t *testing.T) {
419+
if runtime.GOOS == "windows" {
420+
t.Skip("POSIX shell")
421+
}
422+
folder := filepath.Join(t.TempDir(), "My Project's App")
423+
if err := os.Mkdir(folder, 0o700); err != nil {
424+
t.Fatal(err)
425+
}
426+
output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output()
427+
resolved, _ := filepath.EvalSymlinks(folder)
428+
if err != nil || strings.TrimSpace(string(output)) != resolved {
429+
t.Errorf("cd landed in %q, %v; want %q", output, err, resolved)
430+
}
431+
}

0 commit comments

Comments
 (0)