Compare commits

..
Author SHA1 Message Date
Quinlan Jung 0aad0bec1d tmp 2021-11-04 14:53:20 -07:00
Quinlan Jung 18e55dafd4 [eas-cli] setup asc api key (#733)
* [eas-cli] setup asc api key

* setup -> set up

* pr feedback

* update CHANGELOG.md
2021-11-04 14:52:25 -07:00
Bartłomiej Klocek 8e013ab793 [eas-cli] Use actor displayName when printing build info (#730)
* [eas-cli] Use actor `displayName` when printing build info

* update CHANGELOG.md
2021-11-04 07:50:03 +01:00
Quinlan JungandDominik Sokal b88e79f0f3 [eas-cli] cleanup up creds prompt method (#728)
* [eas-cli] cleanup up creds prompt method

* update CHANGELOG.md

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-11-03 13:19:10 -07:00
Ryan Blunden 35b505120d Replace eas:secrets with eas:secret (#727) 2021-11-03 09:44:19 +01:00
Brent VatneandDominik Sokal 6807d3080b [eas-cli] Bump @expo/apple-utils to better handle some error messages (#723)
* [eas-cli] Bump @expo/apple-utils to better handle some error messages

* Update CHANGELOG

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>
2021-11-03 09:35:11 +01:00
Quinlan JungandDominik Sokal 0d1cfb3286 [eas-cli] support removal of asc api keys (#721)
* [eas-cli] support removal of asc api keys

* update CHANGELOG.md

* Update packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Update packages/eas-cli/src/credentials/ios/actions/RemoveAscApiKey.ts

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-11-02 21:03:34 -07:00
Quinlan Jung 0b9a5621fe fix changelog 2021-11-02 20:57:46 -07:00
Quinlan Jung 9bd15af02a [eas-cli] assign asc api keys to project (#719)
* [eas-cli] assign asc api keys to project

assign asc test

* fix compilation errors
2021-11-02 20:51:15 -07:00
Quinlan Jung aab3bf1330 [eas-cli] create + validate asc api key, infer issuer ids (#718)
* [eas-cli] create + validate asc api key, infer issuer ids

create asc tests

create asc key:

ascapikeyutils test

* update CHANGELOG.md

* some pr feedback

* API -> Api

* more pr feedbakc

* fix tsc
2021-11-02 20:41:17 -07:00
Expo CI 9e95218523 update CHANGELOG.md 2021-11-02 17:27:32 +00:00
Brent Vatne bb98e6de03 v0.34.1 2021-11-02 10:15:45 -07:00
Dominik Sokal 2604228233 add clean script 2021-11-02 13:41:47 +01:00
Dominik Sokal a7035d3469 clean up package.json 2021-11-02 13:37:00 +01:00
Dominik Sokal f6dce0e17c update CHANGELOG.md 2021-11-02 13:31:56 +01:00
Wojciech KozyraandDominik Sokal ccfa6f9fc3 [eas-cli] check if commit is required after dev client install (#722)
* check if commit is required after dev client install

* update CHANGELOG.md

* Update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2021-11-02 12:51:49 +01:00
Dominik Sokal 0ee8e2f967 Update CONTRIBUTING.md 2021-11-02 11:29:27 +01:00
Quinlan Jung 94dc4c6e93 [eas-cli] asc api key graphql type and print support (#717)
* [eas-cli] asc api key graphql type and print support

* gql

* gql

* update CHANGELOG.md
2021-11-01 14:56:07 -07:00
Brent Vatne 4144e387d6 [eas-cli] Add missing period 2021-11-01 11:39:15 -07:00
Expo CI de4e05e1e0 update CHANGELOG.md 2021-11-01 18:30:08 +00:00
55 changed files with 2273 additions and 457 deletions

No files matched your search

+29 -2
View File
@@ -6,13 +6,40 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
### 🎉 New features
- Add ASC Api Key generation workflow. ([#718](https://github.com/expo/eas-cli/pull/718) by [@quinlanj](https://github.com/quinlanj))
- Add support for removal of App Store Connect Api Keys. ([#721](https://github.com/expo/eas-cli/pull/721) by [@quinlanj](https://github.com/quinlanj))
- Allow users to assign an ASC Api Key to their project. ([#719](https://github.com/expo/eas-cli/pull/719) by [@quinlanj](https://github.com/quinlanj))
- Add setup support for ASC Api Keys. ([#733](https://github.com/expo/eas-cli/pull/733) by [@quinlanj](https://github.com/quinlanj))
- Show initiating user display name when selecting a build to submit. ([#730](https://github.com/expo/eas-cli/pull/730) by [@barthap](https://github.com/barthap))
### 🐛 Bug fixes
### 🧹 Chores
- Clean up credentials prompt method. ([#728](https://github.com/expo/eas-cli/pull/728) by [@quinlanj](https://github.com/quinlanj))
- Bump @expo/apple-utils to 0.0.0-alpha.26. ([#723](https://github.com/expo/eas-cli/pull/723] by [@brentvatne](https://github.com/brentvatne))
## [0.34.1](https://github.com/expo/eas-cli/releases/tag/0.34.1) - 2021-11-02
### 🐛 Bug fixes
- Don't show commit prompt in no-commit workflow after installing `expo-dev-client`. ([#722](https://github.com/expo/eas-cli/pull/722) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Add App Store Connect Api Key graphql type and print support. ([#717](https://github.com/expo/eas-cli/pull/717) by [@quinlanj](https://github.com/quinlanj))
## [0.34.0](https://github.com/expo/eas-cli/releases/tag/0.34.0) - 2021-11-01
### 🛠 Breaking changes
- `[EAS BUILD API]` Use new build job format. ([#701](https://github.com/expo/eas-cli/pull/701) and [#711](https://github.com/expo/eas-cli/pull/711) by [@dsokal](https://github.com/dsokal))
- Don't print logs to `stderr`. ([#708](https://github.com/expo/eas-cli/pull/708) by [@dsokal](https://github.com/dsokal))
- Remove automatic migration for legacy `eas.json` format introduced in [v0.22.2](https://github.com/expo/eas-cli/releases/tag/v0.22.2). ([#695](https://github.com/expo/eas-cli/pull/695) by [@wkozyra95](https://github.com/wkozyra95))
- Add required `cli` field in root of `eas.json` ([#695](https://github.com/expo/eas-cli/pull/695) by [@wkozyra95](https://github.com/wkozyra95))
### 🎉 New features
### 🐛 Bug fixes
- Better error message when eas.json is invalid. ([#707](https://github.com/expo/eas-cli/pull/707) by [@dsokal](https://github.com/dsokal))
+1 -1
View File
@@ -4,7 +4,7 @@
2. Wait, the `release` workflow on GitHub Actions is going to take care of the rest (including publishing packages to npm and updating the changelog).
3. Check the changelog. If any of the entries have been tagged with `[EAS BUILD API]`
- Open `package.json` and note the version of `@expo/eas-build-job` used in the eas-cli.
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commit/) for that version. In most cases, it will be the latest `Publish` commit.
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commits) for that version. In most cases, it will be the latest `Publish` commit.
- Find the `eas-cli-local-build-plugin` version published in that commit.
![example publish commit](./.gh-assets/eas-build-publish-commit.png)
- Run `npm dist-tags add eas-cli-local-build-plugin@VERSION latest` with the version from the previous step.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.34.0"
"version": "0.34.1"
}
+2 -1
View File
@@ -10,7 +10,8 @@
"eas": "packages/eas-cli/bin/run",
"lint": "eslint . --ext .ts --ext .js",
"release": "lerna version",
"test": "jest"
"test": "jest",
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@expo/oclif-dev-cli": "1.24.0-expo",
+443
View File
@@ -33,7 +33,265 @@ eas --help COMMAND
# Commands
<!-- commands -->
* [`eas account:login`](#eas-accountlogin)
* [`eas account:logout`](#eas-accountlogout)
* [`eas account:view`](#eas-accountview)
* [`eas analytics [STATUS]`](#eas-analytics-status)
* [`eas build`](#eas-build)
* [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id)
* [`eas build:configure`](#eas-buildconfigure)
* [`eas build:list`](#eas-buildlist)
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
* [`eas config`](#eas-config)
* [`eas credentials`](#eas-credentials)
* [`eas device:create`](#eas-devicecreate)
* [`eas device:list`](#eas-devicelist)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas diagnostics`](#eas-diagnostics)
* [`eas help [COMMAND]`](#eas-help-command)
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas secret:create`](#eas-secretcreate)
* [`eas secret:delete`](#eas-secretdelete)
* [`eas secret:list`](#eas-secretlist)
* [`eas submit`](#eas-submit)
* [`eas webhook:create`](#eas-webhookcreate)
* [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
* [`eas webhook:list`](#eas-webhooklist)
* [`eas webhook:update`](#eas-webhookupdate)
* [`eas webhook:view ID`](#eas-webhookview-id)
## `eas account:login`
log in with your Expo account
```
USAGE
$ eas account:login
ALIASES
$ eas login
```
_See code: [src/commands/account/login.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/account/login.js)_
## `eas account:logout`
log out
```
USAGE
$ eas account:logout
ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/account/logout.js)_
## `eas account:view`
show the username you are logged in as
```
USAGE
$ eas account:view
ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/account/view.js)_
## `eas analytics [STATUS]`
view or change analytics settings
```
USAGE
$ eas analytics [STATUS]
```
_See code: [src/commands/analytics.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/analytics.js)_
## `eas build`
start a build
```
USAGE
$ eas build
OPTIONS
-p, --platform=(android|ios|all)
--auto-submit Submit on build complete using the submit profile with the same name as the
build profile
--auto-submit-with-profile=PROFILE_NAME Submit on build complete using the submit profile with provided name
--clear-cache Clear cache before the build
--json Enable JSON output, non-JSON messages will be printed to stderr
--local Run build locally [experimental]
--non-interactive Run command in non-interactive mode
--profile=PROFILE_NAME Name of the build profile from eas.json. Defaults to "production" if defined
in eas.json.
--skip-project-configuration Skip project configuration
--[no-]wait Wait for build(s) to complete
```
_See code: [src/commands/build/index.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/build/index.js)_
## `eas build:cancel [BUILD_ID]`
Cancel a build.
```
USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [src/commands/build/cancel.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/build/cancel.js)_
## `eas build:configure`
Configure the project to support EAS Build.
```
USAGE
$ eas build:configure
OPTIONS
-p, --platform=(android|ios|all) Platform to configure
```
_See code: [src/commands/build/configure.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/build/configure.js)_
## `eas build:list`
list all builds for your project
```
USAGE
$ eas build:list
OPTIONS
--appBuildVersion=appBuildVersion
--appIdentifier=appIdentifier
--appVersion=appVersion
--buildProfile=buildProfile
--channel=channel
--distribution=(store|internal|simulator)
--gitCommitHash=gitCommitHash
--json Enable JSON output, non-JSON messages will be printed
to stderr
--limit=limit
--platform=(all|android|ios)
--runtimeVersion=runtimeVersion
--sdkVersion=sdkVersion
--status=(new|in-queue|in-progress|errored|finished|canceled)
```
_See code: [src/commands/build/list.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/build/list.js)_
## `eas build:view [BUILD_ID]`
view a build for your project
```
USAGE
$ eas build:view [BUILD_ID]
OPTIONS
--json Enable JSON output, non-JSON messages will be printed to stderr
```
_See code: [src/commands/build/view.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/build/view.js)_
## `eas config`
show the eas.json config
```
USAGE
$ eas config
OPTIONS
-p, --platform=(android|ios)
--profile=profile
```
_See code: [src/commands/config.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/config.js)_
## `eas credentials`
manage your credentials
```
USAGE
$ eas credentials
```
_See code: [src/commands/credentials.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/credentials.js)_
## `eas device:create`
register new Apple Devices to use for internal distribution
```
USAGE
$ eas device:create
```
_See code: [src/commands/device/create.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/device/create.js)_
## `eas device:list`
list all registered devices for your account
```
USAGE
$ eas device:list
OPTIONS
--apple-team-id=apple-team-id
```
_See code: [src/commands/device/list.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/device/list.js)_
## `eas device:view [UDID]`
view a device for your project
```
USAGE
$ eas device:view [UDID]
```
_See code: [src/commands/device/view.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/device/view.js)_
## `eas diagnostics`
log environment info to the console
```
USAGE
$ eas diagnostics
```
_See code: [src/commands/diagnostics.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/diagnostics.js)_
## `eas help [COMMAND]`
@@ -51,4 +309,189 @@ OPTIONS
```
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.3/src/commands/help.ts)_
## `eas project:info`
information about the current project
```
USAGE
$ eas project:info
```
_See code: [src/commands/project/info.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/project/info.js)_
## `eas project:init`
create or link an EAS project
```
USAGE
$ eas project:init
ALIASES
$ eas init
```
_See code: [src/commands/project/init.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/project/init.js)_
## `eas secret:create`
Create an environment secret on the current project or owner account.
```
USAGE
$ eas secret:create
OPTIONS
--force Delete and recreate existing secrets
--name=name Name of the secret
--scope=(account|project) [default: project] Scope for the secret
--value=value Value of the secret
```
_See code: [src/commands/secret/create.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/secret/create.js)_
## `eas secret:delete`
Delete an environment secret by ID.
```
USAGE
$ eas secret:delete
OPTIONS
--id=id ID of the secret to delete
DESCRIPTION
Unsure where to find the secret's ID? Run eas secret:list
```
_See code: [src/commands/secret/delete.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/secret/delete.js)_
## `eas secret:list`
Lists environment secrets available for your current app
```
USAGE
$ eas secret:list
```
_See code: [src/commands/secret/list.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/secret/list.js)_
## `eas submit`
submit build archive to app store
```
USAGE
$ eas submit
OPTIONS
-p, --platform=(android|ios|all)
--id=id ID of the build to submit
--latest Submit the latest build for specified platform
--non-interactive Run command in non-interactive mode
--path=path Path to the .apk/.aab/.ipa file
--profile=profile Name of the submit profile from eas.json. Defaults to "production" if defined in
eas.json.
--url=url App archive url
--verbose Always print logs from Submission Service
--[no-]wait Wait for submission to complete
DESCRIPTION
See how to configure submits with eas.json:
https://docs.expo.dev/submit/eas-json/
ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/submit.js)_
## `eas webhook:create`
Create a webhook on the current project.
```
USAGE
$ eas webhook:create
OPTIONS
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
header.
--url=url Webhook URL
```
_See code: [src/commands/webhook/create.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/webhook/create.js)_
## `eas webhook:delete [ID]`
Delete a webhook on the current project.
```
USAGE
$ eas webhook:delete [ID]
ARGUMENTS
ID ID of the webhook to delete
```
_See code: [src/commands/webhook/delete.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/webhook/delete.js)_
## `eas webhook:list`
List webhooks on the current project.
```
USAGE
$ eas webhook:list
OPTIONS
--event=(BUILD) Event type that triggers the webhook
```
_See code: [src/commands/webhook/list.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/webhook/list.js)_
## `eas webhook:update`
Create a webhook on the current project.
```
USAGE
$ eas webhook:update
OPTIONS
--event=(BUILD) [default: BUILD] Event type that triggers the webhook
--id=id (required) Webhook ID
--secret=secret Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature'
header.
--url=url Webhook URL
```
_See code: [src/commands/webhook/update.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/webhook/update.js)_
## `eas webhook:view ID`
View a webhook on the current project.
```
USAGE
$ eas webhook:view ID
ARGUMENTS
ID ID of the webhook to view
```
_See code: [src/commands/webhook/view.js](https://github.com/expo/eas-cli/blob/v0.34.1/packages/eas-cli/src/commands/webhook/view.js)_
<!-- commandsstop -->
+138 -272
View File
@@ -924,6 +924,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "displayName",
"description": "Best-effort human readable name for this actor for use in user interfaces during action attribution.\nFor example, when displaying a sentence indicating that actor X created a build or published an update.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "accounts",
"description": "Associated accounts",
@@ -1865,30 +1881,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleAppSpecificPasswords",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppleAppSpecificPassword",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appStoreConnectApiKeys",
"description": "",
@@ -2930,6 +2922,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "releaseChannels",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "builds",
"description": "(EAS Build) Builds associated with this app",
@@ -3637,6 +3653,46 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterPlatforms",
"description": "",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppPlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filterReleaseChannels",
"description": "",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
@@ -5119,6 +5175,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "displayName",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isSecondFactorAuthenticationEnabled",
"description": "",
@@ -8089,18 +8161,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appSpecificPassword",
"description": "",
"args": [],
"type": {
"kind": "OBJECT",
"name": "AppleAppSpecificPassword",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iosAppBuildCredentialsArray",
"description": "",
@@ -9566,109 +9626,6 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppleAppSpecificPassword",
"description": "",
"fields": [
{
"name": "id",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "account",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Account",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleIdUsername",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "passwordLabel",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AndroidAppCredentialsFilter",
@@ -11342,6 +11299,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nextInvoice",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "willCancel",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -11877,8 +11858,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "App directory no longer supported"
}
],
"inputFields": null,
@@ -13548,22 +13529,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleAppSpecificPassword",
"description": "Mutations that modify an App Specific Password for an Apple User Account",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppleAppSpecificPasswordMutation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appleDevice",
"description": "Mutations that modify an Apple Device",
@@ -15844,121 +15809,6 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppleAppSpecificPasswordMutation",
"description": "",
"fields": [
{
"name": "createAppleAppSpecificPassword",
"description": "Create an App Specific Password for an Apple User Account",
"args": [
{
"name": "appleAppSpecificPasswordInput",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AppleAppSpecificPasswordInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "accountId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppleAppSpecificPassword",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AppleAppSpecificPasswordInput",
"description": "",
"fields": null,
"inputFields": [
{
"name": "appleIdUsername",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "passwordLabel",
"description": "",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "appSpecificPassword",
"description": "",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppleDeviceMutation",
@@ -19877,6 +19727,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "displayName",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "accounts",
"description": "Associated accounts",
+5 -5
View File
@@ -1,18 +1,18 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.34.0",
"version": "0.34.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "0.0.0-alpha.25",
"@expo/apple-utils": "0.0.0-alpha.26",
"@expo/config": "6.0.3",
"@expo/config-plugins": "4.0.3",
"@expo/eas-build-job": "0.2.57",
"@expo/eas-json": "^0.34.0",
"@expo/eas-json": "^0.34.1",
"@expo/json-file": "8.2.33",
"@expo/pkcs12": "0.0.4",
"@expo/plist": "0.0.14",
@@ -113,7 +113,6 @@
"cli"
],
"license": "MIT",
"main": "build/index.js",
"oclif": {
"bin": "eas",
"commands": "./build/commands",
@@ -177,6 +176,7 @@
"typecheck": "tsc",
"test": "jest",
"version": "yarn oclif-dev readme && node patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml"
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"clean": "rm -rf dist build node_modules yarn-error.log"
}
}
@@ -11,6 +11,7 @@ import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli';
import { ProfileData } from '../../utils/profiles';
import { getVcsClient } from '../../vcs';
import { reviewAndCommitChangesAsync } from './repository';
export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
@@ -107,7 +108,9 @@ async function installExpoDevClientAsync(
Log.newLine();
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
Log.newLine();
await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive,
});
if (await getVcsClient().isCommitRequiredAsync()) {
await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive,
});
}
}
@@ -120,11 +120,5 @@ export function formatGraphQLBuild(build: BuildFragment): string {
}
const getActorName = (build: BuildFragment): string => {
if (!build.initiatingActor) {
return 'unknown';
} else if (build.initiatingActor.__typename === 'User') {
return build.initiatingActor.username;
} else {
return build.initiatingActor.firstName ?? 'unknown';
}
return build.initiatingActor?.displayName || 'unknown';
};
+1 -1
View File
@@ -423,7 +423,7 @@ export async function handleDeprecatedEasJsonAsync(
Log.log(
`If you want to continue using the Git integration, you can opt in with ${chalk.bold(
'cli.requireCommit'
)} in ${chalk.bold('eas.json')} or with the following prompt`
)} in ${chalk.bold('eas.json')} or with the following prompt.`
);
Log.newLine();
@@ -23,7 +23,7 @@ import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class EnvironmentSecretDelete extends EasCommand {
static description = `Delete an environment secret by ID.
Unsure where to find the secret's ID? Run ${chalk.bold('eas secrets:list')}`;
Unsure where to find the secret's ID? Run ${chalk.bold('eas secret:list')}`;
static flags = {
id: flags.string({
@@ -4,7 +4,7 @@ import { AuthCtx } from '../ios/appstore/authenticate';
export const testAuthCtx: AuthCtx = {
appleId: 'test-apple-id',
appleIdPassword: 'test-apple-password',
team: { id: 'test-team-id', name: 'test-team-name', inHouse: false },
team: { id: 'test-apple-team-identifier', name: 'test-team-name', inHouse: false },
};
export function getAppstoreMock(): AppStoreApi {
@@ -22,5 +22,9 @@ export function getAppstoreMock(): AppStoreApi {
createProvisioningProfileAsync: jest.fn(),
revokeProvisioningProfileAsync: jest.fn(),
createOrReuseAdhocProvisioningProfileAsync: jest.fn(),
createAscApiKeyAsync: jest.fn(),
getAscApiKeyAsync: jest.fn(),
revokeAscApiKeyAsync: jest.fn(),
listAscApiKeysAsync: jest.fn(),
} as any;
}
@@ -1,5 +1,9 @@
import { UserRole } from '@expo/apple-utils';
import {
AppFragment,
AppStoreConnectApiKeyFragment,
AppStoreConnectUserRole,
AppleAppIdentifierFragment,
AppleDistributionCertificateFragment,
AppleProvisioningProfileFragment,
@@ -10,12 +14,27 @@ import {
IosDistributionType,
} from '../../graphql/generated';
import * as IosGraphqlClient from '../ios/api/GraphqlClient';
import { DistributionCertificate, ProvisioningProfile } from '../ios/appstore/Credentials.types';
import {
AscApiKey,
DistributionCertificate,
ProvisioningProfile,
} from '../ios/appstore/Credentials.types';
import { Target } from '../ios/types';
import { testProvisioningProfileBase64 } from './fixtures-base64-data';
const now = new Date();
export const testAscApiKey: AscApiKey = {
keyId: 'test-keyIdentifier',
issuerId: 'test-issuer-id-from-apple',
teamId: 'test-team-id',
teamName: 'test-team-name',
name: 'test-name',
roles: [UserRole.ADMIN],
isRevoked: false,
keyP8: 'test-key-p8',
};
export const testProvisioningProfile: ProvisioningProfile = {
provisioningProfileId: 'test-id',
provisioningProfile: testProvisioningProfileBase64,
@@ -38,6 +57,17 @@ export const testAppFragment: AppFragment = {
slug: 'testapp',
};
export const testAscApiKeyFragment: AppStoreConnectApiKeyFragment = {
id: 'test-asc-api-key-id',
appleTeam: { ...testAppleTeamFragment },
issuerIdentifier: 'test-issuerIdentifier',
keyIdentifier: 'test-keyIdentifier',
name: 'test-name',
roles: [AppStoreConnectUserRole.Admin],
updatedAt: now,
createdAt: now,
};
export const testPushKey: ApplePushKeyFragment = {
id: 'test-push-key-id',
keyIdentifier: 'test-key-identifier',
@@ -112,6 +142,7 @@ export const testCommonIosAppCredentialsFragment: CommonIosAppCredentialsFragmen
appleAppIdentifier: testAppleAppIdentifierFragment,
pushKey: testPushKey,
iosAppBuildCredentialsList: [testIosAppBuildCredentialsFragment],
appStoreConnectApiKeyForSubmissions: testAscApiKeyFragment,
};
export const testAppleTeam = {
@@ -139,6 +170,9 @@ export function getNewIosApiMock(): { [key in keyof typeof IosGraphqlClient]?: a
createPushKeyAsync: jest.fn(),
getPushKeyForAppAsync: jest.fn(),
deletePushKeyAsync: jest.fn(),
createAscApiKeyAsync: jest.fn(),
deleteAscApiKeyAsync: jest.fn(),
getAscApiKeysForAccountAsync: jest.fn(() => []),
};
}
@@ -11,3 +11,9 @@ export class MissingCredentialsError extends Error {
super(message ?? 'Credentials are not set up.');
}
}
export class UnsupportedCredentialsChoiceError extends Error {
constructor(message: string, public choice: string) {
super(message);
}
}
@@ -1,15 +1,122 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import * as uuid from 'uuid';
import { AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { getCredentialsFromUserAsync } from '../../utils/promptForCredentials';
import { confirmAsync, promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { fromNow } from '../../../utils/date';
import { CredentialsContext } from '../../context';
import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../utils/promptForCredentials';
import { AscApiKey } from '../appstore/Credentials.types';
import { AscApiKeyPath, MinimalAscApiKey, ascApiKeyMetadataSchema } from '../credentials';
import {
AscApiKeyPath,
MinimalAscApiKey,
ascApiKeyIdSchema,
ascApiKeyIssuerIdSchema,
} from '../credentials';
import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey';
import { formatAppleTeam } from './AppleTeamUtils';
export async function promptForAscApiKeyAsync(): Promise<AscApiKeyPath> {
export enum AppStoreApiKeyPurpose {
SUBMISSION_SERVICE = 'EAS Submit',
}
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
const { keyId, keyP8Path } = await promptForKeyP8AndIdAsync();
const bestEffortIssuerId = await getBestEffortIssuerIdAsync(ctx, keyId);
if (bestEffortIssuerId) {
Log.log(`Detected Issuer ID: ${bestEffortIssuerId}`);
return { keyId, issuerId: bestEffortIssuerId, keyP8Path };
}
const issuerId = await promptForIssuerIdAsync();
return { keyId, issuerId, keyP8Path };
}
export async function promptForIssuerIdAsync(): Promise<string> {
Log.log(chalk.bold('An App Store Connect Issuer ID is required'));
Log.log(
`If you're not sure what this is or how to find yours, ${learnMore(
'https://expo.fyi/asc-issuer-id'
)}`
);
// Do not perform uuid validation - Apple's issuerIds are not RFC4122 compliant
const { issuerId } = await getCredentialsFromUserAsync(ascApiKeyIssuerIdSchema);
return issuerId;
}
export async function getMinimalAscApiKeyAsync(ascApiKey: AscApiKey): Promise<MinimalAscApiKey> {
return {
...ascApiKey,
issuerId: ascApiKey.issuerId ?? (await promptForIssuerIdAsync()),
};
}
export async function provideOrGenerateAscApiKeyAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<MinimalAscApiKey> {
if (ctx.nonInteractive) {
return await generateAscApiKeyAsync(ctx, purpose);
}
const userProvided = await promptForAscApiKeyAsync(ctx);
if (!userProvided) {
return await generateAscApiKeyAsync(ctx, purpose);
}
if (!ctx.appStore.authCtx) {
Log.warn('Unable to validate App Store Connect Api Key, you are not authenticated with Apple.');
return userProvided;
}
const isValidAndTracked = await isAscApiKeyValidAndTrackedAsync(ctx, userProvided);
if (isValidAndTracked) {
return userProvided;
}
const useUserProvided = await confirmAsync({
message: `App Store Connect Api Key with ID ${userProvided.keyId} is not valid on Apple's servers. Proceed anyway?`,
});
if (useUserProvided) {
return userProvided;
}
return await provideOrGenerateAscApiKeyAsync(ctx, purpose);
}
async function generateAscApiKeyAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<MinimalAscApiKey> {
await ctx.appStore.ensureAuthenticatedAsync();
const ascApiKey = await ctx.appStore.createAscApiKeyAsync({
nickname: getAscApiKeyName(purpose),
});
return await getMinimalAscApiKeyAsync(ascApiKey);
}
export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
const nameParts = ['[Expo]', purpose, nanoid(10)];
return nameParts.join(' ');
}
async function promptForAscApiKeyAsync(ctx: CredentialsContext): Promise<MinimalAscApiKey | null> {
const shouldAutoGenerateCredentials = await shouldAutoGenerateCredentialsAsync(ascApiKeyIdSchema);
if (shouldAutoGenerateCredentials) {
return null;
}
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
const { keyP8Path, keyId, issuerId } = ascApiKeyPath;
return { keyP8: await fs.readFile(keyP8Path, 'utf-8'), keyId, issuerId };
}
async function promptForKeyP8AndIdAsync(): Promise<Pick<AscApiKeyPath, 'keyP8Path' | 'keyId'>> {
Log.log(
chalk.bold('An App Store Connect Api key is required to upload your app to the Apple App Store')
);
@@ -39,33 +146,108 @@ export async function promptForAscApiKeyAsync(): Promise<AscApiKeyPath> {
});
const regex = /^AuthKey_(?<keyId>\w+)\.p8$/; // Common ASC Api file name downloaded from Apple
const bestEffortKeyId = path.basename(keyP8Path).match(regex)?.groups?.keyId;
const ascApiKeyMetadata = await getCredentialsFromUserAsync(ascApiKeyMetadataSchema, {
const { keyId } = await getCredentialsFromUserAsync(ascApiKeyIdSchema, {
keyId: bestEffortKeyId,
});
return { ...ascApiKeyMetadata, keyP8Path };
return { keyId, keyP8Path };
}
export async function promptForIssuerIdAsync(): Promise<string> {
Log.log(chalk.bold('An App Store Connect Issuer ID is required'));
Log.log(
`If you're not sure what this is or how to find yours, ${learnMore(
'https://expo.fyi/asc-issuer-id'
)}`
);
async function getBestEffortIssuerIdAsync(
ctx: CredentialsContext,
ascApiKeyId: string
): Promise<string | null> {
if (!ctx.appStore.authCtx) {
return null;
}
const ascApiKeyInfo = await ctx.appStore.getAscApiKeyAsync(ascApiKeyId);
return ascApiKeyInfo?.issuerId ?? null;
}
const { issuerId } = await promptAsync({
type: 'text',
name: 'issuerId',
message: 'App Store Connect Issuer ID:',
validate: (input: string) => uuid.validate(input),
export async function getAscApiKeysFromAccountAsync(
ctx: CredentialsContext,
account: Account,
{ filterDifferentAppleTeam }: { filterDifferentAppleTeam?: boolean } = {}
): Promise<AppStoreConnectApiKeyFragment[]> {
const ascApiKeysForAccount = await ctx.ios.getAscApiKeysForAccountAsync(account);
if (!filterDifferentAppleTeam) {
return ascApiKeysForAccount;
} else {
return filterKeysFromDifferentAppleTeam(ctx, ascApiKeysForAccount);
}
}
export async function selectAscApiKeysFromAccountAsync(
ctx: CredentialsContext,
account: Account,
{ filterDifferentAppleTeam }: { filterDifferentAppleTeam?: boolean } = {}
): Promise<AppStoreConnectApiKeyFragment | null> {
const ascApiKeysForAccount = await getAscApiKeysFromAccountAsync(ctx, account, {
filterDifferentAppleTeam,
});
return issuerId;
if (ascApiKeysForAccount.length === 0) {
if (filterDifferentAppleTeam) {
const maybeAppleTeamId = ctx.appStore.authCtx?.team.id;
Log.warn(
`There are no App Store Connect Api Keys in your EAS account${
maybeAppleTeamId ? ` matching Apple Team ID: ${maybeAppleTeamId}.` : '.'
}`
);
} else {
Log.warn(`There are no App Store Connect Api Keys available in your EAS account.`);
}
return null;
}
return selectAscApiKeysAsync(ascApiKeysForAccount);
}
export async function getMinimalAscApiKeyAsync(ascApiKey: AscApiKey): Promise<MinimalAscApiKey> {
return {
...ascApiKey,
issuerId: ascApiKey.issuerId ?? (await promptForIssuerIdAsync()),
};
async function selectAscApiKeysAsync(
ascApiKeys: AppStoreConnectApiKeyFragment[]
): Promise<AppStoreConnectApiKeyFragment | null> {
const sortedAscApiKeys = sortAscApiKeysByUpdatedAtDesc(ascApiKeys);
const { chosenAscApiKey } = await promptAsync({
type: 'select',
name: 'chosenAscApiKey',
message: 'Select an Api Key from the list:',
choices: sortedAscApiKeys.map(ascApiKey => ({
title: formatAscApiKey(ascApiKey),
value: ascApiKey,
})),
});
return chosenAscApiKey;
}
function filterKeysFromDifferentAppleTeam(
ctx: CredentialsContext,
keys: AppStoreConnectApiKeyFragment[]
): AppStoreConnectApiKeyFragment[] {
if (!ctx.appStore.authCtx) {
return keys;
}
const teamId = ctx.appStore.authCtx.team.id;
return keys.filter(key => !key.appleTeam || key.appleTeam?.appleTeamIdentifier === teamId);
}
export function sortAscApiKeysByUpdatedAtDesc(
keys: AppStoreConnectApiKeyFragment[]
): AppStoreConnectApiKeyFragment[] {
return keys.sort(
(keyA, keyB) => new Date(keyB.updatedAt).getTime() - new Date(keyA.updatedAt).getTime()
);
}
export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): string {
const { keyIdentifier, appleTeam, name, updatedAt } = ascApiKey;
let line: string = '';
line += `Key ID: ${keyIdentifier}`;
if (name) {
line += chalk.gray(`\n Name: ${name}`);
}
if (appleTeam) {
line += chalk.gray(`\n ${formatAppleTeam(appleTeam)}`);
}
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
return line;
}
@@ -0,0 +1,38 @@
import {
AppStoreConnectApiKeyFragment,
CommonIosAppCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { CredentialsContext } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { AppStoreApiKeyPurpose } from './AscApiKeyUtils';
export class AssignAscApiKey {
constructor(private app: AppLookupParams) {}
public async runAsync(
ctx: CredentialsContext,
ascApiKey: AppStoreConnectApiKeyFragment,
purpose: AppStoreApiKeyPurpose
): Promise<CommonIosAppCredentialsFragment> {
const appleTeam =
(await resolveAppleTeamIfAuthenticatedAsync(ctx, this.app)) ?? ascApiKey.appleTeam ?? null;
const appCredentials = await ctx.ios.createOrGetIosAppCredentialsWithCommonFieldsAsync(
this.app,
{ appleTeam: appleTeam ?? undefined }
);
let updatedAppCredentials;
if (purpose === AppStoreApiKeyPurpose.SUBMISSION_SERVICE) {
updatedAppCredentials = await ctx.ios.updateIosAppCredentialsAsync(appCredentials, {
ascApiKeyIdForSubmissions: ascApiKey.id,
});
} else {
throw new Error(`${purpose} is not yet supported.`);
}
Log.succeed(
`App Store Connect Api Key assigned to ${this.app.projectName}: ${this.app.bundleIdentifier} for ${purpose}.`
);
return updatedAppCredentials;
}
}
@@ -0,0 +1,23 @@
import { AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { AppStoreApiKeyPurpose, provideOrGenerateAscApiKeyAsync } from './AscApiKeyUtils';
export class CreateAscApiKey {
constructor(private account: Account) {}
public async runAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<AppStoreConnectApiKeyFragment> {
if (ctx.nonInteractive) {
throw new Error(`A new App Store Connect Api Key cannot be created in non-interactive mode.`);
}
const ascApiKey = await provideOrGenerateAscApiKeyAsync(ctx, purpose);
const result = await ctx.ios.createAscApiKeyAsync(this.account, ascApiKey);
Log.succeed('Created App Store Connect Api Key');
return result;
}
}
@@ -159,7 +159,7 @@ export async function provideOrGenerateDistributionCertificateAsync(
if (userProvided) {
if (!ctx.appStore.authCtx) {
Log.warn(
'Unable to validate distribution certificate due to insufficient Apple Credentials'
'Unable to validate distribution certificate, you are not authenticated with Apple.'
);
return userProvided;
} else {
@@ -19,7 +19,7 @@ export async function provideOrGeneratePushKeyAsync(ctx: CredentialsContext): Pr
const userProvided = await promptForPushKeyAsync(ctx);
if (userProvided) {
if (!ctx.appStore.authCtx) {
Log.warn('Unable to validate push key due to insufficient Apple Credentials');
Log.warn('Unable to validate push key, you are not authenticated with Apple.');
return userProvided;
} else {
const isValidAndTracked = await isPushKeyValidAndTrackedAsync(ctx, userProvided);
@@ -0,0 +1,53 @@
import { AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { selectAscApiKeysFromAccountAsync } from './AscApiKeyUtils';
export class SelectAndRemoveAscApiKey {
constructor(private account: Account) {}
async runAsync(ctx: CredentialsContext): Promise<void> {
const selected = await selectAscApiKeysFromAccountAsync(ctx, this.account);
if (selected) {
await new RemoveAscApiKey(this.account, selected).runAsync(ctx);
Log.succeed('Removed App Store Connect Api Key');
Log.newLine();
}
}
}
export class RemoveAscApiKey {
constructor(private account: Account, private ascApiKey: AppStoreConnectApiKeyFragment) {}
public async runAsync(ctx: CredentialsContext): Promise<void> {
if (ctx.nonInteractive) {
throw new Error(`Cannot remove App Store Connect Api Keys in non-interactive mode.`);
}
// TODO(quin): add an extra edge on AppStoreConnectApiKey to find the apps using it
const confirm = await confirmAsync({
message: `Deleting this Api Key may affect your projects that rely on it. Do you want to continue?`,
});
if (!confirm) {
Log.log('Aborting');
return;
}
Log.log('Removing Api Key');
await ctx.ios.deleteAscApiKeyAsync(this.ascApiKey.id);
let shouldRevoke = false;
if (!ctx.nonInteractive) {
shouldRevoke = await confirmAsync({
message: `Do you also want to revoke this Api Key on the Apple Developer Portal?`,
});
} else if (ctx.nonInteractive) {
Log.log('Skipping Api Key revocation on the Apple Developer Portal.');
}
if (shouldRevoke) {
await ctx.appStore.revokeAscApiKeyAsync(this.ascApiKey.keyIdentifier);
}
}
}
@@ -0,0 +1,148 @@
import assert from 'assert';
import nullthrows from 'nullthrows';
import {
AppStoreConnectApiKeyFragment,
CommonIosAppCredentialsFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync, promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import {
MissingCredentialsNonInteractiveError,
UnsupportedCredentialsChoiceError,
} from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';
import { getValidAndTrackedAscApiKeysAsync } from '../validators/validateAscApiKey';
import {
AppStoreApiKeyPurpose,
formatAscApiKey,
getAscApiKeysFromAccountAsync,
selectAscApiKeysFromAccountAsync,
sortAscApiKeysByUpdatedAtDesc,
} from './AscApiKeyUtils';
import { AssignAscApiKey } from './AssignAscApiKey';
import { CreateAscApiKey } from './CreateAscApiKey';
export enum SetupAscApiKeyChoice {
GENERATE = 'GENERATE',
USE_EXISTING = 'USE_EXISTING',
}
export class SetUpAscApiKey {
public choices: { title: string; value: string }[] = [
{
title: '[Choose an existing key]',
value: SetupAscApiKeyChoice.USE_EXISTING,
},
{ title: '[Add a new key]', value: SetupAscApiKeyChoice.GENERATE },
];
constructor(private app: AppLookupParams, private purpose: AppStoreApiKeyPurpose) {}
public async runAsync(ctx: CredentialsContext): Promise<CommonIosAppCredentialsFragment> {
const isKeySetup = await this.isAscApiKeySetupAsync(ctx, this.purpose);
if (isKeySetup) {
Log.succeed('App Store Connect Api Key already set up.');
return nullthrows(
await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync(this.app),
'iosAppCredentials cannot be null if App Store Connect Api Key is already set up'
);
}
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'App Store Connect Api Keys cannot be set up in --non-interactive mode.'
);
}
const keysForAccount = await getAscApiKeysFromAccountAsync(ctx, this.app.account, {
filterDifferentAppleTeam: true,
});
const maybeAutoselectedKey = await this.doBestEffortAutoselectAsync(ctx, keysForAccount);
if (maybeAutoselectedKey) {
return await new AssignAscApiKey(this.app).runAsync(ctx, maybeAutoselectedKey, this.purpose);
}
const availableChoices =
keysForAccount.length === 0
? this.choices.filter(choice => choice.value !== SetupAscApiKeyChoice.USE_EXISTING)
: this.choices;
const ascApiKey = await this.processChoicesAsync(ctx, this.purpose, availableChoices);
return await new AssignAscApiKey(this.app).runAsync(ctx, ascApiKey, this.purpose);
}
private async doBestEffortAutoselectAsync(
ctx: CredentialsContext,
keysForAccount: AppStoreConnectApiKeyFragment[]
): Promise<AppStoreConnectApiKeyFragment | null> {
if (!ctx.appStore.authCtx) {
return null;
}
if (keysForAccount.length === 0) {
return null;
}
// only provide autoselect if we can find a key that is certainly valid
const validKeys = await getValidAndTrackedAscApiKeysAsync(ctx, keysForAccount);
if (validKeys.length === 0) {
return null;
}
const [autoselectedKey] = sortAscApiKeysByUpdatedAtDesc(validKeys);
const useAutoselected = await confirmAsync({
message: `Reuse this App Store Connect Api Key?\n${formatAscApiKey(autoselectedKey)}`,
});
if (useAutoselected) {
Log.log(`Using App Store Connect Api Key with ID ${autoselectedKey.keyIdentifier}`);
return autoselectedKey;
} else {
return null;
}
}
private async isAscApiKeySetupAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<boolean> {
const appCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync(this.app);
if (purpose !== AppStoreApiKeyPurpose.SUBMISSION_SERVICE) {
throw new Error(`App Store Connect Api Key setup is not yet supported for ${purpose}.`);
}
return !!appCredentials?.appStoreConnectApiKeyForSubmissions;
}
private async processChoicesAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose,
choices: { title: string; value: string }[]
): Promise<AppStoreConnectApiKeyFragment> {
assert(choices.length > 0, 'SetupAscApiKey: There must be at least one choice');
let choice: string;
if (choices.length === 1) {
choice = choices[0].value;
} else {
const result = await promptAsync({
type: 'select',
name: 'choice',
message: 'Select the App Store Connect Api Key to use for your project:',
choices: this.choices,
});
choice = result.choice;
}
if (choice === SetupAscApiKeyChoice.GENERATE) {
return await new CreateAscApiKey(this.app.account).runAsync(ctx, purpose);
} else if (choice === SetupAscApiKeyChoice.USE_EXISTING) {
const selectedAscApiKey = await selectAscApiKeysFromAccountAsync(ctx, this.app.account, {
filterDifferentAppleTeam: true,
});
if (!selectedAscApiKey) {
return await this.processChoicesAsync(ctx, purpose, choices);
}
return selectedAscApiKey;
}
throw new UnsupportedCredentialsChoiceError(
`AscApiKey Setup does not support choice:${choice}`,
choice
);
}
}
@@ -0,0 +1,61 @@
import chalk from 'chalk';
import wrapAnsi from 'wrap-ansi';
import { CommonIosAppCredentialsFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import { UnsupportedCredentialsChoiceError } from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';
import { AppStoreApiKeyPurpose } from './AscApiKeyUtils';
import { SetupAscApiKey } from './SetupAscApiKey';
const PROMPT_FOR_APP_SPECIFIC_PASSWORD = 'PROMPT_FOR_APP_SPECIFIC_PASSWORD';
export class SetupSubmissionCredential {
private setupAscApiKeyAction;
constructor(app: AppLookupParams) {
this.setupAscApiKeyAction = new SetupAscApiKey(app, AppStoreApiKeyPurpose.SUBMISSION_SERVICE);
this.setupAscApiKeyAction.choices = this.setupAscApiKeyAction.choices.concat({
title: '[Enter an App Specific Password]',
value: PROMPT_FOR_APP_SPECIFIC_PASSWORD,
});
}
public async runAsync(
ctx: CredentialsContext
): Promise<CommonIosAppCredentialsFragment | string> {
try {
return await this.setupAscApiKeyAction.runAsync(ctx);
} catch (e) {
if (e instanceof UnsupportedCredentialsChoiceError) {
const choice = e.choice;
if (choice === PROMPT_FOR_APP_SPECIFIC_PASSWORD) {
return await this.promptForAppSpecificPasswordAsync();
}
}
throw e;
}
}
async promptForAppSpecificPasswordAsync(): Promise<string> {
Log.addNewLineIfNone();
Log.warn(
wrapAnsi(
`This option will be deprecated soon. You will still be able to provide your password with the ${chalk.bold(
'EXPO_APPLE_APP_SPECIFIC_PASSWORD'
)} environment variable.`,
process.stdout.columns || 80
)
);
Log.log(`Please enter your Apple app-specific password.`);
Log.log(learnMore('https://expo.fyi/apple-app-specific-password'));
const { appSpecificPassword } = await promptAsync({
name: 'appSpecificPassword',
message: 'Your Apple app-specific password:',
type: 'password',
validate: (val: string) => val !== '' || 'Apple app-specific password cannot be empty!',
});
return appSpecificPassword;
}
}
@@ -0,0 +1,90 @@
import { asMock } from '../../../../__tests__/utils';
import { promptAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKey } from '../../../__tests__/fixtures-ios';
import { getCredentialsFromUserAsync } from '../../../utils/promptForCredentials';
import {
AppStoreApiKeyPurpose,
getAscApiKeyName,
promptForAscApiKeyPathAsync,
} from '../AscApiKeyUtils';
jest.mock('../../../../prompts');
jest.mock('../../../utils/promptForCredentials');
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
return Object.keys(obj) as K[];
}
afterEach(() => {
asMock(promptAsync).mockClear();
asMock(getCredentialsFromUserAsync).mockClear();
});
describe(getAscApiKeyName, () => {
// Apple enforces a 30 char limit on this name
it('produces a name under 30 chars', async () => {
for (const value of enumKeys(AppStoreApiKeyPurpose)) {
const purpose = AppStoreApiKeyPurpose[value];
const name = getAscApiKeyName(purpose);
expect(name.length < 30).toBe(true);
}
});
});
describe(promptForAscApiKeyPathAsync, () => {
it('prompts for keyId, keyP8Path and issuerId when user is not authenticated to Apple', async () => {
asMock(promptAsync).mockImplementationOnce(() => ({
keyP8Path: '/asc-api-key.p8',
}));
asMock(getCredentialsFromUserAsync).mockImplementation(() => ({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => {
throw new Error('this should not be called');
}),
authCtx: null,
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
keyP8Path: '/asc-api-key.p8',
});
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
});
it('prompts for keyId, keyP8Path and detects issuerId when user is authenticated to Apple', async () => {
asMock(promptAsync).mockImplementationOnce(() => ({
keyP8Path: '/asc-api-key.p8',
}));
asMock(getCredentialsFromUserAsync).mockImplementation(() => ({
keyId: 'test-key-id',
}));
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
authCtx: testAuthCtx,
getAscApiKeyAsync: jest.fn(() => testAscApiKey),
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
issuerId: 'test-issuer-id-from-apple',
keyP8Path: '/asc-api-key.p8',
});
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId
expect(asMock(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
});
});
@@ -0,0 +1,46 @@
import { findApplicationTarget } from '../../../../project/ios/target';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKeyFragment, testTargets } from '../../../__tests__/fixtures-ios';
import { AppStoreApiKeyPurpose } from '../AscApiKeyUtils';
import { AssignAscApiKey } from '../AssignAscApiKey';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
describe(AssignAscApiKey, () => {
it('assigns an App Store Connect Api Key in Interactive Mode for EAS Submit', async () => {
const ctx = createCtxMock({
nonInteractive: false,
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const assignAscApiKeyAction = new AssignAscApiKey(appLookupParams);
await assignAscApiKeyAction.runAsync(
ctx,
testAscApiKeyFragment,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
// expect app credentials to be fetched/created, then updated
expect(ctx.ios.createOrGetIosAppCredentialsWithCommonFieldsAsync).toHaveBeenCalledTimes(1);
expect(ctx.ios.updateIosAppCredentialsAsync).toHaveBeenCalledTimes(1);
});
it('works in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const assignAscApiKeyAction = new AssignAscApiKey(appLookupParams);
await assignAscApiKeyAction.runAsync(
ctx,
testAscApiKeyFragment,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
// dont fail if users are running in non-interactive mode
await expect(
assignAscApiKeyAction.runAsync(
ctx,
testAscApiKeyFragment,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
)
).resolves.not.toThrowError();
});
});
@@ -0,0 +1,44 @@
import { asMock } from '../../../../__tests__/utils';
import { findApplicationTarget } from '../../../../project/ios/target';
import { confirmAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKey, testTargets } from '../../../__tests__/fixtures-ios';
import { AppStoreApiKeyPurpose } from '../AscApiKeyUtils';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { CreateAscApiKey } from '../CreateAscApiKey';
jest.mock('../../../../prompts');
asMock(confirmAsync).mockImplementation(() => true);
describe(CreateAscApiKey, () => {
it('creates a App Store Api Key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
authCtx: testAuthCtx,
createAscApiKeyAsync: jest.fn(() => testAscApiKey),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const createAscApiKeyAction = new CreateAscApiKey(appLookupParams.account);
await createAscApiKeyAction.runAsync(ctx, AppStoreApiKeyPurpose.SUBMISSION_SERVICE);
// expect api key to be created on expo servers
expect(asMock(ctx.ios.createAscApiKeyAsync).mock.calls.length).toBe(1);
// expect api key to be created on apple portal
expect(asMock(ctx.appStore.createAscApiKeyAsync).mock.calls.length).toBe(1);
});
it('errors in Non Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const createAscApiKeyAction = new CreateAscApiKey(appLookupParams.account);
await expect(
createAscApiKeyAction.runAsync(ctx, AppStoreApiKeyPurpose.SUBMISSION_SERVICE)
).rejects.toThrowError();
});
});
@@ -0,0 +1,33 @@
import { asMock } from '../../../../__tests__/utils';
import { findApplicationTarget } from '../../../../project/ios/target';
import { confirmAsync } from '../../../../prompts';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { testAscApiKeyFragment, testTargets } from '../../../__tests__/fixtures-ios';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { RemoveAscApiKey } from '../RemoveAscApiKey';
jest.mock('../../../../prompts');
asMock(confirmAsync).mockImplementation(() => true);
describe(RemoveAscApiKey, () => {
it('removes an Asc Api Key', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const removeAscApiKeyAction = new RemoveAscApiKey(
appLookupParams.account,
testAscApiKeyFragment
);
await removeAscApiKeyAction.runAsync(ctx);
expect(ctx.ios.deleteAscApiKeyAsync).toHaveBeenCalledTimes(1);
expect(ctx.appStore.revokeAscApiKeyAsync).toHaveBeenCalledTimes(1);
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const removeAscApiKeyAction = new RemoveAscApiKey(
appLookupParams.account,
testAscApiKeyFragment
);
await expect(removeAscApiKeyAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -0,0 +1,129 @@
import { asMock } from '../../../../__tests__/utils';
import { findApplicationTarget } from '../../../../project/ios/target';
import { confirmAsync, promptAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
getNewIosApiMock,
testAscApiKey,
testAscApiKeyFragment,
testCommonIosAppCredentialsFragment,
testTargets,
} from '../../../__tests__/fixtures-ios';
import { AppStoreApiKeyPurpose } from '../AscApiKeyUtils';
import { getAppLookupParamsFromContext } from '../BuildCredentialsUtils';
import { SetUpAscApiKey, SetupAscApiKeyChoice } from '../SetUpAscApiKey';
jest.mock('../../../../prompts');
asMock(confirmAsync).mockImplementation(() => true);
describe(SetUpAscApiKey, () => {
it('skips setting up a ASC Api Key if it is already configured', async () => {
const ctx = createCtxMock({
nonInteractive: false,
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const setupAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
await setupAscApiKeyAction.runAsync(ctx);
// expect not to create a new ASC Api Key on expo servers
expect(asMock(ctx.ios.createAscApiKeyAsync).mock.calls.length).toBe(0);
// expect configuration not to be updated with a new ASC Api Key
expect(asMock(ctx.ios.updateIosAppCredentialsAsync).mock.calls.length).toBe(0);
});
it('sets up a ASC Api Key, creating a new one if there are no existing ASC Api Keys', async () => {
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
createAscApiKeyAsync: jest.fn(() => testAscApiKey),
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithCommonFieldsAsync: jest.fn(),
createAscApiKeyAsync: jest.fn(() => testAscApiKeyFragment),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const setupAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
await setupAscApiKeyAction.runAsync(ctx);
// expect to create a new ASC Api Key on expo servers
expect(asMock(ctx.ios.createAscApiKeyAsync).mock.calls.length).toBe(1);
// expect configuration to be updated with a new ASC Api Key
expect(asMock(ctx.ios.updateIosAppCredentialsAsync).mock.calls.length).toBe(1);
});
it('sets up a ASC Api Key, use autoselected ASC Api Key when there are existing keys', async () => {
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
authCtx: testAuthCtx,
listAscApiKeysAsync: jest.fn(() => [testAscApiKey]),
},
ios: {
...getNewIosApiMock(),
getAscApiKeysForAccountAsync: jest.fn(() => [testAscApiKeyFragment]),
},
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const setupAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
await setupAscApiKeyAction.runAsync(ctx);
// expect not to create a new ASC Api Key on expo servers, we are using the existing one
expect(asMock(ctx.ios.createAscApiKeyAsync).mock.calls.length).toBe(0);
// expect configuration to be updated with a new ASC Api Key
expect(asMock(ctx.ios.updateIosAppCredentialsAsync).mock.calls.length).toBe(1);
});
it('sets up a ASC Api Key, allowing user to choose existing keys', async () => {
const ctx = createCtxMock({
nonInteractive: false,
ios: {
...getNewIosApiMock(),
getAscApiKeysForAccountAsync: jest.fn(() => [testAscApiKeyFragment]),
},
});
asMock(promptAsync).mockImplementation(() => ({
choice: SetupAscApiKeyChoice.USE_EXISTING,
chosenAscApiKey: testAscApiKeyFragment,
}));
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const setupAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
await setupAscApiKeyAction.runAsync(ctx);
// expect not to create a new ASC Api Key on expo servers, we are using the existing one
expect(asMock(ctx.ios.createAscApiKeyAsync).mock.calls.length).toBe(0);
// expect configuration to be updated with a new ASC Api Key
expect(asMock(ctx.ios.updateIosAppCredentialsAsync).mock.calls.length).toBe(1);
});
it('errors in Non Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = getAppLookupParamsFromContext(ctx, findApplicationTarget(testTargets));
const createAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
await expect(createAscApiKeyAction.runAsync(ctx)).rejects.toThrowError();
});
});
@@ -1,7 +1,10 @@
import { UserRole } from '@expo/apple-utils';
import nullthrows from 'nullthrows';
import {
AppFragment,
AppStoreConnectApiKeyFragment,
AppStoreConnectUserRole,
AppleAppIdentifierFragment,
AppleDeviceFragment,
AppleDistributionCertificateFragment,
@@ -14,7 +17,9 @@ import {
import { isWildcardBundleIdentifier } from '../../../project/ios/bundleIdentifier';
import { Account } from '../../../user/Account';
import { DistributionCertificate, PushKey } from '../appstore/Credentials.types';
import { MinimalAscApiKey } from '../credentials';
import { AppleTeamMissingError } from '../errors';
import { AppStoreConnectApiKeyMutation } from './graphql/mutations/AppStoreConnectApiKeyMutation';
import { AppleAppIdentifierMutation } from './graphql/mutations/AppleAppIdentifierMutation';
import {
AppleDistributionCertificateMutation,
@@ -29,6 +34,7 @@ import { AppleTeamMutation } from './graphql/mutations/AppleTeamMutation';
import { IosAppBuildCredentialsMutation } from './graphql/mutations/IosAppBuildCredentialsMutation';
import { IosAppCredentialsMutation } from './graphql/mutations/IosAppCredentialsMutation';
import { AppQuery } from './graphql/queries/AppQuery';
import { AppStoreConnectApiKeyQuery } from './graphql/queries/AppStoreConnectApiKeyQuery';
import { AppleAppIdentifierQuery } from './graphql/queries/AppleAppIdentifierQuery';
import { AppleDeviceQuery } from './graphql/queries/AppleDeviceQuery';
import { AppleDistributionCertificateQuery } from './graphql/queries/AppleDistributionCertificateQuery';
@@ -161,8 +167,10 @@ export async function updateIosAppCredentialsAsync(
appCredentials: CommonIosAppCredentialsFragment,
{
applePushKeyId,
ascApiKeyIdForSubmissions,
}: {
applePushKeyId?: string;
ascApiKeyIdForSubmissions?: string;
}
): Promise<CommonIosAppCredentialsFragment> {
let updatedAppCredentials = appCredentials;
@@ -172,6 +180,13 @@ export async function updateIosAppCredentialsAsync(
applePushKeyId
);
}
if (ascApiKeyIdForSubmissions) {
updatedAppCredentials =
await IosAppCredentialsMutation.setAppStoreConnectApiKeyForSubmissionsAsync(
updatedAppCredentials.id,
ascApiKeyIdForSubmissions
);
}
return updatedAppCredentials;
}
@@ -413,5 +428,78 @@ export async function deletePushKeyAsync(pushKeyId: string): Promise<void> {
return await ApplePushKeyMutation.deleteApplePushKeyAsync(pushKeyId);
}
export async function createAscApiKeyAsync(
account: Account,
ascApiKey: MinimalAscApiKey
): Promise<AppStoreConnectApiKeyFragment> {
const maybeAppleTeam = ascApiKey.teamId
? await createOrGetExistingAppleTeamAsync(account, {
appleTeamIdentifier: ascApiKey.teamId,
appleTeamName: ascApiKey.teamName,
})
: null;
return await AppStoreConnectApiKeyMutation.createAppStoreConnectApiKeyAsync(
{
issuerIdentifier: ascApiKey.issuerId,
keyIdentifier: ascApiKey.keyId,
keyP8: ascApiKey.keyP8,
name: ascApiKey.name ?? null,
roles: ascApiKey.roles?.map(role => convertUserRoleToGraphqlType(role)) ?? null,
appleTeamId: maybeAppleTeam ? maybeAppleTeam.id : null,
},
account.id
);
}
export async function getAscApiKeysForAccountAsync(
account: Account
): Promise<AppStoreConnectApiKeyFragment[]> {
return await AppStoreConnectApiKeyQuery.getAllForAccountAsync(account.name);
}
export async function getAscApiKeyForAppSubmissionsAsync(
appLookupParams: AppLookupParams
): Promise<AppStoreConnectApiKeyFragment | null> {
const maybeIosAppCredentials = await getIosAppCredentialsWithCommonFieldsAsync(appLookupParams);
return maybeIosAppCredentials?.appStoreConnectApiKeyForSubmissions ?? null;
}
export async function deleteAscApiKeyAsync(ascApiKeyId: string): Promise<void> {
return await AppStoreConnectApiKeyMutation.deleteAppStoreConnectApiKeyAsync(ascApiKeyId);
}
function convertUserRoleToGraphqlType(userRole: UserRole): AppStoreConnectUserRole {
switch (userRole) {
case UserRole.ADMIN:
return AppStoreConnectUserRole.Admin;
case UserRole.ACCESS_TO_REPORTS:
return AppStoreConnectUserRole.AccessToReports;
case UserRole.ACCOUNT_HOLDER:
return AppStoreConnectUserRole.AccountHolder;
case UserRole.APP_MANAGER:
return AppStoreConnectUserRole.AppManager;
case UserRole.CLOUD_MANAGED_APP_DISTRIBUTION:
return AppStoreConnectUserRole.CloudManagedAppDistribution;
case UserRole.CLOUD_MANAGED_DEVELOPER_ID:
return AppStoreConnectUserRole.CloudManagedDeveloperId;
case UserRole.CREATE_APPS:
return AppStoreConnectUserRole.CreateApps;
case UserRole.CUSTOMER_SUPPORT:
return AppStoreConnectUserRole.CustomerSupport;
case UserRole.DEVELOPER:
return AppStoreConnectUserRole.Developer;
case UserRole.FINANCE:
return AppStoreConnectUserRole.Finance;
case UserRole.MARKETING:
return AppStoreConnectUserRole.Marketing;
case UserRole.READ_ONLY:
return AppStoreConnectUserRole.ReadOnly;
case UserRole.SALES:
return AppStoreConnectUserRole.Sales;
case UserRole.TECHNICAL:
return AppStoreConnectUserRole.Technical;
}
}
const formatProjectFullName = ({ account, projectName }: AppLookupParams): string =>
`@${account.name}/${projectName}`;
@@ -0,0 +1,75 @@
import assert from 'assert';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AppStoreConnectApiKeyFragment,
AppStoreConnectApiKeyInput,
CreateAppStoreConnectApiKeyMutation,
DeleteAppStoreConnectApiKeyMutation,
} from '../../../../../graphql/generated';
import { AppStoreConnectApiKeyFragmentNode } from '../../../../../graphql/types/credentials/AppStoreConnectApiKey';
export const AppStoreConnectApiKeyMutation = {
async createAppStoreConnectApiKeyAsync(
appStoreConnectApiKeyInput: AppStoreConnectApiKeyInput,
accountId: string
): Promise<AppStoreConnectApiKeyFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAppStoreConnectApiKeyMutation>(
gql`
mutation CreateAppStoreConnectApiKeyMutation(
$appStoreConnectApiKeyInput: AppStoreConnectApiKeyInput!
$accountId: ID!
) {
appStoreConnectApiKey {
createAppStoreConnectApiKey(
appStoreConnectApiKeyInput: $appStoreConnectApiKeyInput
accountId: $accountId
) {
id
...AppStoreConnectApiKeyFragment
}
}
}
${print(AppStoreConnectApiKeyFragmentNode)}
`,
{
appStoreConnectApiKeyInput,
accountId,
}
)
.toPromise()
);
assert(
data.appStoreConnectApiKey.createAppStoreConnectApiKey,
'GraphQL: `createAppStoreConnectApiKey` not defined in server response'
);
return data.appStoreConnectApiKey.createAppStoreConnectApiKey;
},
async deleteAppStoreConnectApiKeyAsync(appStoreConnectApiKeyId: string): Promise<void> {
await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteAppStoreConnectApiKeyMutation>(
gql`
mutation DeleteAppStoreConnectApiKeyMutation($appStoreConnectApiKeyId: ID!) {
appStoreConnectApiKey {
deleteAppStoreConnectApiKey(id: $appStoreConnectApiKeyId) {
id
}
}
}
`,
{
appStoreConnectApiKeyId,
},
{
additionalTypenames: ['AppStoreConnectApiKey', 'IosAppCredentials'],
}
)
.toPromise()
);
},
};
@@ -7,6 +7,7 @@ import {
CommonIosAppCredentialsFragment,
CreateIosAppCredentialsMutation,
IosAppCredentialsInput,
SetAppStoreConnectApiKeyForSubmissionsMutation,
SetPushKeyMutation,
} from '../../../../../graphql/generated';
import { CommonIosAppCredentialsFragmentNode } from '../../../../../graphql/types/credentials/IosAppCredentials';
@@ -80,4 +81,37 @@ export const IosAppCredentialsMutation = {
);
return data.iosAppCredentials.setPushKey;
},
async setAppStoreConnectApiKeyForSubmissionsAsync(
iosAppCredentialsId: string,
ascApiKeyId: string
): Promise<CommonIosAppCredentialsFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<SetAppStoreConnectApiKeyForSubmissionsMutation>(
gql`
mutation SetAppStoreConnectApiKeyForSubmissionsMutation(
$iosAppCredentialsId: ID!
$ascApiKeyId: ID!
) {
iosAppCredentials {
setAppStoreConnectApiKeyForSubmissions(
id: $iosAppCredentialsId
ascApiKeyId: $ascApiKeyId
) {
id
...CommonIosAppCredentialsFragment
}
}
}
${print(CommonIosAppCredentialsFragmentNode)}
`,
{
iosAppCredentialsId,
ascApiKeyId,
}
)
.toPromise()
);
return data.iosAppCredentials.setAppStoreConnectApiKeyForSubmissions;
},
};
@@ -0,0 +1,41 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AppStoreConnectApiKeyByAccountQuery,
AppStoreConnectApiKeyFragment,
} from '../../../../../graphql/generated';
import { AppStoreConnectApiKeyFragmentNode } from '../../../../../graphql/types/credentials/AppStoreConnectApiKey';
export const AppStoreConnectApiKeyQuery = {
async getAllForAccountAsync(accountName: string): Promise<AppStoreConnectApiKeyFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppStoreConnectApiKeyByAccountQuery>(
gql`
query AppStoreConnectApiKeyByAccountQuery($accountName: String!) {
account {
byName(accountName: $accountName) {
id
appStoreConnectApiKeys {
id
...AppStoreConnectApiKeyFragment
}
}
}
}
${print(AppStoreConnectApiKeyFragmentNode)}
`,
{
accountName,
},
{
additionalTypenames: ['AppStoreConnectApiKey'],
}
)
.toPromise()
);
return data.account.byName.appStoreConnectApiKeys;
},
};
@@ -30,6 +30,21 @@ export const IosAppCredentialsQuery = {
},
__typename: 'ApplePushKey',
},
appStoreConnectApiKeyForSubmissions: {
id: 'b8fe2225-3024-4ebe-9e36-7b9022d3b13e',
appleTeam: {
id: 'ca27f9f6-c7cb-40d1-8c41-c168e8548261',
appleTeamIdentifier: '77KQ969CHE',
appleTeamName: null,
__typename: 'AppleTeam',
},
issuerIdentifier: '69a6de93-e4e4-47e3-e053-5b8c7c11a4d1',
keyIdentifier: 'NYN468XNN3',
name: 'Expo Submissions pl94k8_lnS',
roles: ['ADMIN'],
updatedAt: '2020-10-13T18:39:04.463Z',
__typename: 'AppStoreConnectApiKey',
},
iosAppBuildCredentialsList: [
{
id: '6f8d0175-0caf-4922-b09b-eae757b83144',
@@ -150,7 +150,7 @@ export default class AppStoreApi {
return await listAscApiKeysAsync(ctx);
}
public async getAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo> {
public async getAscApiKeyAsync(keyId: string): Promise<AscApiKeyInfo | null> {
const ctx = await this.ensureAuthenticatedAsync();
return await getAscApiKeyAsync(ctx, keyId);
}
@@ -76,6 +76,7 @@ export type AscApiKeyInfo = {
name: string;
teamName?: string;
roles: UserRole[];
isRevoked: boolean;
};
export type AscApiKey = AscApiKeyInfo & {
@@ -47,6 +47,7 @@ const mockAscApiKeyInfo = {
roles: ['ADMIN'],
teamId: 'test-id',
teamName: 'test-name',
isRevoked: false,
};
beforeEach(() => {
@@ -6,28 +6,36 @@ import { AscApiKey, AscApiKeyInfo } from './Credentials.types';
import { AuthCtx, getRequestContext } from './authenticate';
export async function listAscApiKeysAsync(authCtx: AuthCtx): Promise<AscApiKeyInfo[]> {
const spinner = ora(`Fetching App Store Connect API Keys.`).start();
const spinner = ora(`Fetching App Store Connect Api Keys.`).start();
try {
const context = getRequestContext(authCtx);
const keys = await ApiKey.getAsync(context);
spinner.succeed(`Fetched App Store Connect API Keys.`);
spinner.succeed(`Fetched App Store Connect Api Keys.`);
return keys.map(key => getAscApiKeyInfo(key, authCtx));
} catch (error) {
spinner.fail(`Failed to fetch App Store Connect API Keys.`);
spinner.fail(`Failed to fetch App Store Connect Api Keys.`);
throw error;
}
}
export async function getAscApiKeyAsync(authCtx: AuthCtx, keyId: string): Promise<AscApiKeyInfo> {
const spinner = ora(`Fetching App Store Connect API Key.`).start();
export async function getAscApiKeyAsync(
authCtx: AuthCtx,
keyId: string
): Promise<AscApiKeyInfo | null> {
const spinner = ora(`Fetching App Store Connect Api Key.`).start();
try {
const context = getRequestContext(authCtx);
const apiKey = await ApiKey.infoAsync(context, { id: keyId });
spinner.succeed(`Fetched App Store Connect API Key (ID: ${keyId}).`);
spinner.succeed(`Fetched App Store Connect Api Key (ID: ${keyId}).`);
return getAscApiKeyInfo(apiKey, authCtx);
} catch (error) {
} catch (error: any) {
const message = error?.message ?? '';
if (message.includes("There is no resource of type 'apiKeys' with id")) {
spinner.stop();
return null;
}
Log.error(error);
spinner.fail(`Failed to fetch App Store Connect API Key.`);
spinner.fail(`Failed to fetch App Store Connect Api Key.`);
throw error;
}
}
@@ -41,7 +49,7 @@ export async function createAscApiKeyAsync(
keyType,
}: Partial<Pick<ApiKeyProps, 'nickname' | 'roles' | 'allAppsVisible' | 'keyType'>>
): Promise<AscApiKey> {
const spinner = ora(`Creating App Store Connect API Key.`).start();
const spinner = ora(`Creating App Store Connect Api Key.`).start();
try {
const context = getRequestContext(authCtx);
const key = await ApiKey.createAsync(context, {
@@ -64,12 +72,15 @@ export async function createAscApiKeyAsync(
}
throw new Error(`Failed to download .p8 file of ${humanReadableKey}.`);
}
// this object has more optional parameters populated
const fullKey = await ApiKey.infoAsync(context, { id: key.id });
spinner.succeed(`Created App Store Connect Api Key.`);
return {
...getAscApiKeyInfo(key, authCtx),
...getAscApiKeyInfo(fullKey, authCtx),
keyP8,
};
} catch (err: any) {
spinner.fail('Failed to create App Store Connect API Key.');
spinner.fail('Failed to create App Store Connect Api Key.');
throw err;
}
}
@@ -78,16 +89,16 @@ export async function revokeAscApiKeyAsync(
authCtx: AuthCtx,
keyId: string
): Promise<AscApiKeyInfo> {
const spinner = ora(`Revoking App Store Connect API Key.`).start();
const spinner = ora(`Revoking App Store Connect Api Key.`).start();
try {
const context = getRequestContext(authCtx);
const apiKey = await ApiKey.infoAsync(context, { id: keyId });
const revokedKey = await apiKey.revokeAsync();
spinner.succeed(`Revoked App Store Connect API Key.`);
spinner.succeed(`Revoked App Store Connect Api Key.`);
return getAscApiKeyInfo(revokedKey, authCtx);
} catch (error) {
Log.error(error);
spinner.fail(`Failed to revoke App Store Connect API Key.`);
spinner.fail(`Failed to revoke App Store Connect Api Key.`);
throw error;
}
}
@@ -100,5 +111,6 @@ export function getAscApiKeyInfo(apiKey: ApiKey, authCtx: AuthCtx): AscApiKeyInf
teamId: authCtx.team.id,
teamName: authCtx.team.name,
roles: apiKey.attributes.roles,
isRevoked: !!apiKey.attributes.revokingDate,
};
}
@@ -118,14 +118,20 @@ export interface AscApiKeyPath {
issuerId: string;
}
export const ascApiKeyMetadataSchema: CredentialSchema<Omit<MinimalAscApiKey, 'keyP8'>> = {
name: 'App Store Connect API Key',
export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>> = {
name: 'App Store Connect Api Key',
questions: [
{
field: 'keyId',
type: 'string',
question: 'Key ID:',
},
],
};
export const ascApiKeyIssuerIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'issuerId'>> = {
name: 'App Store Connect Api Key',
questions: [
{
field: 'issuerId',
type: 'string',
@@ -2,47 +2,55 @@
exports[`print credentials prints the IosAppCredentials map 1`] = `
Array [
"iOS Credentials
Project @quinlanj/test52
Bundle Identifier com.quinlanj.test52
Apple Team test-apple-identifier
Push Key
Developer Portal ID 9839M6AY8W
Apple Team 77KQ969CHE
Updated 6 months ago
App Store Configuration
Distribution Certificate
Serial Number test-serial
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 6 months ago
Provisioning Profile
Developer Portal ID test-profile-id
Status active
Expiration Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 5 months ago
Ad Hoc Configuration
Distribution Certificate
Serial Number test-serial
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 6 months ago
Provisioning Profile
Developer Portal ID test-profile-id
Status active
Expiration Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 5 months ago
",
"iOS Credentials
Project @quinlanj/test52
Bundle Identifier com.quinlanj.test52
Apple Team test-apple-identifier
Push Key
Developer Portal ID 9839M6AY8W
Apple Team 77KQ969CHE
Updated 6 months ago
App Store Connect Api Key
Developer Portal ID NYN468XNN3
Name Expo Submissions pl94k8_lnS
Issuer ID 69a6de93-e4e4-47e3-e053-5b8c7c11a4d1
Roles ADMIN
Apple Team 77KQ969CHE
Updated 6 months ago
App Store Configuration
Distribution Certificate
Serial Number test-serial
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 6 months ago
Provisioning Profile
Developer Portal ID test-profile-id
Status active
Expiration Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 5 months ago
Ad Hoc Configuration
Distribution Certificate
Serial Number test-serial
Expiration Date Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 6 months ago
Provisioning Profile
Developer Portal ID test-profile-id
Status active
Expiration Wed, 13 Oct 2021 18:28:44 UTC
Apple Team test-apple-identifier
Updated 5 months ago
",
]
`;
@@ -2,6 +2,7 @@ import chalk from 'chalk';
import dateformat from 'dateformat';
import {
AppStoreConnectApiKeyFragment,
AppleDeviceFragment,
ApplePushKeyFragment,
IosAppBuildCredentialsFragment,
@@ -91,7 +92,7 @@ export function displayIosCredentials(
continue;
}
const { appleTeam, pushKey } = targetAppCredentials;
const { appleTeam, pushKey, appStoreConnectApiKeyForSubmissions } = targetAppCredentials;
if (appleTeam) {
const { appleTeamIdentifier, appleTeamName } = appleTeam;
fields.push({
@@ -105,6 +106,10 @@ export function displayIosCredentials(
displayApplePushKey(pushKey, fields);
}
if (appStoreConnectApiKeyForSubmissions) {
displayAscApiKey(appStoreConnectApiKeyForSubmissions, fields);
}
const sortedIosAppBuildCredentialsList = sortBuildCredentialsByDistributionType(
targetAppCredentials.iosAppBuildCredentialsList
);
@@ -230,6 +235,35 @@ function displayApplePushKey(
fields.push({ label: '', value: '' });
}
function displayAscApiKey(
maybeAscApiKey: AppStoreConnectApiKeyFragment | null,
fields: { label: string; value: string }[]
): void {
fields.push({ label: 'App Store Connect Api Key', value: '' });
if (maybeAscApiKey) {
const { keyIdentifier, issuerIdentifier, appleTeam, name, roles, updatedAt } = maybeAscApiKey;
fields.push({ label: 'Developer Portal ID', value: keyIdentifier });
if (name) {
fields.push({ label: 'Name', value: name });
}
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
if (roles) {
fields.push({ label: 'Roles', value: roles.join(',') });
}
if (appleTeam) {
const { appleTeamIdentifier, appleTeamName } = appleTeam;
fields.push({
label: 'Apple Team',
value: `${appleTeamIdentifier} ${appleTeamName ? `(${appleTeamName})` : ''}`,
});
}
fields.push({ label: 'Updated', value: `${fromNow(new Date(updatedAt))} ago` });
} else {
fields.push({ label: '', value: 'None assigned yet' });
}
fields.push({ label: '', value: '' });
}
function formatAppleDevice(device: AppleDeviceFragment): string {
let deviceString = '';
if (device.name) {
@@ -0,0 +1,29 @@
import { AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import { CredentialsContext } from '../../context';
import { AscApiKeyInfo } from '../appstore/Credentials.types';
import { MinimalAscApiKey } from '../credentials';
export async function isAscApiKeyValidAndTrackedAsync(
ctx: CredentialsContext,
ascApiKey: MinimalAscApiKey
): Promise<boolean> {
const ascApiKeyInfo = await ctx.appStore.getAscApiKeyAsync(ascApiKey.keyId);
return isKeyValid(ascApiKeyInfo);
}
export async function getValidAndTrackedAscApiKeysAsync(
ctx: CredentialsContext,
ascApiKeys: AppStoreConnectApiKeyFragment[]
): Promise<AppStoreConnectApiKeyFragment[]> {
const ascApiKeysInfo = await ctx.appStore.listAscApiKeysAsync();
const validAscApiKeysInfo = ascApiKeysInfo.filter(keyInfo => isKeyValid(keyInfo));
const validKeyIdentifiers = new Set(validAscApiKeysInfo.map(keyInfo => keyInfo.keyId));
return ascApiKeys.filter(key => validKeyIdentifiers.has(key.keyIdentifier));
}
function isKeyValid(ascApiKeyInfo: AscApiKeyInfo | null): boolean {
if (!ascApiKeyInfo) {
return false;
}
return !ascApiKeyInfo.isRevoked;
}
@@ -34,6 +34,7 @@ export enum IosActionType {
ManageCredentialsJson,
ManageBuildCredentials,
ManagePushKey,
ManageAscApiKey,
GoBackToCaller,
GoBackToHighLevelActions,
SetupBuildCredentials,
@@ -47,4 +48,8 @@ export enum IosActionType {
CreatePushKey,
UseExistingPushKey,
RemovePushKey,
SetUpAscApiKeyForSubmissions,
UseExistingAscApiKeyForSubmissions,
CreateAscApiKeyForSubmissions,
RemoveAscApiKey,
}
@@ -12,6 +12,11 @@ export const highLevelActions: ActionInfo[] = [
title: 'Push Notifications: Manage your Apple Push Notifications Key',
scope: Scope.Manager,
},
{
value: IosActionType.ManageAscApiKey,
title: 'App Store Connect: Manage your Api Key',
scope: Scope.Manager,
},
{
value: IosActionType.ManageCredentialsJson,
title: 'credentials.json: Upload/Download credentials between EAS servers and your local json ',
@@ -72,6 +77,36 @@ export function getPushKeyActions(ctx: CredentialsContext): ActionInfo[] {
];
}
export function getAscApiKeyActions(ctx: CredentialsContext): ActionInfo[] {
return [
{
value: IosActionType.SetUpAscApiKeyForSubmissions,
title: 'Set up your project to use an Api Key for EAS Submit',
scope: Scope.Project,
},
{
value: IosActionType.UseExistingAscApiKeyForSubmissions,
title: 'Use an existing Api Key for EAS Submit',
scope: Scope.Project,
},
{
value: IosActionType.CreateAscApiKeyForSubmissions,
title: 'Add a new Api Key For EAS Submit',
scope: ctx.hasProjectContext ? Scope.Project : Scope.Account,
},
{
value: IosActionType.RemoveAscApiKey,
title: 'Delete an Api Key',
scope: Scope.Account,
},
{
value: IosActionType.GoBackToHighLevelActions,
title: 'Go back',
scope: Scope.Manager,
},
];
}
export function getBuildCredentialsActions(ctx: CredentialsContext): ActionInfo[] {
return [
{
@@ -19,15 +19,23 @@ import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { Account, findAccountByName } from '../../user/Account';
import { ensureActorHasUsername } from '../../user/actions';
import { CredentialsContext } from '../context';
import {
AppStoreApiKeyPurpose,
selectAscApiKeysFromAccountAsync,
} from '../ios/actions/AscApiKeyUtils';
import { AssignAscApiKey } from '../ios/actions/AssignAscApiKey';
import { AssignPushKey } from '../ios/actions/AssignPushKey';
import { getAppLookupParamsFromContext } from '../ios/actions/BuildCredentialsUtils';
import { CreateAscApiKey } from '../ios/actions/CreateAscApiKey';
import { CreateDistributionCertificate } from '../ios/actions/CreateDistributionCertificate';
import { CreatePushKey } from '../ios/actions/CreatePushKey';
import { selectValidDistributionCertificateAsync } from '../ios/actions/DistributionCertificateUtils';
import { selectPushKeyAsync } from '../ios/actions/PushKeyUtils';
import { SelectAndRemoveAscApiKey } from '../ios/actions/RemoveAscApiKey';
import { SelectAndRemoveDistributionCertificate } from '../ios/actions/RemoveDistributionCertificate';
import { RemoveProvisioningProfiles } from '../ios/actions/RemoveProvisioningProfile';
import { SelectAndRemovePushKey } from '../ios/actions/RemovePushKey';
import { SetUpAscApiKey } from '../ios/actions/SetUpAscApiKey';
import { SetupAdhocProvisioningProfile } from '../ios/actions/SetupAdhocProvisioningProfile';
import { SetupBuildCredentials } from '../ios/actions/SetupBuildCredentials';
import { SetupBuildCredentialsFromCredentialsJson } from '../ios/actions/SetupBuildCredentialsFromCredentialsJson';
@@ -42,6 +50,7 @@ import { ActionInfo, IosActionType, Scope } from './Actions';
import { Action, PressAnyKeyToContinue } from './HelperActions';
import {
credentialsJsonActions,
getAscApiKeyActions,
getBuildCredentialsActions,
getPushKeyActions,
highLevelActions,
@@ -58,9 +67,9 @@ export class ManageIos {
): Promise<void> {
const buildCredentialsActions = getBuildCredentialsActions(ctx);
const pushKeyActions = getPushKeyActions(ctx);
const ascApiKeyActions = getAscApiKeyActions(ctx);
await ctx.bestEffortAppStoreAuthenticateAsync();
const accountName = ctx.hasProjectContext
? getProjectAccountName(ctx.exp, ctx.user)
: ensureActorHasUsername(ctx.user);
@@ -70,7 +79,6 @@ export class ManageIos {
throw new Error(`You do not have access to account: ${accountName}`);
}
const { app, targets, buildProfile } = await this.createProjectContextAsync(ctx, account);
while (true) {
try {
if (ctx.hasProjectContext) {
@@ -107,6 +115,9 @@ export class ManageIos {
} else if (chosenAction === IosActionType.ManagePushKey) {
currentActions = pushKeyActions;
continue;
} else if (chosenAction === IosActionType.ManageAscApiKey) {
currentActions = ascApiKeyActions;
continue;
} else if (chosenAction === IosActionType.GoBackToHighLevelActions) {
currentActions = highLevelActions;
continue;
@@ -195,8 +206,12 @@ export class ManageIos {
await new CreateDistributionCertificate(account).runAsync(ctx);
} else if (action === IosActionType.CreatePushKey) {
await new CreatePushKey(account).runAsync(ctx);
} else if (action === IosActionType.CreateAscApiKeyForSubmissions) {
await new CreateAscApiKey(account).runAsync(ctx, AppStoreApiKeyPurpose.SUBMISSION_SERVICE);
} else if (action === IosActionType.RemovePushKey) {
await new SelectAndRemovePushKey(account).runAsync(ctx);
} else if (action === IosActionType.RemoveAscApiKey) {
await new SelectAndRemoveAscApiKey(account).runAsync(ctx);
}
}
@@ -319,6 +334,43 @@ export class ManageIos {
}
return;
}
case IosActionType.SetUpAscApiKeyForSubmissions: {
await new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
).runAsync(ctx);
return;
}
case IosActionType.UseExistingAscApiKeyForSubmissions: {
const ascApiKey = await selectAscApiKeysFromAccountAsync(ctx, appLookupParams.account, {
filterDifferentAppleTeam: true,
});
if (ascApiKey) {
await new AssignAscApiKey(appLookupParams).runAsync(
ctx,
ascApiKey,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
}
return;
}
case IosActionType.CreateAscApiKeyForSubmissions: {
const ascApiKey = await new CreateAscApiKey(appLookupParams.account).runAsync(
ctx,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
const confirm = await confirmAsync({
message: `Do you want ${appLookupParams.projectName} to use the new Api Key?`,
});
if (confirm) {
await new AssignAscApiKey(appLookupParams).runAsync(
ctx,
ascApiKey,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
}
return;
}
default:
throw new Error('Unknown action selected');
}
@@ -49,7 +49,7 @@ export async function askForUserProvidedAsync<T>(
export async function getCredentialsFromUserAsync<T>(
credentialsSchema: CredentialSchema<T>,
initialValues: Partial<T>
initialValues: Partial<T> = {}
): Promise<T> {
const results: any = {};
for (const question of credentialsSchema.questions) {
@@ -63,7 +63,7 @@ export async function getCredentialsFromUserAsync<T>(
: (results as T);
}
async function shouldAutoGenerateCredentialsAsync<T>(
export async function shouldAutoGenerateCredentialsAsync<T>(
schema: CredentialSchema<T>
): Promise<boolean> {
const answer = await confirmAsync({
+105 -35
View File
@@ -147,6 +147,11 @@ export type Actor = {
firstName?: Maybe<Scalars['String']>;
created: Scalars['DateTime'];
isExpoAdmin: Scalars['Boolean'];
/**
* Best-effort human readable name for this actor for use in user interfaces during action attribution.
* For example, when displaying a sentence indicating that actor X created a build or published an update.
*/
displayName: Scalars['String'];
/** Associated accounts */
accounts: Array<Account>;
/** Access Tokens belonging to this actor */
@@ -212,7 +217,6 @@ export type Account = {
applePushKeys: Array<ApplePushKey>;
appleProvisioningProfiles: Array<AppleProvisioningProfile>;
appleDevices: Array<AppleDevice>;
appleAppSpecificPasswords: Array<AppleAppSpecificPassword>;
appStoreConnectApiKeys: Array<AppStoreConnectApiKey>;
/** Android credentials for account */
googleServiceAccountKeys: Array<GoogleServiceAccountKey>;
@@ -436,6 +440,7 @@ export type App = Project & {
privacySetting: AppPrivacy;
latestReleaseId: Scalars['ID'];
pushSecurityEnabled: Scalars['Boolean'];
releaseChannels: Array<Scalars['String']>;
/** (EAS Build) Builds associated with this app */
builds: Array<Build>;
buildJobs: Array<BuildJob>;
@@ -591,6 +596,8 @@ export type AppActivityTimelineProjectActivitiesArgs = {
limit: Scalars['Int'];
createdBefore?: Maybe<Scalars['DateTime']>;
filterTypes?: Maybe<Array<ActivityTimelineProjectActivityType>>;
filterPlatforms?: Maybe<Array<AppPlatform>>;
filterReleaseChannels?: Maybe<Array<Scalars['String']>>;
};
@@ -734,6 +741,7 @@ export type User = Actor & {
appetizeCode?: Maybe<Scalars['String']>;
emailVerified: Scalars['Boolean'];
isExpoAdmin: Scalars['Boolean'];
displayName: Scalars['String'];
isSecondFactorAuthenticationEnabled: Scalars['Boolean'];
/** Get all certified second factor authentication methods */
secondFactorDevices: Array<UserSecondFactorDevice>;
@@ -1115,7 +1123,6 @@ export type IosAppCredentials = {
iosAppBuildCredentialsList: Array<IosAppBuildCredentials>;
pushKey?: Maybe<ApplePushKey>;
appStoreConnectApiKeyForSubmissions?: Maybe<AppStoreConnectApiKey>;
appSpecificPassword?: Maybe<AppleAppSpecificPassword>;
/** @deprecated use iosAppBuildCredentialsList instead */
iosAppBuildCredentialsArray: Array<IosAppBuildCredentials>;
};
@@ -1279,16 +1286,6 @@ export enum AppStoreConnectUserRole {
Unknown = 'UNKNOWN'
}
export type AppleAppSpecificPassword = {
__typename?: 'AppleAppSpecificPassword';
id: Scalars['ID'];
account: Account;
appleIdUsername: Scalars['String'];
passwordLabel?: Maybe<Scalars['String']>;
createdAt: Scalars['DateTime'];
updatedAt: Scalars['DateTime'];
};
export type AndroidAppCredentialsFilter = {
legacyOnly?: Maybe<Scalars['Boolean']>;
applicationIdentifier?: Maybe<Scalars['String']>;
@@ -1475,6 +1472,8 @@ export type AddonDetails = {
id: Scalars['ID'];
planId: Scalars['String'];
name: Scalars['String'];
nextInvoice?: Maybe<Scalars['DateTime']>;
willCancel?: Maybe<Scalars['Boolean']>;
};
export type Charge = {
@@ -1550,7 +1549,10 @@ export type AppQuery = {
/** Look up app by app id */
byId: App;
byFullName: App;
/** Public apps in the app directory */
/**
* Public apps in the app directory
* @deprecated App directory no longer supported
*/
all: Array<App>;
};
@@ -1877,8 +1879,6 @@ export type RootMutation = {
googleServiceAccountKey: GoogleServiceAccountKeyMutation;
/** Mutations that modify an Identifier for an iOS App */
appleAppIdentifier: AppleAppIdentifierMutation;
/** Mutations that modify an App Specific Password for an Apple User Account */
appleAppSpecificPassword: AppleAppSpecificPasswordMutation;
/** Mutations that modify an Apple Device */
appleDevice: AppleDeviceMutation;
/** Mutations that modify an Apple Device registration request */
@@ -2278,24 +2278,6 @@ export type AppleAppIdentifierInput = {
parentAppleAppId?: Maybe<Scalars['ID']>;
};
export type AppleAppSpecificPasswordMutation = {
__typename?: 'AppleAppSpecificPasswordMutation';
/** Create an App Specific Password for an Apple User Account */
createAppleAppSpecificPassword: AppleAppSpecificPassword;
};
export type AppleAppSpecificPasswordMutationCreateAppleAppSpecificPasswordArgs = {
appleAppSpecificPasswordInput: AppleAppSpecificPasswordInput;
accountId: Scalars['ID'];
};
export type AppleAppSpecificPasswordInput = {
appleIdUsername: Scalars['String'];
passwordLabel?: Maybe<Scalars['String']>;
appSpecificPassword: Scalars['String'];
};
export type AppleDeviceMutation = {
__typename?: 'AppleDeviceMutation';
/** Create an Apple Device */
@@ -2880,6 +2862,7 @@ export type Robot = Actor & {
firstName?: Maybe<Scalars['String']>;
created: Scalars['DateTime'];
isExpoAdmin: Scalars['Boolean'];
displayName: Scalars['String'];
/** Associated accounts */
accounts: Array<Account>;
/** Access Tokens belonging to this actor */
@@ -4108,6 +4091,40 @@ export type GoogleServiceAccountKeyByAccountQuery = (
) }
);
export type CreateAppStoreConnectApiKeyMutationVariables = Exact<{
appStoreConnectApiKeyInput: AppStoreConnectApiKeyInput;
accountId: Scalars['ID'];
}>;
export type CreateAppStoreConnectApiKeyMutation = (
{ __typename?: 'RootMutation' }
& { appStoreConnectApiKey: (
{ __typename?: 'AppStoreConnectApiKeyMutation' }
& { createAppStoreConnectApiKey: (
{ __typename?: 'AppStoreConnectApiKey' }
& Pick<AppStoreConnectApiKey, 'id'>
& AppStoreConnectApiKeyFragment
) }
) }
);
export type DeleteAppStoreConnectApiKeyMutationVariables = Exact<{
appStoreConnectApiKeyId: Scalars['ID'];
}>;
export type DeleteAppStoreConnectApiKeyMutation = (
{ __typename?: 'RootMutation' }
& { appStoreConnectApiKey: (
{ __typename?: 'AppStoreConnectApiKeyMutation' }
& { deleteAppStoreConnectApiKey: (
{ __typename?: 'deleteAppStoreConnectApiKeyResult' }
& Pick<DeleteAppStoreConnectApiKeyResult, 'id'>
) }
) }
);
export type CreateAppleAppIdentifierMutationVariables = Exact<{
appleAppIdentifierInput: AppleAppIdentifierInput;
accountId: Scalars['ID'];
@@ -4411,6 +4428,24 @@ export type SetPushKeyMutation = (
) }
);
export type SetAppStoreConnectApiKeyForSubmissionsMutationVariables = Exact<{
iosAppCredentialsId: Scalars['ID'];
ascApiKeyId: Scalars['ID'];
}>;
export type SetAppStoreConnectApiKeyForSubmissionsMutation = (
{ __typename?: 'RootMutation' }
& { iosAppCredentials: (
{ __typename?: 'IosAppCredentialsMutation' }
& { setAppStoreConnectApiKeyForSubmissions: (
{ __typename?: 'IosAppCredentials' }
& Pick<IosAppCredentials, 'id'>
& CommonIosAppCredentialsFragment
) }
) }
);
export type AppByFullNameQueryVariables = Exact<{
fullName: Scalars['String'];
}>;
@@ -4428,6 +4463,27 @@ export type AppByFullNameQuery = (
)> }
);
export type AppStoreConnectApiKeyByAccountQueryVariables = Exact<{
accountName: Scalars['String'];
}>;
export type AppStoreConnectApiKeyByAccountQuery = (
{ __typename?: 'RootQuery' }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& Pick<Account, 'id'>
& { appStoreConnectApiKeys: Array<(
{ __typename?: 'AppStoreConnectApiKey' }
& Pick<AppStoreConnectApiKey, 'id'>
& AppStoreConnectApiKeyFragment
)> }
) }
) }
);
export type AppleAppIdentifierByBundleIdQueryVariables = Exact<{
accountName: Scalars['String'];
bundleIdentifier: Scalars['String'];
@@ -5262,10 +5318,10 @@ export type BuildFragment = (
& Pick<BuildArtifacts, 'buildUrl' | 'xcodeBuildLogsUrl'>
)>, initiatingActor?: Maybe<(
{ __typename: 'User' }
& Pick<User, 'username' | 'id'>
& Pick<User, 'id' | 'displayName'>
) | (
{ __typename: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
& Pick<Robot, 'id' | 'displayName'>
)>, project: (
{ __typename: 'Snack' }
& Pick<Snack, 'id' | 'name'>
@@ -5376,6 +5432,16 @@ export type AndroidKeystoreFragment = (
& Pick<AndroidKeystore, 'id' | 'type' | 'keystore' | 'keystorePassword' | 'keyAlias' | 'keyPassword' | 'md5CertificateFingerprint' | 'sha1CertificateFingerprint' | 'sha256CertificateFingerprint' | 'createdAt' | 'updatedAt'>
);
export type AppStoreConnectApiKeyFragment = (
{ __typename?: 'AppStoreConnectApiKey' }
& Pick<AppStoreConnectApiKey, 'id' | 'issuerIdentifier' | 'keyIdentifier' | 'name' | 'roles' | 'createdAt' | 'updatedAt'>
& { appleTeam?: Maybe<(
{ __typename?: 'AppleTeam' }
& Pick<AppleTeam, 'id'>
& AppleTeamFragment
)> }
);
export type AppleAppIdentifierFragment = (
{ __typename?: 'AppleAppIdentifier' }
& Pick<AppleAppIdentifier, 'id' | 'bundleIdentifier'>
@@ -5505,6 +5571,10 @@ export type CommonIosAppCredentialsWithoutBuildCredentialsFragment = (
{ __typename?: 'ApplePushKey' }
& Pick<ApplePushKey, 'id'>
& ApplePushKeyFragment
)>, appStoreConnectApiKeyForSubmissions?: Maybe<(
{ __typename?: 'AppStoreConnectApiKey' }
& Pick<AppStoreConnectApiKey, 'id'>
& AppStoreConnectApiKeyFragment
)> }
);
+1 -6
View File
@@ -17,12 +17,7 @@ export const BuildFragmentNode = gql`
initiatingActor {
__typename
id
... on User {
username
}
... on Robot {
firstName
}
displayName
}
project {
__typename
@@ -0,0 +1,21 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { AppleTeamFragmentNode } from './AppleTeam';
export const AppStoreConnectApiKeyFragmentNode = gql`
fragment AppStoreConnectApiKeyFragment on AppStoreConnectApiKey {
id
appleTeam {
id
...AppleTeamFragment
}
issuerIdentifier
keyIdentifier
name
roles
createdAt
updatedAt
}
${print(AppleTeamFragmentNode)}
`;
@@ -1,6 +1,7 @@
import gql from 'graphql-tag';
import { AppFragmentNode } from '../App';
import { AppStoreConnectApiKeyFragmentNode } from './AppStoreConnectApiKey';
import { AppleAppIdentifierFragmentNode } from './AppleAppIdentifier';
import { ApplePushKeyFragmentNode } from './ApplePushKey';
import { AppleTeamFragmentNode } from './AppleTeam';
@@ -25,11 +26,16 @@ export const CommonIosAppCredentialsWithoutBuildCredentialsFragmentNode = gql`
id
...ApplePushKeyFragment
}
appStoreConnectApiKeyForSubmissions {
id
...AppStoreConnectApiKeyFragment
}
}
${AppFragmentNode}
${AppleTeamFragmentNode}
${AppleAppIdentifierFragmentNode}
${ApplePushKeyFragmentNode}
${AppStoreConnectApiKeyFragmentNode}
`;
export const CommonIosAppCredentialsFragmentNode = gql`
@@ -288,6 +288,7 @@ function formatBuildChoice(build: BuildFragment, expiryDate: Date): prompts.Choi
buildProfile,
appBuildVersion,
releaseChannel,
initiatingActor,
} = build;
const formatValue = (field?: string | null): string =>
@@ -310,6 +311,7 @@ function formatBuildChoice(build: BuildFragment, expiryDate: Date): prompts.Choi
.filter(it => it != null)
.join(', '),
`\tProfile: ${formatValue(buildProfile)}, Release channel: ${formatValue(releaseChannel)}`,
`\tAuthored by: ${formatValue(initiatingActor?.displayName)}`,
].join('\n');
return {
@@ -1,8 +1,10 @@
import { Platform } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { promptForAscApiKeyAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
import { promptForAscApiKeyPathAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
import { AscApiKeyPath, MinimalAscApiKey } from '../../credentials/ios/credentials';
import Log from '../../log';
import { SubmissionContext } from '../context';
import { isExistingFileAsync } from '../utils/files';
export enum AscApiKeySourceType {
@@ -36,8 +38,11 @@ export type AscApiKeyResult = {
summary: AscApiKeySummary;
};
export async function getAscApiKeyLocallyAsync(source: AscApiKeySource): Promise<AscApiKeyResult> {
const ascApiKeyPath = await getAscApiKeyPathAsync(source);
export async function getAscApiKeyLocallyAsync(
ctx: SubmissionContext<Platform.IOS>,
source: AscApiKeySource
): Promise<AscApiKeyResult> {
const ascApiKeyPath = await getAscApiKeyPathAsync(ctx, source);
const { keyP8Path, keyId, issuerId } = ascApiKeyPath;
const keyP8 = await fs.readFile(keyP8Path, 'utf-8');
@@ -51,27 +56,36 @@ export async function getAscApiKeyLocallyAsync(source: AscApiKeySource): Promise
};
}
export async function getAscApiKeyPathAsync(source: AscApiKeySource): Promise<AscApiKeyPath> {
export async function getAscApiKeyPathAsync(
ctx: SubmissionContext<Platform.IOS>,
source: AscApiKeySource
): Promise<AscApiKeyPath> {
switch (source.sourceType) {
case AscApiKeySourceType.path:
return await handlePathSourceAsync(source);
return await handlePathSourceAsync(ctx, source);
case AscApiKeySourceType.prompt:
return await handlePromptSourceAsync(source);
return await handlePromptSourceAsync(ctx, source);
}
}
async function handlePathSourceAsync(source: AscApiKeyEnvVarSource): Promise<AscApiKeyPath> {
async function handlePathSourceAsync(
ctx: SubmissionContext<Platform.IOS>,
source: AscApiKeyEnvVarSource
): Promise<AscApiKeyPath> {
const { keyP8Path } = source.path;
if (!(await isExistingFileAsync(keyP8Path))) {
Log.warn(`File ${keyP8Path} doesn't exist.`);
return await getAscApiKeyPathAsync({ sourceType: AscApiKeySourceType.prompt });
return await getAscApiKeyPathAsync(ctx, { sourceType: AscApiKeySourceType.prompt });
}
return source.path;
}
async function handlePromptSourceAsync(_source: AscApiKeyPromptSource): Promise<AscApiKeyPath> {
const ascApiKeyPath = await promptForAscApiKeyAsync();
return await getAscApiKeyPathAsync({
async function handlePromptSourceAsync(
ctx: SubmissionContext<Platform.IOS>,
_source: AscApiKeyPromptSource
): Promise<AscApiKeyPath> {
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx.credentialsCtx);
return await getAscApiKeyPathAsync(ctx, {
sourceType: AscApiKeySourceType.path,
path: ascApiKeyPath,
});
@@ -54,6 +54,7 @@ export default class IosSubmitCommand {
: null;
const maybeAscApiKeySource =
'ascApiKeySource' in credentialsSource ? credentialsSource.ascApiKeySource : null;
const maybeCredentialsSource = null; // todo
const ascAppIdentifier = await this.resolveAscAppIdentifierAsync();
const appleIdUsername = await this.resolveAppleIdUsernameAsync();
@@ -69,7 +69,7 @@ export default class IosSubmitter extends BaseSubmitter<Platform.IOS, IosSubmiss
? await getAppSpecificPasswordAsync(this.options.appSpecificPasswordSource)
: null;
const maybeAppStoreConnectApiKey = this.options.ascApiKeySource
? await getAscApiKeyLocallyAsync(this.options.ascApiKeySource)
? await getAscApiKeyLocallyAsync(this.ctx, this.options.ascApiKeySource)
: null;
return {
archive,
@@ -1,8 +1,13 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { v4 as uuidv4 } from 'uuid';
import { asMock } from '../../../__tests__/utils';
import { jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { getCredentialsFromUserAsync } from '../../../credentials/utils/promptForCredentials';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { promptAsync } from '../../../prompts';
import { SubmissionContext, createSubmissionContextAsync } from '../../context';
import {
AscApiKeySource,
AscApiKeySourceType,
@@ -13,6 +18,38 @@ import {
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../credentials/utils/promptForCredentials');
jest.mock('../../../user/User', () => ({
getUserAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../user/Account', () => ({
findAccountByName: jest.fn(() => mockJester.accounts[0]),
}));
const testProject = createTestProject(mockJester, {
android: {
package: 'com.expo.test.project',
},
});
const mockManifest = { exp: testProject.appJSON.expo };
jest.mock('@expo/config', () => ({
getConfig: jest.fn(() => mockManifest),
}));
const projectId = uuidv4();
async function getIosSubmissionContextAsync(): Promise<SubmissionContext<Platform.IOS>> {
return await createSubmissionContextAsync({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
language: 'en-US',
},
nonInteractive: true,
});
}
beforeAll(() => {
vol.fromJSON({
@@ -35,15 +72,16 @@ describe(getAscApiKeyPathAsync, () => {
asMock(promptAsync).mockImplementationOnce(() => ({
keyP8Path: '/asc-api-key.p8',
}));
asMock(getCredentialsFromUserAsync).mockImplementationOnce(() => ({
asMock(getCredentialsFromUserAsync).mockImplementation(() => ({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.path,
path: { keyP8Path: '/doesnt-exist.p8', keyId: 'test-key-id', issuerId: 'test-issuer-id' },
};
const ascApiKeyPath = await getAscApiKeyPathAsync(source);
const ascApiKeyPath = await getAscApiKeyPathAsync(ctx, source);
expect(promptAsync).toHaveBeenCalled();
expect(ascApiKeyPath).toEqual({
keyP8Path: '/asc-api-key.p8',
@@ -53,20 +91,22 @@ describe(getAscApiKeyPathAsync, () => {
});
it("doesn't prompt for path if the provided file exists", async () => {
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.path,
path: { keyP8Path: '/asc-api-key.p8', keyId: 'test-key-id', issuerId: 'test-issuer-id' },
};
await getAscApiKeyPathAsync(source);
await getAscApiKeyPathAsync(ctx, source);
expect(promptAsync).not.toHaveBeenCalled();
});
it('returns the provided file path if the file exists', async () => {
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.path,
path: { keyP8Path: '/asc-api-key.p8', keyId: 'test-key-id', issuerId: 'test-issuer-id' },
};
const ascApiKeyPath = await getAscApiKeyPathAsync(source);
const ascApiKeyPath = await getAscApiKeyPathAsync(ctx, source);
expect(ascApiKeyPath).toEqual({
issuerId: 'test-issuer-id',
keyId: 'test-key-id',
@@ -80,14 +120,15 @@ describe(getAscApiKeyPathAsync, () => {
asMock(promptAsync).mockImplementationOnce(() => ({
keyP8Path: '/asc-api-key.p8',
}));
asMock(getCredentialsFromUserAsync).mockImplementationOnce(() => ({
asMock(getCredentialsFromUserAsync).mockImplementation(() => ({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.prompt,
};
const ascApiKeyPath = await getAscApiKeyPathAsync(source);
const ascApiKeyPath = await getAscApiKeyPathAsync(ctx, source);
expect(promptAsync).toHaveBeenCalled();
expect(ascApiKeyPath).toEqual({
keyP8Path: '/asc-api-key.p8',
@@ -111,10 +152,11 @@ describe(getAscApiKeyPathAsync, () => {
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.prompt,
};
const ascApiKeyPath = await getAscApiKeyPathAsync(source);
const ascApiKeyPath = await getAscApiKeyPathAsync(ctx, source);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(ascApiKeyPath).toEqual({
keyP8Path: '/asc-api-key.p8',
@@ -127,11 +169,12 @@ describe(getAscApiKeyPathAsync, () => {
describe(getAscApiKeyLocallyAsync, () => {
it('returns a local Asc Api Key file with a AscApiKeySourceType.path source', async () => {
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.path,
path: { keyP8Path: '/asc-api-key.p8', keyId: 'test-key-id', issuerId: 'test-issuer-id' },
};
const ascApiKeyResult = await getAscApiKeyLocallyAsync(source);
const ascApiKeyResult = await getAscApiKeyLocallyAsync(ctx, source);
expect(ascApiKeyResult).toMatchObject({
result: {
keyP8: 'super secret',
@@ -154,10 +197,11 @@ describe(getAscApiKeyLocallyAsync, () => {
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.prompt,
};
const serviceAccountResult = await getAscApiKeyLocallyAsync(source);
const serviceAccountResult = await getAscApiKeyLocallyAsync(ctx, source);
expect(serviceAccountResult).toMatchObject({
result: {
keyP8: 'super secret',
+5 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.34.0",
"description": "A library for interacting with eas.json",
"version": "0.34.1",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
@@ -24,13 +24,15 @@
"homepage": "https://github.com/expo/eas-cli",
"license": "MIT",
"main": "build/index.js",
"types": "build/index.d.ts",
"repository": "expo/eas-cli",
"scripts": {
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "rm -rf build && yarn build",
"test": "jest"
"test": "jest",
"clean": "rm -rf build node_modules yarn-error.log"
},
"publishConfig": {
"access": "public"
Generated
+4 -4
View File
@@ -833,10 +833,10 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@expo/[email protected]5":
version "0.0.0-alpha.25"
resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-0.0.0-alpha.25.tgz#fea4f5a409cc1bf79a0d2acec3eca52dbd8443c1"
integrity sha512-meyCJ/5jHJsgZNBMwlAugqD5Z0bcazCLgAyYQCw8O1/sXh4gWf0IiWJGnkevdxnA3g1R9nmJFmjkFuLoGOt+Bw==
"@expo/[email protected]6":
version "0.0.0-alpha.26"
resolved "https://registry.yarnpkg.com/@expo/apple-utils/-/apple-utils-0.0.0-alpha.26.tgz#cf5a893b7c9cc779af5b54a482196325dcde1426"
integrity sha512-t+xOhn4bYSAXkXamhDPUiI2Ol+QIwHRHLn/2QiCmNAGHolaVan/hMaVveSzvCYitpaJ16b4nthvcWFoJipxGlA==
"@expo/bunyan@^4.0.0":
version "4.0.0"