Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
80fbad0
feat(cli): add the apns package for APNs key setups
ArnabChatterjee20k Oct 9, 2026
0101320
feat(cli): init apns --create-key through a chosen APNs key setup
ArnabChatterjee20k Oct 9, 2026
4cfe2c0
fix(cli): use the created APNs key's ID, never --key-id
ArnabChatterjee20k Oct 9, 2026
f2d5d96
feat(cli): add Appwrite's own APNs key setup
ArnabChatterjee20k Oct 9, 2026
a02c29b
feat(cli): offer the appwrite APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
1020be5
feat(cli): add the expo APNs key setup on @expo/apple-utils
ArnabChatterjee20k Oct 9, 2026
85da856
feat(cli): offer the expo APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
f9692f7
feat(cli): add the fastlane APNs key setup on spaceship
ArnabChatterjee20k Oct 9, 2026
84a5d76
feat(cli): offer the fastlane APNs key setup in init apns
ArnabChatterjee20k Oct 9, 2026
b55c767
fix(cli): require fastlane 2.225.0 for the fastlane APNs key setup
ArnabChatterjee20k Oct 9, 2026
4ff11f5
fix(cli): do not count switching to a text message as a code attempt
ArnabChatterjee20k Oct 9, 2026
4ee56d8
fix(cli): send the hashcash date in UTC
ArnabChatterjee20k Oct 9, 2026
5dfc509
fix(cli): run a project's Bundler fastlane with bundle exec
ArnabChatterjee20k Oct 9, 2026
e3b53f6
test(cli): cover the expo helper call without a team
ArnabChatterjee20k Oct 9, 2026
b90e5bf
fix(cli): do not run another fastlane when the project pins one
ArnabChatterjee20k Oct 9, 2026
f900aaa
Merge branch 'messaging-provider-cli-apns-adapter-expo' into messagin…
ArnabChatterjee20k Oct 9, 2026
53d63b3
Merge branch 'messaging-provider-cli-apns-adapter-fastlane' into mess…
ArnabChatterjee20k Oct 9, 2026
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
90 changes: 90 additions & 0 deletions src/SDK/Language/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,96 @@ public function getFiles(): array
'destination' => 'internal/cmd/initpush_test.go',
'template' => 'cli/internal/cmd/initpush_test.go',
],
[
'scope' => 'copy',
'destination' => 'internal/apns/apns.go',
'template' => 'cli/internal/apns/apns.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/apns_test.go',
'template' => 'cli/internal/apns/apns_test.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/apnstest/apnstest.go',
'template' => 'cli/internal/apns/apnstest/apnstest.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/adapter.go',
'template' => 'cli/internal/apns/appwrite/adapter.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/adapter_test.go',
'template' => 'cli/internal/apns/appwrite/adapter_test.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/auth.go',
'template' => 'cli/internal/apns/appwrite/auth.go',
],
[
'scope' => 'copy',
'destination' => 'internal/apns/appwrite/jar.go',
'template' => 'cli/internal/apns/appwrite/jar.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/portal.go',
'template' => 'cli/internal/apns/appwrite/portal.go',
],
[
'scope' => 'copy',
'destination' => 'internal/apns/appwrite/srp.go',
'template' => 'cli/internal/apns/appwrite/srp.go',
],
[
'scope' => 'copy',
'destination' => 'internal/apns/appwrite/srp_test.go',
'template' => 'cli/internal/apns/appwrite/srp_test.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/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',
Expand Down
193 changes: 193 additions & 0 deletions templates/cli/internal/apns/apns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Package apns creates Apple Push Notification service (APNs) auth keys.
//
// Apple has no public API for creating APNs keys. The tools that automate it,
// fastlane's spaceship and @expo/apple-utils, sign in to the Apple Developer
// portal as a person would and call the portal's private API, and so does
// Appwrite's own implementation. Apple can change that API without notice, so
// each implementation is an Adapter, and which one runs is a setting rather
// than a release: when Apple breaks one, people switch to another.
//
// The package imports nothing from the CLI. Prompts and logging come in
// through the Request, so it can move to its own module and repository
// unchanged.
package apns

import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
)

// Environment is the APNs environment a key works in.
type Environment string

// The environments Apple offers when creating a key.
const (
EnvironmentAll Environment = "all"
EnvironmentProduction Environment = "production"
EnvironmentSandbox Environment = "sandbox"
)

// ParseEnvironment reads an environment name. Empty means all.
func ParseEnvironment(value string) (Environment, error) {
switch environment := Environment(strings.ToLower(strings.TrimSpace(value))); environment {
case "", EnvironmentAll:
return EnvironmentAll, nil
case EnvironmentProduction, EnvironmentSandbox:
return environment, nil
default:
return "", fmt.Errorf("unknown APNs environment %q: use production, sandbox or all", value)
}
}

