From 1020be5d5ed3a01d2b168eb29387f603927c920e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:58:20 +0530 Subject: [PATCH 1/3] feat(cli): add the expo APNs key setup on @expo/apple-utils The expo adapter creates APNs keys with @expo/apple-utils 2.2.1 (MIT), the library eas-cli uses. A small Node script, its package.json and a lockfile pinning the package hash are embedded with go:embed and installed into the session folder with npm ci --ignore-scripts when they change. It needs Node.js 18 or later and reports apns.ErrUnavailable otherwise, before contacting Apple. apple-utils prompts on the terminal for the two-factor code and for anything the request leaves out; the request's Apple ID and password are passed as EXPO_APPLE_ID and EXPO_APPLE_PASSWORD, and EXPO_NO_KEYCHAIN=1 keeps the password out of the macOS Keychain. Its InvalidUserCredentialsError maps to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError. apple-utils always creates keys for both environments, which also serve a single-environment request. --- src/SDK/Language/CLI.php | 25 ++ templates/cli/internal/apns/expo/adapter.go | 251 ++++++++++++++++++ .../cli/internal/apns/expo/adapter_test.go | 159 +++++++++++ .../cli/internal/apns/expo/helper/helper.js | 77 ++++++ .../apns/expo/helper/package-lock.json | 25 ++ .../internal/apns/expo/helper/package.json | 11 + 6 files changed, 548 insertions(+) create mode 100644 templates/cli/internal/apns/expo/adapter.go create mode 100644 templates/cli/internal/apns/expo/adapter_test.go create mode 100644 templates/cli/internal/apns/expo/helper/helper.js create mode 100644 templates/cli/internal/apns/expo/helper/package-lock.json create mode 100644 templates/cli/internal/apns/expo/helper/package.json diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..1a4785efa6 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,31 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter.go', + 'template' => 'cli/internal/apns/expo/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter_test.go', + 'template' => 'cli/internal/apns/expo/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/helper.js', + 'template' => 'cli/internal/apns/expo/helper/helper.js', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package.json', + 'template' => 'cli/internal/apns/expo/helper/package.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package-lock.json', + 'template' => 'cli/internal/apns/expo/helper/package-lock.json', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go new file mode 100644 index 0000000000..86eef419c7 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter.go @@ -0,0 +1,251 @@ +// Package expo is the APNs key setup built on @expo/apple-utils, the library +// eas-cli creates APNs keys with. +// +// It runs a small Node script embedded in the binary, with its package.json +// and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are +// installed into the session folder on first use with npm ci +// --ignore-scripts. It needs Node.js 18 or later. +package expo + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/helper.js helper/package.json helper/package-lock.json +var helperFiles embed.FS + +const minimumNodeMajor = 18 + +// Adapter creates APNs keys with @expo/apple-utils. +type Adapter struct { + // Node and Npm are the commands to run. Empty means "node" and "npm". + Node string + Npm string + // apple-utils prompts for anything the request does not answer, and for + // the two-factor code, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using node and npm from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "expo" +} + +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 installs the helper if needed and runs it. apple-utils always +// creates keys for both APNs environments; for a single-environment request +// that key works too. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if err := a.checkNode(ctx); err != nil { + return apns.Key{}, err + } + dir := request.SessionDir + if dir == "" { + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + dir = scratch + } + if err := a.install(ctx, dir); err != nil { + return apns.Key{}, err + } + if request.Environment != apns.EnvironmentAll && request.Log != nil { + request.Log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + } + + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + out := filepath.Join(scratch, "result.json") + + arguments := []string{filepath.Join(dir, "helper.js"), "--name", request.Name, "--out", out} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + command := exec.CommandContext(ctx, a.node(), arguments...) + command.Dir = dir + command.Stdin, command.Stdout, command.Stderr = a.terminal() + // apple-utils reads the Apple ID and password from these instead of + // asking, and stores the password in the macOS Keychain unless told not + // to. The CLI never keeps the password. + command.Env = append(os.Environ(), "EXPO_NO_KEYCHAIN=1") + if request.AppleID != "" { + command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) + } + if request.Password != "" { + command.Env = append(command.Env, "EXPO_APPLE_PASSWORD="+request.Password) + } + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + } + + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper 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 @expo/apple-utils helper 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 @expo/apple-utils helper 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 +} + +func (a *Adapter) node() string { + if a.Node != "" { + return a.Node + } + + return "node" +} + +func (a *Adapter) npm() string { + if a.Npm != "" { + return a.Npm + } + + return "npm" +} + +var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) + +func (a *Adapter) checkNode(ctx context.Context) error { + output, err := exec.CommandContext(ctx, a.node(), "--version").Output() + if err != nil { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) + } + + return supportedNode(string(bytes.TrimSpace(output))) +} + +func supportedNode(version string) error { + match := nodeVersion.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("%w: could not read the Node.js version %q", apns.ErrUnavailable, version) + } + if major, _ := strconv.Atoi(match[1]); major < minimumNodeMajor { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later, found %s", apns.ErrUnavailable, minimumNodeMajor, version) + } + + return nil +} + +// install writes the embedded files into dir and runs npm ci when the +// lockfile changed or the dependencies are missing. The lockfile pins the +// exact package hashes, and --ignore-scripts keeps install scripts from +// running. +func (a *Adapter) install(ctx context.Context, dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + changed := false + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + contents, err := helperFiles.ReadFile("helper/" + name) + if err != nil { + return err + } + path := filepath.Join(dir, name) + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, contents) { + continue + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + return err + } + changed = true + } + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "@expo", "apple-utils", "package.json")); err == nil && !changed { + return nil + } + _, _, stderr := a.terminal() + command := exec.CommandContext(ctx, a.npm(), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command.Dir = dir + command.Stdout, command.Stderr = stderr, stderr + if err := command.Run(); err != nil { + // A failed install must not look complete on the next run. + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + + return fmt.Errorf("%w: npm ci failed in %s: %v", apns.ErrUnavailable, dir, err) + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go new file mode 100644 index 0000000000..c03ca3986b --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -0,0 +1,159 @@ +package expo + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedNode(t *testing.T) { + for version, supported := range map[string]bool{ + "v18.0.0": true, + "v22.11.0": true, + "v16.20.2": false, + "v8.9.0": false, + "garbage": false, + } { + err := supportedNode(version) + if (err == nil) != supported { + t.Errorf("supportedNode(%q) = %v", version, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedNode(%q) is not ErrUnavailable: %v", version, 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].Name != "Old" { + 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") + } +} + +// fakeTools writes node and npm stand-ins that log their arguments. The node +// stand-in answers --version and writes result to the --out file. +func fakeTools(t *testing.T, version, result string) (string, string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +echo "node $* EXPO_NO_KEYCHAIN=$EXPO_NO_KEYCHAIN EXPO_APPLE_ID=$EXPO_APPLE_ID" >> "` + log + `" +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '` + result + `' > "$2"; fi + shift +done +` + npm := `#!/bin/sh +echo "npm $*" >> "` + log + `" +mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json +` + for name, script := range map[string]string{"node": node, "npm": npm} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + + return filepath.Join(bin, "node"), filepath.Join(bin, "npm"), log +} + +func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentAll, + AppleID: "dev@example.com", + SessionDir: filepath.Join(t.TempDir(), "expo"), + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + } + + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "npm ci --ignore-scripts") { + t.Fatalf("calls:\n%s", calls) + } + for _, line := range lines[1:] { + if !strings.Contains(line, "helper.js --name Appwrite Push --out ") || + !strings.HasSuffix(line, "--team-id ABCDE12345 EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper call = %s", line) + } + } + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + if _, err := os.Stat(filepath.Join(request.SessionDir, name)); err != nil { + t.Errorf("%s was not installed: %v", name, err) + } + } +} + +func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { + node, npm, _ := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyNeedsARecentNode(t *testing.T) { + node, npm, log := fakeTools(t, "v16.20.2", `{}`) + adapter := &Adapter{Node: node, Npm: npm} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir()} + + if _, err := adapter.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran npm or the helper with an old Node.js") + } + + missing := &Adapter{Node: filepath.Join(t.TempDir(), "node")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing node err = %v", err) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js new file mode 100644 index 0000000000..339db2a27a --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -0,0 +1,77 @@ +'use strict'; + +// Creates a team-scoped APNs key through the Apple Developer portal with +// @expo/apple-utils, the library eas-cli uses for the same job. +// +// apple-utils asks for the Apple ID, the password and the two-factor code +// itself, so the CLI runs this script with the terminal attached. Its logging +// goes to stdout, so the result is written to the file named by --out instead. + +const fs = require('fs'); +const { Auth, Keys, InvalidUserCredentialsError } = require('@expo/apple-utils'); + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index].replace(/^--/, '')] = argv[index + 1]; + } + if (!values.out || !values.name) { + throw new Error('usage: helper.js [--team-id ] --name --out '); + } + + return values; +} + +function isMaxKeysError(error) { + return ( + error instanceof Keys.MaxKeysCreatedError || + /maximum allowed number of Keys/.test(error?.rawDump?.resultString ?? '') + ); +} + +async function createKey({ 'team-id': teamId, name }) { + // Without a team ID, apple-utils asks which team to use when there are several. + const authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + const context = authState.context; + if (teamId && context.teamId !== teamId) { + throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); + } + + let key; + try { + key = await Keys.createKeyAsync(context, { name, isApns: true }); + } catch (error) { + if (!isMaxKeysError(error)) { + throw error; + } + const keys = await Keys.getKeysAsync(context); + + return { + error: 'max-keys', + keys: keys.map(({ id, name, canRevoke }) => ({ id, name, canRevoke })), + }; + } + const p8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { keyId: key.id, teamId: context.teamId, p8 }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + let result; + try { + result = await createKey(options); + } catch (error) { + const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; + result = { error: kind, message: error?.message ?? String(error) }; + } + fs.writeFileSync(options.out, JSON.stringify(result), { mode: 0o600 }); + if (result.error) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error?.message ?? error); + process.exitCode = 1; +}); diff --git a/templates/cli/internal/apns/expo/helper/package-lock.json b/templates/cli/internal/apns/expo/helper/package-lock.json new file mode 100644 index 0000000000..cd61fd039f --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "appwrite-apple-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appwrite-apple-helper", + "dependencies": { + "@expo/apple-utils": "2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/apple-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.2.1.tgz", + "integrity": "sha512-Jr0yawuiJ83Lu76krKJYysXfvSC678YK8nIBHUoNjrOAQT392LttbfsoPA+8/zDBZOrfo+8eXgBq0a51b7wBVQ==", + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + } + } +} diff --git a/templates/cli/internal/apns/expo/helper/package.json b/templates/cli/internal/apns/expo/helper/package.json new file mode 100644 index 0000000000..2e4c5e4598 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package.json @@ -0,0 +1,11 @@ +{ + "name": "appwrite-apple-helper", + "private": true, + "description": "Creates an APNs key through the Apple Developer portal for appwrite init apns --create-key", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/apple-utils": "2.2.1" + } +} From 85da856e9bde229363b5e8ed5b1593f485e83829 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 13:58:20 +0530 Subject: [PATCH 2/3] feat(cli): offer the expo APNs key setup in init apns Registers the expo adapter, picked with --provider expo or APPWRITE_APNS_SETUP=expo. --- templates/cli/internal/cmd/initpush.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..81fdddf73d 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/expo" "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(expo.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From e3b53f6eb0b1e41041b50e28c080d72460eb83bc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:10:44 +0530 Subject: [PATCH 3/3] test(cli): cover the expo helper call without a team The CLI leaves the team out when none is known and lets the sign-in pick it. The test now checks that the helper then gets no --team-id. --- templates/cli/internal/apns/expo/adapter_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go index c03ca3986b..04dec07b07 100644 --- a/templates/cli/internal/apns/expo/adapter_test.go +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -121,8 +121,10 @@ func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { } } -func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { - node, npm, _ := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) +// Without a team, the helper gets no --team-id and apple-utils picks the +// account's only team or asks, the flow the CLI uses when no team is known. +func TestCreateKeyWithoutATeamNotesTheEnvironment(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} var logged []string request := apns.Request{ @@ -138,6 +140,12 @@ func TestCreateKeyNotesTheEnvironmentItCannotRestrict(t *testing.T) { if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { t.Errorf("logged %v", logged) } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + helper := lines[len(lines)-1] + if !strings.Contains(helper, "helper.js --name Appwrite Push --out ") || strings.Contains(helper, "--team-id") { + t.Errorf("helper call = %s", helper) + } } func TestCreateKeyNeedsARecentNode(t *testing.T) {