Compare commits

...
62 Commits
Author SHA1 Message Date
Jon Samp 432b59a77d SAVEPOINT 2025-09-12 10:41:17 -05:00
Jon Samp f8399b5ddf SAVEPOINT 2025-09-12 10:38:01 -05:00
Expo CI e24b21bd1e update CHANGELOG.md 2025-09-10 11:09:48 +00:00
Expo CI 20da7f8778 v16.18.1 2025-09-10 10:54:43 +00:00
Stanisław Chmiela 14e002274f [eas-cli] Support unknown Android release tracks in submit:internal (#3170)
<!-- 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

Preparation for merging the rest of `track`-related pull requests.

Currently, the only place fetching `track` from `www` for display is `submit:internal` command. There, we fetch the submission config to know what parameters to supply to `fastlane supply` in the Submit workflow job.

Currently, we're using a mapping of `{ PRODUCTION: production, INTERNAL: internal, ... }` to transform the GraphQL value (enum key) to config value ("enum value").

# How

Since we're planning to make `androidConfig.track` be a plain string (e.g. `internal`), we should fall back to the actual value if the mapping does not produce a match.

This currently should not change anything (`track` is guaranteed to be a GraphQL enum value). This is a preparation for landing of https://github.com/expo/universe/pull/21827 which (somewhat breakily, but we expect to be the only consumers of our GraphQL API) changes `track` to be any string.

This `track` value is being used [here](https://github.com/expo/universe/blob/2f143aa8d51821c7e1c306768787c46b18bdd544/server/www/src/data/entities/workflow/job/WorkflowSubmitJob.ts#L399-L403).

# Test Plan

This is currently a no-op. Tested along with all the pull requests in the mix.

<img width="372" height="90" alt="Zrzut ekranu 2025-09-9 o 17 03 38" src="https://github.com/user-attachments/assets/0b260ad2-2379-4597-aa1c-dae8bf86446d" />
<img width="243" height="72" alt="Zrzut ekranu 2025-09-9 o 16 59 37" src="https://github.com/user-attachments/assets/4eef353d-ac7e-4b16-9413-a304a574b685" />
<img width="449" height="110" alt="Zrzut ekranu 2025-09-9 o 17 00 40" src="https://github.com/user-attachments/assets/58a44b7a-16bb-46b4-8d98-f233c196406a" />
<img width="366" height="85" alt="Zrzut ekranu 2025-09-9 o 17 00 35" src="https://github.com/user-attachments/assets/71013cd6-5f5e-4e38-951c-008642fbee8c" />
2025-09-10 12:37:37 +02:00
Douglas Lowder 3b8dc6b7aa CLI debug log should respect DEBUG=eas:* (#3162)
# Why

It would be great if we could use the environment setting `DEBUG=eas:*` to enable debug logs when running the EAS CLI, similarly to the way `DEBUG=expo:*` is used in the Expo CLI.

This is a first step towards implementing that. Ideally, different commands and utility modules would set different tags for the debug log messages, but this will at least allow the debug logs to appear for existing `Log.debug()` calls.

# How

Modify `src/log.ts` to write debug logs if DEBUG is set to a regex that matches `eas:log:debug`.

# Test Plan

CI should pass.
2025-09-05 11:02:56 -07:00
Douglas Lowder 445a48a8dd Sync GraphQL schema and generated code with server schema (#3161)
# Why

Sync up the schema to reflect recent additions to the server GraphQL schema

# How

`yarn --cwd packages/eas-cli verify-graphql-code`, then inspect changes in schema and generated code.

# Test Plan

CI should pass
2025-09-05 11:02:45 -07:00
Douglas Lowder f6436319ed Add better description to update:rollback (#3154)
# Why

Make it easier for customers to use `eas update:rollback` in CI.

# How

Add description text on the underlying commands that do support the non-interactive flag.
2025-08-28 12:52:15 -07:00
Expo CI bd1e32622d update CHANGELOG.md 2025-08-27 18:02:36 +00:00
Expo CI 74dcb40caa v16.18.0 2025-08-27 17:44:02 +00:00
Jon Samp f094146dcd exposes channel:delete as command (#3150) 2025-08-25 09:33:49 -05:00
Vojtech Novak 3784745382 chore: include available profiles in 'Missing build profile' error (#3141)
* chore: include available profiles in 'Missing build profile' error

* address review
2025-08-13 13:44:21 +02:00
Ash Wu 72be7e80bf [eas-cli] fix broken docs links (#3140)
* [eas-cli] fix broken docs links

Signed-off-by: Ash Wu <[email protected]>

* fix eslint

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2025-08-13 12:05:36 +02:00
Douglas Lowder cf1b8cb5eb Show fingerprints in build:view and build:list commands (#3137) 2025-08-07 09:12:02 -07:00
Stanisław Chmiela b6df8d3a57 [chore] Update codemention.yml (#3136) 2025-08-07 17:36:18 +02:00
Kadi Kraman b96a262cd4 [eas-cli] Only make EXPO_PUBLIC_ env vars plain text (#3121) 2025-07-31 12:40:40 +01:00
Douglas Lowder 3df84f28aa Use correct token in release trigger workflow (#3125) 2025-07-30 18:19:29 -07:00
Expo CI 554f411fae update CHANGELOG.md 2025-07-31 00:44:50 +00:00
Expo CI e6ce9837e6 v16.17.4 2025-07-31 00:39:29 +00:00
Douglas Lowder 30d8a0ce99 Adjust release workflow so Lerna detects version changes (#3124)
# Why

The existing release trigger workflow does not obtain the GItHub history and tags, so that Lerna cannot correctly determine which package versions to bump.

# How

Set `fetch-depth: 0` in the checkout action, and do an explicit fetch of all tags.

# Test Plan

- After merge, a dry run of the release trigger should correctly bump only changed packages.
2025-07-30 16:45:53 -07:00
Douglas Lowder a9c5907230 Fix release script (#3123)
# Why

The new release GitHub workflow has an error because the release script needs to be run without prompts:

```
Checking if eas-cli-local-build-plugin upgrade is available
eas-cli-local-build-plugin is already on the latest version - 1.0.171
lerna notice cli v8.2.3
lerna info ci enabled
lerna info current version 16.17.3
lerna info Assuming all packages changed
Changes:
 - eas-build-cache-provider: 16.4.2 => 16.17.4
 - eas-cli: 16.17.3 => 16.17.4
 - @expo/eas-json: 16.13.2 => 16.17.4
error Command failed with exit code 130.
? Are you sure you want to create these versions? (ynH) info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Process completed with exit code 130.
```
# How

- Added the `--yes` flag to the script commands
- Instead of failing when run locally, the script now executes the release flow in "dry run" mode, without committing, tagging, or pushing changes.
- Add workflow input for dry run mode (true by default, must be manually set to false to actually release)
- Get full Git history before running release script, as required for correct Lerna version check

# Test Plan

- Test locally
- Run the workflow in dry run mode after merging
- Attempt a release after this PR is merged
2025-07-30 16:07:20 -07:00
Kadi Kraman ad0f94aca9 [ci] Build eas-json before typecheck (#3122)
* Build eas-json

* Ensure check gets run when the script changes
2025-07-30 12:38:07 -07:00
Douglas Lowder bf66b79126 New workflow to trigger releases (#3119)
# Why

The current release script requires that the user executing the script have permission to push changes to main without any PR requirement or checks.

# How

To allow us to add PR requirements and checks, I have added a manual GitHub workflow so that the existing script is executed by the expo-bot user, which is exempted from PR requirements.

I have also made a few adjustments to the `release.yml` workflow (remove incorrect release name, update Node version to 22).

# Test Plan

- CI should pass
- Try a release after this PR is merged
2025-07-30 09:27:58 -07:00
Will Schurman 🦦 dcc5d62800 [eas-cli] Fix lint (#3116) 2025-07-28 10:40:53 -07:00
Will Schurman 🦦 878ed19f0c Update expoSsoLauncher.ts 2025-07-28 10:35:15 -07:00
Nitzan Raz 0e17e415b7 [TINY] SSO Login: print browser url
Summary:
I'm working in a weird setup where the browser won't launch from cli.
This required me to visit the url manually from my browser.
To get the url, I had to add a `console.log` to the cli's code directly.
Figured this change will make it easier for similar users to login, without exposing sensitive data

Test Plan:
Tested manually on the compiled code, not the best
2025-07-27 17:47:39 +03:00
Expo CI d4c5a4e1e4 update CHANGELOG.md 2025-07-23 22:12:02 +00:00
Douglas Lowder 3776c95f00 v16.17.3 2025-07-23 15:08:48 -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
Phil Pluckthun 72f3ba817f v16.15.0 2025-07-10 18:30:29 +01:00
Phil Pluckthun e8ffdf25f7 feat(eas-cli): Implement new batched EAS Hosting asset upload protocol (#3085)
* Add custom multipart format

* Reuse list of files for upload params

* Remove unused gzip compression

* Clean up UploadParams

* Update file crawling to include size and mime type

* Move file-size check to assets.ts

* Refactor upload functions

* Update upload API function to support multipart uploads

* Update uploadAsync params usage in deploy command

* Upload as multipart batches, if instructed

* Count batch length against total

* Add CHANGELOG

* Fix signal not being used

* Add new assets.json format

* Skip empty assets

* Fix lint error

* Adjust concurrency

Beyond 10 we do risk some CPU contention in rare cases for small files
(especially on Windows)
However, we can now run more requests concurrently, since they're slower
each and hence are less likely to run into network issues due to lower
rate.

* Revert "Skip empty assets"

This reverts commit 18434cdb48.

* feat(eas-cli): Implement new asset upload progress bar for `deploy` command (#3099)

* Update comment

* Add CHANGELOG entry for progress bar change
2025-07-10 18:25:45 +01:00
Quinlan Jung 196abc9a9e fix changelog 2025-07-08 12:59:11 -07:00
Expo CI 037529689a update CHANGELOG.md 2025-07-08 19:15:28 +00:00
Quinlan Jung 2502b843f8 v16.14.1 2025-07-08 12:09:44 -07:00
Quinlan Jung 🦥 012e75f8ee [eas-cli] update apple team info as needed (#3096)
* [eas-cli] update apple team info as needed

* Temporary Commit at 7/7/2025, 2:01:08 PM

* Temporary Commit at 7/7/2025, 2:12:28 PM

* Temporary Commit at 7/8/2025, 11:28:08 AM
2025-07-08 12:08:34 -07:00
Expo CI 5ada92430b update CHANGELOG.md 2025-07-07 22:52:34 +00:00
Will Schurman bf27ec5238 v16.14.0 2025-07-07 15:47:08 -07:00
Will Schurman 🦦 8f18b35fe8 [eas-cli] Fix graphql codegen to look in all directories (#3097)
<!-- 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

While creating a previous PR, I moved a graphql query into a directory not explicitly specified in `graphql-codegen.yml`. It took me a while to figure out what was going on (only error was `tsc`).

To avoid other devs running into this, just generate it for all files in src.

# How

1. Update paths
2. Fix `fetchUserAsync` to be typed and have a unique query name.

# Test Plan

```
neas logout
neas login
neas whoami
```
2025-07-07 14:24:25 -07:00
Will Schurman 🦦 e82b8966b0 [eas-cli] Add update:revert-update-rollout command (#3068)
<!-- 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

The docs for ending an update-based rollout are incorrect: https://docs.expo.dev/eas-update/rollouts/#ending-a-rollout

In order to revert a update-based rollout, it currently says:
> To accomplish this end state, republish the previous update by using the `eas update:republish` command.

But this is incorrect since one is not allowed to publish a new update until the control update is finished (can't have multiple roll-outs).

What actually needs to happen under the hood is one of two options:

- When on a branch with a rollout with a control update
    1. Delete the rollout update
    2. Republish the control update
- When on a branch with a rollout with only a rollout update
    1. Delete the rollout update
    2. Publish a roll-back-to-embedded (after warning since this may not be intended behavior).

This PR adds a new command, `eas update:revert-update-rollout` that does this.

# How

Add command, refactoring shared code where necessary.

This handles a ton of cases. Essentially the cross product of:
- Rollout published on empty branch or branch with existing updates. (rollout has control)
- Rollout published per-platform or single group with both platforms,
- Control published per-platform or single group with both platforms.

So, 8 total cases.

# Test Plan

Tested most of the 8 cases.

Normal update group rollout (both platforms), normal control on same update group:

```
(branch with an existing notmal update group)
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "b0db8e15-d3d0-41db-985a-d74f0bd8fb00"? This will delete the rollout update group and republish the control update group (ID: "7df732c7-ccad-4755-8b80-acd1c03c5a81"). … yes
✔ Provide an update message. … watwatrep
✔ Republished update group
```

Normal update group rollout (both platforms), controls in different update groups (latest updates for each platform at time of rollout publish were in different groups):
```
$ neas update --platform android
$ neas update --platform ios
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "47ef8fe7-6f9b-4c59-bdd5-cdcc12eef0f2"? This will delete the rollout update group and republish the control update groups (IDs: "a821e0be-04af-455b-8b75-ceabea3afc09", "5a07a994-7a71-4d4e-a188-83df9e456e2c"). … yes
✔ Provide an update message. … Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
✔ Provide an update message. … Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
✔ Republished update group

Branch           main
Runtime version  1.0.0
Platform         ios
Update group ID  c1d05402-4391-4cd8-9eb0-ab0c7958bce5
iOS update ID    06b57ea9-4016-40b7-962b-756d463f2ba9
Message          Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
EAS Dashboard    https://staging.expo.dev/accounts/wschurman/projects/test-ojdasksajajaja/updates/c1d05402-4391-4cd8-9eb0-ab0c7958bce5
✔ Republished update group

Branch             main
Runtime version    1.0.0
Platform           android
Update group ID    56d10192-9bbd-4557-ab25-44250b3ccf6b
Android update ID  eb5b1a25-cbba-4f2a-afe9-d698068369ed
Message            Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
EAS Dashboard      https://staging.expo.dev/accounts/wschurman/projects/test-ojdasksajajaja/updates/56d10192-9bbd-4557-ab25-44250b3ccf6b
```

Two single platform rollouts:
```
$ neas update --platform android
$ neas update --platform ios
$ neas update --rollout-percentage=50 --platform android
$ neas update --rollout-percentage=50 --platform ios
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Which rollout update group would you like to revert? › Rollout update group ID: 38369dcd-fe24-40c5-a85d-c7d4c03c9230, Platform: android, Rollout Percentage: 50
✔ Are you sure you want to revert the rollout update group with ID "38369dcd-fe24-40c5-a85d-c7d4c03c9230"? This will delete the rollout update group and republish the control update group (ID: "5a07a994-7a71-4d4e-a188-83df9e456e2c"). … yes
✔ Provide an update message. … Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
✔ Republished update group

$ neas update:revert-update-rollout
(node:96817) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "ae7aaa74-06cc-43b1-98dd-63e019cb4a64"? This will delete the rollout update group and republish the control update group (ID: "a821e0be-04af-455b-8b75-ceabea3afc09"). … yes
✔ Provide an update message. … Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
✔ Republished update group
```

Empty branch rollout (both platforms), no control:

```
(empty branch)
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "1797d1fe-cf58-4134-abe1-b0333250da55"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. … yes
✔ Provide an update message: … geageawga
✔ Published!
```

Empty branch rollout (just one platform), no control:
```
(empty branch)
$ neas update --rollout-percentage=50 --platform android
$ neas update:revert-update-rollout
(node:87664) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "b2115695-ba9f-4f2d-a901-9f147d09e113"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. … yes
✔ Provide an update message: … dwada
✔ Published!

Branch             main
Runtime version    1.0.0
Platform           android
```
2025-07-07 13:58:09 -07:00
Douglas Lowder fad19a1e03 Workflow to test and verify GraphQL (#3093)
# Why

Now that #3091 is resolved, we need to add some CI to guard against similar future issues with GraphQL breakage.

# How

Add a new workflow that

- Executes GraphQL generation, and fails if there is an error.
- Verifies that for a given PR, any changes in GraphQL generated code or schema files have been checked in as part of the PR. If not, the workflow fails.

The new workflow will execute on pushes, and on a daily schedule to guard against breakage due to changes on the server side.

# Test Plan

- CI should pass
- Push a source change in the GraphQL code, but do not regenerate GraphQL, and verify that the new workflow fails
2025-07-07 12:27:32 -07:00
Kadi Kraman 553d648e86 [eas-cli] Remove link and unlink commands (#3092) 2025-07-06 10:30:56 +01:00
Expo CI 5588a7343d update CHANGELOG.md 2025-07-04 20:32:59 +00:00
Gabriel Donadel 8ec3c52be8 v16.13.4 2025-07-04 17:27:19 -03:00
Gabriel Donadel Dall'Agnol e29d7916b8 [eas-cli] remove hidden flag from upload (#3083)
* [eas-cli] remove hidden flag from upload

* Add changelog entry
2025-07-04 21:39:17 +02:00
Expo CI b9840556fc update CHANGELOG.md 2025-07-01 02:39:04 +00:00
95 changed files with 8523 additions and 4304 deletions

No files matched your search

+7 -1
View File
@@ -3,7 +3,7 @@ commentConfiguration:
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['**/*']
mentions: ['sjchmiela']
mentions: ['douglowder']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/update/**']
@@ -22,3 +22,9 @@ rules:
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/deploy/**']
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/commands/workflow/**']
mentions: ['sjchmiela']
+11 -6
View File
@@ -18,6 +18,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
@@ -27,9 +28,13 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- 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: |
@@ -68,7 +73,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
@@ -111,7 +116,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
@@ -153,7 +158,7 @@ jobs:
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 20
node-version: 22
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
@@ -186,7 +191,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
@@ -206,7 +211,7 @@ jobs:
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git commit --allow-empty -m "update CHANGELOG.md"
git push
- name: Prepare changelog for Slack
run: |
+27
View File
@@ -0,0 +1,27 @@
name: Ensure GraphQL schema and generated code is up-to-date
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
concurrency:
group: test-graphql-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+48
View File
@@ -0,0 +1,48 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
default: true
description: 'Run the release in dry run mode, without pushing any changes'
name: Manually trigger a release
jobs:
release:
name: Trigger a release
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
- run: |
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release $INPUT_VERSION
fi
+88 -1
View File
@@ -10,6 +10,93 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
### 🎉 New features
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
### 🎉 New features
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Make EXPO_PUBLIC_ env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
### 🧹 Chores
- Introduce new release process and workflow. ([#3119](https://github.com/expo/eas-cli/pull/3119) by [@douglowder](https://github.com/douglowder))
- SSO Login: print browser url ([#3113](https://github.com/expo/eas-cli/pull/3113) by [@BackSlasher](https://github.com/BackSlasher))
## [16.17.2](https://github.com/expo/eas-cli/releases/tag/v16.17.2) - 2025-07-23
## [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))
## [16.14.1](https://github.com/expo/eas-cli/releases/tag/v16.14.1) - 2025-07-08
### 🧹 Chores
- Update apple team info as needed. ([#3096](https://github.com/expo/eas-cli/pull/3096) by [@quinlanj](https://github.com/quinlanj))
## [16.14.0](https://github.com/expo/eas-cli/releases/tag/v16.14.0) - 2025-07-07
### 🎉 New features
- Add update:revert-update-rollout command. ([#3068](https://github.com/expo/eas-cli/pull/3068) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Remove unreleased `link` and `unlink` commands from `eas env`. ([#3092](https://github.com/expo/eas-cli/pull/3092) by [@kadikraman](https://github.com/kadikraman))
- Add new CI to verify GraphQL generation. ([#3093](https://github.com/expo/eas-cli/pull/3093) by [@douglowder](https://github.com/douglowder))
- Update GraphQL generation to look in all src directories. ([#3097](https://github.com/expo/eas-cli/pull/3097) by [@wschurman](https://github.com/wschurman))
## [16.13.4](https://github.com/expo/eas-cli/releases/tag/v16.13.4) - 2025-07-04
### 🧹 Chores
- Remove hidden flag from `eas upload`. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.13.3](https://github.com/expo/eas-cli/releases/tag/v16.13.3) - 2025-07-01
### 🐛 Bug fixes
- Fix suggested command to switch Xcode Developer Tools. ([#3079](https://github.com/expo/eas-cli/pull/3079) by [@rolfb](https://github.com/rolfb))
- Skip configuring build profiles when running `eas update:configure` if no build profiles exist. ([#3084](https://github.com/expo/eas-cli/pull/3084) by [@brentvatne](https://github.com/brentvatne))
@@ -28,7 +115,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- Fix bug in log during update:republish. ([#3067](https://github.com/expo/eas-cli/pull/3067) by [@wschurman](https://github.com/wschurman))
Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
- Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
## [16.13.0](https://github.com/expo/eas-cli/releases/tag/v16.13.0) - 2025-06-24
+1 -1
View File
@@ -1,6 +1,6 @@
# Releasing EAS CLI
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
## Choosing the next version
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.13.3",
"version": "16.18.1",
"$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",
+298 -88
View File
@@ -81,6 +81,7 @@ eas --help COMMAND
* [`eas build:version:sync`](#eas-buildversionsync)
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
* [`eas channel:delete [NAME]`](#eas-channeldelete-name)
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
* [`eas channel:list`](#eas-channellist)
* [`eas channel:pause [NAME]`](#eas-channelpause-name)
@@ -90,8 +91,10 @@ eas --help COMMAND
* [`eas config`](#eas-config)
* [`eas credentials`](#eas-credentials)
* [`eas credentials:configure-build`](#eas-credentialsconfigure-build)
* [`eas deploy [options]`](#eas-deploy-2moptions22m)
* [`eas deploy [options]`](#eas-deploy-options)
* [`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)
@@ -129,9 +132,11 @@ eas --help COMMAND
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
* [`eas update:list`](#eas-updatelist)
* [`eas update:republish`](#eas-updaterepublish)
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
* [`eas update:roll-back-to-embedded`](#eas-updateroll-back-to-embedded)
* [`eas update:rollback`](#eas-updaterollback)
* [`eas update:view GROUPID`](#eas-updateview-groupid)
* [`eas upload`](#eas-upload)
* [`eas webhook:create`](#eas-webhookcreate)
* [`eas webhook:delete [ID]`](#eas-webhookdelete-id)
* [`eas webhook:list`](#eas-webhooklist)
@@ -139,12 +144,16 @@ eas --help COMMAND
* [`eas webhook:view ID`](#eas-webhookview-id)
* [`eas whoami`](#eas-whoami)
* [`eas worker:alias`](#eas-workeralias)
* [`eas deploy [options]`](#eas-deploy-2moptions22m-1)
* [`eas worker:alias:delete [ALIAS_NAME]`](#eas-workeraliasdelete-alias_name)
* [`eas worker:delete [DEPLOYMENT_ID]`](#eas-workerdelete-deployment_id)
* [`eas deploy [options]`](#eas-deploy-options-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`
@@ -164,7 +173,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -181,7 +190,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -198,7 +207,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -212,7 +221,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.13.3/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -264,7 +273,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -285,7 +294,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -305,7 +314,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -325,7 +334,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -348,7 +357,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -389,7 +398,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -408,7 +417,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -425,7 +434,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.13.3/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.18.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -444,7 +453,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -464,7 +473,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.13.3/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.18.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -485,7 +494,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.13.3/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.18.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -520,7 +529,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.13.3/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.18.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -572,7 +581,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.13.3/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.18.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -601,7 +610,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.13.3/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.18.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -627,7 +636,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.13.3/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.18.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -681,7 +690,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.13.3/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.18.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -700,7 +709,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.13.3/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.18.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -719,7 +728,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.13.3/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.18.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -736,7 +745,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.13.3/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.18.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -757,7 +766,28 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
Delete a channel
```
USAGE
$ eas channel:delete [NAME] [--json --non-interactive]
ARGUMENTS
NAME Name of the channel to delete
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 channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -779,7 +809,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.13.3/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.18.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -799,7 +829,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -821,7 +851,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.13.3/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.18.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -843,7 +873,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.13.3/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.18.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -876,7 +906,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.13.3/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.18.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -899,7 +929,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -920,7 +950,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.13.3/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -937,7 +967,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -955,9 +985,9 @@ 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.13.3/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.18.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
## `eas deploy [options]`
deploy your Expo Router web build and API Routes
@@ -983,7 +1013,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1008,7 +1038,49 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/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.18.1/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.18.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1045,7 +1117,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.13.3/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.18.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1065,7 +1137,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.13.3/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.18.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1086,7 +1158,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.13.3/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.18.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1107,7 +1179,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1121,7 +1193,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.13.3/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.18.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1135,7 +1207,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.13.3/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1164,7 +1236,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.13.3/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.18.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1188,7 +1260,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.13.3/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.18.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1209,7 +1281,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.13.3/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.18.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1234,7 +1306,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.13.3/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.18.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1259,7 +1331,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.13.3/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.18.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1282,7 +1354,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.13.3/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.18.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1303,7 +1375,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.13.3/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.18.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1334,7 +1406,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.13.3/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.18.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1379,7 +1451,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.13.3/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.18.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1410,7 +1482,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.13.3/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.18.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1518,7 +1590,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.13.3/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.18.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1535,7 +1607,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.13.3/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.18.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1552,7 +1624,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.13.3/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.18.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1582,7 +1654,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.13.3/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1596,7 +1668,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.13.3/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.18.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1619,7 +1691,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1637,7 +1709,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1672,7 +1744,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.13.3/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.18.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1714,7 +1786,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1734,7 +1806,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.13.3/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.18.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1755,7 +1827,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.13.3/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.18.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1779,7 +1851,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.13.3/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.18.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1801,7 +1873,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1837,7 +1909,34 @@ 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.13.3/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.18.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
revert a rollout update for a project
```
USAGE
$ eas update:revert-update-rollout [--channel <value> | --branch <value> | --group <value>] [-m <value>] [--private-key-path
<value>] [--json --non-interactive]
FLAGS
-m, --message=<value> Short message describing the revert
--branch=<value> Branch name to select an update group to revert the rollout update from
--channel=<value> Channel name to select an update group to revert the rollout update from
--group=<value> Rollout update group ID to revert
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
certificate's directory. Only relevant if you are using code signing:
https://docs.expo.dev/eas-update/code-signing/
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.18.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -1865,11 +1964,11 @@ 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.13.3/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.18.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
roll back to an embedded update or an existing update
Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
USAGE
@@ -1882,10 +1981,11 @@ FLAGS
https://docs.expo.dev/eas-update/code-signing/
DESCRIPTION
roll back to an embedded update or an existing update
Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -1905,7 +2005,28 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
upload a local build and generate a sharable link
```
USAGE
$ eas upload [-p ios|android] [--build-path <value>] [--fingerprint <value>] [--json --non-interactive]
FLAGS
-p, --platform=(ios|android)
--build-path=<value> Path for the local build
--fingerprint=<value> Fingerprint hash of the local build
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
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.18.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -1926,7 +2047,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1946,7 +2067,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1964,7 +2085,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1986,7 +2107,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2003,7 +2124,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2043,7 +2164,45 @@ ALIASES
$ eas deploy:promote
```
## `eas deploy [options]`
## `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
@@ -2085,7 +2244,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.13.3/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.18.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2105,7 +2264,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.13.3/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.18.1/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.18.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run [FILE]`
@@ -2113,26 +2296,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.13.3/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.18.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2157,7 +2345,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.13.3/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.18.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:validate PATH`
@@ -2177,5 +2365,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.13.3/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.18.1/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.18.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+1 -7
View File
@@ -1,13 +1,7 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
- 'src/branch/**/*.ts'
- 'src/channel/**/*.ts'
- 'src/worker/**/*.ts'
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
+1413 -670
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.13.3",
"version": "16.18.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -13,7 +13,7 @@
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.173",
"@expo/eas-json": "16.13.2",
"@expo/eas-json": "16.18.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.117",
@@ -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",
@@ -234,6 +234,7 @@
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log"
},
"volta": {
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
function cleanup()
{
echo 'Cleaning up...'
rm -f graphql.schema.json.bak src/graphql/generated.ts.bak
}
# Fail if anything errors
set -eox pipefail
# If this script exits, trap it first and clean up
trap cleanup EXIT
cp graphql.schema.json graphql.schema.json.bak
cp src/graphql/generated.ts src/graphql/generated.ts.bak
yarn generate-graphql-code
if cmp -s graphql.schema.json graphql.schema.json.bak; then
echo "GraphQL schema is up-to-date"
if cmp -s src/graphql/generated.ts src/graphql/generated.ts.bak; then
echo "GraphQL generated code is up-to-date"
exit 0
else
echo "GraphQL code has changed but has not been regenerated. Run `yarn generate-graphql-code` and commit the changes."
exit 1
fi
else
echo "GraphQL schema has changed on the server."
echo "Building eas-json"
cd ../eas-json
yarn build
cd ../eas-cli
echo "Running `yarn tsc` to check for breaking changes..."
yarn tsc
# If there are no errors, then the schema is backwards compatible
exit 0
fi
@@ -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',
@@ -6,6 +6,7 @@ import {
BuildFragment,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { link } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import formatFields from '../../utils/formatFields';
@@ -81,7 +82,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Logs',
value: getBuildLogsUrl(build),
value: link(getBuildLogsUrl(build)),
},
{
label: 'Artifact',
@@ -97,7 +98,28 @@ export function formatGraphQLBuild(build: BuildFragment): string {
return null;
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
return url ? link(url) : chalk.red('not found');
}
default:
return null;
}
},
},
{
label: 'Fingerprint',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case GraphQLBuildStatus.PendingCancel:
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return null;
case GraphQLBuildStatus.Finished: {
const hash = build.fingerprint?.hash;
return hash ? chalk.green(hash) : chalk.red('not found');
}
default:
return null;
@@ -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;
};
@@ -16,7 +16,6 @@ import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelDelete extends EasCommand {
static override hidden = true;
static override description = 'Delete a channel';
static override args = [
@@ -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'],
};
}
}
+70 -56
View File
@@ -13,7 +13,6 @@ import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { createProgressTracker } from '../../utils/progress';
import * as WorkerAssets from '../../worker/assets';
import {
assignWorkerDeploymentAliasAsync,
@@ -21,9 +20,10 @@ import {
getSignedDeploymentUrlAsync,
} from '../../worker/deployment';
import {
UploadParams,
UploadPayload,
batchUploadAsync,
callUploadApiAsync,
createProgressBar,
uploadAsync,
} from '../../worker/upload';
import {
@@ -32,6 +32,8 @@ import {
getDeploymentUrlFromFullName,
} from '../../worker/utils/logs';
const MAX_UPLOAD_SIZE = 5e8; // 500MB
const isDirectory = (directoryPath: string): Promise<boolean> =>
fs.promises
.stat(directoryPath)
@@ -60,11 +62,16 @@ interface RawDeployFlags {
'dry-run': boolean;
}
interface UploadAssetBatchInstruction {
sha512: string[];
}
interface DeployInProgressParams {
id: string;
fullName: string;
baseURL: string;
token: string;
upload?: UploadAssetBatchInstruction[];
}
export default class WorkerDeploy extends EasCommand {
@@ -162,14 +169,14 @@ export default class WorkerDeploy extends EasCommand {
tarPath: string,
uploadUrl: string
): Promise<DeployInProgressParams> {
const { response } = await uploadAsync({
url: uploadUrl,
filePath: tarPath,
compress: false,
headers: {
accept: 'application/json',
const payload = { filePath: tarPath };
const { response } = await uploadAsync(
{
baseURL: uploadUrl,
method: 'POST',
},
});
payload
);
if (response.status === 413) {
throw new Error(
'Upload failed! (Payload too large)\n' +
@@ -185,73 +192,79 @@ export default class WorkerDeploy extends EasCommand {
if (!json.success || !json.result || typeof json.result !== 'object') {
throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!');
}
const { id, fullName, token } = json.result;
const { id, fullName, token, upload } = json.result;
if (typeof token !== 'string') {
throw new Error('Upload failed: API failed to return a deployment token');
} else if (typeof id !== 'string') {
throw new Error('Upload failed: API failed to return a deployment identifier');
} else if (typeof fullName !== 'string') {
throw new Error('Upload failed: API failed to return a script name');
} else if (!Array.isArray(upload) && upload !== undefined) {
throw new Error('Upload failed: API returned invalid asset upload instructions');
}
const baseURL = new URL('/', uploadUrl).toString();
return { id, fullName, baseURL, token };
return { id, fullName, baseURL, token, upload };
}
}
async function uploadAssetsAsync(
assetMap: WorkerAssets.AssetMap,
assetFiles: WorkerAssets.AssetFileEntry[],
deployParams: DeployInProgressParams
): Promise<void> {
const uploadParams: UploadParams[] = [];
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
if (!assetPath) {
return;
const baseURL = new URL('/asset/', deployParams.baseURL);
const uploadInit = { baseURL, method: 'POST' };
uploadInit.baseURL.searchParams.set('token', deployParams.token);
const uploadPayloads: UploadPayload[] = [];
if (deployParams.upload) {
const assetsBySHA512 = assetFiles.reduce((map, asset) => {
map.set(asset.sha512, asset);
return map;
}, new Map<string, WorkerAssets.AssetFileEntry>());
const payloads = deployParams.upload
.map(instruction =>
instruction.sha512.map(sha512 => {
const asset = assetsBySHA512.get(sha512);
if (!asset) {
// NOTE(@kitten): This should never happen
throw new Error(
`Uploading assets failed: API instructed us to upload an asset that does not exist`
);
}
return asset;
})
)
.filter(assets => assets && assets.length > 0)
.map(assets => (assets.length > 1 ? { multipart: assets } : { asset: assets[0] }));
uploadPayloads.push(...payloads);
} else {
// NOTE(@kitten): Legacy format which uploads assets one-by-one
uploadPayloads.push(...assetFiles.map(asset => ({ asset })));
}
for await (const asset of WorkerAssets.listAssetMapFilesAsync(assetPath, assetMap)) {
const uploadURL = new URL(`/asset/${asset.sha512}`, deployParams.baseURL);
uploadURL.searchParams.set('token', deployParams.token);
uploadParams.push({ url: uploadURL.toString(), filePath: asset.path });
}
const progress = {
total: uploadParams.length,
pending: 0,
percent: 0,
transferred: 0,
};
const updateProgress = createProgressTracker({
total: progress.total,
message(ratio) {
const percent = `${Math.floor(ratio * 100)}`;
const details = chalk.dim(
`(${progress.pending} Pending, ${progress.transferred} Completed, ${progress.total} Total)`
);
return `Uploading assets: ${percent.padStart(3)}% ${details}`;
},
completedMessage: 'Uploaded assets',
});
const progressTotal = uploadPayloads.reduce(
(acc, payload) => acc + ('multipart' in payload ? payload.multipart.length : 1),
0
);
const progressTracker = createProgressBar(`Uploading ${progressTotal} assets`);
try {
for await (const signal of batchUploadAsync(uploadParams)) {
if ('response' in signal) {
progress.pending--;
progress.percent = ++progress.transferred / progress.total;
} else {
progress.pending++;
}
updateProgress({ progress });
for await (const signal of batchUploadAsync(
uploadInit,
uploadPayloads,
progressTracker.update
)) {
progressTracker.update(signal.progress);
}
} catch (error: any) {
updateProgress({ isComplete: true, error });
progressTracker.stop();
throw error;
} finally {
progressTracker.stop();
}
updateProgress({ isComplete: true });
}
let assetMap: WorkerAssets.AssetMap;
let tarPath: string;
let assetFiles: WorkerAssets.AssetFileEntry[];
let deployResult: DeployInProgressParams;
let progress = ora('Preparing project').start();
@@ -271,12 +284,13 @@ export default class WorkerDeploy extends EasCommand {
manifestResult.conflictingVariableNames.join(' ')
);
}
assetMap = await WorkerAssets.createAssetMapAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
assetFiles = await WorkerAssets.collectAssetsAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
{ maxFileSize: MAX_UPLOAD_SIZE }
);
tarPath = await WorkerAssets.packFilesIterableAsync(
emitWorkerTarballAsync({
assetMap,
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
manifest: manifestResult.manifest,
})
);
@@ -334,7 +348,7 @@ export default class WorkerDeploy extends EasCommand {
throw error;
}
await uploadAssetsAsync(assetMap, deployResult);
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
@@ -289,7 +289,7 @@ describe(EnvCreate, () => {
});
});
it('creates an account-wide variable and links it', async () => {
it('creates an account-wide variable', async () => {
const command = new EnvCreate(
[
'--name',
@@ -302,7 +302,6 @@ describe(EnvCreate, () => {
'development',
'--scope',
'account',
'--link',
],
mockConfig
);
@@ -330,18 +329,6 @@ describe(EnvCreate, () => {
},
testAccountId
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Development
);
});
it('prompts for missing arguments', async () => {
@@ -1,271 +0,0 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvLink from '../link';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvLink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('links an account-wide variable to the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('links an account-wide variable to the current project to a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('uses --variable-environment to select the variable with ambigous name', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockImplementation(
(_client, { environment }) => {
return mockVariables.filter(v => v.environments.includes(environment));
}
);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'other-id',
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
@@ -1,226 +0,0 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvUnlink from '../unlink';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvUnlink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Production],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
+1 -61
View File
@@ -33,12 +33,11 @@ import {
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
import { isEnvironment } from '../../utils/variableUtils';
interface RawCreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -50,7 +49,6 @@ interface RawCreateFlags {
interface CreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -78,10 +76,6 @@ export default class EnvCreate extends EasCommand {
value: Flags.string({
description: 'Text value or the variable',
}),
link: Flags.boolean({
description: 'Link account-wide variable to the current project',
hidden: true, // every account-wide variable is global for now so it's not user facing
}),
force: Flags.boolean({
description: 'Overwrite existing variable',
default: false,
@@ -114,7 +108,6 @@ export default class EnvCreate extends EasCommand {
'non-interactive': nonInteractive,
environment: environments,
visibility,
link,
force,
type,
fileName,
@@ -154,29 +147,6 @@ export default class EnvCreate extends EasCommand {
});
overwrite = true;
}
if (existingVariable.scope === EnvironmentVariableScope.Shared) {
await this.promptForOverwriteAsync({
nonInteractive,
force,
message: `Account-wide variable with ${name} name already exists on this account.`,
suggestion: 'Do you want to unlink it first?',
});
Log.withTick(
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
existingVariable.id,
projectId,
environment
);
});
}
}
const variable =
@@ -265,29 +235,6 @@ export default class EnvCreate extends EasCommand {
Log.withTick(
`Created a new variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
if (link) {
Log.withTick(
`Linking account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
variable.id,
projectId,
environment
);
});
Log.withTick(
`Linked account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
}
@@ -402,7 +349,6 @@ export default class EnvCreate extends EasCommand {
value,
environment: newEnvironments,
visibility: newVisibility,
link: rest.link ?? false,
force: rest.force ?? false,
'non-interactive': nonInteractive,
type: newType,
@@ -412,12 +358,6 @@ export default class EnvCreate extends EasCommand {
}
private sanitizeFlags(flags: RawCreateFlags): CreateFlags {
if (flags.scope !== 'account' && flags.link) {
throw new Error(
`Unexpected argument: --link can only be used when creating account-wide variables`
);
}
return {
...flags,
scope:
-204
View File
@@ -1,204 +0,0 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type LinkFlags = {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvLink extends EasCommand {
static override description = 'link an account-wide environment variable to the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to link',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvLink);
let {
'variable-name': name,
'variable-environment': currentEnvironment,
'non-interactive': nonInteractive,
environment: environments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvLink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment: currentEnvironment,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error(
'Multiple variables found, run command with --variable-name and --variable-environment arguments.'
);
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !environments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
}
if (!environments) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
try {
if (
selectedVariable.linkedEnvironments?.includes(environment) ===
environments.includes(environment)
) {
if (!explicitSelect && environments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already linked to ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (environments.includes(environment)) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
} catch (err: any) {
Log.warn(err.message);
}
}
}
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
environment = environment?.toUpperCase();
if (environment && !isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment as EnvironmentVariableEnvironment]
: undefined;
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
}
return {
...flags,
environment: environments,
};
}
}
+3 -3
View File
@@ -246,9 +246,9 @@ export default class EnvPush extends EasCommand {
name,
value,
environments,
visibility: name.startsWith('EXPO_SENSITIVE')
? EnvironmentVariableVisibility.Sensitive
: EnvironmentVariableVisibility.Public,
visibility: name.startsWith('EXPO_PUBLIC_')
? EnvironmentVariableVisibility.Public
: EnvironmentVariableVisibility.Sensitive,
};
}
-192
View File
@@ -1,192 +0,0 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type UnlinkFlags = {
'variable-name'?: string;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvUnlink extends EasCommand {
static override description =
'unlink an account-wide environment variable from the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
description:
"Environment to unlink the variable from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvUnlink);
let {
'variable-name': name,
'non-interactive': nonInteractive,
environment: unlinkEnvironments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvUnlink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error("Multiple variables found, please select one using '--variable-name'");
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !unlinkEnvironments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
const environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter(
env => !environments.includes(env)
);
}
if (!unlinkEnvironments) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
if (
selectedVariable.linkedEnvironments?.includes(environment) !==
unlinkEnvironments.includes(environment)
) {
if (!explicitSelect && unlinkEnvironments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already unlinked from ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (unlinkEnvironments.includes(environment)) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
}
}
private validateInputs(
flags: UnlinkFlags,
{ environment }: { environment?: string }
): UnlinkFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
}
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
environment: [environment],
...flags,
};
}
return flags;
}
}
@@ -86,7 +86,7 @@ export default class MetadataPull extends EasCommand {
- Update the ${chalk.bold(relativePath)} file to prepare the app information.
- Run ${chalk.bold('eas submit')} or manually upload a new app version to the app stores.
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store config.
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
- ${learnMore('https://docs.expo.dev/eas/metadata/')}`);
} catch (error: any) {
handleMetadataError(error);
}
@@ -0,0 +1,38 @@
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
export default class New extends EasCommand {
static override aliases = ['new'];
static override description = "create a new project set up with Expo's services.";
static override flags = {};
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
const {
args: { TARGET_PROJECT_DIRECTORY: targetProjectDirInput },
} = await this.parse(New);
const {
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(New, {
nonInteractive: false,
});
if (actor.__typename === 'Robot') {
throw new Error(
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
);
}
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
}
}
@@ -141,7 +141,7 @@ export default class SubmitInternal extends EasCommand {
graphqlClient,
})
);
const track = graphQlTrackToConfigTrack[androidConfig.track];
const track = graphQlTrackToConfigTrack[androidConfig.track] ?? androidConfig.track;
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> = {
changesNotSentForReview,
+1 -38
View File
@@ -1,50 +1,13 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
BackgroundJobReceiptDataFragment,
ScheduleUpdateGroupDeletionMutation,
ScheduleUpdateGroupDeletionMutationVariables,
} from '../../graphql/generated';
import { BackgroundJobReceiptNode } from '../../graphql/types/BackgroundJobReceipt';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
async function scheduleUpdateGroupDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
group,
}: {
group: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleUpdateGroupDeletionMutation, ScheduleUpdateGroupDeletionMutationVariables>(
gql`
mutation ScheduleUpdateGroupDeletion($group: ID!) {
update {
scheduleUpdateGroupDeletion(group: $group) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ group }
)
.toPromise()
);
return result.update.scheduleUpdateGroupDeletion;
}
export default class UpdateDelete extends EasCommand {
static override description = 'delete all the updates in an update group';
+11 -183
View File
@@ -1,20 +1,18 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import {
UpdateToRepublish,
getOrAskUpdateMessageAsync,
getUpdateGroupOrAskForUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
@@ -120,7 +118,11 @@ export default class UpdateRepublish extends EasCommand {
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
const existingUpdates = await getUpdateGroupOrAskForUpdateGroupAsync(
graphqlClient,
projectId,
flags
);
const updatesToPublish = existingUpdates.filter(update =>
flags.platform.includes(update.platform as Platform)
);
@@ -234,177 +236,3 @@ async function getOrAskTargetBranchAsync(
// if neither provided, assume republish on same branch
return { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName };
}
/** Retrieve the update group from either the update group id, or select from branch name. */
async function getOrAskUpdatesAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
if (flags.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (flags.branchName) {
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: flags.branchName,
projectId,
});
}
if (flags.channelName) {
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: flags.channelName,
projectId,
});
}
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: name,
projectId,
});
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: name,
projectId,
});
} else {
throw new Error('Must choose update via channel or branch');
}
}
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
async function askUpdatesFromBranchNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
async function askUpdatesFromChannelNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
channelName,
json,
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
projectId,
channelName
);
return await askUpdatesFromBranchNameAsync(graphqlClient, {
projectId,
branchName,
json,
nonInteractive,
});
}
/** Get or ask the user for the update (group) message for the republish */
async function getOrAskUpdateMessageAsync(
updates: UpdateToRepublish[],
flags: UpdateRepublishFlags
): Promise<string> {
if (flags.updateMessage) {
return sanitizeUpdateMessage(flags.updateMessage);
}
if (flags.nonInteractive || flags.json) {
throw new Error('Must supply --message when in non-interactive mode');
}
// This command only uses a single update group to republish, meaning these values are always identical
const oldGroupId = updates[0].groupId;
const oldUpdateMessage = updates[0].message;
const { updateMessage } = await promptAsync({
type: 'text',
name: 'updateMessage',
message: 'Provide an update message.',
initial: `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`,
validate: (value: any) => (value ? true : 'Update message may not be empty.'),
});
return sanitizeUpdateMessage(updateMessage);
}
function sanitizeUpdateMessage(updateMessage: string): string {
if (updateMessage !== truncateUpdateMessage(updateMessage, 1024)) {
Log.warn('Update message exceeds the allowed 1024 character limit, truncated update message.');
return truncateUpdateMessage(updateMessage, 1024);
}
return updateMessage;
}
@@ -0,0 +1,416 @@
import { ExpoConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { learnMore } from '../../log';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
import { confirmAsync, promptAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import {
UpdateToRepublish,
askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync,
getOrAskUpdateMessageAsync,
getUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
import { Client } from '../../vcs/vcs';
type RolloutUpdate = UpdateToRepublish & {
rolloutPercentage: NonNullable<UpdateToRepublish['rolloutPercentage']>;
};
type RolloutUpdateWithControlUpdate = RolloutUpdate & {
rolloutControlUpdate: NonNullable<UpdateToRepublish['rolloutControlUpdate']>;
};
type UpdateRevertUpdateRolloutRawFlags = {
branch?: string;
channel?: string;
group?: string;
message?: string;
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
};
type UpdateRevertUpdateRolloutFlags = {
branchName?: string;
channelName?: string;
groupId?: string;
updateMessage?: string;
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
};
export function nonNullish<TValue>(value: TValue | null | undefined): value is NonNullable<TValue> {
return value !== null && value !== undefined;
}
export default class UpdateRevertUpdateRollout extends EasCommand {
static override description = 'revert a rollout update for a project';
static override flags = {
channel: Flags.string({
description: 'Channel name to select an update group to revert the rollout update from',
exclusive: ['branch', 'group'],
}),
branch: Flags.string({
description: 'Branch name to select an update group to revert the rollout update from',
exclusive: ['channel', 'group'],
}),
group: Flags.string({
description: 'Rollout update group ID to revert',
exclusive: ['branch', 'channel'],
}),
message: Flags.string({
char: 'm',
description: 'Short message describing the revert',
required: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdateRevertUpdateRollout);
const flags = this.sanitizeFlags(rawFlags);
const {
privateProjectConfig: { exp, projectId, projectDir },
loggedIn: { graphqlClient },
vcsClient,
} = await this.getContextAsync(UpdateRevertUpdateRollout, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});
if (flags.json) {
enableJsonOutput();
}
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
let updateGroupToRepublish: RolloutUpdate[];
if (flags.groupId) {
const updateGroup = await getUpdateGroupAsync(graphqlClient, flags.groupId);
if (!updateGroupIsRolloutUpdateGroup(updateGroup)) {
throw new Error(
`The update group with ID "${flags.groupId}" is not a rollout update group.`
);
}
updateGroupToRepublish = updateGroup;
} else {
const latestUpdateGroupForEachPublishPlatform =
await askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync(
graphqlClient,
projectId,
flags
);
const uniqueUpdateGroups = getUniqueUpdateGroups(
Object.values(latestUpdateGroupForEachPublishPlatform).filter(nonNullish)
);
const rolloutUpdateGroups = uniqueUpdateGroups.filter(updateGroupIsRolloutUpdateGroup);
if (rolloutUpdateGroups.length === 0) {
throw new Error(`No rollout update groups found.`);
}
if (rolloutUpdateGroups.length === 1) {
updateGroupToRepublish = rolloutUpdateGroups[0];
} else {
const { choice: chosenId } = await promptAsync({
type: 'select',
message: 'Which rollout update group would you like to revert?',
name: 'choice',
choices: rolloutUpdateGroups.map(ug => ({
title: `Rollout update group ID: ${ug[0].groupId}, Platform: ${ug[0].platform}, Rollout Percentage: ${ug[0].rolloutPercentage}`,
value: ug[0].groupId,
})),
});
if (!chosenId) {
throw new Error('No rollout update group selected.');
}
const chosenUpdateGroup = rolloutUpdateGroups.find(ug => ug[0].groupId === chosenId);
if (!chosenUpdateGroup) {
throw new Error('No rollout update group selected.');
}
updateGroupToRepublish = chosenUpdateGroup;
}
}
const rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[] | null =
updateGroupIsUpdateGroupWithControlUpdate(updateGroupToRepublish)
? updateGroupToRepublish
: null;
if (rolloutUpdateGroupWithControlUpdates) {
await this.deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
});
} else {
await this.deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup: updateGroupToRepublish,
codeSigningInfo,
flags,
});
}
}
private async deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
exp: ExpoConfig;
projectId: string;
rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const controlUpdateGroupIdsToRepublish = Array.from(
new Set(rolloutUpdateGroupWithControlUpdates.map(update => update.rolloutControlUpdate.group))
);
const updateGroupsToRepublish = await Promise.all(
controlUpdateGroupIdsToRepublish.map(controlUpdateGroupIdToRepublish =>
getUpdateGroupAsync(graphqlClient, controlUpdateGroupIdToRepublish)
)
);
const updateGroupOrGroupsClause =
controlUpdateGroupIdsToRepublish.length > 1
? `control update groups (IDs: ${controlUpdateGroupIdsToRepublish
.map(id => `"${id}"`)
.join(', ')})`
: `control update group (ID: "${controlUpdateGroupIdsToRepublish[0]}")`;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupWithControlUpdates[0].groupId}"? This will delete the rollout update group and republish the ${updateGroupOrGroupsClause}.`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
const updateMessages: string[] = [];
for (const updateGroup of updateGroupsToRepublish) {
updateMessages.push(await getOrAskUpdateMessageAsync(updateGroup, flags));
}
// assert all updateGroupsToRepublish have the same branch name and id
const branchNames = updateGroupsToRepublish.flatMap(updateGroup => updateGroup[0].branchName);
const branchIds = updateGroupsToRepublish.map(updateGroup => updateGroup[0].branchId);
assert(
branchNames.every(name => name === branchNames[0]),
'All update groups being republished must belong to the same branch.'
);
assert(
branchIds.every(id => id === branchIds[0]),
'All update groups being republished must belong to the same branch.'
);
const targetBranch = {
branchName: branchNames[0],
branchId: branchIds[0],
};
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup: rolloutUpdateGroupWithControlUpdates,
});
for (let i = 0; i < updateGroupsToRepublish.length; i++) {
const updateGroupToRepublish = updateGroupsToRepublish[i];
const updateMessage = updateMessages[i];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish: updateGroupToRepublish,
targetBranch,
updateMessage,
codeSigningInfo,
json: flags.json,
});
}
}
private async deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
vcsClient: Client;
exp: ExpoConfig;
projectDir: string;
projectId: string;
rolloutUpdateGroup: RolloutUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupId}"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. ${learnMore(
'https://expo.fyi/eas-update-update-rollouts',
{ learnMoreMessage: 'More info' }
)})`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
// check that the expo-updates package version supports roll back to embedded
await enforceRollBackToEmbeddedUpdateSupportAsync(projectDir);
const updateMessage = await getUpdateMessageForCommandAsync(vcsClient, {
updateMessageArg: flags.updateMessage,
autoFlag: false,
nonInteractive: flags.nonInteractive,
jsonFlag: flags.json,
});
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
});
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
const targetBranch = {
name: rolloutUpdateGroup[0].branchName,
id: rolloutUpdateGroup[0].branchId,
};
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch: targetBranch,
codeSigningInfo,
platforms,
runtimeVersion,
json: flags.json,
});
}
private async deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
}: {
graphqlClient: ExpoGraphqlClient;
rolloutUpdateGroup: RolloutUpdate[];
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
const updateGroupDeletionReceipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, {
group: rolloutUpdateGroupId,
});
const successfulReceipt = await pollForBackgroundJobReceiptAsync(
graphqlClient,
updateGroupDeletionReceipt
);
Log.debug('Rollout update group deletion result', { successfulReceipt });
}
private sanitizeFlags(
rawFlags: UpdateRevertUpdateRolloutRawFlags
): UpdateRevertUpdateRolloutFlags {
const branchName = rawFlags.branch;
const channelName = rawFlags.channel;
const groupId = rawFlags.group;
const nonInteractive = rawFlags['non-interactive'];
const privateKeyPath = rawFlags['private-key-path'];
if (nonInteractive && !groupId) {
throw new Error('Only --group can be used in non-interactive mode');
}
return {
branchName,
channelName,
groupId,
updateMessage: rawFlags.message,
privateKeyPath,
json: rawFlags.json ?? false,
nonInteractive,
};
}
}
function getUniqueUpdateGroups(updateGroups: UpdateToRepublish[][]): UpdateToRepublish[][] {
const uniqueUpdateGroups = new Map<string, UpdateToRepublish[]>();
for (const updateGroup of updateGroups) {
const groupId = updateGroup[0].groupId;
if (!uniqueUpdateGroups.has(groupId)) {
uniqueUpdateGroups.set(groupId, updateGroup);
}
}
return Array.from(uniqueUpdateGroups.values());
}
function updateGroupIsRolloutUpdateGroup(
updateGroup: UpdateToRepublish[]
): updateGroup is RolloutUpdate[] {
return updateGroup.every(updateIsRolloutUpdate);
}
function updateIsRolloutUpdate(updateGroup: UpdateToRepublish): updateGroup is RolloutUpdate {
return updateGroup.rolloutPercentage !== undefined && updateGroup.rolloutPercentage !== null;
}
function updateGroupIsUpdateGroupWithControlUpdate(
updateGroup: RolloutUpdate[]
): updateGroup is RolloutUpdateWithControlUpdate[] {
return updateGroup.every(updateIsRolloutWithControlUpdate);
}
function updateIsRolloutWithControlUpdate(
updateGroup: RolloutUpdate
): updateGroup is RolloutUpdateWithControlUpdate {
return !!updateGroup.rolloutControlUpdate;
}
@@ -1,51 +1,24 @@
import { Platform as PublishPlatform } from '@expo/config';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
PublishUpdateGroupInput,
RuntimeFragment,
StatuspageServiceName,
UpdatePublishMutation,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { StatuspageServiceName } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import {
enforceRollBackToEmbeddedUpdateSupportAsync,
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import {
RuntimeVersionInfo,
UpdatePublishPlatform,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
getUpdateMessageForCommandAsync,
} from '../../project/publish';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getDirectiveBodyAsync,
signBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { selectRuntimeOnBranchAsync } from '../../update/queries';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
type RawUpdateFlags = {
@@ -199,7 +172,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
const selectedRuntime =
runtimeVersionArg ??
(
await UpdateRollBackToEmbedded.selectRuntimeAsync(graphqlClient, {
await selectRuntimeOnBranchAsync(graphqlClient, {
appId: projectId,
branchName,
})
@@ -208,196 +181,16 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
Errors.error('Must select a runtime or provide the --runtimeVersion flag', { exit: 1 });
}
const runtimeToPlatformsAndFingerprintInfoMapping =
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
realizedPlatforms.map(platform => ({
platform,
runtimeVersionInfo: {
runtimeVersion: selectedRuntime,
expoUpdatesRuntimeFingerprint: null,
expoUpdatesRuntimeFingerprintHash: null,
},
}))
);
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
const publishSpinner = ora('Publishing...').start();
try {
newUpdates = await this.publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId: branch.id,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to publish updates');
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
} else {
Log.addNewLineIfNone();
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: updateMessage ?? '' },
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
}
}
}
private async publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
}: {
graphqlClient: ExpoGraphqlClient;
updateMessage: string | undefined;
branchId: string;
codeSigningInfo: CodeSigningInfo | undefined;
runtimeToPlatformsAndFingerprintInfoMapping: (RuntimeVersionInfo & {
platforms: UpdatePublishPlatform[];
})[];
realizedPlatforms: PublishPlatform[];
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
const rollbackInfoGroups = Object.fromEntries(
realizedPlatforms.map(platform => [platform, true])
);
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoMapping.map(
({ runtimeVersion, platforms }) => {
const localRollbackInfoGroup = Object.fromEntries(
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
);
return {
branchId,
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
runtimeVersion,
message: updateMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
);
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
if (codeSigningInfo) {
Log.log('🔒 Signing roll back');
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
);
return {
updateGroup,
newUpdates,
};
});
await Promise.all(
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
await Promise.all(
newUpdates.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
const directiveSignature = signBody(directiveBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: directiveSignature,
});
})
);
})
);
}
return newUpdates;
}
private static async selectRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
batchSize = 5,
}: {
appId: string;
branchName: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return runtime.version;
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch,
codeSigningInfo,
platforms: realizedPlatforms,
runtimeVersion: selectedRuntime,
json: jsonFlag,
});
}
@@ -6,7 +6,8 @@ import EasCommand from '../../commandUtils/EasCommand';
import { promptAsync } from '../../prompts';
export default class UpdateRollback extends EasCommand {
static override description = 'roll back to an embedded update or an existing update';
static override description =
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
static override flags = {
'private-key-path': Flags.string({
-1
View File
@@ -33,7 +33,6 @@ import { createProgressTracker } from '../utils/progress';
export default class BuildUpload extends EasCommand {
static override description = 'upload a local build and generate a sharable link';
static override hidden = true;
static override flags = {
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
@@ -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();
});
}
}
@@ -176,7 +176,7 @@ export function getNewIosApiMock(): { [key in keyof typeof IosGraphqlClient]?: a
updateIosAppCredentialsAsync: jest.fn(),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(),
createOrGetExistingAppleTeamAsync: () => testAppleTeam,
createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync: () => testAppleTeam,
createOrGetExistingAppleAppIdentifierAsync: jest.fn(),
getDevicesForAppleTeamAsync: jest.fn(),
createProvisioningProfileAsync: jest.fn(),
@@ -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;
}
@@ -9,8 +9,12 @@ export async function resolveAppleTeamIfAuthenticatedAsync(
if (!ctx.appStore.authCtx) {
return null;
}
return await ctx.ios.createOrGetExistingAppleTeamAsync(ctx.graphqlClient, app.account, {
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
});
return await ctx.ios.createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
ctx.graphqlClient,
app.account.id,
{
appleTeamIdentifier: ctx.appStore.authCtx.team.id,
appleTeamName: ctx.appStore.authCtx.team.name,
}
);
}
@@ -227,7 +227,7 @@ async function generateDistributionCertificateAsync(
`✅ Distribution Certificates can be revoked with no side effects for App Store builds.`
)
);
Log.log(learnMore('https://docs.expo.dev/distribution/app-signing/#summary'));
Log.log(learnMore('https://docs.expo.dev/app-signing/app-credentials/#summary'));
Log.newLine();
const { distCertsToRevoke } = await promptAsync({
@@ -76,7 +76,9 @@ async function generatePushKeyAsync(ctx: CredentialsContext): Promise<PushKey> {
}
Log.log(chalk.grey(`⚠️ Revoking a Push Key will affect other apps that rely on it`));
Log.log(learnMore('https://docs.expo.dev/distribution/app-signing/#push-notification-keys'));
Log.log(
learnMore('https://docs.expo.dev/app-signing/app-credentials/#push-notification-keys')
);
Log.log();
const { pushKeysToRevoke } = await promptAsync({
@@ -44,9 +44,9 @@ export class SetUpTargetBuildCredentialsFromCredentialsJson {
const appleTeamFromProvisioningProfile = readAppleTeam(
this.targetCredentials.provisioningProfile
);
const appleTeam = await ctx.ios.createOrGetExistingAppleTeamAsync(
const appleTeam = await ctx.ios.createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
ctx.graphqlClient,
this.app.account,
this.app.account.id,
{
appleTeamIdentifier: appleTeamFromProvisioningProfile.teamId,
appleTeamName: appleTeamFromProvisioningProfile.teamName,
@@ -260,23 +260,34 @@ async function createOrGetExistingIosAppCredentialsWithBuildCredentialsAsync(
}
}
export async function createOrGetExistingAppleTeamAsync(
export async function createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
graphqlClient: ExpoGraphqlClient,
account: AccountFragment,
accountId: string,
{ appleTeamIdentifier, appleTeamName }: { appleTeamIdentifier: string; appleTeamName?: string }
): Promise<AppleTeamFragment> {
const appleTeam = await AppleTeamQuery.getByAppleTeamIdentifierAsync(
graphqlClient,
account.id,
accountId,
appleTeamIdentifier
);
if (appleTeam) {
const didAppleTeamNameChange =
appleTeamName && appleTeam && appleTeamName !== appleTeam.appleTeamName;
if (didAppleTeamNameChange) {
return await AppleTeamMutation.updateAppleTeamAsync(
graphqlClient,
{
appleTeamName,
},
appleTeam.id
);
}
return appleTeam;
} else {
return await AppleTeamMutation.createAppleTeamAsync(
graphqlClient,
{ appleTeamIdentifier, appleTeamName },
account.id
accountId
);
}
}
@@ -424,10 +435,14 @@ export async function createDistributionCertificateAsync(
account: AccountFragment,
distCert: DistributionCertificate
): Promise<AppleDistributionCertificateMutationResult> {
const appleTeam = await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
appleTeamIdentifier: distCert.teamId,
appleTeamName: distCert.teamName,
});
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
graphqlClient,
account.id,
{
appleTeamIdentifier: distCert.teamId,
appleTeamName: distCert.teamName,
}
);
return await AppleDistributionCertificateMutation.createAppleDistributionCertificateAsync(
graphqlClient,
{
@@ -456,10 +471,14 @@ export async function createPushKeyAsync(
account: AccountFragment,
pushKey: PushKey
): Promise<ApplePushKeyFragment> {
const appleTeam = await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
appleTeamIdentifier: pushKey.teamId,
appleTeamName: pushKey.teamName,
});
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
graphqlClient,
account.id,
{
appleTeamIdentifier: pushKey.teamId,
appleTeamName: pushKey.teamName,
}
);
return await ApplePushKeyMutation.createApplePushKeyAsync(
graphqlClient,
{
@@ -502,7 +521,7 @@ export async function createAscApiKeyAsync(
ascApiKey: MinimalAscApiKey
): Promise<AppStoreConnectApiKeyFragment> {
const maybeAppleTeam = ascApiKey.teamId
? await createOrGetExistingAppleTeamAsync(graphqlClient, account, {
? await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(graphqlClient, account.id, {
appleTeamIdentifier: ascApiKey.teamId,
appleTeamName: ascApiKey.teamName,
})
@@ -4,42 +4,35 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AccountFragment,
AppleTeamFragment,
AppleTeamInput,
AppleTeamUpdateInput,
CreateAppleTeamMutation,
CreateAppleTeamMutationVariables,
UpdateAppleTeamMutation,
UpdateAppleTeamMutationVariables,
} from '../../../../../graphql/generated';
import { AccountFragmentNode } from '../../../../../graphql/types/Account';
import { AppleTeamFragmentNode } from '../../../../../graphql/types/credentials/AppleTeam';
export type AppleTeamMutationResult = AppleTeamFragment & {
account: AccountFragment;
};
export const AppleTeamMutation = {
async createAppleTeamAsync(
graphqlClient: ExpoGraphqlClient,
appleTeamInput: AppleTeamInput,
accountId: string
): Promise<AppleTeamMutationResult> {
): Promise<AppleTeamFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAppleTeamMutation>(
.mutation<CreateAppleTeamMutation, CreateAppleTeamMutationVariables>(
gql`
mutation CreateAppleTeamMutation($appleTeamInput: AppleTeamInput!, $accountId: ID!) {
appleTeam {
createAppleTeam(appleTeamInput: $appleTeamInput, accountId: $accountId) {
id
...AppleTeamFragment
account {
id
...AccountFragment
}
}
}
}
${print(AppleTeamFragmentNode)}
${print(AccountFragmentNode)}
`,
{
appleTeamInput,
@@ -50,4 +43,35 @@ export const AppleTeamMutation = {
);
return data.appleTeam.createAppleTeam;
},
async updateAppleTeamAsync(
graphqlClient: ExpoGraphqlClient,
appleTeamInput: AppleTeamUpdateInput,
appleTeamEntityId: string
): Promise<AppleTeamFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateAppleTeamMutation, UpdateAppleTeamMutationVariables>(
gql`
mutation UpdateAppleTeamMutation(
$appleTeamInput: AppleTeamUpdateInput!
$appleTeamEntityId: ID!
) {
appleTeam {
updateAppleTeam(appleTeamUpdateInput: $appleTeamInput, id: $appleTeamEntityId) {
id
...AppleTeamFragment
}
}
}
${print(AppleTeamFragmentNode)}
`,
{
appleTeamInput,
appleTeamEntityId,
}
)
.toPromise()
);
return data.appleTeam.updateAppleTeam;
},
};
@@ -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)
);
+10 -31
View File
@@ -4,9 +4,8 @@ import chalk from 'chalk';
import DeviceCreateAction from './actions/create/action';
import { DeviceManagerContext } from './context';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleTeamMutation } from '../credentials/ios/api/graphql/mutations/AppleTeamMutation';
import { AppleTeamQuery } from '../credentials/ios/api/graphql/queries/AppleTeamQuery';
import { AccountFragment, AppleTeamFragment } from '../graphql/generated';
import { createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync } from '../credentials/ios/api/GraphqlClient';
import { AccountFragment } from '../graphql/generated';
import Log from '../log';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { Choice, confirmAsync, promptAsync } from '../prompts';
@@ -29,10 +28,14 @@ export default class DeviceManager {
const account = await this.resolveAccountAsync();
const appleAuthCtx = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = await ensureAppleTeamExistsAsync(this.ctx.graphqlClient, account.id, {
appleTeamIdentifier: appleAuthCtx.team.id,
appleTeamName: appleAuthCtx.team.name,
});
const appleTeam = await createOrGetExistingAppleTeamAndUpdateNameIfChangedAsync(
this.ctx.graphqlClient,
account.id,
{
appleTeamIdentifier: appleAuthCtx.team.id,
appleTeamName: appleAuthCtx.team.name,
}
);
const action = new DeviceCreateAction(
this.ctx.graphqlClient,
this.ctx.appStore,
@@ -92,27 +95,3 @@ export class AccountResolver {
return account;
}
}
async function ensureAppleTeamExistsAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
{ appleTeamIdentifier, appleTeamName }: { appleTeamIdentifier: string; appleTeamName?: string }
): Promise<AppleTeamFragment> {
const appleTeam = await AppleTeamQuery.getByAppleTeamIdentifierAsync(
graphqlClient,
accountId,
appleTeamIdentifier
);
if (appleTeam) {
return appleTeam;
} else {
return await AppleTeamMutation.createAppleTeamAsync(
graphqlClient,
{
appleTeamIdentifier,
appleTeamName,
},
accountId
);
}
}
+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(
+303 -158
View File
File diff suppressed because it is too large. Load diff
@@ -12,8 +12,6 @@ import {
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableVisibility,
LinkSharedEnvironmentVariableMutation,
UnlinkSharedEnvironmentVariableMutation,
UpdateEnvironmentVariableMutation,
} from '../generated';
import { EnvironmentVariableFragmentNode } from '../types/EnvironmentVariable';
@@ -37,76 +35,6 @@ export type EnvironmentVariablePushInput = {
};
export const EnvironmentVariableMutation = {
async linkSharedEnvironmentVariableAsync(
graphqlClient: ExpoGraphqlClient,
environmentVariableId: string,
appId: string,
environment?: EnvironmentVariableEnvironment
): Promise<EnvironmentVariableFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<LinkSharedEnvironmentVariableMutation>(
gql`
mutation LinkSharedEnvironmentVariable(
$appId: ID!
$environment: EnvironmentVariableEnvironment
$environmentVariableId: ID!
) {
environmentVariable {
linkSharedEnvironmentVariable(
appId: $appId
environmentVariableId: $environmentVariableId
environment: $environment
) {
id
...EnvironmentVariableFragment
}
}
}
${print(EnvironmentVariableFragmentNode)}
`,
{ appId, environment, environmentVariableId }
)
.toPromise()
);
return data.environmentVariable.linkSharedEnvironmentVariable;
},
async unlinkSharedEnvironmentVariableAsync(
graphqlClient: ExpoGraphqlClient,
environmentVariableId: string,
appId: string,
environment?: EnvironmentVariableEnvironment
): Promise<EnvironmentVariableFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UnlinkSharedEnvironmentVariableMutation>(
gql`
mutation UnlinkSharedEnvironmentVariable(
$appId: ID!
$environment: EnvironmentVariableEnvironment
$environmentVariableId: ID!
) {
environmentVariable {
unlinkSharedEnvironmentVariable(
appId: $appId
environmentVariableId: $environmentVariableId
environment: $environment
) {
id
...EnvironmentVariableFragment
}
}
}
${print(EnvironmentVariableFragmentNode)}
`,
{ appId, environment, environmentVariableId }
)
.toPromise()
);
return data.environmentVariable.unlinkSharedEnvironmentVariable;
},
async createSharedVariableAsync(
graphqlClient: ExpoGraphqlClient,
input: CreateVariableArgs,
@@ -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 },
{
@@ -20,6 +20,10 @@ export const BuildFragmentNode = gql`
applicationArchiveUrl
buildArtifactsUrl
}
fingerprint {
id
hash
}
initiatingActor {
__typename
id
@@ -36,6 +36,7 @@ export const UpdateFragmentNode = gql`
rolloutPercentage
rolloutControlUpdate {
id
group
}
fingerprint {
id
@@ -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
+5
View File
@@ -6,6 +6,9 @@ import terminalLink from 'terminal-link';
type Color = (...text: string[]) => string;
// eslint-disable-next-line import/no-extraneous-dependencies
const nodeDebug = require('debug')('eas:log:debug') as (...args: any[]) => void;
export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false);
@@ -34,6 +37,8 @@ export default class Log {
public static debug(...args: any[]): void {
if (Log.isDebug) {
Log.consoleLog(...args);
} else {
nodeDebug(args);
}
}
@@ -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');
+1
View File
@@ -1099,6 +1099,7 @@ export async function getUpdateRolloutInfoGroupAsync(
platforms: UpdatePublishPlatform[];
}
): Promise<UpdateRolloutInfoGroup> {
// note that this could return control updates in different update groups if the update groups only have a single platform
return Object.fromEntries(
await Promise.all(
platforms.map<Promise<[string, UpdateRolloutInfo]>>(async platform => {
@@ -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',
+39
View File
@@ -0,0 +1,39 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import {
BackgroundJobReceiptDataFragment,
ScheduleUpdateGroupDeletionMutation,
ScheduleUpdateGroupDeletionMutationVariables,
} from '../graphql/generated';
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
export async function scheduleUpdateGroupDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
group,
}: {
group: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleUpdateGroupDeletionMutation, ScheduleUpdateGroupDeletionMutationVariables>(
gql`
mutation ScheduleUpdateGroupDeletion($group: ID!) {
update {
scheduleUpdateGroupDeletion(group: $group) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ group }
)
.toPromise()
);
return result.update.scheduleUpdateGroupDeletion;
}
+91
View File
@@ -10,21 +10,27 @@ import {
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import {
AppPlatform,
RuntimeFragment,
UpdateFragment,
ViewUpdateGroupsOnAppQueryVariables,
ViewUpdateGroupsOnBranchQueryVariables,
} from '../graphql/generated';
import { RuntimeQuery } from '../graphql/queries/RuntimeQuery';
import { UpdateQuery } from '../graphql/queries/UpdateQuery';
import Log from '../log';
import { UpdatePublishPlatform } from '../project/publish';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { Connection, QueryParams, selectPaginatedAsync } from '../utils/relay';
export const UPDATES_LIMIT = 50;
export const UPDATE_GROUPS_LIMIT = 25;
export const RUNTIME_VERSIONS_LIMIT = 25;
export async function listAndRenderUpdateGroupsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
@@ -100,6 +106,58 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
}
}
export async function selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
paginatedQueryOptions,
}: {
projectId: string;
branchName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<Record<UpdatePublishPlatform, UpdateFragment[] | undefined>> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select an update in non-interactive mode.');
}
const runtimeVersion = await selectRuntimeOnBranchAsync(graphqlClient, {
appId: projectId,
branchName,
});
if (!runtimeVersion) {
throw new Error('No runtime version selected.');
}
return {
ios: (
await queryUpdateGroupsOnBranchAsync(graphqlClient, {
appId: projectId,
branchName,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtimeVersion.version],
platform: AppPlatform.Ios,
},
})
)[0],
android: (
await queryUpdateGroupsOnBranchAsync(graphqlClient, {
appId: projectId,
branchName,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtimeVersion.version],
platform: AppPlatform.Android,
},
})
)[0],
};
}
export async function selectUpdateGroupOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
@@ -230,3 +288,36 @@ function renderUpdateGroupsOnApp({
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
export async function selectRuntimeOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
batchSize = 5,
}: {
appId: string;
branchName: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return runtime.version;
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
+359
View File
@@ -2,15 +2,26 @@ import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import nullthrows from 'nullthrows';
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from './getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
import {
selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync,
selectUpdateGroupOnBranchAsync,
} from './queries';
import { truncateString as truncateUpdateMessage } from './utils';
import { selectBranchOnAppAsync } from '../branch/queries';
import { getUpdateGroupUrl } from '../build/utils/url';
import { selectChannelOnAppAsync } from '../channel/queries';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { getPaginatedQueryOptions } from '../commandUtils/pagination';
import fetch from '../fetch';
import { UpdateFragment, UpdateInfoGroup } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { UpdateQuery } from '../graphql/queries/UpdateQuery';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { UpdatePublishPlatform, getUpdateRolloutInfoGroupAsync } from '../project/publish';
import { promptAsync } from '../prompts';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
@@ -265,3 +276,351 @@ export async function republishAsync({
])
);
}
type GetUpdateOrAskForUpdatesOptions = {
nonInteractive: boolean;
json: boolean;
groupId?: string;
branchName?: string;
channelName?: string;
};
export async function getUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
groupId: string
): Promise<UpdateToRepublish[]> {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId,
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
type AskUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionOptions = {
nonInteractive: boolean;
json: boolean;
branchName?: string;
channelName?: string;
};
export async function askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
options: AskUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionOptions
): Promise<Record<UpdatePublishPlatform, UpdateToRepublish[] | undefined>> {
if (options.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (options.branchName) {
return await askUpdateGroupForEachPublishPlatformFromBranchNameFilteringByRuntimeVersionAsync(
graphqlClient,
{
...options,
branchName: options.branchName,
projectId,
}
);
}
if (options.channelName) {
return await askUpdateGroupForEachPublishPlatformFromChannelNameFilteringByRuntimeVersionAsync(
graphqlClient,
{
...options,
channelName: options.channelName,
projectId,
}
);
}
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: options.json,
nonInteractive: options.nonInteractive,
offset: 0,
},
});
return await askUpdateGroupForEachPublishPlatformFromChannelNameFilteringByRuntimeVersionAsync(
graphqlClient,
{
...options,
channelName: name,
projectId,
}
);
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: options.json,
nonInteractive: options.nonInteractive,
offset: 0,
},
});
return await askUpdateGroupForEachPublishPlatformFromBranchNameFilteringByRuntimeVersionAsync(
graphqlClient,
{
...options,
branchName: name,
projectId,
}
);
} else {
throw new Error('Must choose update via channel or branch');
}
}
export async function getUpdateGroupOrAskForUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
options: GetUpdateOrAskForUpdatesOptions
): Promise<UpdateToRepublish[]> {
if (options.groupId) {
return await getUpdateGroupAsync(graphqlClient, options.groupId);
}
if (options.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (options.branchName) {
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...options,
branchName: options.branchName,
projectId,
});
}
if (options.channelName) {
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...options,
channelName: options.channelName,
projectId,
});
}
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: options.json,
nonInteractive: options.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...options,
channelName: name,
projectId,
});
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: options.json,
nonInteractive: options.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...options,
branchName: name,
projectId,
});
} else {
throw new Error('Must choose update via channel or branch');
}
}
async function askUpdateGroupForEachPublishPlatformFromBranchNameFilteringByRuntimeVersionAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<Record<UpdatePublishPlatform, UpdateToRepublish[] | undefined>> {
const publishPlatformToLatestUpdateGroup =
await selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return {
ios: publishPlatformToLatestUpdateGroup.ios?.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
})),
android: publishPlatformToLatestUpdateGroup.android?.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
})),
};
}
async function askUpdatesFromBranchNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
async function askUpdateGroupForEachPublishPlatformFromChannelNameFilteringByRuntimeVersionAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
channelName,
json,
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<Record<UpdatePublishPlatform, UpdateToRepublish[] | undefined>> {
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
projectId,
channelName
);
return await askUpdateGroupForEachPublishPlatformFromBranchNameFilteringByRuntimeVersionAsync(
graphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}
);
}
async function askUpdatesFromChannelNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
channelName,
json,
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
projectId,
channelName
);
return await askUpdatesFromBranchNameAsync(graphqlClient, {
projectId,
branchName,
json,
nonInteractive,
});
}
type GetOrAskUpdateMessageOptions = {
updateMessage?: string;
nonInteractive: boolean;
json: boolean;
};
/**
* Get or ask the user for the update (group) message for the republish
*/
export async function getOrAskUpdateMessageAsync(
updateGroup: UpdateToRepublish[],
options: GetOrAskUpdateMessageOptions
): Promise<string> {
if (options.updateMessage) {
return sanitizeUpdateMessage(options.updateMessage);
}
if (options.nonInteractive || options.json) {
throw new Error('Must supply --message when in non-interactive mode');
}
// This command only uses a single update group to republish, meaning these values are always identical
const oldGroupId = updateGroup[0].groupId;
const oldUpdateMessage = updateGroup[0].message;
const { updateMessage } = await promptAsync({
type: 'text',
name: 'updateMessage',
message: 'Provide an update message.',
initial: `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`,
validate: (value: any) => (value ? true : 'Update message may not be empty.'),
});
return sanitizeUpdateMessage(updateMessage);
}
function sanitizeUpdateMessage(updateMessage: string): string {
if (updateMessage !== truncateUpdateMessage(updateMessage, 1024)) {
Log.warn('Update message exceeds the allowed 1024 character limit, truncated update message.');
return truncateUpdateMessage(updateMessage, 1024);
}
return updateMessage;
}
@@ -0,0 +1,203 @@
import { ExpoConfig } from '@expo/config';
import nullthrows from 'nullthrows';
import { getUpdateJsonInfosForUpdates } from './utils';
import { getUpdateGroupUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import fetch from '../fetch';
import { PublishUpdateGroupInput, UpdatePublishMutation } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import {
RuntimeVersionInfo,
UpdatePublishPlatform,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
} from '../project/publish';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
getDirectiveBodyAsync,
signBody,
} from '../utils/code-signing';
import uniqBy from '../utils/expodash/uniqBy';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
export async function publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch,
codeSigningInfo,
platforms,
runtimeVersion,
json,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
exp: ExpoConfig;
updateMessage: string | undefined;
branch: { name: string; id: string };
codeSigningInfo: CodeSigningInfo | undefined;
platforms: UpdatePublishPlatform[];
runtimeVersion: string;
json: boolean;
}): Promise<void> {
const runtimeToPlatformsAndFingerprintInfoMapping =
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
platforms.map(platform => ({
platform,
runtimeVersionInfo: {
runtimeVersion,
expoUpdatesRuntimeFingerprint: null,
expoUpdatesRuntimeFingerprintHash: null,
},
}))
);
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
const publishSpinner = ora('Publishing...').start();
try {
newUpdates = await publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId: branch.id,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
platforms,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to publish updates');
throw e;
}
if (json) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
} else {
Log.addNewLineIfNone();
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branch.name },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate ? [{ label: 'Android update ID', value: newAndroidUpdate.id }] : []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: updateMessage ?? '' },
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
}
}
}
async function publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
platforms,
}: {
graphqlClient: ExpoGraphqlClient;
updateMessage: string | undefined;
branchId: string;
codeSigningInfo: CodeSigningInfo | undefined;
runtimeToPlatformsAndFingerprintInfoMapping: (RuntimeVersionInfo & {
platforms: UpdatePublishPlatform[];
})[];
platforms: UpdatePublishPlatform[];
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
const rollbackInfoGroups = Object.fromEntries(platforms.map(platform => [platform, true]));
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoMapping.map(
({ runtimeVersion, platforms }) => {
const localRollbackInfoGroup = Object.fromEntries(
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
);
return {
branchId,
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
runtimeVersion,
message: updateMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
);
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
if (codeSigningInfo) {
Log.log('🔒 Signing roll back');
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
);
return {
updateGroup,
newUpdates,
};
});
await Promise.all(
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
await Promise.all(
newUpdates.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
const directiveSignature = signBody(directiveBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: directiveSignature,
});
})
);
})
);
}
return newUpdates;
}
@@ -96,6 +96,9 @@ export async function getSessionUsingBrowserAuthFlowAsync({
);
const port = address.port;
const authorizeUrl = buildExpoSsoLoginUrl(port);
Log.log(
`If your browser doesn't automatically open, visit this link to log in: ${authorizeUrl}`
);
void openBrowserAsync(authorizeUrl);
});
+24 -18
View File
@@ -1,6 +1,8 @@
import gql from 'graphql-tag';
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../graphql/client';
import { MeUserActorQuery, MeUserActorQueryVariables } from '../graphql/generated';
export async function fetchUserAsync({
sessionSecret,
@@ -8,26 +10,30 @@ export async function fetchUserAsync({
sessionSecret: string;
}): Promise<{ id: string; username: string }> {
const graphqlClient = createGraphqlClient({ accessToken: null, sessionSecret });
const result = await graphqlClient
.query(
gql`
query UserQuery {
meUserActor {
id
username
const result = await withErrorHandlingAsync(
graphqlClient
.query<MeUserActorQuery, MeUserActorQueryVariables>(
gql`
query MeUserActorQuery {
meUserActor {
id
username
}
}
}
`,
{},
{
additionalTypenames: [] /* UserQuery has immutable fields */,
}
)
.toPromise();
`,
{},
{ additionalTypenames: ['UserActor'] }
)
.toPromise()
);
const meUserActor = result.meUserActor;
if (!meUserActor) {
throw new Error('Failed to fetch user data after login.');
}
const { data } = result;
return {
id: data.meUserActor.id,
username: data.meUserActor.username,
id: meUserActor.id,
username: meUserActor.username,
};
}
@@ -26,6 +26,7 @@ const humanReadableServiceName: Record<StatuspageServiceName, string> = {
[StatuspageServiceName.EasUpdate]: 'EAS Update',
[StatuspageServiceName.GithubApiRequests]: 'GitHub API Requests',
[StatuspageServiceName.GithubWebhooks]: 'Github Webhooks',
[StatuspageServiceName.EasWorkflows]: 'EAS Workflows',
};
function warnAboutServiceOutage(service: StatuspageServiceFragment): void {
+79 -51
View File
@@ -1,6 +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';
@@ -36,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()}`;
}
@@ -49,6 +47,7 @@ async function computeSha512HashAsync(
interface RecursiveFileEntry {
normalizedPath: string;
path: string;
size: number;
}
/** Lists plain files in base path recursively and outputs normalized paths */
@@ -61,9 +60,12 @@ function listFilesRecursively(basePath: string): AsyncGenerator<RecursiveFileEnt
if (isIgnoredName(dirent.name)) {
continue;
} else if (dirent.isFile()) {
const absolutePath = path.resolve(target, dirent.name);
const stats = await fs.promises.stat(absolutePath);
yield {
normalizedPath,
path: path.resolve(target, dirent.name),
path: absolutePath,
size: stats.size,
};
} else if (dirent.isDirectory()) {
yield* recurseAsync(normalizedPath);
@@ -73,25 +75,79 @@ function listFilesRecursively(basePath: string): AsyncGenerator<RecursiveFileEnt
return recurseAsync();
}
async function determineMimeTypeAsync(filePath: string): Promise<string | null> {
let contentType = mime.getType(path.basename(filePath));
if (!contentType) {
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
try {
// check if file is valid JSON without an extension, e.g. for the apple app site association file
const parsedData = JSON.parse(fileContent);
if (parsedData) {
contentType = 'application/json';
}
} catch {}
}
return contentType;
}
interface AssetMapOptions {
hashOptions?: HashOptions;
maxFileSize: number;
}
export interface AssetFileEntry {
normalizedPath: string;
path: string;
size: number;
sha512: string;
type: string | null;
}
/** Collects assets from a given target path */
export async function collectAssetsAsync(
assetPath: string | undefined,
options: AssetMapOptions
): Promise<AssetFileEntry[]> {
const assets: AssetFileEntry[] = [];
if (assetPath) {
for await (const file of listFilesRecursively(assetPath)) {
if (file.size > options.maxFileSize) {
throw new Error(
`Upload of "${file.normalizedPath}" aborted: File size is greater than the upload limit (>500MB)`
);
}
const sha512$ = computeSha512HashAsync(file.path);
const contentType$ = determineMimeTypeAsync(file.path);
assets.push({
normalizedPath: file.normalizedPath,
path: file.path,
size: file.size,
sha512: await sha512$,
type: await contentType$,
});
}
}
return assets;
}
/** Mapping of normalized file paths to a SHA512 hash */
export type AssetMap = Record<string, string>;
/** Creates an asset map of a given target path */
async function createAssetMapAsync(
assetPath?: string,
options?: AssetMapOptions
): Promise<AssetMap> {
const map: AssetMap = Object.create(null);
if (assetPath) {
for await (const file of listFilesRecursively(assetPath)) {
map[file.normalizedPath] = await computeSha512HashAsync(file.path, options?.hashOptions);
export type AssetMap = Record<
string,
| string
| {
sha512: string;
size: number;
}
}
return map;
>;
/** Converts array of asset entries into AssetMap (as sent to deployment-api) */
export function assetsToAssetsMap(assets: AssetFileEntry[]): AssetMap {
return assets.reduce((map, entry) => {
map[entry.normalizedPath] = {
sha512: entry.sha512,
size: entry.size,
};
return map;
}, Object.create(null));
}
export interface Manifest {
@@ -148,7 +204,7 @@ interface WorkerFileEntry {
}
/** Reads worker files while normalizing sourcemaps and providing normalized paths */
async function* listWorkerFilesAsync(workerPath: string): AsyncGenerator<WorkerFileEntry> {
export async function* listWorkerFilesAsync(workerPath: string): AsyncGenerator<WorkerFileEntry> {
for await (const file of listFilesRecursively(workerPath)) {
yield {
normalizedPath: file.normalizedPath,
@@ -158,32 +214,11 @@ async function* listWorkerFilesAsync(workerPath: string): AsyncGenerator<WorkerF
}
}
interface AssetFileEntry {
normalizedPath: string;
sha512: string;
path: string;
}
/** Reads files of an asset maps and enumerates normalized paths and data */
async function* listAssetMapFilesAsync(
assetPath: string,
assetMap: AssetMap
): AsyncGenerator<AssetFileEntry> {
for (const normalizedPath in assetMap) {
const filePath = path.resolve(assetPath, normalizedPath.split('/').join(path.sep));
yield {
normalizedPath,
path: filePath,
sha512: assetMap[normalizedPath],
};
}
}
/** Entry of a normalized (gzip-safe) path and file data */
export type FileEntry = readonly [normalizedPath: string, data: Buffer | string];
/** Packs file entries into a tar.gz file (path to tgz returned) */
async function packFilesIterableAsync(
export async function packFilesIterableAsync(
iterable: Iterable<FileEntry> | AsyncIterable<FileEntry>,
options?: GzipOptions
): Promise<string> {
@@ -199,10 +234,3 @@ async function packFilesIterableAsync(
await writeTask$;
return writePath;
}
export {
createAssetMapAsync,
listWorkerFilesAsync,
listAssetMapFilesAsync,
packFilesIterableAsync,
};
+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;
},
};
+129 -103
View File
@@ -1,64 +1,36 @@
import cliProgress from 'cli-progress';
import * as https from 'https';
import createHttpsProxyAgent from 'https-proxy-agent';
import mime from 'mime';
import { Gzip } from 'minizlib';
import fetch, { Headers, HeadersInit, RequestInit, Response } from 'node-fetch';
import fs, { createReadStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import fetch, { BodyInit, Headers, HeadersInit, RequestInit, Response } from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
import { Readable } from 'node:stream';
import promiseRetry from 'promise-retry';
import { AssetFileEntry } from './assets';
import {
createMultipartBodyFromFilesAsync,
createReadStreamAsync,
multipartContentType,
} from './utils/multipart';
const MAX_CONCURRENCY = Math.min(10, Math.max(os.availableParallelism() * 2, 20));
const MAX_RETRIES = 4;
const MAX_CONCURRENCY = 10;
const MIN_RETRY_TIMEOUT = 100;
const MAX_UPLOAD_SIZE = 5e8; // 5MB
const MIN_COMPRESSION_SIZE = 5e4; // 50kB
const isCompressible = (contentType: string | null, size: number): boolean => {
if (size < MIN_COMPRESSION_SIZE) {
// Don't compress small files
return false;
} else if (contentType && /^(?:audio|video|image)\//i.test(contentType)) {
// Never compress images, audio, or videos as they're presumably precompressed
return false;
} else if (contentType && /^application\//i.test(contentType)) {
// Only compress `application/` files if they're marked as XML/JSON/JS
return /(?:xml|json5?|javascript)$/i.test(contentType);
} else {
return true;
}
};
export type UploadPayload =
| { filePath: string }
| { asset: AssetFileEntry }
| { multipart: AssetFileEntry[] };
const getContentTypeAsync = async (filePath: string): Promise<string | null> => {
let contentType = mime.getType(path.basename(filePath));
if (!contentType) {
const fileContent = await readFile(filePath, 'utf-8');
try {
// check if file is valid JSON without an extension, e.g. for the apple app site association file
const parsedData = JSON.parse(fileContent);
if (parsedData) {
contentType = 'application/json';
}
} catch {}
}
return contentType;
};
export interface UploadParams extends Omit<RequestInit, 'signal' | 'body'> {
filePath: string;
compress?: boolean;
url: string;
export interface UploadRequestInit {
baseURL: string | URL;
method?: string;
headers?: HeadersInit;
body?: undefined;
signal?: AbortSignal;
}
export interface UploadResult {
params: UploadParams;
payload: UploadPayload;
response: Response;
}
@@ -79,51 +51,60 @@ const getAgent = (): https.Agent => {
}
};
export async function uploadAsync(params: UploadParams): Promise<UploadResult> {
const {
filePath,
signal,
compress,
method = 'POST',
url,
headers: headersInit,
...requestInit
} = params;
const stat = await fs.promises.stat(filePath);
if (stat.size > MAX_UPLOAD_SIZE) {
throw new Error(
`Upload of "${filePath}" aborted: File size is greater than the upload limit (>500MB)`
);
}
const contentType = await getContentTypeAsync(filePath);
type OnProgressUpdateCallback = (progress: number) => void;
export async function uploadAsync(
init: UploadRequestInit,
payload: UploadPayload,
onProgressUpdate?: OnProgressUpdateCallback
): Promise<UploadResult> {
return await promiseRetry(
async retry => {
const headers = new Headers(headersInit);
if (contentType) {
headers.set('content-type', contentType);
if (onProgressUpdate) {
onProgressUpdate(0);
}
let bodyStream: NodeJS.ReadableStream = createReadStream(filePath);
if (compress && isCompressible(contentType, stat.size)) {
const gzip = new Gzip({ portable: true });
bodyStream.on('error', error => gzip.emit('error', error));
// @ts-expect-error: Gzip implements a Readable-like interface
bodyStream = bodyStream.pipe(gzip) as NodeJS.ReadableStream;
headers.set('content-encoding', 'gzip');
const headers = new Headers(init.headers);
const url = new URL(`${init.baseURL}`);
let errorPrefix: string;
let body: BodyInit | undefined;
let method = init.method || 'POST';
if ('asset' in payload) {
const { asset } = payload;
errorPrefix = `Upload of "${asset.normalizedPath}" failed`;
if (asset.type) {
headers.set('content-type', asset.type);
}
if (asset.size) {
headers.set('content-length', `${asset.size}`);
}
method = 'POST';
url.pathname = `/asset/${asset.sha512}`;
body = Readable.from(createReadStreamAsync(asset), { objectMode: false });
} else if ('filePath' in payload) {
const { filePath } = payload;
errorPrefix = 'Worker deployment failed';
body = fs.createReadStream(filePath);
} else if ('multipart' in payload) {
const { multipart } = payload;
errorPrefix = `Upload of ${multipart.length} assets failed`;
headers.set('content-type', multipartContentType);
method = 'PATCH';
url.pathname = '/asset/batch';
body = Readable.from(createMultipartBodyFromFilesAsync(multipart, onProgressUpdate), {
objectMode: false,
});
}
let response: Response;
try {
response = await fetch(params.url, {
...requestInit,
response = await fetch(url, {
method,
body: bodyStream,
body,
headers,
agent: getAgent(),
// @ts-expect-error: Internal types don't match
signal,
signal: init.signal as any,
});
} catch (error) {
return retry(error);
@@ -141,10 +122,10 @@ export async function uploadAsync(params: UploadParams): Promise<UploadResult> {
if (rayId) {
message += `\nReport this error quoting Request ID ${rayId}`;
}
return `Upload of "${filePath}" failed: ${message}`;
return `${errorPrefix}: ${message}`;
} else {
const json = await response.json().catch(() => null);
return json?.error ?? `Upload of "${filePath}" failed: ${response.statusText}`;
return json?.error ?? `${errorPrefix}: ${response.statusText}`;
}
};
@@ -156,22 +137,23 @@ export async function uploadAsync(params: UploadParams): Promise<UploadResult> {
) {
return retry(new Error(await getErrorMessageAsync()));
} else if (response.status === 413) {
const message = `Upload of "${filePath}" failed: File size exceeded the upload limit`;
const message = `${errorPrefix!}: File size exceeded the upload limit`;
throw new Error(message);
} else if (!response.ok) {
throw new Error(await getErrorMessageAsync());
} else if (onProgressUpdate) {
onProgressUpdate(1);
}
return {
params,
payload,
response,
};
},
{
retries: MAX_RETRIES,
minTimeout: MIN_RETRY_TIMEOUT,
randomize: true,
factor: 2,
minTimeout: 50,
randomize: false,
}
);
}
@@ -199,30 +181,53 @@ export async function callUploadApiAsync(url: string | URL, init?: RequestInit):
}
export interface UploadPending {
params: UploadParams;
payload: UploadPayload;
progress: number;
}
export type BatchUploadSignal = UploadResult | UploadPending;
export async function* batchUploadAsync(
uploads: readonly UploadParams[]
): AsyncGenerator<BatchUploadSignal> {
init: UploadRequestInit,
payloads: UploadPayload[],
onProgressUpdate?: OnProgressUpdateCallback
): AsyncGenerator<UploadPending> {
const progressTracker = new Array(payloads.length).fill(0);
const controller = new AbortController();
const queue = new Set<Promise<UploadResult>>();
const initWithSignal = { ...init, signal: controller.signal };
const getProgressValue = (): number => {
const progress = progressTracker.reduce((acc, value) => acc + value, 0);
return progress / payloads.length;
};
const sendProgressUpdate =
onProgressUpdate &&
(() => {
onProgressUpdate(getProgressValue());
});
try {
let index = 0;
while (index < uploads.length || queue.size > 0) {
while (queue.size < MAX_CONCURRENCY && index < uploads.length) {
const uploadParams = uploads[index++];
let uploadPromise: Promise<UploadResult>;
queue.add(
(uploadPromise = uploadAsync({ ...uploadParams, signal: controller.signal }).finally(() =>
queue.delete(uploadPromise)
))
while (index < payloads.length || queue.size > 0) {
while (queue.size < MAX_CONCURRENCY && index < payloads.length) {
const currentIndex = index++;
const payload = payloads[currentIndex];
const onChildProgressUpdate =
sendProgressUpdate &&
((progress: number) => {
progressTracker[currentIndex] = progress;
sendProgressUpdate();
});
const uploadPromise = uploadAsync(initWithSignal, payload, onChildProgressUpdate).finally(
() => {
queue.delete(uploadPromise);
progressTracker[currentIndex] = 1;
}
);
yield { params: uploadParams };
queue.add(uploadPromise);
yield { payload, progress: getProgressValue() };
}
yield await Promise.race(queue);
yield {
...(await Promise.race(queue)),
progress: getProgressValue(),
};
}
if (queue.size > 0) {
@@ -234,3 +239,24 @@ export async function* batchUploadAsync(
}
}
}
interface UploadProgressBar {
update(progress: number): void;
stop(): void;
}
export function createProgressBar(label = 'Uploading assets'): UploadProgressBar {
const queueProgressBar = new cliProgress.SingleBar(
{ format: `|{bar}| {percentage}% ${label}` },
cliProgress.Presets.rect
);
queueProgressBar.start(1, 0);
return {
update(progress: number) {
queueProgressBar.update(progress);
},
stop() {
queueProgressBar.stop();
},
};
}
@@ -0,0 +1,125 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
const CRLF = '\r\n';
const BOUNDARY_HYPHEN_CHARS = '--';
const BOUNDARY_ID = '----formdata-eas-cli';
const FORM_FOOTER = `${BOUNDARY_HYPHEN_CHARS}${BOUNDARY_ID}${BOUNDARY_HYPHEN_CHARS}${CRLF}${CRLF}`;
const encodeName = (input: string): string => {
return input.replace(/["\n\\]/g, (c: string) => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return '%22';
case '\n':
return '%0A';
default:
return `%${c.charCodeAt(0).toString(16).toUpperCase()}`;
}
});
};
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();
}
}
const makeFormHeader = (params: {
name: string;
contentType: string | null;
contentLength: number | null;
}): string => {
const name = encodeName(params.name);
let header = BOUNDARY_HYPHEN_CHARS + BOUNDARY_ID + CRLF;
header += `Content-Disposition: form-data; name="${name}"; filename="${name}"`;
if (params.contentType) {
header += `${CRLF}Content-Type: ${params.contentType}`;
}
if (params.contentLength) {
header += `${CRLF}Content-Length: ${params.contentLength}`;
}
header += CRLF;
header += CRLF;
return header;
};
export interface MultipartFileEntry {
sha512: string;
path: string;
type: string | null;
size: number;
}
export const multipartContentType = `multipart/form-data; boundary=${BOUNDARY_ID}`;
type OnProgressUpdateCallback = (progress: number) => void;
export async function* createMultipartBodyFromFilesAsync(
entries: MultipartFileEntry[],
onProgressUpdate?: OnProgressUpdateCallback
): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder();
for (let idx = 0; idx < entries.length; idx++) {
const entry = entries[idx];
const header = makeFormHeader({
name: entry.sha512,
contentType: entry.type,
contentLength: entry.size,
});
yield encoder.encode(header);
yield* createReadStreamAsync(entry);
yield encoder.encode(CRLF);
if (onProgressUpdate) {
onProgressUpdate((idx + 1) / entries.length);
}
}
yield encoder.encode(FORM_FOOTER);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "16.13.2",
"version": "16.18.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
@@ -610,7 +610,9 @@ test('valid eas.json with missing profile', async () => {
const accessor = EasJsonAccessor.fromProjectPath('/project');
const promise = EasJsonUtils.getBuildProfileAsync(accessor, Platform.ANDROID, 'debug');
await expect(promise).rejects.toThrowError('Missing build profile in eas.json: debug');
await expect(promise).rejects.toThrowError(
'Missing build profile in eas.json: "debug". Available profiles: ["production"]'
);
});
test('invalid eas.json when using wrong buildType', async () => {
@@ -633,7 +635,9 @@ test('empty json', async () => {
const accessor = EasJsonAccessor.fromProjectPath('/project');
const promise = EasJsonUtils.getBuildProfileAsync(accessor, Platform.ANDROID, 'production');
await expect(promise).rejects.toThrowError('Missing build profile in eas.json: production');
await expect(promise).rejects.toThrowError(
'Missing build profile in eas.json: "production". Available profiles: []'
);
});
test('invalid semver value', async () => {
+8 -2
View File
@@ -46,10 +46,16 @@ function resolveProfile({
const profile = easJson.build?.[profileName];
if (!profile) {
if (depth === 0) {
throw new MissingProfileError(`Missing build profile in eas.json: ${profileName}`);
throw new MissingProfileError(
`Missing build profile in eas.json: "${profileName}". Available profiles: [${Object.keys(
easJson.build ?? {}
)
.map(profile => `"${profile}"`)
.join(', ')}]`
);
} else {
throw new MissingParentProfileError(
`Extending non-existent build profile in eas.json: ${profileName}`
`Extending non-existent build profile in eas.json: "${profileName}"`
);
}
}
+10 -1
View File
@@ -2,6 +2,9 @@
set -eo pipefail
GITHUB_USER=`git config get --global user.name`
GITHUB_EMAIL=`git config get --global user.email`
SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
ROOT_DIR="$( cd "$SCRIPTS_DIR"/.. && pwd )"
@@ -10,4 +13,10 @@ $SCRIPTS_DIR/bin/run update-local-plugin
next_version_bump=$($SCRIPTS_DIR/bin/run next-version)
next_version=${1:-$next_version_bump}
lerna version --exact "$next_version"
if [[ "$GITHUB_USER" == "Expo CI" && "$GITHUB_EMAIL" == "[email protected]" && "$INPUT_DRY_RUN" != "true" ]]; then
echo "Releasing with version $next_version"
lerna version --yes --exact "$next_version"
else
echo "We are running with a normal GitHub user, or the dry run flag is enabled, so run the script in dry run mode, without committing or pushing any changes."
lerna version --yes --exact "$next_version" --no-push --no-git-tag-version
fi
Generated
+1134 -576
View File
File diff suppressed because it is too large. Load diff