// Key is a newly created APNs key. Apple lets its contents be downloaded once.
type Key struct {
KeyID string
TeamID string
P8 string
}

// ExistingKey is a key already on the team.
type ExistingKey struct {
ID string
Name string
CanRevoke bool
}

// MaxKeysError means the team already has as many APNs keys as Apple allows.
// Keys lists the team's keys when the adapter could read them.
type MaxKeysError struct {
Keys []ExistingKey
}

func (e *MaxKeysError) Error() string {
return "the Apple team already has the maximum number of APNs keys"
}

var (
// ErrUnavailable means the adapter cannot run here, for example because
// the runtime it needs is missing. Nothing was sent to Apple.
ErrUnavailable = errors.New("this APNs key setup is not available here")
// ErrInvalidCredentials means Apple refused the Apple ID or password.
ErrInvalidCredentials = errors.New("Apple did not accept the Apple ID and password")
)

// Asker asks the person at the terminal.
type Asker interface {
// Ask reads one line. secret hides what is typed.
Ask(question string, secret bool) (string, error)
// Choose returns the index of the chosen option.
Choose(question string, options []string) (int, error)
}

// Request describes the key to create.
type Request struct {
// TeamID is the Apple Developer team that owns the key. Empty lets the
// adapter use the account's only team, or ask which one.
TeamID string
Name string
Environment Environment
// AppleID and Password answer the sign-in questions when set.
AppleID string
Password string
// SessionDir is where an adapter may keep a signed-in session between
// runs, readable by the user only.
SessionDir string
Asker Asker
// Log prints a line for the person running the command.
Log func(format string, args ...any)
}

// Adapter creates APNs keys one way.
type Adapter interface {
// Name is how people pick the adapter, for example "appwrite".
Name() string
// CreateKey signs in, creates the key and downloads it. It returns
// ErrUnavailable when it cannot run here, ErrInvalidCredentials when
// Apple refuses the sign-in, and a *MaxKeysError when the team has no
// free key slot.
CreateKey(ctx context.Context, request Request) (Key, error)
}

var (
keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`)
teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`)
)

// CreateKey checks the request, runs the adapter, and checks what it returned,
// so every adapter is held to the same contract.
func CreateKey(ctx context.Context, adapter Adapter, request Request) (Key, error) {
environment, err := ParseEnvironment(string(request.Environment))
if err != nil {
return Key{}, err
}
request.Environment = environment
if strings.TrimSpace(request.Name) == "" {
return Key{}, errors.New("an APNs key needs a name")
}
if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) {
return Key{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID)
}
if request.Log == nil {
request.Log = func(string, ...any) {}
}

key, err := adapter.CreateKey(ctx, request)
if err != nil {
return Key{}, err
}
switch {
case !keyIDPattern.MatchString(key.KeyID):
return Key{}, fmt.Errorf("the %s APNs setup returned an invalid key ID %q", adapter.Name(), key.KeyID)
case !teamIDPattern.MatchString(key.TeamID):
return Key{}, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), key.TeamID)
case request.TeamID != "" && key.TeamID != request.TeamID:
return Key{}, fmt.Errorf("the %s APNs setup created key %s on team %s, not %s", adapter.Name(), key.KeyID, key.TeamID, request.TeamID)
case !strings.Contains(key.P8, "PRIVATE KEY"):
return Key{}, fmt.Errorf("the %s APNs setup created key %s but returned no .p8 contents", adapter.Name(), key.KeyID)
}

return key, nil
}

// Registry holds the adapters a build offers, by name.
type Registry struct {
adapters map[string]Adapter
}

// NewRegistry returns a registry holding adapters.
func NewRegistry(adapters ...Adapter) *Registry {
registry := &Registry{adapters: map[string]Adapter{}}
for _, adapter := range adapters {
registry.adapters[adapter.Name()] = adapter
}

return registry
}

// Names lists the registered adapters in order.
func (r *Registry) Names() []string {
names := make([]string, 0, len(r.adapters))
for name := range r.adapters {
names = append(names, name)
}
sort.Strings(names)

return names
}

// Get returns the adapter called name.
func (r *Registry) Get(name string) (Adapter, error) {
name = strings.ToLower(strings.TrimSpace(name))
if adapter, ok := r.adapters[name]; ok {
return adapter, nil
}
if len(r.adapters) == 0 {
return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", name)
}

return nil, fmt.Errorf("unknown APNs key setup %q: use one of %s", name, strings.Join(r.Names(), ", "))
}
Loading
Loading