diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..fb86987483 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,21 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..ee72b6d0d2 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,296 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. +package fastlane + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} + +// Adapter creates APNs keys with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. + Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// CreateKey runs the embedded lane. The Apple ID and password are asked here +// when the request has none, and handed to fastlane as FASTLANE_USER and +// FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + runner, err := a.runner() + if err != nil { + return apns.Key{}, err + } + if err := runner.check(ctx); err != nil { + return apns.Key{}, err + } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return apns.Key{}, err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return apns.Key{}, err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return apns.Key{}, err + } + out := filepath.Join(scratch, "result.json") + + arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + command := runner.command(ctx, arguments...) + command.Dir = scratch + command.Stdin, command.Stdout, command.Stderr = a.terminal() + command.Env = append(command.Env, + "FASTLANE_USER="+appleID, + "FASTLANE_PASSWORD="+password, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane failed: %w", runErr) + } + + return apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + case "invalid-credentials": + return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) + default: + if decoded.Message == "" { + decoded.Message = decoded.Error + } + + return apns.Key{}, errors.New(decoded.Message) + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + if a.Fastlane != "" { + return runner{name: a.Fastlane, env: base}, nil + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) + } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil + } + + return runner{name: "fastlane", env: base}, nil +} + +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) + + return command +} + +func (r runner) check(ctx context.Context) error { + 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 supportedVersion(string(output)) +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func supportedVersion(output string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + 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)) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..8a95dafa4a --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,253 @@ +package fastlane + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.225.0": true, + "fastlane 3.0.0": true, + "fastlane 2.224.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output) + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(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) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..57ad078f83 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,54 @@ +# Creates a team-scoped APNs key for `appwrite init apns --create-key +# --provider fastlane` with spaceship, fastlane's Apple Developer portal +# client, so any fastlane installation (gem, Homebrew or bundler) can run it. +# +# spaceship asks for the two-factor code, and for a team when the account has +# several, on the terminal. The Apple ID and password come from FASTLANE_USER +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + result = begin + create_apns_key_with_spaceship(options) + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +def create_apns_key_with_spaceship(options) + require 'spaceship' + + Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + team_id = options[:team_id].to_s + if team_id.empty? + Spaceship::Portal.select_team + else + Spaceship::Portal.select_team(team_id: team_id) + end + client = Spaceship::Portal.client + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + raise unless error.message.include?('maximum allowed number of Keys') + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..f7d298d0ab 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -23,6 +23,7 @@ import ( "time" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" @@ -565,7 +566,7 @@ const ( // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry() + return apns.NewRegistry(fastlane.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then