Compare commits

...
19 Commits
Author SHA1 Message Date
Douglas Lowder 8186a27aa2 v16.17.2 2025-07-23 15:06:56 -07:00
Expo CI 7804a00597 update CHANGELOG.md 2025-07-23 20:55:31 +00:00
Douglas Lowder 5c27574df2 Bump form-data, fix dependencies and GraphQL (#3111) 2025-07-23 13:43:50 -07:00
Expo CI f2ab223f0e update CHANGELOG.md 2025-07-18 17:37:57 +00:00
Kadi Kraman 38e64cbd9a v16.17.0 2025-07-18 18:14:57 +01:00
Kadi Kraman 2cd7194ef6 Fix formatting in changelog 2025-07-18 17:58:55 +01:00
Kadi Kraman 6c33c8a656 Fix changelog 2025-07-18 17:56:27 +01:00
Kadi Kraman 92643026f3 [eas-cli] Add command for deleting worker aliases (#3107) 2025-07-18 16:28:53 +01:00
Kadi Kraman b8a056b43e [eas-cli] Add command for deleting worker deployments (#3106) 2025-07-18 12:56:09 +01:00
Vojtech Novak 1ba5cb07ce [eas-cli] change push notification confirmation message (#3077) 2025-07-18 10:56:28 +02:00
Phil Pluckthun 8e96143b59 v16.16.0 2025-07-16 23:35:52 +01:00
Phil Pluckthun 52512c333d [eas-cli] Fix batch uploads being subject to early-termination issues corrupting multipart upload parts (#3103)
* Workaround early stream termination using filehandle.read

* Add length check to createReadStreamAsync

* Align asset and multipart entry file types

* Recalculate and check hash during upload

* Update error messages and add comment

* Emit errors earlier and refactor loop

* Use same reader function and disable objectMode

* Add CHANGELOG entry

* Apply lints
2025-07-16 14:34:10 +01:00
Douglas Lowder 576c798666 New commands workflow:view and workflow:logs (#3090)
# Why

Adding the new `workflow:logs` command (see the [workflow CLI design document](https://www.notion.so/expo/EAS-CLI-workflow-commands-8bc5ccc4f9f949c8adfec1347817113c)) and the related command `workflow:view`, to provide more details on workflow runs, jobs, and their associated logs.

```bash
$ eas workflow:view --help
view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select
from recent workflow runs for the current project.

USAGE
  $ eas workflow:view [ID] [--json] [--non-interactive]

ARGUMENTS
  ID  ID of the workflow run to view

FLAGS
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.

DESCRIPTION
  view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select
from recent
  workflow runs for the current project.
```
```bash
$ eas workflow:logs --help
view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. 
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.

USAGE
  $ eas workflow:logs [ID] [--json] [--non-interactive] [--allSteps]

ARGUMENTS
  ID  ID of the workflow run or workflow job to view logs for

FLAGS
  --allSteps         Print all logs, rather than prompting for a specific step. This will be automatically set when in
                     non-interactive mode.
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.

DESCRIPTION
  view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID.
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
# How

Implemented the new commands. New queries and and a new fragment for workflow jobs have been added.

A state machine has been implemented to ensure correct flow for the `workflow:logs` command, allowing the user to drill down from workflow runs, to jobs, to viewing logs for an individual step. Where it makes sense, the selection prompts allow backtracking to search for a different run, job, etc.

# Test Plan

- CI should pass
- Tested locally with different inputs, and with different flag values
- New unit tests added for `workflow:logs`
2025-07-15 21:19:01 -07:00
Vojtech Novak 356d6267ec [eas-cli] fix CapabilityModel creation and syncing (#3088)
# Why

The current implementation of capabilities syncing can lead to provisioning profiles becoming invalid. This PR refactors the syncing process to avoid this issue by making the following improvements:

1. Preventing unnecessary creation of capability identifiers that already exist
2. Only updating bundle capabilities when necessary

closes https://github.com/expo/eas-cli/issues/1069
closes https://github.com/expo/eas-cli/issues/1445

# How

I refactored the capability syncing logic to:

- Add test coverage for faulty behaviors. More tests are [up the stack](https://github.com/expo/eas-cli/pull/3086/files#diff-09c5aad6e72a7a336e9783710d3899ff2ea432146929bc07ba8bfec7c7bbd2a8).
- Only create capability identifiers that don't already exist on the server
- Skip updating bundle capabilities when no changes are needed


# Test Plan

`easd` command below is from https://github.com/expo/eas-cli/blob/main/CONTRIBUTING.md#development.

To best test this locally, have an iOS app with 

(1) no capabilities enabled. Enable some capabilities in `app.json` and run prebuild. [iCloud](https://docs.expo.dev/versions/latest/sdk/document-picker/#example-appjson-with-config-plugin), Apple Sign In, app groups, or `"com.apple.developer.default-data-protection": "NSFileProtectionComplete / ..."` are of special interest, but any will do.

OR

(2) a buch of capabilities already enabled

using this app, run `easd build -p ios` TWO times. Confirm that the first build creates new capabilities on your app bundle (https://developer.apple.com/account/resources/identifiers/list) and provisioning profiles (if any). Confirm the second build passes and doesn't require any provisioning profile changes.

Finally, change the capabilities, and again run `easd build -p ios` twice to confirm no capabilities / provisioning profiles are changed on the second run (that's what should happen on the first run).

- green CI
- local tests
2025-07-14 22:34:47 +02:00
Vojtech Novak a88897b40e [eas-cli] refactor capability syncing (#3087)
# Why

This PR refactors the iOS capability synchronization logic to make it more "linear" and easier to maintain

# How

- Encapsulating capability-specific logic with the capability definition
- Making the operation decision process more explicit and easier to follow

# Test Plan

- green CI
- local tests
2025-07-14 22:29:17 +02:00
Vojtech Novak 8431167a13 [eas-cli] fix capability syncing (#3086)
# Why

This PR refactors the iOS bundle ID capabilities management to improve handling of capabilities with complex settings. The current implementation doesn't properly handle capabilities that have settings - Sign In with Apple, iCloud, and Data Protection.


# How

- Added test cases that would previously fail
- Extracted capability list to a separate file (`capabilityList.ts`) to improve code organization
- Fixed synchronization logic to make tests pass (in a dirty way, refactored later)
- Fixed handling of MDM Managed Associated Domains to prevent disabling when Associated Domains is enabled
- Improved test assertions to use non-deprecated matchers (`toHaveBeenCalledWith` instead of `toBeCalled`)

# Test Plan

- green CI
- local testing
2025-07-14 22:09:24 +02:00
Stanisław Chmiela d87f8d5750 [eas-cli] Add support for workflow:run inputs (#3095)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to allow users to provide inputs to workflow runs.

# How

Added support for providing inputs with `-F` flag. It follows [`gh-cli`'s example](https://docs.github.com/en/actions/how-tos/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow#running-a-workflow).

<img width="748" alt="Zrzut ekranu 2025-07-7 o 14 44 09" src="https://github.com/user-attachments/assets/d2ae7fda-5ad8-44c2-9873-d396b02e4f17" />

I am explicitly not supporting `-f` because the backend does not allow "raw field with no type coercion". It always tries to resolve from whatever is the input to the right type.

# Test Plan

Ran `easd workflow:run upload-download-test.yml -F name=test -F test=test2` and the workflow run defined two inputs: `name` and `test`.

- [workflow run](https://expo.dev/accounts/sjchmiela/projects/test-production/workflows/0197f5ea-a397-734f-81e0-8a5e78e3aa90) from `easd workflow:run inputs-test.yml`
- [workflow run](https://expo.dev/accounts/sjchmiela/projects/test-production/workflows/0197f5f0-7206-73e2-b741-409decc6e9ca) from `easd workflow:run inputs-test.yml -F name=Doug`
2025-07-14 20:41:04 +02:00
Douglas Lowder 1a261cf043 Add GraphQL check to release workflow (#3101)
# Why

Noticed that a recent release failed the GraphQL check. This ensures that the release will fail if the check does not pass.
2025-07-11 12:35:16 -07:00
Expo CI 9daa933990 update CHANGELOG.md 2025-07-10 17:37:51 +00:00
47 changed files with 5670 additions and 1819 deletions

No files matched your search

+5
View File
@@ -18,6 +18,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: 'eas-cli@${{ env.EAS_CLI_VERSION }}'
draft: true
build-linux:
name: Build for Linux
@@ -30,6 +31,10 @@ jobs:
node-version: 20
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
+1
View File
@@ -17,6 +17,7 @@ concurrency:
jobs:
test:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
+28 -1
View File
@@ -8,10 +8,37 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
### 🐛 Bug fixes
### 🧹 Chores
## [16.17.1](https://github.com/expo/eas-cli/releases/tag/v16.17.1) - 2025-07-23
## [16.17.0](https://github.com/expo/eas-cli/releases/tag/v16.17.0) - 2025-07-18
### 🎉 New features
- Add `deploy:delete` for deleting worker deployments ([#3106](https://github.com/expo/eas-cli/pull/3106) by [@kadikraman](https://github.com/kadikraman))
- Add `deploy:alias:delete` for deleting worker deployment aliases ([#3107](https://github.com/expo/eas-cli/pull/3107) by [@kadikraman](https://github.com/kadikraman))
## [16.16.0](https://github.com/expo/eas-cli/releases/tag/v16.16.0) - 2025-07-16
### 🎉 New features
- Add workflow:view and workflow:logs commands. ([#3090](https://github.com/expo/eas-cli/pull/3090) by [@douglowder](https://github.com/douglowder))
- Add support for providing inputs to new workflow runs ([#3095](https://github.com/expo/eas-cli/pull/3095) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Refactor capabilities syncing to avoid provisioning profiles becoming invalid. ([#3088](https://github.com/expo/eas-cli/pull/3088) by [@vonovak](https://github.com/vonovak))
- Avoid corrupted EAS Hosting batch upload bodies and double-check length and checksums while uploading. ([#3103](https://github.com/expo/eas-cli/pull/3103) by [@kitten](https://github.com/kitten))
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
### 🎉 New features
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Update the EAS Hosting worker deployment progress indicator to use a progress bar for asset uploads instead ([#3099](https://github.com/expo/eas-cli/pull/3099) by [@kitten](https://github.com/kitten))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.15.0",
"version": "16.17.2",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -28,7 +28,7 @@
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "7.4.2",
"lerna": "8.2.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
+221 -84
View File
@@ -92,6 +92,8 @@ eas --help COMMAND
* [`eas credentials:configure-build`](#eas-credentialsconfigure-build)
* [`eas deploy [options]`](#eas-deploy-2moptions22m)
* [`eas deploy:alias`](#eas-deployalias)
* [`eas deploy:alias:delete [ALIAS_NAME]`](#eas-deployaliasdelete-alias_name)
* [`eas deploy:delete [DEPLOYMENT_ID]`](#eas-deploydelete-deployment_id)
* [`eas deploy:promote`](#eas-deploypromote)
* [`eas device:create`](#eas-devicecreate)
* [`eas device:delete`](#eas-devicedelete)
@@ -141,12 +143,16 @@ eas --help COMMAND
* [`eas webhook:view ID`](#eas-webhookview-id)
* [`eas whoami`](#eas-whoami)
* [`eas worker:alias`](#eas-workeralias)
* [`eas worker:alias:delete [ALIAS_NAME]`](#eas-workeraliasdelete-alias_name)
* [`eas worker:delete [DEPLOYMENT_ID]`](#eas-workerdelete-deployment_id)
* [`eas deploy [options]`](#eas-deploy-2moptions22m-1)
* [`eas workflow:cancel`](#eas-workflowcancel)
* [`eas workflow:create [NAME]`](#eas-workflowcreate-name)
* [`eas workflow:logs [ID]`](#eas-workflowlogs-id)
* [`eas workflow:run [FILE]`](#eas-workflowrun-file)
* [`eas workflow:runs`](#eas-workflowruns)
* [`eas workflow:validate PATH`](#eas-workflowvalidate-path)
* [`eas workflow:view [ID]`](#eas-workflowview-id)
## `eas account:login`
@@ -166,7 +172,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -183,7 +189,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -200,7 +206,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -214,7 +220,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -266,7 +272,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -287,7 +293,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -307,7 +313,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -327,7 +333,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -350,7 +356,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -391,7 +397,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -410,7 +416,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -427,7 +433,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -446,7 +452,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -466,7 +472,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -487,7 +493,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -522,7 +528,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -574,7 +580,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -603,7 +609,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -629,7 +635,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -683,7 +689,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -702,7 +708,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -721,7 +727,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -738,7 +744,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -759,7 +765,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -781,7 +787,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -801,7 +807,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -823,7 +829,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -845,7 +851,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -878,7 +884,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -901,7 +907,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -922,7 +928,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -939,7 +945,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -957,7 +963,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -985,7 +991,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1010,7 +1016,49 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/deploy/alias.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
Delete deployment aliases.
```
USAGE
$ eas deploy:alias:delete [ALIAS_NAME] [--json --non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
Delete deployment aliases.
ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
Delete a deployment.
```
USAGE
$ eas deploy:delete [DEPLOYMENT_ID] [--json --non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
Delete a deployment.
ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1047,7 +1095,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1067,7 +1115,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1088,7 +1136,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1109,7 +1157,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1123,7 +1171,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1137,7 +1185,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1166,7 +1214,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1190,7 +1238,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1211,7 +1259,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1236,7 +1284,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1261,7 +1309,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1284,7 +1332,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1305,7 +1353,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1336,7 +1384,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1381,7 +1429,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1412,7 +1460,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1520,7 +1568,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1537,7 +1585,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1554,7 +1602,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1584,7 +1632,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1598,7 +1646,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1621,7 +1669,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1639,7 +1687,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1674,7 +1722,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1716,7 +1764,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1736,7 +1784,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1757,7 +1805,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1781,7 +1829,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1803,7 +1851,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1839,7 +1887,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -1866,7 +1914,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -1894,7 +1942,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -1914,7 +1962,7 @@ DESCRIPTION
roll back to an embedded update or an existing update
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -1934,7 +1982,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -1955,7 +2003,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -1976,7 +2024,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1996,7 +2044,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2014,7 +2062,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2036,7 +2084,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2053,7 +2101,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2093,6 +2141,44 @@ ALIASES
$ eas deploy:promote
```
## `eas worker:alias:delete [ALIAS_NAME]`
Delete deployment aliases.
```
USAGE
$ eas worker:alias:delete [ALIAS_NAME] [--json --non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
Delete deployment aliases.
ALIASES
$ eas worker:alias:delete
```
## `eas worker:delete [DEPLOYMENT_ID]`
Delete a deployment.
```
USAGE
$ eas worker:delete [DEPLOYMENT_ID] [--json --non-interactive]
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
Delete a deployment.
ALIASES
$ eas worker:delete
```
## `eas deploy [options]`
deploy your Expo Router web build and API Routes
@@ -2135,7 +2221,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2155,7 +2241,31 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
USAGE
$ eas workflow:logs [ID] [--json] [--non-interactive] [--all-steps]
ARGUMENTS
ID ID of the workflow run or workflow job to view logs for
FLAGS
--all-steps Print all logs, rather than prompting for a specific step. This will be automatically set when in
non-interactive mode.
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID.
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2163,26 +2273,31 @@ run an EAS workflow
```
USAGE
$ eas workflow:run [FILE] [--non-interactive] [--wait] [--json]
$ eas workflow:run [FILE] [--non-interactive] [--wait] [-F <value>] [--json]
ARGUMENTS
FILE Path to the workflow file to run
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--[no-]wait Wait for workflow run to complete
-F, --input=<value>... Set workflow inputs
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--[no-]wait Wait for workflow run to complete
DESCRIPTION
run an EAS workflow
FLAG DESCRIPTIONS
-F, --input=<value>... Set workflow inputs
Add a parameter in key=value format. Use multiple instances of this flag to set multiple inputs.
--[no-]wait Wait for workflow run to complete
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2207,7 +2322,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:validate PATH`
@@ -2227,5 +2342,27 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.15.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.
```
USAGE
$ eas workflow:view [ID] [--json] [--non-interactive]
ARGUMENTS
ID ID of the workflow run to view
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.17.2/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+683 -103
View File
@@ -1710,8 +1710,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "No longer needed"
},
{
"name": "id",
@@ -2987,6 +2987,18 @@
"description": null,
"fields": null,
"inputFields": [
{
"name": "displayName",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@@ -22950,87 +22962,6 @@
"name": "BuildQuery",
"description": null,
"fields": [
{
"name": "all",
"description": "Get all builds.\nBy default, they are sorted from latest to oldest.\nAvailable only for admin users.",
"args": [
{
"name": "limit",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "offset",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "order",
"description": null,
"type": {
"kind": "ENUM",
"name": "Order",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "statuses",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "BuildStatus",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Build",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "allForApp",
"description": "Get all builds for a specific app.\nThey are sorted from latest to oldest.",
@@ -23586,6 +23517,175 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ChannelRuntimeCumulativeMetricsOverTimeData",
"description": null,
"fields": [
{
"name": "data",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "LineChartData",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metricsAtLastTimestamp",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "LineDatapoint",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mostPopularUpdates",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Update",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ChannelRuntimeEdge",
"description": null,
"fields": [
{
"name": "cursor",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "RuntimeWithLastActivity",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ChannelRuntimesConnection",
"description": null,
"fields": [
{
"name": "edges",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ChannelRuntimeEdge",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Charge",
@@ -26799,6 +26899,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filter",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "DeploymentBuildFilterInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "first",
"description": null,
@@ -27006,6 +27118,29 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DeploymentBuildFilterInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "status",
"description": null,
"type": {
"kind": "ENUM",
"name": "BuildStatus",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DeploymentBuildsConnection",
@@ -27803,6 +27938,55 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteWorkerDeploymentByIdentifier",
"description": null,
"args": [
{
"name": "appId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deploymentIdentifier",
"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": "DeleteWorkerDeploymentResult",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -45276,6 +45460,49 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RuntimeWithLastActivity",
"description": null,
"fields": [
{
"name": "lastActivityPostgresSerialized",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runtime",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Runtime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RuntimesConnection",
@@ -45547,8 +45774,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account.appsPaginated instead"
},
{
"name": "bestContactEmail",
@@ -50436,6 +50663,71 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "latestRuntimes",
"description": null,
"args": [
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ChannelRuntimesConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@@ -50452,6 +50744,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runtimeInsights",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UpdateChannelRuntimeInsights",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateBranches",
"description": null,
@@ -50751,6 +51059,221 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UpdateChannelRuntimeInsights",
"description": null,
"fields": [
{
"name": "cumulativeMetricsOverTime",
"description": null,
"args": [
{
"name": "timespan",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "InsightsTimespan",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ChannelRuntimeCumulativeMetricsOverTimeData",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "embeddedUpdateTotalUniqueUsers",
"description": null,
"args": [
{
"name": "runtimeVersion",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timespan",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "InsightsTimespan",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mostPopularUpdates",
"description": null,
"args": [
{
"name": "runtimeVersion",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timespan",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "InsightsTimespan",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Update",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uniqueUsersOverTime",
"description": null,
"args": [
{
"name": "runtimeVersion",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timespan",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "InsightsTimespan",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UniqueUsersOverTimeData",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UpdateDeploymentEdge",
@@ -52579,8 +53102,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account.appsPaginated instead"
},
{
"name": "bestContactEmail",
@@ -53463,8 +53986,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account.appsPaginated instead"
},
{
"name": "bestContactEmail",
@@ -55761,6 +56284,59 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateUserInvitationPermissions",
"description": "Update UserInvitation permissions by ID",
"args": [
{
"name": "invitationID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "permissions",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "Permission",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserInvitation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -58513,13 +59089,9 @@
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkerDeploymentCrashNode",
"ofType": null
}
"kind": "OBJECT",
"name": "WorkerDeploymentCrashNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -58596,13 +59168,9 @@
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "WorkerDeploymentCrashNode",
"ofType": null
}
"kind": "OBJECT",
"name": "WorkerDeploymentCrashNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -63586,6 +64154,18 @@
"description": null,
"fields": null,
"inputFields": [
{
"name": "inputs",
"description": null,
"type": {
"kind": "SCALAR",
"name": "JSONObject",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "projectSource",
"description": null,
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.15.0",
"version": "16.17.2",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -49,7 +49,7 @@
"fast-deep-equal": "3.1.3",
"fast-glob": "3.3.2",
"figures": "3.2.0",
"form-data": "4.0.0",
"form-data": "^4.0.4",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
@@ -108,7 +108,7 @@
"@types/getenv": "^1.0.0",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.2",
"@types/node-fetch": "2.6.12",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4",
"@types/promise-retry": "1.1.3",
@@ -119,7 +119,7 @@
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.7",
"@types/wrap-ansi": "3.0.0",
"axios": "0.28.1",
"axios": "1.11.0",
"eslint-plugin-graphql": "4.0.0",
"express": "4.20.0",
"memfs": "3.4.13",
@@ -7,7 +7,16 @@ import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { AppFragment, Role, WorkflowRunFragment, WorkflowRunStatus } from '../../graphql/generated';
import {
AppFragment,
Role,
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
export function getMockEasJson(): EasJson {
return {
@@ -182,14 +191,16 @@ export function getMockWorkflowRunsFragment(
successes?: number;
failures?: number;
pending?: number;
withJobs?: number;
}
): WorkflowRunFragment[] {
const { successes = 0, failures = 0, pending = 0 } = params ?? {};
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
const runs: any[] = [];
for (let i = 0; i < successes; i++) {
runs.push({
id: `success-${i}`,
status: WorkflowRunStatus.Success,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
@@ -204,6 +215,7 @@ export function getMockWorkflowRunsFragment(
for (let i = 0; i < failures; i++) {
runs.push({
id: `failure-${i}`,
errors: ['Failed'],
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
@@ -219,6 +231,7 @@ export function getMockWorkflowRunsFragment(
for (let i = 0; i < pending; i++) {
runs.push({
id: `pending-${i}`,
errors: [],
status: WorkflowRunStatus.InProgress,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
@@ -231,5 +244,64 @@ export function getMockWorkflowRunsFragment(
},
});
}
for (let i = 0; i < withJobs; i++) {
runs.push(getMockWorkflowRunWithJobsFragment());
}
return runs;
}
export function getMockWorkflowJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
id: 'job1',
key: 'job1',
name: 'job1',
status: WorkflowJobStatus.Success,
type: WorkflowJobType.Build,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
outputs: {},
errors: [],
workflowRun: {
id: runId ?? 'build1',
},
turtleJobRun: {
id: 'job1',
logFileUrls: ['https://example.com/log1'],
errors: [],
},
};
}
export function getMockWorkflowRunWithJobsFragment(
runID?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
const id = runID ?? 'build1';
return {
id,
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
app: {
id: mockProjectId,
__typename: 'App',
name: 'App',
ownerAccount: {
id: 'account-id',
name: 'account-name',
__typename: 'Account',
},
},
},
jobs: [getMockWorkflowJobFragment(id)],
};
}
@@ -0,0 +1,188 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowJobFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/WorkflowJobQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
});
afterEach(() => {
jest.clearAllMocks();
});
test('view logs, passing in no parameters, no runs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No workflow runs found');
});
test('view logs, passing in no parameters, runs found, no jobs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ successes: 1 }));
jest.mocked(promptAsync).mockResolvedValue({ selectedRun: 'build1' });
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No job found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, no logs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(2);
expect(Log.error).toHaveBeenCalledWith('No logs found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, ['job1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowJobQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'job1', {
useCache: false,
});
});
test('view logs for a workflow job, passing in a run ID', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowRunQuery.withJobsByIdAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
'build1',
{
useCache: false,
}
);
});
test('view logs for a workflow job, passing in a run ID, non-interactive', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--non-interactive'], ctx);
try {
await cmd.run();
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect((e as Error).message).toEqual('No workflow job found that matched the provided ID');
}
});
});
@@ -6,7 +6,7 @@ import {
mockTestCommand,
} from './utils';
import WorkflowRunList from '../../commands/workflow/runs';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -75,8 +75,10 @@ describe(WorkflowRunList, () => {
{
id: 'build',
status: WorkflowRunStatus.Success,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
errors: [],
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
@@ -119,6 +121,8 @@ describe(WorkflowRunList, () => {
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Other',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
@@ -135,10 +139,12 @@ describe(WorkflowRunList, () => {
{
id: 'build1',
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
@@ -165,6 +171,8 @@ describe(WorkflowRunList, () => {
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Manual',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
@@ -0,0 +1,15 @@
import { WorkflowJobResult } from './types';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
@@ -0,0 +1,324 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
/*
* State machine types and functions for moving between different workflow command states
*/
export enum WorkflowCommandSelectionStateValue {
WORKFLOW_RUN_SELECTION = 'WORKFLOW_RUN_SELECTION',
WORKFLOW_JOB_SELECTION = 'WORKFLOW_JOB_SELECTION',
WORKFLOW_STEP_SELECTION = 'WORKFLOW_STEP_SELECTION',
FINISH = 'FINISH',
ERROR = 'ERROR',
}
const workflowCommandSelectionAllowedStateTransitions: {
[key in WorkflowCommandSelectionStateValue]: WorkflowCommandSelectionStateValue[];
} = {
WORKFLOW_JOB_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
WORKFLOW_RUN_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
],
WORKFLOW_STEP_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
ERROR: [WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION],
FINISH: [],
};
export type WorkflowCommandSelectionState = {
graphqlClient: ExpoGraphqlClient;
projectId: string;
nonInteractive: boolean;
allSteps: boolean;
state: WorkflowCommandSelectionStateValue;
runId?: string;
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
message?: string;
};
export type WorkflowCommandSelectionStateParameters = Omit<
WorkflowCommandSelectionState,
'graphqlClient' | 'projectId' | 'state' | 'nonInteractive' | 'allSteps'
>;
export type WorkflowCommandSelectionAction = (
prevState: WorkflowCommandSelectionState
) => Promise<WorkflowCommandSelectionState>;
export function moveToNewWorkflowCommandSelectionState(
previousState: WorkflowCommandSelectionState,
newStateValue: WorkflowCommandSelectionStateValue,
parameters: WorkflowCommandSelectionStateParameters
): WorkflowCommandSelectionState {
if (
!workflowCommandSelectionAllowedStateTransitions[previousState.state].includes(newStateValue)
) {
const errorMessage = `Invalid state transition from ${
previousState.state
} to ${newStateValue}. Allowed transitions from ${
previousState.state
}: ${workflowCommandSelectionAllowedStateTransitions[previousState.state].join(', ')}`;
throw new Error(errorMessage);
}
return {
...previousState,
state: newStateValue,
...parameters,
};
}
export function moveToWorkflowRunSelectionState(
previousState: WorkflowCommandSelectionState,
params?: {
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
{
runId: params?.runId,
jobId: undefined,
}
);
}
export function moveToWorkflowJobSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
jobId?: string | undefined;
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
params
);
}
export function moveToWorkflowStepSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
job: WorkflowJobResult;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
params
);
}
export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.FINISH,
params
);
}
export function moveToWorkflowSelectionErrorState(
previousState: WorkflowCommandSelectionState,
message: string
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.ERROR,
{
message,
}
);
}
// eslint-disable-next-line async-protect/async-suffix
export const workflowRunSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, projectId, runId, jobId, allSteps } = prevState;
Log.debug(
`workflowRunSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (runId) {
return moveToWorkflowJobSelectionState(prevState, { runId });
}
const runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
undefined,
20
);
if (runs.length === 0) {
return moveToWorkflowSelectionErrorState(prevState, 'No workflow runs found');
}
const processedRuns = processWorkflowRuns(runs);
const choices = processedRuns.map(run => choiceFromWorkflowRun(run));
const selectedId = (
await promptAsync({
type: 'select',
name: 'selectedRun',
message: 'Select a workflow run:',
choices,
})
).selectedRun;
return moveToWorkflowJobSelectionState(prevState, { runId: selectedId });
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowJobSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, runId, jobId, nonInteractive, allSteps } = prevState;
Log.debug(
`workflowJobSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (jobId) {
let workflowJobResult = undefined;
try {
workflowJobResult = await WorkflowJobQuery.byIdAsync(graphqlClient, jobId, {
useCache: false,
});
return moveToWorkflowStepSelectionState(prevState, { job: workflowJobResult });
} catch {}
if (nonInteractive && !workflowJobResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job found that matched the provided ID'
);
} else {
// The passed in ID may be a run ID, pass it back to run selection action
return moveToWorkflowRunSelectionState(prevState, { runId: jobId });
}
} else {
// No job ID was passed in
if (nonInteractive) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job ID provided in non-interactive mode'
);
} else if (!runId) {
// If no jobId or runId, we should go back to run selection
return moveToWorkflowRunSelectionState(prevState);
}
const workflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, runId, {
useCache: false,
});
if (!workflowRunResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow run found that matched the provided ID'
);
}
const choices: Choice[] = [
...workflowRunResult.jobs.map((job, i) => choiceFromWorkflowJob(job, i)),
{
title: 'Go back and select a different workflow run',
value: -1,
},
];
const selectedJobIndex = (
await promptAsync({
type: 'select',
name: 'selectedJob',
message: 'Select a job:',
choices,
})
).selectedJob;
if (selectedJobIndex === -1) {
return moveToWorkflowRunSelectionState(prevState);
}
const selectedJob = workflowRunResult.jobs[selectedJobIndex] as WorkflowJobResult;
return moveToWorkflowStepSelectionState(prevState, { job: selectedJob });
}
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { job, allSteps } = prevState;
Log.debug(`workflowStepSelectionAction: job = ${job?.id}, allSteps = ${allSteps}`);
if (!job) {
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
}
const logs = await processLogsFromJobAsync(job);
if (!logs) {
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
}
if (allSteps) {
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
},
];
const selectedStep: string =
(
await promptAsync({
type: 'select',
name: 'selectedStep',
message: 'Select a step:',
choices,
})
).selectedStep ?? '';
if (selectedStep === 'go-back') {
return moveToWorkflowJobSelectionState(prevState, {
runId: job.workflowRun.id,
jobId: undefined,
});
}
return moveToWorkflowSelectionFinishedState(prevState, { step: selectedStep, logs });
};
export const executeWorkflowSelectionActionsAsync: WorkflowCommandSelectionAction =
async prevState => {
let currentState = prevState;
while (
currentState.state !== WorkflowCommandSelectionStateValue.FINISH &&
currentState.state !== WorkflowCommandSelectionStateValue.ERROR
) {
Log.debug(`${currentState.state}`);
switch (currentState.state) {
case WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION:
currentState = await workflowJobSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION:
currentState = await workflowRunSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION:
currentState = await workflowStepSelectionAction(currentState);
break;
}
}
return currentState;
};
@@ -0,0 +1,46 @@
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
/*
* Utility types for workflow commands
*/
export enum WorkflowTriggerType {
MANUAL = 'Manual',
GITHUB = 'GitHub',
SCHEDULED = 'Scheduled',
OTHER = 'Other',
}
export type WorkflowRunResult = {
id: string;
status: string;
gitCommitMessage: string | null;
gitCommitHash: string | null;
triggerType: WorkflowTriggerType;
trigger: string | null;
startedAt: string;
finishedAt: string;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
};
export type WorkflowResult = {
id: string;
name?: string | null | undefined;
fileName: string;
createdAt: string;
updatedAt: string;
};
export type WorkflowJobResult =
| WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]
| WorkflowJobByIdQuery['workflowJobs']['byId'];
export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
@@ -0,0 +1,128 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import {
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { Choice } from '../../prompts';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
trigger: string | null;
} {
let triggerType = WorkflowTriggerType.OTHER;
let trigger = '';
if (run.actor?.__typename === 'Robot') {
if (run.actor.firstName?.startsWith('GitHub App · ')) {
trigger = `${run.requestedGitRef ?? ''}@${run.gitCommitHash?.substring(0, 12) ?? ''}`;
}
} else if (run.actor?.__typename === 'User') {
trigger = run.actor.username;
}
switch (run.triggerEventType) {
case WorkflowRunTriggerEventType.Manual:
triggerType = WorkflowTriggerType.MANUAL;
break;
case WorkflowRunTriggerEventType.GithubPullRequestLabeled:
case WorkflowRunTriggerEventType.GithubPullRequestOpened:
case WorkflowRunTriggerEventType.GithubPullRequestReopened:
case WorkflowRunTriggerEventType.GithubPullRequestSynchronize:
case WorkflowRunTriggerEventType.GithubPush:
triggerType = WorkflowTriggerType.GITHUB;
break;
case WorkflowRunTriggerEventType.Schedule:
triggerType = WorkflowTriggerType.SCHEDULED;
trigger = run.triggeringSchedule ?? '';
break;
}
return { triggerType, trigger };
}
export function choiceFromWorkflowRun(run: WorkflowRunResult): Choice {
const titleArray = [
run.workflowFileName,
run.status,
run.startedAt,
run.triggerType,
run.trigger,
];
return {
title: titleArray.join(' - '),
value: run.id,
description: `ID: ${run.id}, Message: ${run.gitCommitMessage?.split('\n')[0] ?? ''}`,
};
}
export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Choice {
return {
title: `${job.name} - ${job.status}`,
value: index,
description: `ID: ${job.id}`,
};
}
export function choicesFromWorkflowLogs(logs: WorkflowLogs): Choice[] {
return Array.from(logs.keys()).map(step => {
const logLines = logs.get(step);
const stepStatus =
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
return {
title: `${step} - ${stepStatus}`,
value: step,
};
});
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
return {
id: run.id,
status: run.status,
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
gitCommitHash: run.gitCommitHash ?? null,
startedAt: run.createdAt,
finishedAt,
triggerType,
trigger,
workflowId: run.workflow.id,
workflowName: run.workflow.name ?? null,
workflowFileName: run.workflow.fileName,
};
});
}
export async function processLogsFromJobAsync(
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const rawLogs = await fetchRawLogsForJobAsync(job);
if (!rawLogs) {
return null;
}
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach(line => {
try {
const parsedLine = JSON.parse(line);
const { result, marker } = parsedLine;
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
const stepId = buildStepDisplayName ?? buildStepInternalId;
if (stepId) {
if (!logKeys.has(stepId)) {
logKeys.add(stepId);
logs.set(stepId, []);
}
logs.get(stepId)?.push({ time, msg, result, marker });
}
} catch {}
});
return logs;
}
@@ -1,37 +0,0 @@
import { WorkflowRunFragment, WorkflowRunStatus } from '../graphql/generated';
export type WorkflowRunResult = {
id: string;
status: string;
gitCommitMessage: string | null;
gitCommitHash: string | null;
startedAt: string;
finishedAt: string;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
};
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
return {
id: run.id,
status: run.status,
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
gitCommitHash: run.gitCommitHash ?? null,
startedAt: run.createdAt,
finishedAt,
workflowId: run.workflow.id,
workflowName: run.workflow.name ?? null,
workflowFileName: run.workflow.fileName,
};
});
}
export type WorkflowResult = {
id: string;
name?: string | null | undefined;
fileName: string;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,142 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
deleteWorkerDeploymentAliasAsync,
selectWorkerDeploymentAliasOnAppAsync,
} from '../../../worker/deployment';
interface DeployAliasDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'Delete deployment aliases.';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = [{ name: 'ALIAS_NAME' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { ALIAS_NAME: aliasNameFromArg },
flags: rawFlags,
} = await this.parse(WorkerAliasDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerAliasDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
const aliasName = await resolveDeploymentAliasAsync(
flags,
graphqlClient,
projectId,
aliasNameFromArg
);
const isProduction = !aliasName;
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
isProduction
? `You are about to delete your production alias`
: `You are about to delete alias: "${aliasName}"`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: null | Ora = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAliasAsync>> = null;
try {
progress = ora(chalk`Deleting alias {bold ${aliasName ?? 'production'}}`).start();
deleteResult = await deleteWorkerDeploymentAliasAsync({
graphqlClient,
appId: projectId,
aliasName,
});
progress.text = chalk`Deleted alias {bold ${aliasName ?? 'production'}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete alias {bold ${aliasName ?? 'production'}}`);
throw error;
}
progress?.succeed(chalk`Deleted alias {bold ${aliasName ?? 'production'}}`);
if (flags.json) {
printJsonOnlyOutput({
aliasName: deleteResult.aliasName,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
async function resolveDeploymentAliasAsync(
flags: DeployAliasDeleteFlags,
graphqlClient: ExpoGraphqlClient,
projectId: string,
aliasNameFromArg?: string
): Promise<string> {
if (aliasNameFromArg?.trim()) {
return aliasNameFromArg.trim().toLowerCase();
}
if (flags.nonInteractive) {
throw new Error('Alias name must be provided in non-interactive mode');
}
const alias = await selectWorkerDeploymentAliasOnAppAsync({
graphqlClient,
appId: projectId,
selectTitle: 'alias to delete',
});
if (!alias) {
throw new Error('No aliases found for this project, create aliases with "eas deploy:alias"');
}
return alias.aliasName;
}
@@ -1,20 +1,23 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../graphql/generated';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
assignWorkerDeploymentAliasAsync,
assignWorkerDeploymentProductionAsync,
selectWorkerDeploymentOnAppAsync,
} from '../../worker/deployment';
import { formatWorkerDeploymentJson, formatWorkerDeploymentTable } from '../../worker/utils/logs';
} from '../../../worker/deployment';
import {
formatWorkerDeploymentJson,
formatWorkerDeploymentTable,
} from '../../../worker/utils/logs';
interface DeployAliasFlags {
nonInteractive: boolean;
@@ -0,0 +1,114 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { deleteWorkerDeploymentAsync } from '../../worker/deployment';
interface DeployDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
interface RawDeployDeleteFlags {
'non-interactive': boolean;
json: boolean;
}
export default class WorkerDelete extends EasCommand {
static override description = 'Delete a deployment.';
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { DEPLOYMENT_ID: deploymentIdFromArg },
flags: rawFlags,
} = await this.parse(WorkerDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
if (!deploymentIdFromArg) {
if (flags.nonInteractive) {
throw new Error('Deployment ID must be provided in non-interactive mode');
}
throw new Error('Deployment ID is required');
}
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete deployment with ID: "${deploymentIdFromArg}"` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: Ora | null = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAsync>> = null;
try {
progress = ora(chalk`Deleting deployment {bold ${deploymentIdFromArg}}`).start();
deleteResult = await deleteWorkerDeploymentAsync({
graphqlClient,
appId: projectId,
deploymentIdentifier: deploymentIdFromArg,
});
progress.text = chalk`Deleted deployment {bold ${deploymentIdFromArg}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete deployment {bold ${deploymentIdFromArg}}`);
throw error;
}
progress?.succeed(chalk`Deleted deployment {bold ${deploymentIdFromArg}}`);
if (flags.json) {
printJsonOnlyOutput({
deploymentId: deleteResult.deploymentIdentifier,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
@@ -0,0 +1,243 @@
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
const inputs = parseInputs(['key=value']);
expect(inputs).toEqual({ key: 'value' });
});
it('should parse multiple key=value pairs', () => {
const inputs = parseInputs(['key1=value1', 'key2=value2', 'key3=value3']);
expect(inputs).toEqual({
key1: 'value1',
key2: 'value2',
key3: 'value3',
});
});
it('should handle empty value', () => {
const inputs = parseInputs(['key=']);
expect(inputs).toEqual({ key: '' });
});
it('should handle values with equals signs', () => {
const inputs = parseInputs(['key=value=with=equals']);
expect(inputs).toEqual({ key: 'value=with=equals' });
});
it('should handle values with spaces', () => {
const inputs = parseInputs(['key=value with spaces']);
expect(inputs).toEqual({ key: 'value with spaces' });
});
it('should handle special characters in values', () => {
const inputs = parseInputs(['key=value!@#$%^&*()']);
expect(inputs).toEqual({ key: 'value!@#$%^&*()' });
});
it('should throw error for invalid format without equals', () => {
expect(() => parseInputs(['invalid_format'])).toThrow(
'Invalid input format: invalid_format. Expected key=value format.'
);
});
it('should throw error for empty key', () => {
expect(() => parseInputs(['=value'])).toThrow(
'Invalid input format: =value. Key cannot be empty.'
);
});
it('should handle empty array', () => {
const inputs = parseInputs([]);
expect(inputs).toEqual({});
});
it('should handle mix of valid and complex inputs', () => {
const inputs = parseInputs([
'simple=value',
'complex=key=value=with=multiple=equals',
'empty=',
'spaces=value with spaces',
]);
expect(inputs).toEqual({
simple: 'value',
complex: 'key=value=with=multiple=equals',
empty: '',
spaces: 'value with spaces',
});
});
});
describe('parseJsonInputs', () => {
it('should parse simple JSON object', () => {
const json = '{"key": "value", "number": 42, "bool": true}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
key: 'value',
number: 42,
bool: true,
});
});
it('should handle complex objects', () => {
const json = '{"obj": {"nested": "value"}, "arr": [1, 2, 3]}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
obj: { nested: 'value' },
arr: [1, 2, 3],
});
});
it('should handle empty object', () => {
const json = '{}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({});
});
it('should handle string values with special characters', () => {
const json =
'{"special": "value with spaces", "equals": "key=value", "quotes": "value\\"with\\"quotes"}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
special: 'value with spaces',
equals: 'key=value',
quotes: 'value"with"quotes',
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{"invalid": json}';
expect(() => parseJsonInputs(invalidJson)).toThrow('Invalid JSON input.');
});
it('should throw error for non-object JSON', () => {
expect(() => parseJsonInputs('["array"]')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('"string"')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('42')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('null')).toThrow('Invalid JSON input.');
});
});
describe('parseWorkflowInputsFromYaml', () => {
it('should parse workflow inputs from YAML', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
environment:
type: string
required: true
description: "Environment to deploy to"
debug:
type: boolean
default: false
description: "Enable debug mode"
version:
type: number
required: true
description: "Version number"
deployment_type:
type: choice
options: ["staging", "production"]
default: "staging"
description: "Type of deployment"
target_env:
type: environment
default: "preview"
description: "Target environment"
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({
environment: {
type: 'string',
required: true,
description: 'Environment to deploy to',
},
debug: {
type: 'boolean',
required: false,
default: false,
description: 'Enable debug mode',
},
version: {
type: 'number',
required: true,
description: 'Version number',
},
deployment_type: {
type: 'choice',
required: false,
default: 'staging',
options: ['staging', 'production'],
description: 'Type of deployment',
},
target_env: {
type: 'environment',
required: false,
default: 'preview',
description: 'Target environment',
},
});
});
it('should handle YAML without workflow_dispatch inputs', () => {
const yamlConfig = `
on:
push:
branches: [main]
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle empty YAML', () => {
const yamlConfig = '';
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle invalid YAML gracefully', () => {
const yamlConfig = 'invalid: yaml: content:';
const { parseWorkflowInputsFromYaml } = require('../run');
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should default type to string when not specified', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
simple_input:
description: "A simple input"
required: true
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs.simple_input).toEqual({
type: 'string',
description: 'A simple input',
required: true,
});
});
});
@@ -1,6 +1,6 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { processWorkflowRuns } from '../../commandUtils/workflows';
import { choiceFromWorkflowRun, processWorkflowRuns } from '../../commandUtils/workflow/utils';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -65,12 +65,7 @@ export default class WorkflowRunCancel extends EasCommand {
type: 'multiselect',
name: 'selectedRuns',
message: 'Select IN_PROGRESS workflow runs to cancel',
choices: runs.map(run => ({
title: `${run.id} - ${run.workflowFileName}, ${run.gitCommitMessage ?? ''}, ${
run.startedAt
}`,
value: run.id,
})),
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
answers.selectedRuns.forEach((id: string) => {
workflowRunIds.add(id);
@@ -1,6 +1,6 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { WorkflowResult } from '../../commandUtils/workflows';
import { WorkflowResult } from '../../commandUtils/workflow/types';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import formatFields from '../../utils/formatFields';
@@ -0,0 +1,111 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
WorkflowCommandSelectionState,
WorkflowCommandSelectionStateValue,
executeWorkflowSelectionActionsAsync,
} from '../../commandUtils/workflow/stateMachine';
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
[...logs.keys()].forEach(step => {
const logLines = logs.get(step);
if (logLines) {
Log.log(`Step: ${step}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
Log.addNewLineIfNone();
});
}
export default class WorkflowLogView extends EasCommand {
static override description =
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.';
static override flags = {
...EasJsonOnlyFlag,
...EASNonInteractiveFlag,
'all-steps': Flags.boolean({
description:
'Print all logs, rather than prompting for a specific step. This will be automatically set when in non-interactive mode.',
default: false,
}),
};
static override args = [
{ name: 'id', description: 'ID of the workflow run or workflow job to view logs for' },
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(WorkflowLogView);
const nonInteractive = flags['non-interactive'];
const allSteps = nonInteractive ? true : flags['all-steps'];
Log.debug(`allSteps = ${allSteps}`);
Log.debug(`nonInteractive = ${nonInteractive}`);
Log.debug(`flags.json = ${flags.json}`);
Log.debug(`args.id = ${args.id}`);
const {
loggedIn: { graphqlClient },
projectId,
} = await this.getContextAsync(WorkflowLogView, {
nonInteractive,
});
if (flags.json) {
enableJsonOutput();
}
const finalSelectionState: WorkflowCommandSelectionState =
await executeWorkflowSelectionActionsAsync({
graphqlClient,
projectId,
nonInteractive,
allSteps,
state: WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
jobId: args.id,
});
if (finalSelectionState.state === WorkflowCommandSelectionStateValue.ERROR) {
Log.error(finalSelectionState.message);
return;
}
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
if (allSteps) {
if (logs) {
if (flags.json) {
printJsonOnlyOutput(Object.fromEntries(logs));
} else {
printLogsForAllSteps(logs);
}
}
} else {
const selectedStep = finalSelectionState?.step as unknown as string;
const logLines = logs?.get(selectedStep);
if (logLines) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
output[selectedStep] = logLines ?? null;
printJsonOnlyOutput(output);
} else {
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
}
}
}
}
@@ -1,9 +1,53 @@
/**
* EAS Workflow Run Command
*
* This command runs an EAS workflow with support for interactive input prompting.
*
* Input Sources (in order of precedence):
* 1. Command line flags (-F key=value)
* 2. STDIN JSON input (echo '{"key": "value"}' | eas workflow:run)
* 3. Interactive prompts (when required inputs are missing and not in non-interactive mode)
*
* Interactive Prompting:
* - When running in interactive mode (default), the command will automatically prompt
* for any required inputs that are not provided via flags or STDIN
* - Input types supported: string, boolean, number, choice, environment
* - Each input type has appropriate validation and default values
* - Use --non-interactive flag to disable prompting and require all inputs via flags
*
* Example workflow with inputs:
* ```yaml
* on:
* workflow_dispatch:
* inputs:
* environment:
* type: string
* required: true
* description: "Environment to deploy to"
* debug:
* type: boolean
* default: false
* description: "Enable debug mode"
* version:
* type: number
* required: true
* description: "Version number"
* deployment_type:
* type: choice
* options: ["staging", "production"]
* default: "staging"
* description: "Type of deployment"
* ```
*/
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import * as fs from 'node:fs';
import * as path from 'node:path';
import slash from 'slash';
import * as YAML from 'yaml';
import { z } from 'zod';
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
@@ -22,6 +66,7 @@ import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import { WorkflowFile } from '../../utils/workflowFile';
@@ -45,6 +90,14 @@ export default class WorkflowRun extends EasCommand {
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
summary: 'Wait for workflow run to complete',
}),
input: Flags.string({
char: 'F',
aliases: ['f', 'field'],
multiple: true,
description:
'Add a parameter in key=value format. Use multiple instances of this flag to set multiple inputs.',
summary: 'Set workflow inputs',
}),
...EasJsonOnlyFlag,
};
@@ -109,6 +162,41 @@ export default class WorkflowRun extends EasCommand {
}
}
let inputs: Record<string, unknown> | undefined;
// Check for stdin input
const stdinData = await maybeReadStdinAsync();
const inputsFromFlags = [...(flags.input ?? [])];
// Validate that both stdin and -F flags are not provided simultaneously
if (stdinData && inputsFromFlags.length > 0) {
throw new Error(
'Cannot use both stdin JSON input and -F flags simultaneously. Please use only one input method.'
);
}
if (stdinData) {
inputs = parseJsonInputs(stdinData);
} else if (inputsFromFlags.length > 0) {
inputs = parseInputs(inputsFromFlags);
}
// Parse workflow inputs from YAML and prompt for missing required inputs
const inputSpecs = parseWorkflowInputsFromYaml(yamlConfig);
if (!flags['non-interactive']) {
inputs = await maybePromptForMissingInputsAsync({ inputSpecs, inputs: inputs ?? {} });
}
if (inputs && Object.keys(inputs).length > 0) {
Log.addNewLineIfNone();
Log.newLine();
Log.log('Running with inputs:');
for (const [key, value] of Object.entries(inputs)) {
Log.log(`- ${chalk.bold(key)}: ${JSON.stringify(value)}`);
}
}
let projectArchiveBucketKey: string;
let easJsonBucketKey: string | null = null;
let packageJsonBucketKey: string | null = null;
@@ -176,6 +264,7 @@ export default class WorkflowRun extends EasCommand {
yamlConfig,
},
workflowRunInput: {
inputs,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
@@ -288,3 +377,246 @@ async function fileExistsAsync(filePath: string): Promise<boolean> {
.then(() => true)
.catch(() => false);
}
export function parseInputs(inputFlags: string[]): Record<string, string> {
const inputs: Record<string, string> = {};
for (const inputFlag of inputFlags) {
const equalIndex = inputFlag.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
}
const key = inputFlag.substring(0, equalIndex);
const value = inputFlag.substring(equalIndex + 1);
if (!key) {
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
}
inputs[key] = value;
}
return inputs;
}
export async function maybeReadStdinAsync(): Promise<string | null> {
// Check if there's data on stdin
if (process.stdin.isTTY) {
return null;
}
return await new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
const trimmedData = data.trim();
resolve(trimmedData || null);
});
process.stdin.on('error', err => {
reject(err);
});
});
}
export function parseJsonInputs(jsonString: string): Record<string, string> {
try {
const parsed = JSON.parse(jsonString);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON input must be an object.');
}
return parsed;
} catch (error) {
throw new Error(`Invalid JSON input.`, { cause: error });
}
}
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
// which is not what we want. See:
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
const booleanLike = z.union([
z.boolean(),
z.number().pipe(z.coerce.boolean()),
z.preprocess(val => {
if (typeof val === 'string') {
if (val.toLowerCase() === 'true') {
return true;
}
if (val.toLowerCase() === 'false') {
return false;
}
}
return val;
}, z.boolean()),
]);
const stringLike = z
.union([
// We're going to coerce numbers and strings into strings.
z.number(),
z.string(),
// We do not allow other primitives, like:
// - bigints, symbols - because YAML does not support them,
// - booleans - because YAML accepts `True` and `true` as boolean input
// and parses both as JS `true` -- if we stringified that,
// we would lose the capital "T" which may not be what the user expects,
// - nulls - user should do `"null"` or not pass the property at all.
])
.pipe(z.coerce.string());
export const WorkflowDispatchInputZ = z
.object({
description: stringLike.optional().describe('Description of the input'),
required: booleanLike.default(false).describe('Whether the input is required'),
})
.and(
z.union([
z.object({
type: z.literal('string').default('string'),
default: stringLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('boolean'),
default: booleanLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('number'),
default: z.number().optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('choice'),
default: stringLike.optional().describe('Default value for the input'),
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
}),
z.object({
type: z.literal('environment'),
default: z.string().optional().describe('Default value for the input'),
}),
])
);
export function parseWorkflowInputsFromYaml(
yamlConfig: string
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
try {
const parsed = YAML.parse(yamlConfig);
return z
.record(z.string(), WorkflowDispatchInputZ)
.default({})
.parse(parsed?.on?.workflow_dispatch?.inputs);
} catch (error) {
Log.warn('Failed to parse workflow inputs from YAML:', error);
return {};
}
}
async function maybePromptForMissingInputsAsync({
inputSpecs,
inputs,
}: {
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
inputs: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
if (missingRequiredInputs.length === 0) {
return inputs;
}
Log.addNewLineIfNone();
Log.log('Some required inputs are missing. Please provide them:');
const nextInputs = { ...inputs };
for (const [key, spec] of missingRequiredInputs) {
const value = await promptForMissingInputAsync({ key, spec });
nextInputs[key] = value;
}
return nextInputs;
}
async function promptForMissingInputAsync({
key,
spec,
}: {
key: string;
spec: z.infer<typeof WorkflowDispatchInputZ>;
}): Promise<unknown> {
const message = spec.description ? `${key} (${spec.description})` : key;
switch (spec.type) {
case 'boolean': {
const { value } = await promptAsync({
type: 'confirm',
name: 'value',
message,
initial: spec.default,
});
return value;
}
case 'number': {
const { value } = await promptAsync({
type: 'number',
name: 'value',
message,
initial: spec.default,
validate: (val: number) => {
if (isNaN(val)) {
return 'Please enter a valid number';
}
return true;
},
});
return value;
}
case 'choice': {
const { value } = await promptAsync({
type: 'select',
name: 'value',
message,
choices: spec.options.map(option => ({
title: option,
value: option,
})),
initial: spec.default,
});
return value;
}
case 'string':
case 'environment': {
const { value } = await promptAsync({
type: 'text',
name: 'value',
message,
initial: spec.default,
validate: (val: string) => {
if (spec.required && (!val || val.trim() === '')) {
return 'This field is required';
}
return true;
},
});
return value;
}
}
}
@@ -3,7 +3,7 @@ import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import { processWorkflowRuns } from '../../commandUtils/workflows';
import { processWorkflowRuns } from '../../commandUtils/workflow/utils';
import { WorkflowRunFragment, WorkflowRunStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
@@ -85,8 +85,9 @@ export default class WorkflowRunList extends EasCommand {
{ label: 'Status', value: run.status },
{ label: 'Started At', value: run.startedAt },
{ label: 'Finished At', value: run.finishedAt },
{ label: 'Trigger Type', value: run.triggerType },
{ label: 'Trigger', value: run.trigger ?? 'null' },
{ label: 'Git Commit Message', value: run.gitCommitMessage ?? 'null' },
{ label: 'Git Commit Hash', value: run.gitCommitHash ?? 'null' },
])
);
Log.addNewLineIfNone();
@@ -0,0 +1,128 @@
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
WorkflowCommandSelectionStateValue,
workflowRunSelectionAction,
} from '../../commandUtils/workflow/stateMachine';
import { WorkflowTriggerType } from '../../commandUtils/workflow/types';
import { computeTriggerInfoForWorkflowRun } from '../../commandUtils/workflow/utils';
import { WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log, { link } from '../../log';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowView extends EasCommand {
static override description =
'view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
static override flags = {
...EasJsonOnlyFlag,
...EASNonInteractiveFlag,
};
static override args = [{ name: 'id', description: 'ID of the workflow run to view' }];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(WorkflowView);
const nonInteractive = flags['non-interactive'];
const {
loggedIn: { graphqlClient },
projectId,
} = await this.getContextAsync(WorkflowView, {
nonInteractive,
});
if (flags.json) {
enableJsonOutput();
}
if (nonInteractive && !args.id) {
throw new Error('If non-interactive, this command requires a workflow run ID as argument');
}
const actionResult = await workflowRunSelectionAction({
graphqlClient,
projectId,
nonInteractive,
allSteps: false,
state: WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
runId: args.id,
});
if (actionResult.state === WorkflowCommandSelectionStateValue.ERROR) {
Log.error(actionResult.message);
return;
}
const idToQuery = actionResult.runId ?? '';
type WorkflowRunResult = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] & {
logURL?: string;
triggerType?: WorkflowTriggerType;
trigger?: string | null;
};
const result: WorkflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(
graphqlClient,
idToQuery,
{
useCache: false,
}
);
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(result);
result.triggerType = triggerType;
result.trigger = trigger;
result.jobs.forEach(job => {
delete job.turtleJobRun;
});
result.logURL = getWorkflowRunUrl(
result.workflow.app.ownerAccount.name,
result.workflow.app.name,
result.id
);
if (flags.json) {
printJsonOnlyOutput(result);
return;
}
Log.log(
formatFields([
{ label: 'Run ID', value: result.id },
{ label: 'Workflow', value: result.workflow.fileName },
{ label: 'Trigger Type', value: result.triggerType },
{ label: 'Trigger', value: result.trigger ?? 'null' },
{
label: 'Git Commit Message',
value: result.gitCommitMessage?.split('\n')[0] ?? null ?? 'null',
},
{ label: 'Status', value: result.status },
{ label: 'Errors', value: result.errors.map(error => error.title).join('\n') },
{ label: 'Created At', value: result.createdAt },
{ label: 'Updated At', value: result.updatedAt },
{ label: 'Log URL', value: link(result.logURL) },
])
);
Log.addNewLineIfNone();
result.jobs.forEach(job => {
Log.log(
formatFields([
{ label: 'Job ID', value: job.id },
{ label: ' Key', value: job.key },
{ label: ' Name', value: job.name },
{ label: ' Status', value: job.status },
{ label: ' Type', value: job.type },
{ label: ' Created At', value: job.createdAt },
{ label: ' Updated At', value: job.updatedAt },
{ label: ' Outputs', value: JSON.stringify(job.outputs, null, 2) },
{ label: ' Errors', value: job.errors.map(error => error.title).join('\n') },
])
);
Log.addNewLineIfNone();
});
}
}
@@ -98,9 +98,7 @@ export default class IosCredentialsProvider {
const setupPushKeyAction = new SetUpPushKey(appLookupParams);
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(ctx);
if (isPushKeySetup) {
Log.succeed(
`Push Notifications setup for ${app.projectName}: ${applicationTarget.bundleIdentifier}`
);
Log.succeed(`Push Notifications are set up`);
return null;
}
@@ -1,10 +1,20 @@
import { BundleIdCapability } from '@expo/apple-utils';
import {
CapabilityMapping,
assertValidOptions,
syncCapabilitiesForEntitlementsAsync,
} from '../bundleIdCapabilities';
import { assertValidOptions, syncCapabilitiesForEntitlementsAsync } from '../bundleIdCapabilities';
import { CapabilityMapping } from '../capabilityList';
const noBroadcastNotificationOption = {
usesBroadcastPushNotifications: false,
};
// Helper function to create reusable bundleId objects for testing
function createMockBundleId(id: string = 'XXX', capabilities: BundleIdCapability[] = []): any {
return {
getBundleIdCapabilitiesAsync: jest.fn(() => capabilities),
updateBundleIdCapabilityAsync: jest.fn(),
id,
} as any;
}
describe(assertValidOptions, () => {
it(`adds a reason for asserting capability identifiers`, () => {
@@ -13,7 +23,7 @@ describe(assertValidOptions, () => {
)!;
expect(() => {
assertValidOptions(classifier, ['foobar']);
}).toThrowError(/Expected an array of strings, where each string is prefixed with "merchant."/);
}).toThrow(/Expected an array of strings, where each string is prefixed with "merchant."/);
});
});
@@ -39,17 +49,17 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
'com.apple.developer.associated-domains': ['applinks:packagename.fr'],
};
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => capabilities),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('U78L9459DG', capabilities);
await syncCapabilitiesForEntitlementsAsync(bundleId, entitlements, {
usesBroadcastPushNotifications: false,
});
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
bundleId,
entitlements,
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(enabled).toStrictEqual(['Associated Domains']);
expect(disabled).toStrictEqual([]);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{
capabilityType: 'ASSOCIATED_DOMAINS',
option: 'ON',
@@ -57,12 +67,255 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
]);
});
describe('capabilities with settings', () => {
const ctx = { providerId: 123195, teamId: 'MyteamId' };
const capabilities = [
{
id: 'U78L9459DG_APPLE_ID_AUTH',
attributes: {
ownerType: 'BUNDLE',
settings: [
{
key: 'APPLE_ID_AUTH_APP_CONSENT',
options: [
{
key: 'PRIMARY_APP_CONSENT',
inputs: [
{
key: 'SERVER_TO_SERVER_NOTIF_URL',
name: 'Server to Server Notification URL',
description: 'Server to Server Notification URL',
allowedInstances: 'SINGLE',
minInstances: 0,
displayOrder: 0,
maxLimit: 1,
values: [
{
value: 'https://example.com/path/to/endpoint',
},
],
},
],
properties: [null],
},
],
},
],
editable: true,
inputs: null,
enabled: true,
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
},
},
{
id: 'U78L9459DG_ICLOUD',
attributes: {
ownerType: 'BUNDLE',
settings: [
{
key: 'ICLOUD_VERSION',
options: [
{
key: 'XCODE_6',
},
],
},
],
editable: true,
inputs: null,
enabled: true,
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
},
},
{
id: 'U78L9459DG_DATA_PROTECTION',
attributes: {
ownerType: 'BUNDLE',
settings: [
{
key: 'DATA_PROTECTION_PERMISSION_LEVEL',
options: [
{
key: 'COMPLETE_PROTECTION',
},
],
},
],
editable: true,
inputs: null,
enabled: true,
responseId: '570fdbea-7996-4ace-8397-e318973d2f36',
},
},
].map(({ id, attributes }) => new BundleIdCapability(ctx, id, attributes as any));
const entitlements = {
'com.apple.developer.applesignin': ['Default'],
'com.apple.developer.default-data-protection': 'NSFileProtectionComplete',
'com.apple.developer.icloud-container-identifiers': ['iCloud.com.vonovak.edfapp'],
'com.apple.developer.icloud-services': ['CloudDocuments'],
'com.apple.developer.ubiquity-container-identifiers': ['iCloud.com.vonovak.edfapp'],
'com.apple.developer.ubiquity-kvstore-identifier':
'$(TeamIdentifierPrefix)com.vonovak.edfapp',
};
it(`given a nonempty set S of remote capabilities, and a local boolean capability that is disabled and not in S, it does not disable any capability from S`, async () => {
// this is for an edge case
const bundleId = createMockBundleId('U78L9459DG', capabilities);
const result = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.healthkit': false,
...entitlements,
},
noBroadcastNotificationOption
);
expect(result).toStrictEqual({
enabled: [],
disabled: [],
});
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
});
it('does not sync a capability that is already enabled', async () => {
const bundleId = createMockBundleId('U78L9459DG', capabilities);
const result = await syncCapabilitiesForEntitlementsAsync(
bundleId,
entitlements,
noBroadcastNotificationOption
);
expect(result).toStrictEqual({
enabled: [],
disabled: [],
});
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
});
it('enables capabilities when they are not present', async () => {
const bundleId = createMockBundleId('U78L9459DG', []);
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
bundleId,
entitlements,
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'APPLE_ID_AUTH', option: 'ON' },
{ capabilityType: 'DATA_PROTECTION', option: 'COMPLETE_PROTECTION' },
{ capabilityType: 'ICLOUD', option: 'ON' },
]);
expect(enabled).toStrictEqual(['Sign In with Apple', 'Data Protection', 'iCloud']);
expect(disabled).toStrictEqual([]);
});
it('disables capabilities when no entitlements are provided', async () => {
const bundleId = createMockBundleId('U78L9459DG', capabilities);
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{}, // no entitlements
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'APPLE_ID_AUTH', option: 'OFF' },
{ capabilityType: 'ICLOUD', option: 'OFF' },
{ capabilityType: 'DATA_PROTECTION', option: 'OFF' },
]);
expect(enabled).toStrictEqual([]);
expect(disabled).toStrictEqual(['Sign In with Apple', 'iCloud', 'Data Protection']);
});
});
describe('boolean capabilities: given a bundleId with no capabilities', () => {
it('enables boolean capability when set to true', async () => {
const bundleId = createMockBundleId();
const result = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.networking.wifi-info': true,
},
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'ACCESS_WIFI_INFORMATION', option: 'ON' },
]);
expect(result).toStrictEqual({
enabled: ['Access WiFi Information'],
disabled: [],
});
});
it('does not enable a capability when it is set to false', async () => {
const bundleId = createMockBundleId();
const result = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.networking.wifi-info': false,
},
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
expect(result).toStrictEqual({
enabled: [],
disabled: [],
});
});
describe('given a bundleId with a capability that is enabled', () => {
const ctx = { providerId: 123195, teamId: 'MyteamId' };
const remote = {
id: 'UFJ54VZ75A_ACCESS_WIFI_INFORMATION',
attributes: {
ownerType: 'BUNDLE',
settings: null,
editable: true,
inputs: null,
enabled: true,
responseId: '31746b7d-4728-49f5-a8f9-a81c0cecabb1',
},
};
const capability = new BundleIdCapability(ctx, remote.id, remote.attributes as any);
it('and the entitlement is set to false, the capability is disabled', async () => {
const bundleId = createMockBundleId('UFJ54VZ75A', [capability]);
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.networking.wifi-info': false,
},
noBroadcastNotificationOption
);
expect(enabled).toStrictEqual([]);
expect(disabled).toStrictEqual(['Access WiFi Information']);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'ACCESS_WIFI_INFORMATION', option: 'OFF' },
]);
});
it('and the entitlement is set to true, the capability is skipped', async () => {
const bundleId = createMockBundleId('UFJ54VZ75A', [capability]);
const { enabled, disabled } = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.networking.wifi-info': true,
},
noBroadcastNotificationOption
);
expect(enabled).toStrictEqual([]);
expect(disabled).toStrictEqual([]);
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
});
});
});
it('enables all capabilities', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => []),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId();
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
@@ -124,9 +377,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
'com.apple.developer.shared-with-you': true,
'aps-environment': 'production',
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
@@ -324,25 +575,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('skips simple duplicates', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.healthkit': true,
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
expect(results.enabled).toStrictEqual([]);
expect(results.disabled).toStrictEqual([]);
@@ -369,9 +614,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
{
'com.apple.developer.healthkit': true,
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
)
).rejects.toThrowError(
`https://developer-mdn.apple.com/account/resources/identifiers/bundleId/edit/XXX333`
@@ -379,25 +622,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('cannot skip complex duplicates', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: [] }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_HEALTHKIT', { settings: [] }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.healthkit': true,
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'HEALTHKIT', option: 'ON' },
]);
@@ -406,25 +643,19 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('disables some capabilities', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_HOMEKIT', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_HOMEKIT', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.healthkit': true,
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'HEALTHKIT', option: 'ON' },
{ capabilityType: 'HOMEKIT', option: 'OFF' },
]);
@@ -435,46 +666,34 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
// We don't disable APNS, IAP, or GC
it('does not disable special capabilities', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_IN_APP_PURCHASE', { settings: null }),
new BundleIdCapability({}, 'XXX_GAME_CENTER', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_IN_APP_PURCHASE', { settings: null }),
new BundleIdCapability({}, 'XXX_GAME_CENTER', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalledWith();
expect(results.enabled).toStrictEqual([]);
expect(results.disabled).toStrictEqual([]);
});
// Only disable known capabilities
it('does not disable unhandled capabilities', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_UNKNOWN_NEW_THING', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_UNKNOWN_NEW_THING', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'com.apple.developer.healthkit': true,
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
@@ -486,11 +705,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('enables push notifications capability with broadcast option when the capability is disabled and usesBroadcastPushNotifications is true', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => []),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId();
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
@@ -502,7 +717,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
}
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
]);
@@ -511,13 +726,9 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('updates push notifications capability with broadcast option when the capability is enabled without settings and usesBroadcastPushNotifications is true', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
@@ -529,7 +740,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
}
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'PUSH_NOTIFICATION_FEATURE_BROADCAST' },
]);
@@ -538,13 +749,9 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('disables push notifications capability', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', { settings: null }),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
@@ -554,7 +761,7 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
}
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'OFF' },
]);
@@ -563,15 +770,11 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('does nothing when push notifications capability is enabled with broadcast options and usesBroadcastPushNotifications is true', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: [],
}),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: [],
}),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
@@ -590,24 +793,18 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('does nothing when push notifications capability is enabled without broadcast options and usesBroadcastPushNotifications is false', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: null,
}),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: null,
}),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'aps-environment': 'production',
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).not.toBeCalled();
@@ -617,27 +814,21 @@ describe(syncCapabilitiesForEntitlementsAsync, () => {
});
it('updates push notifications capability without broadcast options when it is enabled and usesBroadcastPushNotifications is false', async () => {
const bundleId = {
getBundleIdCapabilitiesAsync: jest.fn(() => [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: [],
}),
]),
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
const bundleId = createMockBundleId('XXX', [
new BundleIdCapability({}, 'XXX_PUSH_NOTIFICATIONS', {
settings: [],
}),
]);
const results = await syncCapabilitiesForEntitlementsAsync(
bundleId,
{
'aps-environment': 'production',
},
{
usesBroadcastPushNotifications: false,
}
noBroadcastNotificationOption
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledWith([
{ capabilityType: 'PUSH_NOTIFICATIONS', option: 'ON' },
]);
@@ -1,56 +1,61 @@
import { syncCapabilityIdentifiersForEntitlementsAsync } from '../capabilityIdentifiers';
const mockItems = {
merchant: {
expo: { id: 'XXX-merch-1', attributes: { identifier: 'merchant.expo' } },
},
appGroup: {
expo: { id: 'XXX-group-1', attributes: { identifier: 'group.expo' } },
},
cloudContainer: {
expo: { id: 'XXX-icloud-1', attributes: { identifier: 'iCloud.expo' } },
},
};
function mockCapabilities(Apple: any): void {
Apple.MerchantId.getAsync = jest.fn(() => [
{
id: 'XXX-merch-1',
const mockGetAsync = (existingItems: any[]): jest.Mock =>
jest.fn(() => Promise.resolve(existingItems));
const mockCreateAsync = (newId: string): jest.Mock =>
jest.fn((_ctx, { identifier }) => ({
id: newId,
attributes: {
identifier: 'merchant.expo',
identifier,
},
},
}));
Apple.MerchantId.getAsync = mockGetAsync([mockItems.merchant.expo]);
Apple.MerchantId.createAsync = mockCreateAsync('XXX-merch-2');
Apple.AppGroup.getAsync = mockGetAsync([mockItems.appGroup.expo]);
Apple.AppGroup.createAsync = mockCreateAsync('XXX-group-2');
Apple.CloudContainer.getAsync = mockGetAsync([mockItems.cloudContainer.expo]);
Apple.CloudContainer.createAsync = mockCreateAsync('XXX-icloud-2');
}
function mockCapabilitiesAlreadyCreated(Apple: any): void {
Apple.MerchantId.getAsync.mockResolvedValue([
mockItems.merchant.expo,
{ id: 'XXX-merch-2', attributes: { identifier: 'merchant.bacon' } },
]);
Apple.MerchantId.createAsync = jest.fn((_ctx, { identifier }) => ({
id: 'XXX-merch-2',
attributes: {
identifier,
},
}));
Apple.AppGroup.getAsync = jest.fn(() => [
{
id: 'XXX-group-1',
attributes: {
identifier: 'group.expo',
},
},
]);
Apple.AppGroup.createAsync = jest.fn((_ctx, { identifier }) => ({
id: 'XXX-group-2',
attributes: {
identifier,
},
}));
Apple.CloudContainer.getAsync = jest.fn(() => [
{
id: 'XXX-icloud-1',
attributes: {
identifier: 'iCloud.expo',
},
},
Apple.AppGroup.getAsync.mockResolvedValue([
mockItems.appGroup.expo,
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
]);
Apple.CloudContainer.createAsync = jest.fn((_ctx, { identifier }) => ({
id: 'XXX-icloud-2',
attributes: {
identifier,
},
}));
Apple.CloudContainer.getAsync.mockResolvedValue([
mockItems.cloudContainer.expo,
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
]);
}
describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
it(`creates missing capability identifiers`, async () => {
it(`creates missing capability identifiers, and once they are created, they are not re-created`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
@@ -60,10 +65,26 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
id: 'XXX',
} as any;
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
'com.apple.developer.in-app-payments': ['merchant.bacon'],
'com.apple.security.application-groups': ['group.bacon'],
'com.apple.developer.icloud-container-identifiers': ['iCloud.bacon'],
const syncCapabilities = (): Promise<{
created: string[];
linked: string[];
}> =>
syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
'com.apple.developer.in-app-payments': ['merchant.bacon'],
'com.apple.security.application-groups': ['group.bacon'],
'com.apple.developer.icloud-container-identifiers': ['iCloud.bacon'],
});
const result = await syncCapabilities();
expect(result).toStrictEqual({
created: ['group.bacon', 'merchant.bacon', 'iCloud.bacon'],
linked: ['group.bacon', 'merchant.bacon', 'iCloud.bacon'],
});
mockCapabilitiesAlreadyCreated(Apple);
const result2 = await syncCapabilities();
expect(result2).toStrictEqual({
created: [],
linked: [],
});
// Ensure we create missing ids
@@ -73,6 +94,7 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
// props
{ identifier: 'merchant.bacon' }
);
expect(Apple.MerchantId.createAsync).toHaveBeenCalledTimes(1);
// Ensure we create missing ids
expect(Apple.AppGroup.createAsync).toHaveBeenLastCalledWith(
// auth context
@@ -80,6 +102,7 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
// props
{ identifier: 'group.bacon' }
);
expect(Apple.AppGroup.createAsync).toHaveBeenCalledTimes(1);
// Ensure we create missing ids
expect(Apple.CloudContainer.createAsync).toHaveBeenLastCalledWith(
// auth context
@@ -87,8 +110,8 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
// props
{ identifier: 'iCloud.bacon' }
);
expect(bundleId.updateBundleIdCapabilityAsync).toBeCalledWith([
expect(Apple.CloudContainer.createAsync).toHaveBeenCalledTimes(1);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
{
capabilityType: 'APP_GROUPS',
option: 'ON',
@@ -111,9 +134,10 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
},
},
]);
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
});
it(`removes local duplicates`, async () => {
it(`doesn't perform duplicate CapabilityModel.getAsync calls`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
const bundleId = {
@@ -122,28 +146,38 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
id: 'XXX',
} as any;
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
'com.apple.developer.in-app-payments': ['merchant.expo', 'merchant.expo'],
});
// Only called once because we remove local duplicates to minimize network requests.
expect(Apple.MerchantId.getAsync).toBeCalledTimes(1);
expect(Apple.MerchantId.getAsync).toHaveBeenCalledTimes(1);
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
expect(result).toStrictEqual({
created: [],
linked: [],
});
});
it(`creates missing capability identifiers`, async () => {
it(`does not create capability identifiers (merchant.expo) when it already exists`, async () => {
const Apple = require('@expo/apple-utils');
mockCapabilities(Apple);
const bundleId = {
context: {},
updateBundleIdCapabilityAsync: jest.fn(),
id: 'XXX',
} as any;
};
await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId as any, {
'com.apple.developer.in-app-payments': ['merchant.expo'],
});
expect(Apple.MerchantId.createAsync).not.toBeCalled();
expect(Apple.MerchantId.createAsync).not.toHaveBeenCalled();
expect(bundleId.updateBundleIdCapabilityAsync).not.toHaveBeenCalled();
expect(result).toStrictEqual({
created: [],
linked: [],
});
});
it(`throws when creating a missing capability that is reserved`, async () => {
@@ -1,70 +1,22 @@
import {
AppGroup,
BundleId,
BundleIdCapability,
CapabilityOptionMap,
CapabilityType,
CapabilityTypeDataProtectionOption,
CapabilityTypeOption,
CapabilityTypePushNotificationsOption,
CloudContainer,
MerchantId,
} from '@expo/apple-utils';
import { JSONObject, JSONValue } from '@expo/json-file';
import { JSONObject } from '@expo/json-file';
import getenv from 'getenv';
import { inspect } from 'util';
import {
CapabilityClassifier,
CapabilityMapping,
associatedDomainsCapabilityType,
} from './capabilityList';
import Log from '../../../log';
export const EXPO_NO_CAPABILITY_SYNC = getenv.boolish('EXPO_NO_CAPABILITY_SYNC', false);
type GetOptionsMethod<T extends CapabilityType = any> = (
entitlement: JSONValue,
entitlementsJson: JSONObject,
additionalOptions: {
usesBroadcastPushNotifications?: boolean;
}
) => CapabilityOptionMap[T];
const validateBooleanOptions = (options: any): boolean => {
return typeof options === 'boolean';
};
const validatePrefixedStringArrayOptions =
(prefix: string) =>
(options: any): boolean => {
return (
Array.isArray(options) &&
options.every(option => typeof option === 'string' && option.startsWith(prefix))
);
};
const validateStringArrayOptions = (options: any): boolean => {
return Array.isArray(options) && options.every(option => typeof option === 'string');
};
const createValidateStringOptions =
(allowed: string[]) =>
(options: any): boolean => {
return allowed.includes(options);
};
const createValidateStringArrayOptions =
(allowed: string[]) =>
(options: any): boolean => {
return Array.isArray(options) && options.every(option => allowed.includes(option));
};
const validateDevProdString = createValidateStringOptions(['development', 'production']);
const getBooleanOptions: GetOptionsMethod = entitlement => {
return entitlement === true ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
};
const getDefinedOptions: GetOptionsMethod = entitlement => {
return entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
};
/**
* Given an entitlements JSON object, synchronizes the remote capabilities for a bundle identifier.
*
@@ -78,6 +30,7 @@ const getDefinedOptions: GetOptionsMethod = entitlement => {
*
* @param bundleId bundle identifier object
* @param entitlements JSON representation of the entitlements plist
* @param additionalOptions Additional options to consider when syncing capabilities.
* @returns
*/
export async function syncCapabilitiesForEntitlementsAsync(
@@ -105,7 +58,8 @@ export async function syncCapabilitiesForEntitlementsAsync(
const { disabledCapabilityNames, request: modifiedRequest } = getCapabilitiesToDisable(
bundleId,
remainingCapabilities,
request
request,
entitlements
);
if (modifiedRequest.length) {
@@ -133,21 +87,8 @@ interface CapabilitiesRequest {
option: any;
}
function shouldSkipPushNotificationsCapabilityUpdate(
existing: BundleIdCapability,
additionalOptions: {
usesBroadcastPushNotifications: boolean;
}
): boolean {
// For push notifications, we should always update the capabaility if
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
const noSettingsAttributes = existing.attributes.settings == null;
return !noSettingsAttributes === additionalOptions.usesBroadcastPushNotifications;
}
function getCapabilitiesToEnable(
currentCapabilities: BundleIdCapability[],
currentRemoteCapabilities: BundleIdCapability[],
entitlements: JSONObject,
additionalOptions: {
usesBroadcastPushNotifications: boolean;
@@ -159,7 +100,7 @@ function getCapabilitiesToEnable(
} {
const enabledCapabilityNames: string[] = [];
const request: { capabilityType: CapabilityType; option: any }[] = [];
const remainingCapabilities = [...currentCapabilities];
const remainingCapabilities = [...currentRemoteCapabilities];
for (const [key, value] of Object.entries(entitlements)) {
const staticCapabilityInfo = CapabilityMapping.find(
capability => capability.entitlement === key
@@ -172,47 +113,35 @@ function getCapabilitiesToEnable(
continue;
}
assertValidOptions(staticCapabilityInfo, value);
const existingIndex = remainingCapabilities.findIndex(existing =>
existing.isType(staticCapabilityInfo.capability)
);
const existing = existingIndex > -1 ? remainingCapabilities[existingIndex] : null;
const existingRemote = existingIndex > -1 ? remainingCapabilities[existingIndex] : null;
// Only skip if the existing capability is a simple boolean value,
// if it has more complex settings then we should always update it.
// If the `existing.attributes.settings` object is defined, then we can determine that it has extra configuration.
// For push notifications, we should always update the capabaility if
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
const isPushNotificationsCapability =
staticCapabilityInfo.capability === CapabilityType.PUSH_NOTIFICATIONS;
if (
existing &&
((!isPushNotificationsCapability && existing.attributes.settings == null) ||
(isPushNotificationsCapability &&
shouldSkipPushNotificationsCapabilityUpdate(existing, additionalOptions)))
) {
// Remove the item from the list of capabilities so we don't disable it.
remainingCapabilities.splice(existingIndex, 1);
if (Log.isDebug) {
Log.log(`Skipping existing capability: ${key} (${staticCapabilityInfo.name})`);
Log.log(
`Remaining to remove: `,
remainingCapabilities.map(({ id }) => id)
);
}
continue;
}
assertValidOptions(staticCapabilityInfo, value);
enabledCapabilityNames.push(staticCapabilityInfo.name);
const option = staticCapabilityInfo.getOptions(value, entitlements, additionalOptions);
request.push({
capabilityType: staticCapabilityInfo.capability,
option,
const operation = staticCapabilityInfo.getSyncOperation({
existingRemote,
entitlements,
entitlementValue: value,
additionalOptions,
});
const { op } = operation;
if (Log.isDebug) {
Log.log(`Will ${op} remote capability: ${key} (${staticCapabilityInfo.name}.`);
}
if (op === 'enable') {
enabledCapabilityNames.push(staticCapabilityInfo.name);
request.push({
capabilityType: staticCapabilityInfo.capability,
option: operation.option,
});
} else if (op === 'skip' && existingIndex >= 0) {
// Remove the item from the list of capabilities so we don't disable it in the next step.
remainingCapabilities.splice(existingIndex, 1);
}
}
return { enabledCapabilityNames, request, remainingCapabilities };
@@ -234,7 +163,8 @@ export function assertValidOptions(classifier: CapabilityClassifier, value: any)
function getCapabilitiesToDisable(
bundleId: BundleId,
currentCapabilities: BundleIdCapability[],
request: CapabilitiesRequest[]
request: CapabilitiesRequest[],
entitlements: JSONObject
): { disabledCapabilityNames: string[]; request: CapabilitiesRequest[] } {
if (Log.isDebug) {
Log.log(
@@ -257,9 +187,13 @@ function getCapabilitiesToDisable(
}
if (existingCapability.attributes) {
let adjustedType: string | undefined = existingCapability.attributes.capabilityType;
if (!adjustedType) {
adjustedType = existingCapability.id.replace(`${bundleId.id}_`, '');
const adjustedType = getAdjustedCapabilityType(existingCapability, bundleId);
if (
adjustedType === CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS &&
entitlements[associatedDomainsCapabilityType.entitlement]
) {
// MDM Managed Associated Domains is a special case, it should not be disabled if Associated Domains is enabled.
continue;
}
// Only disable capabilities that we handle,
@@ -287,579 +221,18 @@ function getCapabilitiesToDisable(
return { disabledCapabilityNames, request };
}
type CapabilityClassifier = {
name: string;
entitlement: string;
capability: CapabilityType;
validateOptions: (options: any) => boolean;
getOptions: GetOptionsMethod;
capabilityIdModel?: typeof MerchantId;
capabilityIdPrefix?: string;
options?: undefined;
};
// NOTE(Bacon): From manually toggling values in Xcode and checking the git diff and network requests.
// Last Updated: July 22nd, 2021
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements
export const CapabilityMapping: CapabilityClassifier[] = [
{
name: 'HomeKit',
entitlement: 'com.apple.developer.homekit',
capability: CapabilityType.HOME_KIT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Hotspot',
entitlement: 'com.apple.developer.networking.HotspotConfiguration',
capability: CapabilityType.HOT_SPOT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Multipath',
entitlement: 'com.apple.developer.networking.multipath',
capability: CapabilityType.MULTIPATH,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'SiriKit',
entitlement: 'com.apple.developer.siri',
capability: CapabilityType.SIRI_KIT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Wireless Accessory Configuration',
entitlement: 'com.apple.external-accessory.wireless-configuration',
capability: CapabilityType.WIRELESS_ACCESSORY,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Extended Virtual Address Space',
entitlement: 'com.apple.developer.kernel.extended-virtual-addressing',
capability: CapabilityType.EXTENDED_VIRTUAL_ADDRESSING,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Access WiFi Information',
entitlement: 'com.apple.developer.networking.wifi-info',
capability: CapabilityType.ACCESS_WIFI,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Associated Domains',
entitlement: 'com.apple.developer.associated-domains',
capability: CapabilityType.ASSOCIATED_DOMAINS,
validateOptions: validateStringArrayOptions,
getOptions: getDefinedOptions,
},
{
name: 'AutoFill Credential Provider',
entitlement: 'com.apple.developer.authentication-services.autofill-credential-provider',
capability: CapabilityType.AUTO_FILL_CREDENTIAL,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'HealthKit',
entitlement: 'com.apple.developer.healthkit',
capability: CapabilityType.HEALTH_KIT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
// {
// // ?? -- adds UIRequiredDeviceCapabilities gamekit
// // Always locked on in dev portal
// name: 'Game Center',
// entitlement: 'com.apple.developer.game-center',
// capability: CapabilityType.GAME_CENTER,
// validateOptions: validateBooleanOptions,
// getOptions: getBooleanOptions,
// },
{
name: 'App Groups',
entitlement: 'com.apple.security.application-groups',
capability: CapabilityType.APP_GROUP,
// Ex: ['group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender']
validateOptions: validatePrefixedStringArrayOptions('group.'),
getOptions: getDefinedOptions,
capabilityIdModel: AppGroup,
capabilityIdPrefix: 'group.',
},
{
name: 'Apple Pay Payment Processing',
entitlement: 'com.apple.developer.in-app-payments',
capability: CapabilityType.APPLE_PAY,
// Ex: ['merchant.com.example.development']
validateOptions: validatePrefixedStringArrayOptions('merchant.'),
getOptions: getDefinedOptions,
capabilityIdModel: MerchantId,
capabilityIdPrefix: 'merchant.',
},
{
name: 'iCloud',
entitlement: 'com.apple.developer.icloud-container-identifiers',
capability: CapabilityType.ICLOUD,
validateOptions: validatePrefixedStringArrayOptions('iCloud.'),
// Only supports Xcode +6, 5 could be added if needed.
getOptions: getDefinedOptions,
capabilityIdModel: CloudContainer,
capabilityIdPrefix: 'iCloud.',
},
{
name: 'ClassKit',
entitlement: 'com.apple.developer.ClassKit-environment',
capability: CapabilityType.CLASS_KIT,
validateOptions: validateDevProdString,
getOptions: getDefinedOptions,
},
{
name: 'Communication Notifications',
entitlement: 'com.apple.developer.usernotifications.communication',
capability: CapabilityType.USER_NOTIFICATIONS_COMMUNICATION,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Time Sensitive Notifications',
entitlement: 'com.apple.developer.usernotifications.time-sensitive',
capability: CapabilityType.USER_NOTIFICATIONS_TIME_SENSITIVE,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Group Activities',
entitlement: 'com.apple.developer.group-session',
capability: CapabilityType.GROUP_ACTIVITIES,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
name: 'Family Controls',
entitlement: 'com.apple.developer.family-controls',
capability: CapabilityType.FAMILY_CONTROLS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_default-data-protection
name: 'Data Protection',
entitlement: 'com.apple.developer.default-data-protection',
capability: CapabilityType.DATA_PROTECTION,
validateOptions: createValidateStringOptions([
'NSFileProtectionCompleteUnlessOpen',
'NSFileProtectionCompleteUntilFirstUserAuthentication',
'NSFileProtectionNone',
'NSFileProtectionComplete',
]),
getOptions(entitlement) {
if (entitlement === 'NSFileProtectionComplete') {
return CapabilityTypeDataProtectionOption.COMPLETE_PROTECTION;
} else if (entitlement === 'NSFileProtectionCompleteUnlessOpen') {
return CapabilityTypeDataProtectionOption.PROTECTED_UNLESS_OPEN;
} else if (entitlement === 'NSFileProtectionCompleteUntilFirstUserAuthentication') {
return CapabilityTypeDataProtectionOption.PROTECTED_UNTIL_FIRST_USER_AUTH;
}
// NSFileProtectionNone isn't documented, not sure how to handle
function getAdjustedCapabilityType(
existingCapability: BundleIdCapability,
bundleId: BundleId
): string {
let adjustedType: string | undefined = existingCapability.attributes.capabilityType;
if (!adjustedType) {
if (process.env.NODE_ENV === 'test' && !existingCapability.id.startsWith(`${bundleId.id}_`)) {
throw new Error(
`iOS entitlement "com.apple.developer.default-data-protection" is using unsupported value "${entitlement}"`
`Capability ID "${existingCapability.id}" does not start with the bundle ID "${bundleId.id}_". This is likely a test setup issue.`
);
},
},
{
// Deprecated
name: 'Inter-App Audio',
entitlement: 'inter-app-audio',
capability: CapabilityType.INTER_APP_AUDIO,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_networkextension
name: 'Network Extensions',
entitlement: 'com.apple.developer.networking.networkextension',
capability: CapabilityType.NETWORK_EXTENSIONS,
validateOptions: createValidateStringArrayOptions([
'dns-proxy',
'app-proxy-provider',
'content-filter-provider',
'packet-tunnel-provider',
'dns-proxy-systemextension',
'app-proxy-provider-systemextension',
'content-filter-provider-systemextension',
'packet-tunnel-provider-systemextension',
'dns-settings',
'app-push-provider',
]),
getOptions: getDefinedOptions,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_nfc_readersession_formats
name: 'NFC Tag Reading',
entitlement: 'com.apple.developer.nfc.readersession.formats',
capability: CapabilityType.NFC_TAG_READING,
// Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
getOptions: getDefinedOptions,
},
{
name: 'Personal VPN',
entitlement: 'com.apple.developer.networking.vpn.api',
capability: CapabilityType.PERSONAL_VPN,
// Ex: ['allow-vpn']
validateOptions: createValidateStringArrayOptions(['allow-vpn']),
getOptions: getDefinedOptions,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_vpn_api
name: 'Push Notifications',
// com.apple.developer.aps-environment
entitlement: 'aps-environment',
capability: CapabilityType.PUSH_NOTIFICATIONS,
validateOptions: validateDevProdString,
getOptions(entitlement, _entitlementsJson, { usesBroadcastPushNotifications }) {
const option = entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
if (option === CapabilityTypeOption.ON && usesBroadcastPushNotifications) {
return CapabilityTypePushNotificationsOption.PUSH_NOTIFICATION_FEATURE_BROADCAST;
}
return option;
},
},
{
name: 'Wallet',
entitlement: 'com.apple.developer.pass-type-identifiers',
capability: CapabilityType.WALLET,
// Ex: ['$(TeamIdentifierPrefix)*']
validateOptions: validateStringArrayOptions,
getOptions: getDefinedOptions,
},
{
name: 'Sign In with Apple',
entitlement: 'com.apple.developer.applesignin',
capability: CapabilityType.APPLE_ID_AUTH,
// Ex: ['Default']
validateOptions: createValidateStringArrayOptions(['Default']),
getOptions: getDefinedOptions,
},
{
name: 'Fonts',
entitlement: 'com.apple.developer.user-fonts',
capability: CapabilityType.FONT_INSTALLATION,
validateOptions: createValidateStringArrayOptions(['app-usage', 'system-installation']),
getOptions: getDefinedOptions,
},
{
name: 'Apple Pay Later Merchandising',
entitlement: 'com.apple.developer.pay-later-merchandising',
capability: CapabilityType.APPLE_PAY_LATER_MERCHANDISING,
validateOptions: createValidateStringArrayOptions(['payinfour-merchandising']),
getOptions: getDefinedOptions,
},
{
name: 'Sensitive Content Analysis',
entitlement: 'com.apple.developer.sensitivecontentanalysis.client',
capability: CapabilityType.SENSITIVE_CONTENT_ANALYSIS,
validateOptions: createValidateStringArrayOptions(['analysis']),
getOptions: getDefinedOptions,
},
{
// Not in Xcode
// https://developer-mdn.apple.com/documentation/devicecheck/preparing_to_use_the_app_attest_service
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_devicecheck_appattest-environment
name: 'App Attest',
entitlement: 'com.apple.developer.devicecheck.appattest-environment',
capability: CapabilityType.APP_ATTEST,
validateOptions: validateDevProdString,
getOptions: getDefinedOptions,
},
{
entitlement: 'com.apple.developer.coremedia.hls.low-latency',
name: 'Low Latency HLS',
capability: CapabilityType.HLS_LOW_LATENCY,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.associated-domains.mdm-managed',
name: 'MDM Managed Associated Domains',
capability: CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.fileprovider.testing-mode',
name: 'FileProvider TestingMode',
capability: CapabilityType.FILE_PROVIDER_TESTING_MODE,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.healthkit.recalibrate-estimates',
name: 'Recalibrate Estimates',
capability: CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.maps',
name: 'Maps',
capability: CapabilityType.MAPS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.user-management',
name: 'TV Services',
capability: CapabilityType.USER_MANAGEMENT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.networking.custom-protocol',
name: 'Custom Network Protocol',
capability: CapabilityType.NETWORK_CUSTOM_PROTOCOL,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.system-extension.install',
name: 'System Extension',
capability: CapabilityType.SYSTEM_EXTENSION_INSTALL,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.push-to-talk',
name: 'Push to Talk',
capability: CapabilityType.PUSH_TO_TALK,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.transport.usb',
name: 'DriverKit USB Transport (development)',
capability: CapabilityType.DRIVER_KIT_USB_TRANSPORT_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.kernel.increased-memory-limit',
name: 'Increased Memory Limit',
capability: CapabilityType.INCREASED_MEMORY_LIMIT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.communicates-with-drivers',
name: 'Communicates with Drivers',
capability: CapabilityType.DRIVER_KIT_COMMUNICATES_WITH_DRIVERS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.media-device-discovery-extension',
name: 'Media Device Discovery',
capability: CapabilityType.MEDIA_DEVICE_DISCOVERY,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.allow-third-party-userclients',
name: 'DriverKit Allow Third Party UserClients',
capability: CapabilityType.DRIVER_KIT_ALLOW_THIRD_PARTY_USER_CLIENTS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.weatherkit',
name: 'WeatherKit',
capability: CapabilityType.WEATHER_KIT,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.on-demand-install-capable',
name: 'On Demand Install Capable for App Clip Extensions',
capability: CapabilityType.ON_DEMAND_INSTALL_EXTENSIONS,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.scsicontroller',
name: 'DriverKit Family SCSIController (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_SCSI_CONTROLLER_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.serial',
name: 'DriverKit Family Serial (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_SERIAL_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.networking',
name: 'DriverKit Family Networking (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_NETWORKING_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.hid.eventservice',
name: 'DriverKit Family HID EventService (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_EVENT_SERVICE_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.hid.device',
name: 'DriverKit Family HID Device (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_DEVICE_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit',
name: 'DriverKit for Development',
capability: CapabilityType.DRIVER_KIT_PUBLIC,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.transport.hid',
name: 'DriverKit Transport HID (development)',
capability: CapabilityType.DRIVER_KIT_TRANSPORT_HID_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.driverkit.family.audio',
name: 'DriverKit Family Audio (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_AUDIO_PUB,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.shared-with-you',
name: 'Shared with You',
capability: CapabilityType.SHARED_WITH_YOU,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.shared-with-you.collaboration',
name: 'Messages Collaboration',
capability: CapabilityType.MESSAGES_COLLABORATION,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.submerged-shallow-depth-and-pressure',
name: 'Shallow Depth and Pressure',
capability: CapabilityType.SHALLOW_DEPTH_PRESSURE,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.proximity-reader.identity.display',
name: 'Tap to Present ID on iPhone (Display Only)',
capability: CapabilityType.TAP_TO_DISPLAY_ID,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.proximity-reader.payment.acceptance',
name: 'Tap to Pay on iPhone',
capability: CapabilityType.TAP_TO_PAY_ON_IPHONE,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.matter.allow-setup-payload',
name: 'Matter Allow Setup Payload',
capability: CapabilityType.MATTER_ALLOW_SETUP_PAYLOAD,
validateOptions: validateBooleanOptions,
getOptions: getBooleanOptions,
},
{
entitlement: 'com.apple.developer.journal.allow',
name: 'Journaling Suggestions',
capability: CapabilityType.JOURNALING_SUGGESTIONS,
validateOptions: createValidateStringArrayOptions(['suggestions']),
getOptions: getDefinedOptions,
},
{
entitlement: 'com.apple.developer.managed-app-distribution.install-ui',
name: 'Managed App Installation UI',
capability: CapabilityType.MANAGED_APP_INSTALLATION_UI,
validateOptions: createValidateStringArrayOptions(['managed-app']),
getOptions: getDefinedOptions,
},
{
entitlement: 'com.apple.developer.networking.slicing.appcategory',
name: '5G Network Slicing',
capability: CapabilityType.NETWORK_SLICING,
validateOptions: createValidateStringArrayOptions([
'gaming-6014',
'communication-9000',
'streaming-9001',
]),
getOptions: getDefinedOptions,
},
{
entitlement: 'com.apple.developer.networking.slicing.trafficcategory',
name: '5G Network Slicing',
capability: CapabilityType.NETWORK_SLICING,
validateOptions: createValidateStringArrayOptions([
'defaultslice-1',
'video-2',
'background-3',
'voice-4',
'callsignaling-5',
'responsivedata-6',
'avstreaming-7',
'responsiveav-8',
]),
getOptions: getDefinedOptions,
},
// VMNET
// These don't appear to have entitlements, so it's unclear how we can automatically enable / disable them at this time.
// TODO: Maybe add a warning about manually enabling features?
// ?? -- links `StoreKit.framework`
// Always locked on in dev portal
// {
// entitlement: '',
// name: 'In-App Purchase',
// capability: CapabilityType.IN_APP_PURCHASE,
// },
// {
// entitlement: '',
// name: 'HLS Interstitial Previews',
// capability: 'HLS_INTERSTITIAL_PREVIEW',
// },
// "Game Controllers" doesn't appear in Dev Portal but it does show up in Xcode,
// toggling in Xcode causes no network request to be sent.
// Therefore it seems that it's a mistake in Xcode,
// the key `GCSupportsControllerUserInteraction` just needs to be present in Info.plist
// "Keychain Sharing" doesn't appear in Dev Portal but it does show up in Xcode,
// toggling in Xcode causes no network request to be sent.
// Adding to Xcode puts 'keychain-access-groups' into the entitlements so
// it's not clear if it needs to be updated.
// "Contact Notes" requires the user to ask Apple in a form:
// https://developer-mdn.apple.com/contact/request/contact-note-field
// com.apple.developer.contacts.notes: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_contacts_notes/
// "Exposure Notification" requires the user to ask Apple in a form:
// https://developer-mdn.apple.com/contact/request/exposure-notification-entitlement
// com.apple.developer.exposure-notification: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_exposure-notification/
];
}
adjustedType = existingCapability.id.replace(`${bundleId.id}_`, '');
}
return adjustedType;
}
@@ -1,11 +1,8 @@
import { BundleId, CapabilityTypeOption } from '@expo/apple-utils';
import { JSONObject } from '@expo/json-file';
import {
CapabilityMapping,
EXPO_NO_CAPABILITY_SYNC,
assertValidOptions,
} from './bundleIdCapabilities';
import { EXPO_NO_CAPABILITY_SYNC, assertValidOptions } from './bundleIdCapabilities';
import { CapabilityMapping } from './capabilityList';
import Log from '../../../log';
import { isAppStoreConnectTokenOnlyContext } from '../utils/authType';
@@ -42,6 +39,7 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
const createdIds: string[] = [];
const linkedIds: string[] = [];
// these are only APPLE_PAY, ICLOUD, APP_GROUPS
const CapabilityIdMapping = CapabilityMapping.filter(capability => capability.capabilityIdModel);
const updateRequest: UpdateCapabilityRequest = [];
@@ -78,51 +76,53 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
// A list of server IDs for linking.
const capabilityIdOpaqueIds = [];
const capabilitiesWithoutRemoteModels = capabilityIds.filter(
localId => existingIds.find(model => model.attributes.identifier === localId) === undefined
);
// Iterate through all the local IDs and see if they exist on the server.
for (const localId of capabilityIds) {
let remoteIdModel = existingIds.find(model => model.attributes.identifier === localId);
for (const localId of capabilitiesWithoutRemoteModels) {
let remoteIdModel = undefined;
// If a remote ID exists, then create it.
if (!remoteIdModel) {
if (Log.isDebug) {
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
}
try {
remoteIdModel = await CapabilityModel.createAsync(bundleId.context, {
identifier: localId,
});
} catch (error: any) {
// Add a more helpful error message.
error.message += `\n\nRemove the value '${localId}' from the array '${classifier.entitlement}' in the iOS project entitlements.\nIf you know that the ID is registered to one of your apps, try again with a different Apple account.`;
throw error;
}
// Create a list of newly created IDs for displaying in the CLI.
createdIds.push(localId);
if (Log.isDebug) {
Log.log(`Created capability ID: ${remoteIdModel.id}`);
}
if (Log.isDebug) {
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
}
try {
remoteIdModel = await CapabilityModel.createAsync(bundleId.context, {
identifier: localId,
});
} catch (error: any) {
// Add a more helpful error message.
error.message += `\n\nRemove the value '${localId}' from the array '${classifier.entitlement}' in the iOS project entitlements.\nIf you know that the ID is registered to one of your apps, try again with a different Apple account.`;
throw error;
}
if (Log.isDebug) {
Log.log(`Linking ID to ${CapabilityModel.type}: ${localId} (${remoteIdModel.id})`);
Log.log(`Created capability ID: ${remoteIdModel.id}`);
}
// Create a list of linked IDs for displaying in the CLI.
// add to a list of newly created IDs for displaying in the CLI.
createdIds.push(localId);
// add to a list of linked IDs for displaying in the CLI.
linkedIds.push(remoteIdModel.attributes.identifier);
capabilityIdOpaqueIds.push(remoteIdModel.id);
}
updateRequest.push({
capabilityType: classifier.capability,
option: CapabilityTypeOption.ON,
relationships: {
// One of: `merchantIds`, `appGroups`, `cloudContainers`.
[CapabilityModel.type]: capabilityIdOpaqueIds,
},
});
if (capabilityIdOpaqueIds.length) {
updateRequest.push({
capabilityType: classifier.capability,
option: CapabilityTypeOption.ON,
relationships: {
// One of: `merchantIds`, `appGroups`, `cloudContainers`.
[CapabilityModel.type]: capabilityIdOpaqueIds,
},
});
}
}
if (updateRequest.length) {
if (Log.isDebug) {
Log.log(`Updating bundle identifier with capability identifiers:`, updateRequest);
Log.log(
`Updating bundle identifier with capability identifiers:`,
JSON.stringify(updateRequest, null, 2)
);
}
await bundleId.updateBundleIdCapabilityAsync(updateRequest);
} else if (Log.isDebug) {
@@ -0,0 +1,710 @@
import {
AppGroup,
BundleIdCapability,
CapabilityType,
CapabilityTypeDataProtectionOption,
CapabilityTypeOption,
CapabilityTypePushNotificationsOption,
CloudContainer,
MerchantId,
} from '@expo/apple-utils';
import { JSONObject, JSONValue } from '@expo/json-file';
import { invariant } from 'graphql/jsutils/invariant';
import nullthrows from 'nullthrows';
const validateBooleanOptions = (options: any): boolean => {
return typeof options === 'boolean';
};
const validatePrefixedStringArrayOptions =
(prefix: string) =>
(options: any): boolean => {
return (
Array.isArray(options) &&
options.every(option => typeof option === 'string' && option.startsWith(prefix))
);
};
const validateStringArrayOptions = (options: any): boolean => {
return Array.isArray(options) && options.every(option => typeof option === 'string');
};
const createValidateStringOptions =
(allowed: string[]) =>
(options: any): boolean => {
return allowed.includes(options);
};
const createValidateStringArrayOptions =
(allowed: string[]) =>
(options: any): boolean => {
return Array.isArray(options) && options.every(option => allowed.includes(option));
};
const validateDevProdString = createValidateStringOptions(['development', 'production']);
const skipOp = { op: 'skip' } as const;
const disableOp = { op: 'disable' } as const;
const enableOp = { op: 'enable', option: CapabilityTypeOption.ON } as const;
type GetSyncOperation = (opts: {
existingRemote: BundleIdCapability | null;
entitlementValue: JSONValue;
entitlements: JSONObject;
additionalOptions: {
usesBroadcastPushNotifications: boolean;
};
}) => { op: 'enable'; option: JSONValue } | typeof skipOp | typeof disableOp;
const getBooleanSyncOperation: GetSyncOperation = ({ existingRemote, entitlementValue }) => {
if (existingRemote) {
// If the remote capability exists, we only disable it if the value is false.
// Otherwise, we skip it.
if ('enabled' in existingRemote.attributes) {
return existingRemote.attributes['enabled'] === entitlementValue ? skipOp : disableOp;
}
return existingRemote.attributes.settings === null ? skipOp : enableOp;
} else {
return entitlementValue === false ? skipOp : enableOp;
}
};
const getDefinedValueSyncOperation: GetSyncOperation = ({ existingRemote }) => {
if (!existingRemote) {
// If the remote capability does not exist, we create it.
return enableOp;
}
return existingRemote.attributes.settings === null ? skipOp : enableOp;
};
const capabilityWithSettingsSyncOperation: GetSyncOperation = ({
existingRemote,
entitlementValue,
}) => {
// settings are defined for only a few capabilities: iCloud, data protection and Sign in with Apple
// https://developer.apple.com/documentation/appstoreconnectapi/capabilitysetting
if (!existingRemote) {
return enableOp;
}
invariant(
'enabled' in existingRemote.attributes,
`Expected "enabled" attribute in ${existingRemote.id}`
);
const existingEnabled = existingRemote.attributes.enabled === true;
const newOption = entitlementValue ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
// If both are enabled and the existing one has settings, skip the update
if (existingEnabled && entitlementValue && existingRemote.attributes.settings) {
return skipOp;
}
// If the states don't match, we need to update
const newEnabled = newOption === CapabilityTypeOption.ON;
return existingEnabled === newEnabled ? skipOp : { op: 'enable', option: newOption };
};
export type CapabilityClassifier = {
name: string;
entitlement: string;
capability: CapabilityType;
validateOptions: (options: any) => boolean;
capabilityIdModel?: typeof MerchantId;
getSyncOperation: GetSyncOperation;
capabilityIdPrefix?: string;
options?: undefined;
};
// NOTE(Bacon): From manually toggling values in Xcode and checking the git diff and network requests.
// Last Updated: July 22nd, 2021
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements
export const CapabilityMapping: CapabilityClassifier[] = [
{
name: 'HomeKit',
entitlement: 'com.apple.developer.homekit',
capability: CapabilityType.HOME_KIT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Hotspot',
entitlement: 'com.apple.developer.networking.HotspotConfiguration',
capability: CapabilityType.HOT_SPOT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Multipath',
entitlement: 'com.apple.developer.networking.multipath',
capability: CapabilityType.MULTIPATH,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'SiriKit',
entitlement: 'com.apple.developer.siri',
capability: CapabilityType.SIRI_KIT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Wireless Accessory Configuration',
entitlement: 'com.apple.external-accessory.wireless-configuration',
capability: CapabilityType.WIRELESS_ACCESSORY,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Extended Virtual Address Space',
entitlement: 'com.apple.developer.kernel.extended-virtual-addressing',
capability: CapabilityType.EXTENDED_VIRTUAL_ADDRESSING,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Access WiFi Information',
entitlement: 'com.apple.developer.networking.wifi-info',
capability: CapabilityType.ACCESS_WIFI,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Associated Domains',
entitlement: 'com.apple.developer.associated-domains',
capability: CapabilityType.ASSOCIATED_DOMAINS,
validateOptions: validateStringArrayOptions,
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'AutoFill Credential Provider',
entitlement: 'com.apple.developer.authentication-services.autofill-credential-provider',
capability: CapabilityType.AUTO_FILL_CREDENTIAL,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'HealthKit',
entitlement: 'com.apple.developer.healthkit',
capability: CapabilityType.HEALTH_KIT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
// {
// // ?? -- adds UIRequiredDeviceCapabilities gamekit
// // Always locked on in dev portal
// name: 'Game Center',
// entitlement: 'com.apple.developer.game-center',
// capability: CapabilityType.GAME_CENTER,
// validateOptions: validateBooleanOptions,
// getSyncOperation: getBooleanSyncOperation,
// },
{
name: 'App Groups',
entitlement: 'com.apple.security.application-groups',
capability: CapabilityType.APP_GROUP,
// Ex: ['group.CY-A5149AC2-49FC-11E7-B3F3-0335A16FFB8D.com.cydia.Extender']
validateOptions: validatePrefixedStringArrayOptions('group.'),
getSyncOperation: getDefinedValueSyncOperation,
capabilityIdModel: AppGroup,
capabilityIdPrefix: 'group.',
},
{
name: 'Apple Pay Payment Processing',
entitlement: 'com.apple.developer.in-app-payments',
capability: CapabilityType.APPLE_PAY,
// Ex: ['merchant.com.example.development']
validateOptions: validatePrefixedStringArrayOptions('merchant.'),
getSyncOperation: getDefinedValueSyncOperation,
capabilityIdModel: MerchantId,
capabilityIdPrefix: 'merchant.',
},
{
name: 'iCloud',
entitlement: 'com.apple.developer.icloud-container-identifiers',
capability: CapabilityType.ICLOUD,
validateOptions: validatePrefixedStringArrayOptions('iCloud.'),
// Only supports Xcode +6
getSyncOperation: capabilityWithSettingsSyncOperation,
capabilityIdModel: CloudContainer,
capabilityIdPrefix: 'iCloud.',
},
{
name: 'ClassKit',
entitlement: 'com.apple.developer.ClassKit-environment',
capability: CapabilityType.CLASS_KIT,
validateOptions: validateDevProdString,
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'Communication Notifications',
entitlement: 'com.apple.developer.usernotifications.communication',
capability: CapabilityType.USER_NOTIFICATIONS_COMMUNICATION,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Time Sensitive Notifications',
entitlement: 'com.apple.developer.usernotifications.time-sensitive',
capability: CapabilityType.USER_NOTIFICATIONS_TIME_SENSITIVE,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Group Activities',
entitlement: 'com.apple.developer.group-session',
capability: CapabilityType.GROUP_ACTIVITIES,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
name: 'Family Controls',
entitlement: 'com.apple.developer.family-controls',
capability: CapabilityType.FAMILY_CONTROLS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_default-data-protection
name: 'Data Protection',
entitlement: 'com.apple.developer.default-data-protection',
capability: CapabilityType.DATA_PROTECTION,
validateOptions: createValidateStringOptions([
'NSFileProtectionCompleteUnlessOpen',
'NSFileProtectionCompleteUntilFirstUserAuthentication',
'NSFileProtectionNone',
'NSFileProtectionComplete',
]),
getSyncOperation: ({ existingRemote, entitlementValue: entitlement }) => {
const newValue = (() => {
if (entitlement === 'NSFileProtectionComplete') {
return CapabilityTypeDataProtectionOption.COMPLETE_PROTECTION;
} else if (entitlement === 'NSFileProtectionCompleteUnlessOpen') {
return CapabilityTypeDataProtectionOption.PROTECTED_UNLESS_OPEN;
} else if (entitlement === 'NSFileProtectionCompleteUntilFirstUserAuthentication') {
return CapabilityTypeDataProtectionOption.PROTECTED_UNTIL_FIRST_USER_AUTH;
}
// NSFileProtectionNone isn't documented, not sure how to handle
throw new Error(
`iOS entitlement "com.apple.developer.default-data-protection" is using unsupported value "${entitlement}"`
);
})();
const enableOp = { op: 'enable', option: newValue } as const;
if (!existingRemote) {
return enableOp;
}
invariant(existingRemote.attributes.settings?.[0].key === 'DATA_PROTECTION_PERMISSION_LEVEL');
const oldValue = existingRemote.attributes.settings[0]?.options?.[0]?.key;
return oldValue === newValue ? skipOp : enableOp;
},
},
{
// Deprecated
name: 'Inter-App Audio',
entitlement: 'inter-app-audio',
capability: CapabilityType.INTER_APP_AUDIO,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_networkextension
name: 'Network Extensions',
entitlement: 'com.apple.developer.networking.networkextension',
capability: CapabilityType.NETWORK_EXTENSIONS,
validateOptions: createValidateStringArrayOptions([
'dns-proxy',
'app-proxy-provider',
'content-filter-provider',
'packet-tunnel-provider',
'dns-proxy-systemextension',
'app-proxy-provider-systemextension',
'content-filter-provider-systemextension',
'packet-tunnel-provider-systemextension',
'dns-settings',
'app-push-provider',
]),
getSyncOperation: getDefinedValueSyncOperation,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_nfc_readersession_formats
name: 'NFC Tag Reading',
entitlement: 'com.apple.developer.nfc.readersession.formats',
capability: CapabilityType.NFC_TAG_READING,
// Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'Personal VPN',
entitlement: 'com.apple.developer.networking.vpn.api',
capability: CapabilityType.PERSONAL_VPN,
// Ex: ['allow-vpn']
validateOptions: createValidateStringArrayOptions(['allow-vpn']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_vpn_api
name: 'Push Notifications',
// com.apple.developer.aps-environment
entitlement: 'aps-environment',
capability: CapabilityType.PUSH_NOTIFICATIONS,
validateOptions: validateDevProdString,
getSyncOperation: ({
existingRemote,
entitlementValue: entitlement,
additionalOptions: { usesBroadcastPushNotifications },
}) => {
const newOption = (() => {
const option = entitlement ? CapabilityTypeOption.ON : CapabilityTypeOption.OFF;
if (option === CapabilityTypeOption.ON && usesBroadcastPushNotifications) {
return CapabilityTypePushNotificationsOption.PUSH_NOTIFICATION_FEATURE_BROADCAST;
}
return option;
})();
const createOp = { op: 'enable', option: newOption } as const;
if (!existingRemote) {
// If the remote capability does not exist, we create it.
return createOp;
}
// For push notifications, we should always update the capability if
// - settings are not defined in the existing capability, but usesBroadcastPushNotifications is enabled (we want to add settings for this capability)
// - settings are defined in the existing capability, but usesBroadcastPushNotifications is disabled (we want to remove settings for this capability)
const noSettingsAttributes = existingRemote.attributes.settings == null;
return !noSettingsAttributes === usesBroadcastPushNotifications ? skipOp : createOp;
},
},
{
name: 'Wallet',
entitlement: 'com.apple.developer.pass-type-identifiers',
capability: CapabilityType.WALLET,
// Ex: ['$(TeamIdentifierPrefix)*']
validateOptions: validateStringArrayOptions,
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'Sign In with Apple',
entitlement: 'com.apple.developer.applesignin',
capability: CapabilityType.APPLE_ID_AUTH,
// Ex: ['Default']
validateOptions: createValidateStringArrayOptions(['Default']),
getSyncOperation: capabilityWithSettingsSyncOperation,
},
{
name: 'Fonts',
entitlement: 'com.apple.developer.user-fonts',
capability: CapabilityType.FONT_INSTALLATION,
validateOptions: createValidateStringArrayOptions(['app-usage', 'system-installation']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'Apple Pay Later Merchandising',
entitlement: 'com.apple.developer.pay-later-merchandising',
capability: CapabilityType.APPLE_PAY_LATER_MERCHANDISING,
validateOptions: createValidateStringArrayOptions(['payinfour-merchandising']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
name: 'Sensitive Content Analysis',
entitlement: 'com.apple.developer.sensitivecontentanalysis.client',
capability: CapabilityType.SENSITIVE_CONTENT_ANALYSIS,
validateOptions: createValidateStringArrayOptions(['analysis']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
// Not in Xcode
// https://developer-mdn.apple.com/documentation/devicecheck/preparing_to_use_the_app_attest_service
// https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_devicecheck_appattest-environment
name: 'App Attest',
entitlement: 'com.apple.developer.devicecheck.appattest-environment',
capability: CapabilityType.APP_ATTEST,
validateOptions: validateDevProdString,
getSyncOperation: getDefinedValueSyncOperation,
},
{
entitlement: 'com.apple.developer.coremedia.hls.low-latency',
name: 'Low Latency HLS',
capability: CapabilityType.HLS_LOW_LATENCY,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.associated-domains.mdm-managed',
name: 'MDM Managed Associated Domains',
capability: CapabilityType.MDM_MANAGED_ASSOCIATED_DOMAINS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.fileprovider.testing-mode',
name: 'FileProvider TestingMode',
capability: CapabilityType.FILE_PROVIDER_TESTING_MODE,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.healthkit.recalibrate-estimates',
name: 'Recalibrate Estimates',
capability: CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.maps',
name: 'Maps',
capability: CapabilityType.MAPS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.user-management',
name: 'TV Services',
capability: CapabilityType.USER_MANAGEMENT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.networking.custom-protocol',
name: 'Custom Network Protocol',
capability: CapabilityType.NETWORK_CUSTOM_PROTOCOL,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.system-extension.install',
name: 'System Extension',
capability: CapabilityType.SYSTEM_EXTENSION_INSTALL,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.push-to-talk',
name: 'Push to Talk',
capability: CapabilityType.PUSH_TO_TALK,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.transport.usb',
name: 'DriverKit USB Transport (development)',
capability: CapabilityType.DRIVER_KIT_USB_TRANSPORT_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.kernel.increased-memory-limit',
name: 'Increased Memory Limit',
capability: CapabilityType.INCREASED_MEMORY_LIMIT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.communicates-with-drivers',
name: 'Communicates with Drivers',
capability: CapabilityType.DRIVER_KIT_COMMUNICATES_WITH_DRIVERS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.media-device-discovery-extension',
name: 'Media Device Discovery',
capability: CapabilityType.MEDIA_DEVICE_DISCOVERY,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.allow-third-party-userclients',
name: 'DriverKit Allow Third Party UserClients',
capability: CapabilityType.DRIVER_KIT_ALLOW_THIRD_PARTY_USER_CLIENTS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.weatherkit',
name: 'WeatherKit',
capability: CapabilityType.WEATHER_KIT,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.on-demand-install-capable',
name: 'On Demand Install Capable for App Clip Extensions',
capability: CapabilityType.ON_DEMAND_INSTALL_EXTENSIONS,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.scsicontroller',
name: 'DriverKit Family SCSIController (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_SCSI_CONTROLLER_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.serial',
name: 'DriverKit Family Serial (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_SERIAL_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.networking',
name: 'DriverKit Family Networking (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_NETWORKING_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.hid.eventservice',
name: 'DriverKit Family HID EventService (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_EVENT_SERVICE_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.hid.device',
name: 'DriverKit Family HID Device (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_HID_DEVICE_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit',
name: 'DriverKit for Development',
capability: CapabilityType.DRIVER_KIT_PUBLIC,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.transport.hid',
name: 'DriverKit Transport HID (development)',
capability: CapabilityType.DRIVER_KIT_TRANSPORT_HID_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.driverkit.family.audio',
name: 'DriverKit Family Audio (development)',
capability: CapabilityType.DRIVER_KIT_FAMILY_AUDIO_PUB,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.shared-with-you',
name: 'Shared with You',
capability: CapabilityType.SHARED_WITH_YOU,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.shared-with-you.collaboration',
name: 'Messages Collaboration',
capability: CapabilityType.MESSAGES_COLLABORATION,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.submerged-shallow-depth-and-pressure',
name: 'Shallow Depth and Pressure',
capability: CapabilityType.SHALLOW_DEPTH_PRESSURE,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.proximity-reader.identity.display',
name: 'Tap to Present ID on iPhone (Display Only)',
capability: CapabilityType.TAP_TO_DISPLAY_ID,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.proximity-reader.payment.acceptance',
name: 'Tap to Pay on iPhone',
capability: CapabilityType.TAP_TO_PAY_ON_IPHONE,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.matter.allow-setup-payload',
name: 'Matter Allow Setup Payload',
capability: CapabilityType.MATTER_ALLOW_SETUP_PAYLOAD,
validateOptions: validateBooleanOptions,
getSyncOperation: getBooleanSyncOperation,
},
{
entitlement: 'com.apple.developer.journal.allow',
name: 'Journaling Suggestions',
capability: CapabilityType.JOURNALING_SUGGESTIONS,
validateOptions: createValidateStringArrayOptions(['suggestions']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
entitlement: 'com.apple.developer.managed-app-distribution.install-ui',
name: 'Managed App Installation UI',
capability: CapabilityType.MANAGED_APP_INSTALLATION_UI,
validateOptions: createValidateStringArrayOptions(['managed-app']),
getSyncOperation: getDefinedValueSyncOperation,
},
{
entitlement: 'com.apple.developer.networking.slicing.appcategory',
name: '5G Network Slicing',
capability: CapabilityType.NETWORK_SLICING,
validateOptions: createValidateStringArrayOptions([
'gaming-6014',
'communication-9000',
'streaming-9001',
]),
getSyncOperation: getDefinedValueSyncOperation,
},
{
entitlement: 'com.apple.developer.networking.slicing.trafficcategory',
name: '5G Network Slicing',
capability: CapabilityType.NETWORK_SLICING,
validateOptions: createValidateStringArrayOptions([
'defaultslice-1',
'video-2',
'background-3',
'voice-4',
'callsignaling-5',
'responsivedata-6',
'avstreaming-7',
'responsiveav-8',
]),
getSyncOperation: getDefinedValueSyncOperation,
},
// VMNET
// These don't appear to have entitlements, so it's unclear how we can automatically enable / disable them at this time.
// TODO: Maybe add a warning about manually enabling features?
// ?? -- links `StoreKit.framework`
// Always locked on in dev portal
// {
// entitlement: '',
// name: 'In-App Purchase',
// capability: CapabilityType.IN_APP_PURCHASE,
// },
// {
// entitlement: '',
// name: 'HLS Interstitial Previews',
// capability: 'HLS_INTERSTITIAL_PREVIEW',
// },
// "Game Controllers" doesn't appear in Dev Portal but it does show up in Xcode,
// toggling in Xcode causes no network request to be sent.
// Therefore it seems that it's a mistake in Xcode,
// the key `GCSupportsControllerUserInteraction` just needs to be present in Info.plist
// "Keychain Sharing" doesn't appear in Dev Portal but it does show up in Xcode,
// toggling in Xcode causes no network request to be sent.
// Adding to Xcode puts 'keychain-access-groups' into the entitlements so
// it's not clear if it needs to be updated.
// "Contact Notes" requires the user to ask Apple in a form:
// https://developer-mdn.apple.com/contact/request/contact-note-field
// com.apple.developer.contacts.notes: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_contacts_notes/
// "Exposure Notification" requires the user to ask Apple in a form:
// https://developer-mdn.apple.com/contact/request/exposure-notification-entitlement
// com.apple.developer.exposure-notification: https://developer-mdn.apple.com/documentation/bundleresources/entitlements/com_apple_developer_exposure-notification/
];
export const associatedDomainsCapabilityType = nullthrows(
CapabilityMapping.find(it => it.capability === CapabilityType.ASSOCIATED_DOMAINS)
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { Agent } from 'https';
import createHttpsProxyAgent from 'https-proxy-agent';
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';
export * from 'node-fetch';
export { Headers, RequestInfo, RequestInit, Response } from 'node-fetch';
export class RequestError extends Error {
constructor(
+144 -23
View File
@@ -132,6 +132,7 @@ export type Account = {
googleServiceAccountKeys: Array<GoogleServiceAccountKey>;
/** Android credentials for account */
googleServiceAccountKeysPaginated: AccountGoogleServiceAccountKeysConnection;
/** @deprecated No longer needed */
hasBuilds: Scalars['Boolean']['output'];
id: Scalars['ID']['output'];
isCurrent: Scalars['Boolean']['output'];
@@ -531,6 +532,7 @@ export enum AccountAppsSortByField {
}
export type AccountDataInput = {
displayName?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
@@ -3351,12 +3353,6 @@ export type BuildPublicDataQueryByIdArgs = {
export type BuildQuery = {
__typename?: 'BuildQuery';
/**
* Get all builds.
* By default, they are sorted from latest to oldest.
* Available only for admin users.
*/
all: Array<Build>;
/**
* Get all builds for a specific app.
* They are sorted from latest to oldest.
@@ -3368,14 +3364,6 @@ export type BuildQuery = {
};
export type BuildQueryAllArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Order>;
statuses?: InputMaybe<Array<BuildStatus>>;
};
export type BuildQueryAllForAppArgs = {
appId: Scalars['String']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -3472,6 +3460,25 @@ export type ChannelQueryByIdArgs = {
channelId: Scalars['ID']['input'];
};
export type ChannelRuntimeCumulativeMetricsOverTimeData = {
__typename?: 'ChannelRuntimeCumulativeMetricsOverTimeData';
data: LineChartData;
metricsAtLastTimestamp: Array<LineDatapoint>;
mostPopularUpdates: Array<Update>;
};
export type ChannelRuntimeEdge = {
__typename?: 'ChannelRuntimeEdge';
cursor: Scalars['String']['output'];
node: RuntimeWithLastActivity;
};
export type ChannelRuntimesConnection = {
__typename?: 'ChannelRuntimesConnection';
edges: Array<ChannelRuntimeEdge>;
pageInfo: PageInfo;
};
export type Charge = {
__typename?: 'Charge';
amount: Scalars['Int']['output'];
@@ -3915,6 +3922,7 @@ export type DeploymentBuildCountArgs = {
export type DeploymentBuildsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<DeploymentBuildFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
@@ -3932,6 +3940,10 @@ export type DeploymentBuildEdge = {
node: Build;
};
export type DeploymentBuildFilterInput = {
status?: InputMaybe<BuildStatus>;
};
/** Represents the connection over the builds edge of a Deployment */
export type DeploymentBuildsConnection = {
__typename?: 'DeploymentBuildsConnection';
@@ -4031,6 +4043,7 @@ export type DeploymentsMutation = {
createSignedDeploymentUrl: DeploymentSignedUrlResult;
deleteAlias: DeleteAliasResult;
deleteWorkerDeployment: DeleteWorkerDeploymentResult;
deleteWorkerDeploymentByIdentifier: DeleteWorkerDeploymentResult;
};
@@ -4057,6 +4070,12 @@ export type DeploymentsMutationDeleteWorkerDeploymentArgs = {
workerDeploymentId: Scalars['ID']['input'];
};
export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
appId: Scalars['ID']['input'];
deploymentIdentifier: Scalars['ID']['input'];
};
export type DiscordUser = {
__typename?: 'DiscordUser';
discordIdentifier: Scalars['String']['output'];
@@ -6485,6 +6504,12 @@ export type RuntimeQueryByIdArgs = {
runtimeId: Scalars['ID']['input'];
};
export type RuntimeWithLastActivity = {
__typename?: 'RuntimeWithLastActivity';
lastActivityPostgresSerialized: Scalars['String']['output'];
runtime: Runtime;
};
/** Represents the connection over the runtime edge of an App */
export type RuntimesConnection = {
__typename?: 'RuntimesConnection';
@@ -6503,7 +6528,10 @@ export type SsoUser = Actor & UserActor & {
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/** Apps this user has published. If this user is the viewer, this field returns the apps the user has access to. */
/**
* Apps this user has published. If this user is the viewer, this field returns the apps the user has access to.
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
@@ -7200,12 +7228,22 @@ export type UpdateChannel = {
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
isPaused: Scalars['Boolean']['output'];
latestRuntimes: ChannelRuntimesConnection;
name: Scalars['String']['output'];
runtimeInsights: UpdateChannelRuntimeInsights;
updateBranches: Array<UpdateBranch>;
updatedAt: Scalars['DateTime']['output'];
};
export type UpdateChannelLatestRuntimesArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
export type UpdateChannelUpdateBranchesArgs = {
limit: Scalars['Int']['input'];
offset: Scalars['Int']['input'];
@@ -7263,6 +7301,38 @@ export type UpdateChannelMutationResumeUpdateChannelArgs = {
channelId: Scalars['ID']['input'];
};
export type UpdateChannelRuntimeInsights = {
__typename?: 'UpdateChannelRuntimeInsights';
cumulativeMetricsOverTime: ChannelRuntimeCumulativeMetricsOverTimeData;
embeddedUpdateTotalUniqueUsers: Scalars['Int']['output'];
id: Scalars['ID']['output'];
mostPopularUpdates: Array<Update>;
uniqueUsersOverTime: UniqueUsersOverTimeData;
};
export type UpdateChannelRuntimeInsightsCumulativeMetricsOverTimeArgs = {
timespan: InsightsTimespan;
};
export type UpdateChannelRuntimeInsightsEmbeddedUpdateTotalUniqueUsersArgs = {
runtimeVersion: Scalars['String']['input'];
timespan: InsightsTimespan;
};
export type UpdateChannelRuntimeInsightsMostPopularUpdatesArgs = {
runtimeVersion: Scalars['String']['input'];
timespan: InsightsTimespan;
};
export type UpdateChannelRuntimeInsightsUniqueUsersOverTimeArgs = {
runtimeVersion: Scalars['String']['input'];
timespan: InsightsTimespan;
};
export type UpdateDeploymentEdge = {
__typename?: 'UpdateDeploymentEdge';
cursor: Scalars['String']['output'];
@@ -7501,7 +7571,10 @@ export type User = Actor & UserActor & {
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/** Apps this user has published */
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
@@ -7612,7 +7685,10 @@ export type UserActor = {
appCount: Scalars['Int']['output'];
/** @deprecated No longer supported */
appetizeCode?: Maybe<Scalars['String']['output']>;
/** Apps this user has published */
/**
* Apps this user has published
* @deprecated Use Account.appsPaginated instead
*/
apps: Array<App>;
bestContactEmail?: Maybe<Scalars['String']['output']>;
created: Scalars['DateTime']['output'];
@@ -7955,6 +8031,8 @@ export type UserInvitationMutation = {
deleteUserInvitationByToken: RescindUserInvitationResult;
/** Re-send UserInivitation by ID */
resendUserInvitation: UserInvitation;
/** Update UserInvitation permissions by ID */
updateUserInvitationPermissions: UserInvitation;
};
@@ -7989,6 +8067,12 @@ export type UserInvitationMutationResendUserInvitationArgs = {
id: Scalars['ID']['input'];
};
export type UserInvitationMutationUpdateUserInvitationPermissionsArgs = {
invitationID: Scalars['ID']['input'];
permissions: Array<InputMaybe<Permission>>;
};
/** Publicly visible data for a UserInvitation. */
export type UserInvitationPublicData = {
__typename?: 'UserInvitationPublicData';
@@ -8334,7 +8418,7 @@ export type WorkerDeploymentCrashesHashEdge = {
__typename?: 'WorkerDeploymentCrashesHashEdge';
crashHash: Scalars['ID']['output'];
node: WorkerDeploymentCrashesAggregationNode;
sample: WorkerDeploymentCrashNode;
sample?: Maybe<WorkerDeploymentCrashNode>;
timeseries: Array<WorkerDeploymentCrashesTimeseriesEdge>;
};
@@ -8342,7 +8426,7 @@ export type WorkerDeploymentCrashesNameEdge = {
__typename?: 'WorkerDeploymentCrashesNameEdge';
name: Scalars['String']['output'];
node: WorkerDeploymentCrashesAggregationNode;
sample: WorkerDeploymentCrashNode;
sample?: Maybe<WorkerDeploymentCrashNode>;
timeseries: Array<WorkerDeploymentCrashesTimeseriesEdge>;
};
@@ -8909,6 +8993,7 @@ export type WorkflowRunGitBranchFilterInput = {
};
export type WorkflowRunInput = {
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
projectSource: WorkflowProjectSourceInput;
};
@@ -9782,7 +9867,7 @@ export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
}>;
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
export type AppByIdWorkflowRunsFilteredByStatusQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, runs: { __typename?: 'AppWorkflowRunsConnection', edges: Array<{ __typename?: 'AppWorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
export type AppStoreConnectApiKeyByIdQueryVariables = Exact<{
ascApiKeyId: Scalars['ID']['input'];
@@ -10096,6 +10181,13 @@ export type WebhookByIdQueryVariables = Exact<{
export type WebhookByIdQuery = { __typename?: 'RootQuery', webhook: { __typename?: 'WebhookQuery', byId: { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any } } };
export type WorkflowJobByIdQueryVariables = Exact<{
workflowJobId: Scalars['ID']['input'];
}>;
export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
export type WorkflowRunByIdQueryVariables = Exact<{
workflowRunId: Scalars['ID']['input'];
}>;
@@ -10108,7 +10200,7 @@ export type WorkflowRunByIdWithJobsQueryVariables = Exact<{
}>;
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, name: string, status: WorkflowRunStatus, createdAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, type: WorkflowJobType, status: WorkflowJobStatus, outputs: any, createdAt: any }> } } };
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> }>, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }> } } };
export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
appId: Scalars['ID']['input'];
@@ -10118,7 +10210,7 @@ export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
}>;
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
export type WorkflowRunsForAppIdFileNameAndStatusQuery = { __typename?: 'RootQuery', workflows: { __typename?: 'WorkflowQuery', byAppIdAndFileName: { __typename?: 'Workflow', id: string, runs: { __typename?: 'WorkflowRunsConnection', edges: Array<{ __typename?: 'WorkflowRunEdge', node: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } } }> } } } };
export type AccountFragment = { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> };
@@ -10158,7 +10250,9 @@ export type WebhookFragment = { __typename?: 'Webhook', id: string, event: Webho
export type WorkflowFragment = { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any };
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } };
export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } };
export type AndroidAppBuildCredentialsFragment = { __typename?: 'AndroidAppBuildCredentials', id: string, isDefault: boolean, isLegacy: boolean, name: string, androidKeystore?: { __typename?: 'AndroidKeystore', id: string, type: AndroidKeystoreType, keystore: string, keystorePassword: string, keyAlias: string, keyPassword?: string | null, md5CertificateFingerprint?: string | null, sha1CertificateFingerprint?: string | null, sha256CertificateFingerprint?: string | null, createdAt: any, updatedAt: any } | null };
@@ -10235,6 +10329,22 @@ export type AssignAliasMutationVariables = Exact<{
export type AssignAliasMutation = { __typename?: 'RootMutation', deployments: { __typename?: 'DeploymentsMutation', assignAlias: { __typename?: 'WorkerDeploymentAlias', id: string, aliasName?: any | null, url: string, workerDeployment: { __typename?: 'WorkerDeployment', id: string, url: string, deploymentIdentifier: any, deploymentDomain: string, createdAt: any } } } };
export type DeleteAliasMutationVariables = Exact<{
appId: Scalars['ID']['input'];
aliasName?: InputMaybe<Scalars['WorkerDeploymentIdentifier']['input']>;
}>;
export type DeleteAliasMutation = { __typename?: 'RootMutation', deployments: { __typename?: 'DeploymentsMutation', deleteAlias: { __typename?: 'DeleteAliasResult', id: string, aliasName?: any | null } } };
export type DeleteDeploymentMutationVariables = Exact<{
appId: Scalars['ID']['input'];
deploymentIdentifier: Scalars['ID']['input'];
}>;
export type DeleteDeploymentMutation = { __typename?: 'RootMutation', deployments: { __typename?: 'DeploymentsMutation', deleteWorkerDeploymentByIdentifier: { __typename?: 'DeleteWorkerDeploymentResult', id: string, deploymentIdentifier: any } } };
export type PaginatedWorkerDeploymentsQueryVariables = Exact<{
appId: Scalars['String']['input'];
first?: InputMaybe<Scalars['Int']['input']>;
@@ -10252,3 +10362,14 @@ export type SuggestedDevDomainNameQueryVariables = Exact<{
export type SuggestedDevDomainNameQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, suggestedDevDomainName: string } } };
export type PaginatedWorkerDeploymentAliasesQueryVariables = Exact<{
appId: Scalars['String']['input'];
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
}>;
export type PaginatedWorkerDeploymentAliasesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workerDeploymentAliases: { __typename?: 'WorkerDeploymentAliasesConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null }, edges: Array<{ __typename?: 'WorkerDeploymentAliasEdge', cursor: string, node: { __typename?: 'WorkerDeploymentAlias', id: string, aliasName?: any | null, url: string } }> } } } };
@@ -0,0 +1,39 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { WorkflowJobByIdQuery, WorkflowJobByIdQueryVariables } from '../generated';
import { WorkflowJobFragmentNode } from '../types/WorkflowJob';
export const WorkflowJobQuery = {
async byIdAsync(
graphqlClient: ExpoGraphqlClient,
workflowJobId: string,
{ useCache = true }: { useCache?: boolean } = {}
): Promise<WorkflowJobByIdQuery['workflowJobs']['byId']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<WorkflowJobByIdQuery, WorkflowJobByIdQueryVariables>(
gql`
query WorkflowJobById($workflowJobId: ID!) {
workflowJobs {
byId(workflowJobId: $workflowJobId) {
id
...WorkflowJobFragment
}
}
}
${print(WorkflowJobFragmentNode)}
`,
{ workflowJobId },
{
requestPolicy: useCache ? 'cache-first' : 'network-only',
additionalTypenames: ['WorkflowJob'],
}
)
.toPromise()
);
return data.workflowJobs.byId;
},
};
@@ -13,6 +13,7 @@ import {
WorkflowRunStatus,
WorkflowRunsForAppIdFileNameAndStatusQuery,
} from '../generated';
import { WorkflowJobFragmentNode } from '../types/WorkflowJob';
import { WorkflowRunFragmentNode } from '../types/WorkflowRun';
export const WorkflowRunQuery = {
@@ -57,28 +58,27 @@ export const WorkflowRunQuery = {
workflowRuns {
byId(workflowRunId: $workflowRunId) {
id
name
status
createdAt
workflow {
id
name
fileName
app {
id
name
ownerAccount {
id
name
}
}
}
jobs {
id
key
name
type
status
outputs
createdAt
...WorkflowJobFragment
}
...WorkflowRunFragment
}
}
}
${print(WorkflowRunFragmentNode)}
${print(WorkflowJobFragmentNode)}
`,
{ workflowRunId },
{
@@ -0,0 +1,29 @@
import gql from 'graphql-tag';
export const WorkflowJobFragmentNode = gql`
fragment WorkflowJobFragment on WorkflowJob {
id
key
name
status
workflowRun {
id
}
type
turtleJobRun {
id
logFileUrls
errors {
errorCode
message
}
}
outputs
errors {
title
message
}
createdAt
updatedAt
}
`;
@@ -6,8 +6,26 @@ export const WorkflowRunFragmentNode = gql`
status
gitCommitMessage
gitCommitHash
requestedGitRef
actor {
id
__typename
... on UserActor {
username
}
... on Robot {
firstName
}
}
triggeringLabelName
triggerEventType
triggeringSchedule
createdAt
updatedAt
errors {
title
message
}
workflow {
id
name
@@ -1,8 +1,8 @@
import { getRequestClient } from '@expo/apple-utils';
export { default } from 'nock';
import { getAdapter } from 'axios';
// Axios from apple-utils needs to be patched when we use Nock.
// Instead of the default adapter, we need to use `axios/lib/adapters/http`
// see: https://github.com/nock/nock#axios
getRequestClient().defaults.adapter = require('axios/lib/adapters/http');
export { default } from 'nock';
getRequestClient().defaults.adapter = getAdapter('http');
@@ -7,6 +7,7 @@ import {
Runtime,
UpdateBranch,
UpdateChannel,
UpdateChannelRuntimeInsights,
UpdateInsights,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
@@ -111,6 +112,8 @@ function mockUpdateChannel({
return {
id: 'a2a1fa12-9d6a-433a-a432-49c64ef8439f',
app: {} as App,
latestRuntimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
runtimeInsights: {} as UpdateChannelRuntimeInsights,
name: channelName ?? 'default-channel-name',
createdAt: '2022-12-07T02:24:29.786Z',
appId: '123',
+4 -8
View File
@@ -1,7 +1,7 @@
import { parseProjectEnv } from '@expo/env';
import mime from 'mime';
import { Gzip, GzipOptions } from 'minizlib';
import { HashOptions, createHash, randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import fs, { createWriteStream } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -37,11 +37,8 @@ async function createTempWritePathAsync(): Promise<string> {
}
/** Computes a SHA512 hash for a file */
async function computeSha512HashAsync(
filePath: fs.PathLike,
options?: HashOptions
): Promise<string> {
const hash = createHash('sha512', { encoding: 'hex', ...options });
async function computeSha512HashAsync(filePath: fs.PathLike): Promise<string> {
const hash = createHash('sha512', { encoding: 'hex' });
await pipeline(fs.createReadStream(filePath), hash);
return `${hash.read()}`;
}
@@ -94,7 +91,6 @@ async function determineMimeTypeAsync(filePath: string): Promise<string | null>
}
interface AssetMapOptions {
hashOptions?: HashOptions;
maxFileSize: number;
}
@@ -119,7 +115,7 @@ export async function collectAssetsAsync(
`Upload of "${file.normalizedPath}" aborted: File size is greater than the upload limit (>500MB)`
);
}
const sha512$ = computeSha512HashAsync(file.path, options?.hashOptions);
const sha512$ = computeSha512HashAsync(file.path);
const contentType$ = determineMimeTypeAsync(file.path);
assets.push({
normalizedPath: file.normalizedPath,
+55 -1
View File
@@ -5,7 +5,7 @@ import { DeploymentsMutation } from './mutations';
import { DeploymentsQuery } from './queries';
import { EXPO_BASE_DOMAIN } from './utils/logs';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { WorkerDeploymentFragment } from '../graphql/generated';
import { WorkerDeploymentAliasFragment, WorkerDeploymentFragment } from '../graphql/generated';
import Log from '../log';
import { promptAsync } from '../prompts';
import { memoize } from '../utils/expodash/memoize';
@@ -231,3 +231,57 @@ export async function selectWorkerDeploymentOnAppAsync({
).toLocaleString()}}`,
});
}
export async function deleteWorkerDeploymentAliasAsync({
graphqlClient,
appId,
aliasName,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
aliasName: string;
}): Promise<{ aliasName?: string; id: string }> {
return await DeploymentsMutation.deleteAliasAsync(graphqlClient, {
appId,
aliasName,
});
}
export async function selectWorkerDeploymentAliasOnAppAsync({
graphqlClient,
appId,
selectTitle,
pageSize,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
selectTitle?: string;
pageSize?: number;
}): ReturnType<typeof selectPaginatedAsync<WorkerDeploymentAliasFragment>> {
return await selectPaginatedAsync({
pageSize: pageSize ?? 25,
printedType: selectTitle ?? 'worker deployment alias',
queryAsync: async queryParams =>
await DeploymentsQuery.getAllAliasesPaginatedAsync(graphqlClient, {
...queryParams,
appId,
}),
getTitleAsync: async (alias: WorkerDeploymentAliasFragment) =>
chalk`${alias.aliasName ?? 'production'}{dim - ${alias.url}}`,
});
}
export async function deleteWorkerDeploymentAsync({
graphqlClient,
appId,
deploymentIdentifier,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
deploymentIdentifier: string;
}): Promise<{ deploymentIdentifier: string; id: string }> {
return await DeploymentsMutation.deleteWorkerDeploymentAsync(graphqlClient, {
appId,
deploymentIdentifier,
});
}
+56
View File
@@ -13,6 +13,9 @@ import {
AssignDevDomainNameMutationVariables,
CreateDeploymentUrlMutation,
CreateDeploymentUrlMutationVariables,
DeleteAliasResult,
DeleteDeploymentMutation,
DeleteDeploymentMutationVariables,
} from '../graphql/generated';
export const DeploymentsMutation = {
@@ -113,4 +116,57 @@ export const DeploymentsMutation = {
return data.deployments.assignAlias;
},
async deleteAliasAsync(
graphqlClient: ExpoGraphqlClient,
deleteAliasVariables: { appId: string; aliasName: string }
): Promise<DeleteAliasResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation(
gql`
mutation DeleteAlias($appId: ID!, $aliasName: WorkerDeploymentIdentifier) {
deployments {
deleteAlias(appId: $appId, aliasName: $aliasName) {
id
aliasName
}
}
}
`,
deleteAliasVariables
)
.toPromise()
);
return data.deployments.deleteAlias;
},
async deleteWorkerDeploymentAsync(
graphqlClient: ExpoGraphqlClient,
deleteVariables: { appId: string; deploymentIdentifier: string }
): Promise<{ deploymentIdentifier: string; id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteDeploymentMutation, DeleteDeploymentMutationVariables>(
gql`
mutation DeleteDeployment($appId: ID!, $deploymentIdentifier: ID!) {
deployments {
deleteWorkerDeploymentByIdentifier(
appId: $appId
deploymentIdentifier: $deploymentIdentifier
) {
id
deploymentIdentifier
}
}
}
`,
deleteVariables
)
.toPromise()
);
return data.deployments.deleteWorkerDeploymentByIdentifier;
},
};
+66
View File
@@ -2,6 +2,7 @@ import { print } from 'graphql';
import gql from 'graphql-tag';
import { WorkerDeploymentFragmentNode } from './fragments/WorkerDeployment';
import { WorkerDeploymentAliasFragmentNode } from './fragments/WorkerDeploymentAlias';
import type { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
@@ -9,6 +10,7 @@ import {
type PaginatedWorkerDeploymentsQueryVariables,
SuggestedDevDomainNameQuery,
SuggestedDevDomainNameQueryVariables,
type WorkerDeploymentAliasFragment,
type WorkerDeploymentFragment,
} from '../graphql/generated';
import type { Connection } from '../utils/relay';
@@ -86,4 +88,68 @@ export const DeploymentsQuery = {
return data.app.byId.suggestedDevDomainName;
},
async getAllAliasesPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
first,
after,
last,
before,
}: {
appId: string;
first?: number;
after?: string;
last?: number;
before?: string;
}
): Promise<Connection<WorkerDeploymentAliasFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query(
gql`
query PaginatedWorkerDeploymentAliases(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
workerDeploymentAliases(
first: $first
after: $after
last: $last
before: $before
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
...WorkerDeploymentAliasFragment
}
}
}
}
}
}
${print(WorkerDeploymentAliasFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['WorkerDeploymentAlias'] }
)
.toPromise()
);
return data.app.byId.workerDeploymentAliases;
},
};
+9 -12
View File
@@ -8,7 +8,11 @@ import { Readable } from 'node:stream';
import promiseRetry from 'promise-retry';
import { AssetFileEntry } from './assets';
import { createMultipartBodyFromFilesAsync, multipartContentType } from './utils/multipart';
import {
createMultipartBodyFromFilesAsync,
createReadStreamAsync,
multipartContentType,
} from './utils/multipart';
const MAX_CONCURRENCY = Math.min(10, Math.max(os.availableParallelism() * 2, 20));
const MAX_RETRIES = 4;
@@ -77,7 +81,7 @@ export async function uploadAsync(
}
method = 'POST';
url.pathname = `/asset/${asset.sha512}`;
body = fs.createReadStream(asset.path);
body = Readable.from(createReadStreamAsync(asset), { objectMode: false });
} else if ('filePath' in payload) {
const { filePath } = payload;
errorPrefix = 'Worker deployment failed';
@@ -88,16 +92,9 @@ export async function uploadAsync(
headers.set('content-type', multipartContentType);
method = 'PATCH';
url.pathname = '/asset/batch';
const iterator = createMultipartBodyFromFilesAsync(
multipart.map(asset => ({
name: asset.sha512,
filePath: asset.path,
contentType: asset.type,
contentLength: asset.size,
})),
onProgressUpdate
);
body = Readable.from(iterator);
body = Readable.from(createMultipartBodyFromFilesAsync(multipart, onProgressUpdate), {
objectMode: false,
});
}
let response: Response;
+57 -12
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
const CRLF = '\r\n';
@@ -20,10 +21,54 @@ const encodeName = (input: string): string => {
});
};
async function* createReadStreamAsync(filePath: string): AsyncGenerator<Uint8Array> {
for await (const raw of fs.createReadStream(filePath)) {
const chunk = raw as Buffer;
yield new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
export async function* createReadStreamAsync(
fileEntry: MultipartFileEntry
): AsyncGenerator<Uint8Array> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(fileEntry.path);
const hash = createHash('sha512');
// NOTE(@kitten): fs.createReadStream() was previously used here as an async iterator
// However, if an early 'end' event is emitted, the async iterator may abort too early and cut off file contents
let bytesTotal = 0;
while (bytesTotal < fileEntry.size) {
const read = await handle.read();
const output = read.buffer.subarray(0, read.bytesRead);
bytesTotal += output.byteLength;
if (bytesTotal > fileEntry.size) {
throw new RangeError(
`Asset "${fileEntry.path}" was modified during the upload (length mismatch)`
);
}
if (output.byteLength) {
hash.update(output);
}
if (bytesTotal === fileEntry.size) {
const sha512 = hash.digest('hex');
if (sha512 !== fileEntry.sha512) {
throw new Error(
`Asset "${fileEntry.path}" was modified during the upload (checksum mismatch)`
);
}
}
if (output.byteLength) {
yield output;
} else {
break;
}
}
if (bytesTotal < fileEntry.size) {
throw new RangeError(
`Asset "${fileEntry.path}" was modified during the upload (length mismatch)`
);
}
} finally {
await handle?.close();
}
}
@@ -47,10 +92,10 @@ const makeFormHeader = (params: {
};
export interface MultipartFileEntry {
name: string;
filePath: string;
contentType: string | null;
contentLength: number | null;
sha512: string;
path: string;
type: string | null;
size: number;
}
export const multipartContentType = `multipart/form-data; boundary=${BOUNDARY_ID}`;
@@ -65,12 +110,12 @@ export async function* createMultipartBodyFromFilesAsync(
for (let idx = 0; idx < entries.length; idx++) {
const entry = entries[idx];
const header = makeFormHeader({
name: entry.name,
contentType: entry.contentType,
contentLength: entry.contentLength,
name: entry.sha512,
contentType: entry.type,
contentLength: entry.size,
});
yield encoder.encode(header);
yield* createReadStreamAsync(entry.filePath);
yield* createReadStreamAsync(entry);
yield encoder.encode(CRLF);
if (onProgressUpdate) {
onProgressUpdate((idx + 1) / entries.length);
Generated
+1134 -576
View File
File diff suppressed because it is too large. Load diff