Compare commits

..
Author SHA1 Message Date
Douglas Lowder b3bba882b1 Add mutation for getting workflows from git ref 2025-11-20 16:30:50 -08:00
Expo CI 4476c87962 update CHANGELOG.md 2025-11-20 19:58:01 +00:00
Expo CI 18ecd0844a v16.28.0 2025-11-20 19:39:20 +00:00
Colin MacKenzie cc3e7cdbe4 remove package json changes (#3263) 2025-11-20 13:36:15 -06:00
Colin MacKenzie aa7fecb9fe [eas-cli] Fix usage warning bug in eas:build (#3260)
* fix overages bug

* stub changelog

* update pr number

* fix test
2025-11-20 13:24:00 -06:00
Douglas Lowder c73a3e3a1e Update GraphQL from server, fix type error in test (#3262)
# Why

GraphQL test started failing today after a recent server change in updates GraphQL queries. Failed test run at https://github.com/expo/eas-cli/actions/runs/19529909252

# How

- Updated GraphQL generated code
- Fixed a TS type error in one test

# Test Plan

- CI should pass
2025-11-20 11:18:21 -08:00
Quinlan Jung 🦥 37ca2ebd55 [ENG-17987][eas-cli] delete branch in background (#3258)
* [eas-cli] delete branch in background

* Temporary Commit at 11/18/2025, 4:28:38 PM
2025-11-18 16:33:40 -08:00
Colin MacKenzie dead11964e [eas-cli] Sort organization accounts first in eas:new (#3256)
* sort accounts

* update changelog

* fix failing test
2025-11-17 13:44:50 -06:00
Expo CI b66d31d7d7 update CHANGELOG.md 2025-11-13 02:21:00 +00:00
Expo CI ed8170047b v16.27.0 2025-11-13 02:14:02 +00:00
Quinlan Jung 🦥 0e4056ba82 [eas-cli] kick off update patch generation if enabled (#3250)
* [eas-cli] kick off update patch generation if enabled

* Temporary Commit at 11/5/2025, 3:09:27 PM

* Temporary Commit at 11/5/2025, 3:19:31 PM

* Temporary Commit at 11/5/2025, 3:25:14 PM

* Temporary Commit at 11/11/2025, 2:06:29 PM

* Temporary Commit at 11/11/2025, 2:51:21 PM

* Temporary Commit at 11/11/2025, 2:53:51 PM

* Temporary Commit at 11/11/2025, 4:27:57 PM

* Temporary Commit at 11/12/2025, 5:31:14 PM
2025-11-12 18:05:53 -08:00
Kudo Chien 6182ea5a1d [eas-cli] remove plugins evaluation from workflow:run (#3254) 2025-11-12 01:46:16 +08:00
Douglas Lowder c9e7b649ad Fix workflow:status when run ID passed in (#3253)
# Why

In `workflow:status`, the argument name for the passed-in workflow run ID is incorrect. Instead of showing the status of that run, it shows the dialog to select from recent runs.

# How

Fixed the argument name.

# Test Plan

Works when testing locally.
2025-11-10 01:55:11 -08:00
Quinlan Jung 🦥 7a6b796242 [eas-cli] fix yarn watch (#3251)
* [eas-cli] fix yarn watch

* Temporary Commit at 11/6/2025, 12:22:09 PM
2025-11-06 12:37:12 -08:00
Kadi Kraman 804eb9a326 [eas-cli] Fix updating file env vars with env:update --non-interactive (#3249) 2025-11-05 12:12:40 +00:00
Expo CI 8da14f0101 update CHANGELOG.md 2025-10-29 19:26:44 +00:00
Expo CI dc62d22816 v16.26.0 2025-10-29 19:17:21 +00:00
Colin MacKenzie 258c625611 [eas-cli] eas:new - remove hidden tag; update output and project README (#3243)
* fix typo in the readme, change output formatting

* remove hidden flag

* update readme

* remove dev code
2025-10-29 14:10:30 -05:00
Douglas Lowder e84340774d New command workflow:status (#3242)
# Why

Shows the status of a workflow run that has already started.

If the run is in progress, and the `--wait` flag is set, the command will show periodic updates on running jobs, the current job steps, and recent log lines, similarly to `eas workflow:run --wait`.

Otherwise, the command immediately returns with information on the run result, exactly as `eas workflow:run --wait` will show at the end of a run. If the run failed, the failed jobs, failed steps, and last few log lines are shown.

```bash
USAGE
  $ eas workflow:status [WORKFLOW_RUN_ID] [--non-interactive] [--wait] [--json]

ARGUMENTS
  WORKFLOW_RUN_ID  A workflow run ID.

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. Defaults to false.

DESCRIPTION
  show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent
  workflow runs for the current project.

FLAG DESCRIPTIONS
  --[no-]wait  Wait for workflow run to complete. Defaults to false.

    Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```

# How

Leverages utility code already written for `eas workflow:run` and `eas workflow:logs`.

# Test Plan

- Tested on running and completed workflow runs.
- CI should pass.
2025-10-29 12:00:07 -07:00
Expo CI fbc23f7a7d update CHANGELOG.md 2025-10-28 15:05:45 +00:00
Expo CI 97255ec90d v16.25.1 2025-10-28 13:14:43 +00:00
Phil Pluckthun cfccc87b82 [eas-cli] Remove preview log output from EAS Hosting commands (#3241)
* Remove preview labels from EAS Hosting commands

* Add CHANGELOG entry
2025-10-27 22:25:44 +00:00
Expo CI 09b4f8367e update CHANGELOG.md 2025-10-27 16:55:04 +00:00
Expo CI 45481f9758 v16.25.0 2025-10-27 15:57:32 +00:00
Colin MacKenzie 1119008317 [eas-cli] Display usage warnings on eas:build (#3236)
* first pass

* cleanup

* clean up query

* show warning when builds are close to threshold

* cleaned up query

* tweak test

* more test tweaks
2025-10-27 10:34:40 -05:00
Douglas Lowder 123a0c1b7e Remove outdated update-local-plugin from release workflow (#3240)
# Why

The manual release workflow is broken after #3237, since the local plugin version is no longer hardcoded, but tied to the `@expo/eas-build-job` dependency.

# How

Remove this step from the workflow.

# Test Plan

Workflow should succeed in dry run mode.
2025-10-27 05:51:52 -07:00
Douglas Lowder 2f21fa62b1 New workflow to move latest-eas-build tag (#3233)
# Why

Provide a way to move the "latest-eas-build" version tag added in the EAS CLI release process. After https://github.com/expo/universe/pull/22530, this is the EAS CLI version that will be used by EAS workflows.

# Test Plan

Test with different version inputs, ensure failures are handled correctly.
2025-10-27 04:32:34 -07:00
Colin MacKenzieandDouglas Lowder 8ad449c6b7 [eas-cli] New command - incorporate more user feedback (#3235)
* incorporated feedback

* more feedback

* add changelog entry

* prompt for name

* update agents.md

* fix test

* add PR to changelog

* Update CHANGELOG.md

Co-authored-by: Douglas Lowder <[email protected]>

* remove dev warning

---------

Co-authored-by: Douglas Lowder <[email protected]>
2025-10-24 10:08:43 -05:00
Douglas Lowder fe8ec50806 Add missing env for local builds with latest plugin (#3237)
# Why

Currently, the CLI is using an old version of `eas-cli-local-build-plugin` for builds with `eas build --local`. This is not ideal, as this means that local builds are not using the most recent `build-tools` and other packages.

# How

- Modify `local.ts` so that the version of the plugin matches the version of `@expo/eas-build-job` used by the CLI
- Add some missing environment variables that are required in the most recent releases of `@expo/build-tools`

# Test Plan

- Testing with local builds on my dev machine
- CI should pass
2025-10-23 14:08:22 -07:00
Douglas Lowder 86cefeb259 workflow:run can use --wait and --json together (#3239)
# Why

Customers would like to wait for a workflow run to complete, and get JSON output.

# How

Remove the restriction on --wait and --json flags together, and ensure that when --json is enabled, all messages other than the final JSON result will go to stderr.

# Test Plan

- Tested locally by running workflows
- CI should pass
2025-10-23 12:00:41 -07:00
Kadi Kraman 034e0a1e7f [eas-json] Add default values for android track and releaseStatus (#3229) 2025-10-20 17:55:27 +01:00
Expo CI a6b776915f update CHANGELOG.md 2025-10-19 21:51:50 +00:00
Expo CI 4ae52d0e3d v16.24.1 2025-10-19 21:45:41 +00:00
Douglas Lowder f62720ef5a fix (regression): revert ora dependency change (#3231) 2025-10-19 14:42:20 -07:00
Expo CI af09aaedb3 update CHANGELOG.md 2025-10-19 20:05:21 +00:00
Expo CI 184616e874 v16.24.0 2025-10-19 19:59:27 +00:00
Douglas Lowder 6bb09c4e99 feat: workflow:run --wait should show status updates (#3227)
# Why

If a user executes `eas workflow:run --wait`, it would be desirable to show status updates for the individual jobs and steps in the workflow while the workflow run is in progress.

# How

Added code that leverages some of the same utilities used by `workflow:logs`. 

During the run, the logs for the various workflow jobs are retrieved and parsed, and the progress of each job is shown in the text of a spinner, showing the current step and the last few lines of the step's logs.

If the workflow run fails, the output will show jobs that failed, along with the failed step for each, and any logs for the failed steps.

Other changes:

- Bumped the "ora" package to the latest version, to pick up bug fixes
- Removed deprecated Node 18 from test runs
- Refactored the code in `workflow:run` that parses inputs, moving that code to a file in `commandUtils`
- Modified the Zod type for inputs to align with server side changes in https://github.com/expo/universe/pull/21950

# Test Plan

- Testing with different workflows
- CI should pass
2025-10-19 12:52:46 -07:00
Douglas Lowder 81add86c61 fix: workflow:logs should show build and repack logs (#3226)
# Why

Build and repack jobs do not have logs at the same URL as other workflow jobs, so no logs were showing for these job types.

# How

- Update BuildFragment to include the log file URL
- Check for build jobs, and add the correct queries and fetch calls for those
- Update and fix tests
- Bump `@expo/eas-build-job` to 1.0.239 to fix TS error in environment type

# Test Plan

- Tested with various workflow runs, including ones with custom, build, repack, and update jobs.
- CI should pass.
2025-10-16 14:05:26 -07:00
Expo CI 4bb1c784d8 update CHANGELOG.md 2025-10-16 15:42:51 +00:00
Expo CI 322082c28b v16.23.1 2025-10-16 15:32:16 +00:00
Colin MacKenzieandDouglas Lowder 5db1d21aa5 [eas-cli] Incorporating feedback on eas new command (#3205)
* incorporating feedback

* small cleanup

* moved some files around

* moved tests

* remove unused line

* tweak one line

* unit tests for all the helper functions

* more tests pass

* last batch of tests for now

* update changelog

* fix changelog

* move src/new to src/commandUtils/new

* pr feedback

* update changelog

* update path in copy-new-templates command

* fix test import

* Update packages/eas-cli/package.json

Co-authored-by: Douglas Lowder <[email protected]>

* feat: add --package-manager flag to project:new command

- Add --package-manager (-p) flag with options: npm, yarn, pnpm, bun
- Default to npm when flag not provided
- Remove promptForPackageManagerAsync call
- Add corresponding tests

* feat: add --no-install flag to skip dependency installation

- Add --no-install flag to skip installing dependencies
- Update README with installation instructions when --no-install is used
- Pass skipInstall flag through to updateReadmeAsync
- Add corresponding tests

* feat: add hideOutput option to suppress dependency installation logs

- Add hideOutput parameter to runCommandAsync to use stdio: 'ignore'
- Update installDependenciesAsync to hide npm/yarn/pnpm/bun output
- Show only spinner during installation instead of verbose logs
- Apply hideOutput to all dependency installation steps
- Update tests to reflect flag definition changes

* revert: remove --no-install flag

Remove the --no-install flag and related skipInstall functionality as requested

* feat: remove prompts and use smart defaults for project name and directory

- Remove project name prompt, use smart fallback logic:
  - Default: new-expo-project
  - If exists: new-expo-project-USERNAME-DATE
  - If exists: new-expo-project-SHORTID (using nanoid)

- Remove target directory prompt, use cwd/projectName as default

- Refactor naming to reflect generation vs prompting:
  - Rename prompts.ts → configs.ts
  - Combine generateDefaultProjectNameAsync + promptForProjectNameAsync → generateProjectNameAsync
  - Rename promptForTargetDirectoryAsync → generateDirectoryAsync
  - Rename promptForConfigsAsync → generateConfigsAsync
  - Rename promptedConfigs → initialConfigs

- Add nanoid and fs-extra dependencies for smart defaults
- Update all tests to reflect new behavior (11 tests in configs.test.ts passing)
- All existing tests continue to pass

* refactor: simplify CLI args for project:new command

- Remove --name flag, always use smart generation logic
- Change --directory flag to positional 'path' argument
  - Defaults to current working directory
  - If no slashes: treated as subdirectory of cwd
  - If relative path with slashes: resolved from cwd
  - If absolute path: used as-is

- Update generateProjectNameAsync to remove optional name parameter
- Update generateDirectoryAsync to handle path logic
- Add 4 new tests for path handling scenarios
- Remove 1 test for name arg (no longer supported)
- All 17 tests passing

* more feedback

* one more big refactor

* more cleanup

* one last round of cleanup

* one last round of cleanup

* fix changelog

* fix tests

---------

Co-authored-by: Douglas Lowder <[email protected]>
2025-10-16 10:20:22 -05:00
Expo CI 0db5585b20 update CHANGELOG.md 2025-10-14 19:13:02 +00:00
Expo CI 10ec54345a v16.23.0 2025-10-14 15:53:39 +00:00
Kadi Kraman 27b4710264 [eas-cli] [eas-json] support custom environment variable environments (#3220) 2025-10-14 13:56:42 +01:00
Kadi Kraman 4132e92da5 Add tests for eas env:push --force (#3223) 2025-10-14 11:31:36 +01:00
Giorgio FellipeandKadi Kraman 264340a717 Add --force parameter to env:push (#3135)
Co-authored-by: Kadi Kraman <[email protected]>
2025-10-14 10:40:22 +01:00
Expo CI 1d09c2c97d update CHANGELOG.md 2025-10-13 20:55:14 +00:00
Expo CI 72cff1df1a v16.22.0 2025-10-13 20:47:55 +00:00
Douglas Lowder 6912e453ef Improvements to workflow:create (#3194)
# Why

The existing `workflow:create` command simply creates a very bare-bones workflow, that is not very useful to developers who need to know how to fully use workflows for managing builds, updates, and other EAS operations.

# How

- Define four different workflow templates, initialized as JSON objects.
- After the user selects which type of workflow (build, update, deploy, custom), sanity checks are made, EAS build and EAS update are configured if needed, and any missing build profiles are created in `eas.json`.
- Once the user has made their selections, the final workflow YAML is dynamically generated and written to disk with an explanatory header.
- After writing the workflow, next steps are provided for any further configuration that may be needed, especially if build credentials or app store credentials are needed.

In future, we can easily add more templates that use other workflow job types (fingerprint, repack, etc.).

# Test Plan

- Tested against different projects
- CI should pass
2025-10-13 13:42:00 -07:00
Expo CI 839cbac47b update CHANGELOG.md 2025-10-13 15:45:46 +00:00
Expo CI 5ba16dbc67 v16.21.0 2025-10-13 15:31:06 +00:00
Phil Pluckthun 896b456dd3 [eas-cli] Pass _expo/.routes.json for SSG exports as routes.json to Hosting deployments (#3211)
* Derive `routes.json` payload from static export `_expo/.routes.json`

* Add CHANGELOG entry
2025-10-10 15:22:32 +01:00
Kadi Kraman f2c0202f06 Add tests for the env commands (#3217) 2025-10-09 15:18:43 +01:00
Expo CI 497bac1cbf update CHANGELOG.md 2025-10-08 05:36:29 +00:00
Expo CI 5b9734b60b v16.20.4 2025-10-08 05:25:57 +00:00
Kadi Kraman 3ac70d8977 Switch to lowercase in exec and pull (#3216) 2025-10-08 06:23:06 +01:00
Douglas Lowder 32adc788d0 Support workflow:run from a Git ref (#3203)
# Why

Add a `--ref` flag so that `eas workflow:run` can initiate a workflow run from an existing ref in the project's GitHub repository.

- Avoids the need to upload the entire project from a local machine
- Ensures that the workflow starts from a known reference point in the project's source control, with no unexpected local changes

# How

- If the ref flag is set, the run command will ensure that the ref is valid, and that the workflow can be used for that ref (using the new mutation for this from https://github.com/expo/universe/pull/22132)
- If the ref flag is set, the run command starts the run from that ref (using https://github.com/expo/universe/pull/20161)
- If the user chooses ref = HEAD, `git rev-parse` is called to convert that to a commit
- The flow is designed to work the same for running from GitHub ref as running from local, so inputs and waiting for results are supported 

# Test Plan

- Tested in an existing project with workflows with and without inputs
- CI should pass
2025-10-07 15:06:24 -07:00
Expo CI e465ed57cb update CHANGELOG.md 2025-10-07 20:04:20 +00:00
Expo CI df819fc232 v16.20.3 2025-10-07 18:42:28 +00:00
Kadi Kraman 539e201972 Lower instead of uppercase environment (#3213) 2025-10-07 19:39:06 +01:00
Expo CI c14b3e179b update CHANGELOG.md 2025-10-07 15:23:38 +00:00
Expo CI f093197dea v16.20.2 2025-10-07 13:29:54 +00:00
Kadi Kraman 13834039a8 [eas-cli] Use local EnvironmentVariableEnvironment enum (#3208) 2025-10-07 14:18:07 +01:00
Expo CI 4d3023f7f1 update CHANGELOG.md 2025-10-03 15:51:00 +00:00
Expo CI eb7d3b5ecd v16.20.1 2025-10-03 15:14:03 +00:00
Stanisław Chmiela 8c721759bd [chore] Remove FingerprintSource (#3200)
<!-- 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

In https://github.com/expo/eas-build/pull/604 I removed `fingerprintSource` from metadata passed to a build (1). It was previously used to inform the build process of, well, the fingerprint source so it can optionally diff current fingerprint and fingerprint calculated by the `eas build` command.

In `eas build`, the fingerprint would get calculated and uploaded. Then it was included in the build mutation as `{ type: GCS, bucketKey }` (2). Worker expected that somewhere in the process of starting a job, something (`www` or `turtle-v2`) would swap out the persisted `fingerprintSource` to `{ type: URL, url }` (because worker can't download arbitrary GCS objects).

In https://github.com/expo/eas-build/pull/587 I changed the flow so that now worker does not expect any changes to the job or metadata and instead fetches the fingerprint sources straight from `www`.

This made `fingerprintSource` in `metadata` obsolete (see (1)). However, we still need to be able to send fingerprint sources information to `www` (see (2)).

# How

This pull request:
- bumps `@expo/eas-build-job` (removing `FingerprintSource` type)
- `FingerprintSource` gets replaced with `FingerprintSourceInput` generated by GraphQL schema
- this lets us remove `transformFingerprintSource` (it's being passed around in its final form)

# Test Plan

Set up my staging test app to use runtime version policy = fingerprint, installed and configured `expo-updates`, [ran a build](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/builds/7a44f4c3-e448-4657-a8f3-71e24a2a29c8).

I had a few `console.log`s in place:

<img width="943" height="779" alt="Zrzut ekranu 2025-09-30 o 17 39 14" src="https://github.com/user-attachments/assets/2b117a17-8c0a-4445-bf56-27f93a590f87" />

and the build has fingerprint:

<img width="1256" height="161" alt="Zrzut ekranu 2025-09-30 o 17 39 33" src="https://github.com/user-attachments/assets/b6133402-9910-42d2-b351-836ae9c10c95" />

Before I installed node dependencies, fingerprint was being set to `file:fingerprint` (probably from https://github.com/expo/expo/pull/26901, see https://github.com/expo/eas-cli/blob/b447194c8521270ba8b551296c4329c3edb7aa1c/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts#L88).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Replaces internal FingerprintSource with GraphQL FingerprintSourceInput across build/update flows, removes transformFingerprintSource, adjusts metadata wiring, and bumps related packages.
> 
> - **Build & GraphQL**:
>   - Replace `Metadata.fingerprintSource` usage with GraphQL `FingerprintSourceInput`; update builders to accept `BuildMetadataInput` and pass pre-transformed metadata.
>   - Remove `transformFingerprintSource` and associated imports/usages; simplify `transformMetadata` to accept `{ metadata, fingerprintSource }`.
>   - Update types for fingerprint source across build paths (`computeAndMaybeUpload*`, `sendBuildRequestAsync`, Android/iOS build senders).
> - **Update/Publish**:
>   - Stop transforming fingerprint source in `update publish`; pass through GraphQL `FingerprintSourceInput`.
>   - Adjust runtime fingerprint computation/assembly to use `FingerprintSourceInput`.
> - **Fingerprint upload**:
>   - `maybeUploadFingerprintAsync` now returns `FingerprintSourceInput`; use GraphQL enum casing (`Gcs`).
>   - Mutation `createFingerprintAsync` accepts `FingerprintSourceInput`.
> - **Dependencies**:
>   - Bump `@expo/eas-build-job` and `@expo/steps`; update yarn.lock accordingly.
> 
> <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5a61fdee68f3b0bbda043b8f3160c4c2f1ea3a71. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-10-03 13:06:42 +02:00
Vojtech Novak c6a741bb8c [eas-cli] skip syncing capabilities if they don't contain 'enabled' attribute (#3201) 2025-10-01 23:56:44 +02:00
Expo CI af5b76fe9f update CHANGELOG.md 2025-09-30 15:55:00 +00:00
Expo CI aa428b7571 v16.20.0 2025-09-30 15:39:44 +00:00
Colin MacKenzie ba68f40922 [eas-cli] Create new command to configure a new project (#3196)
* incorporate pr feedback

* playing around with adding build

* create expo project

* clean up getAccountChoices

* update unit test

* removed some unused code

* wip

* working on test files

* fix previews, add more end comments

* small tweaks to tests

* update changelog
2025-09-30 10:32:12 -05:00
Stanisław Chmiela 1380b023ba [eas-cli] Deprecate isGlobal GraphQL input property of environment variables (#3190)
<!-- 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

See https://github.com/expo/universe/pull/22108.

# How

- Updated GraphQL code.
- Adjusted code to the updated GraphQL code (no `isGlobal`).
- Updated types in `EnvironmentVariableMutation` so they don't re-implement what GraphQL is generating for us.
- Updated types in `env:push`.

# Test Plan

Adjusted tests.
2025-09-25 18:42:44 +02:00
Colin MacKenzie adfc79abd4 [eas-cli] Scaffolding out the new command (#3177)
* scaffolding out new command

* remove a newline

* incorporate pr feedback

* add unit test for package manager functions

* get rid of unnecessary lockfile git add

* remove unused test
2025-09-23 10:36:23 -05:00
Douglas Lowder 92e6ecd03c Improvements to workflow:validate (#3165)
# Why

Improve the validation of workflow files:

- Add client-side checks for valid syntax, and compatibility with the user's EAS build profiles
- Make validation usable with future improvements to `workflow:create`

# How

- New `validation.ts` in `commandUtils` with the various validation checks separated cleanly into different functions
- `workflow:validate` command refactored to use the new utility and wrap errors
- The new code reads the workflow schema from `<ExpoAPIBaseURL>/v2/workflows/schema`
- For testing purposes only, a local copy of the schema can be used by setting EXPO_TESTING_WORKFLOW_SCHEMA_PATH
- An existing check in `workflowFile.ts` has been modified to remove the requirement for a Github repository, as that is no longer required to run workflows

# Test Plan

- Tested against different workflow templates, and against workflows in a real project.
- Tested against the www server from the main branch running locally, to verify recent server side changes (e.g. https://github.com/expo/universe/pull/21950)
2025-09-19 02:45:58 -07:00
Expo CI 05546f0bfd update CHANGELOG.md 2025-09-17 06:43:24 +00:00
Expo CI 6c5c99b5d1 v16.19.3 2025-09-17 06:36:33 +00:00
Brent Vatne f27ea08839 [cli] Temporarily remove warnings when no compatible build found, need to refine the message (#3182) 2025-09-16 23:21:34 -07:00
Expo CI 15f47aea81 update CHANGELOG.md 2025-09-15 19:24:30 +00:00
Expo CI fd23f031b6 v16.19.2 2025-09-15 19:16:48 +00:00
Douglas Lowder a74c434718 Add artifact URLs to workflow:view (#3175)
# Why

To allow CLI users to get artifacts of builds or workflow jobs directly using the CLI, and make the URLs available in JSON output also (for easier use by automation scripts).

# How

- Add artifact info to WorkflowJobFragment
- Add this info to the results of `workflow:view`
- For workflow jobs that are of type=BUILD, use the existing build query to get the build artifact URLs for the build and show those as well
- Make some improvements to the formatting of workflow job outputs

# Test Plan

- Tested against different EAS projects
- CI should pass
2025-09-15 11:42:09 -07:00
Expo CI 29950a7629 update CHANGELOG.md 2025-09-12 11:54:08 +00:00
Expo CI 0fd33a95f5 v16.19.1 2025-09-12 11:48:48 +00:00
Stanisław Chmiela 843ed0d0aa [eas-cli] Upgrade Zod to v4 (#3173)
<!-- 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

Unify packages versions across the Expo package ecosystem (Zod, eas-build-job)

# How

Copied Zod definitions from `www` to `run.ts` where we are parsing workflow to get inputs definitions.

# Test Plan

There are, fortunately, tests for this part of the code.
2025-09-12 13:38:49 +02:00
Expo CI bf2cde45c6 update CHANGELOG.md 2025-09-12 10:35:59 +00:00
Expo CI ecab803cfa v16.19.0 2025-09-12 10:29:03 +00:00
Stanisław Chmiela 570ff126a0 [eas-cli] Allow freeform Android release track (#3163)
<!-- 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

[exponent-internal.slack.com/archives/C011ZMXQ6SZ/p1755530483378679](https://exponent-internal.slack.com/archives/C011ZMXQ6SZ/p1755530483378679)

# How

Updated GraphQL code which removed `SubmissionAndroidTrack` enum (it's a scalar now). Adjusted code to pass track around as string.

Removed `AndroidReleaseTrack` from `eas-json`. Updated `eas.schema.json` to allow any string as `track`.

Technically, to support `track: string` we'll need to update `eas-json` in `www`. I think it's ok to merge this, publish releases and then update `eas-json` in `www`. Alternatively we could publish `eas-json` from this branch as prerelease, bump in `www` and then merge this. This would be more _correct_, but I think it's ok to have this 1 hour of unannounced discrepancy between `www` and `eas-cli` (users not being able to use custom tracks _yet_). 

# Test Plan


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-12 12:08:56 +02: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
164 changed files with 14738 additions and 2794 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']
+64
View File
@@ -0,0 +1,64 @@
on:
workflow_dispatch:
inputs:
staging_only:
type: boolean
required: true
default: false
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
version:
type: string
required: true
description: "Version number to move the tag to"
dry_run:
type: boolean
required: true
default: true
description: "Run the workflow in dry run mode, without making any NPM changes"
name: Move NPM tags used by EAS builds and workflows
jobs:
move-tag:
name: Move NPM tags used by EAS builds and workflows
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
node-version: 22
- name: Resolve version (fails if the version does not exist)
run: |
set -o pipefail
npm pack --dry-run eas-cli@$INPUT_VERSION
- name: Add latest-eas-build-staging tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
$cmd
+7 -7
View File
@@ -18,7 +18,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: 'eas-cli@${{ env.EAS_CLI_VERSION }}'
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
@@ -28,7 +28,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: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
@@ -73,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
@@ -116,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
@@ -158,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
@@ -191,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
@@ -211,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: |
-3
View File
@@ -5,9 +5,6 @@ on:
branches: [main]
pull_request:
branches: [main]
paths:
- .github/workflows/test-graphql.yml
- packages/eas-cli/src/**
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['18', '20', '22']
node: ['20', '22']
include:
- node: '20'
coverage: true
+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
+205 -2
View File
@@ -8,6 +8,211 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
### 🧹 Chores
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
### 🎉 New features
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
### 🧹 Chores
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
### 🎉 New features
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
### 🎉 New features
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
### 🧹 Chores
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
### 🎉 New features
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
### 🐛 Bug fixes
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
### 🎉 New features
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
### 🐛 Bug fixes
- Multiple bugfixes and small features for the `eas:new` command: handle project name and directory generation more smoothly; support `bun` as a package manager; add `--package-manager` flag with a default to `npm`; ensure workflow files are in the correct location after build; update the generated project's README with the provided package manager; ensure `app.json`'s `scheme` is valid; silence lots of noisy output. ([#3205](https://github.com/expo/eas-cli/pull/3205) by [@mackenco](https://github.com/mackenco))
## [16.23.0](https://github.com/expo/eas-cli/releases/tag/v16.23.0) - 2025-10-14
### 🎉 New features
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
- Add --force flag to eas env:push command to bypass confirmation prompts. ([#3135](https://github.com/expo/eas-cli/pull/3135) by [@giorgiofellipe](https://github.com/giorgiofellipe))
## [16.22.0](https://github.com/expo/eas-cli/releases/tag/v16.22.0) - 2025-10-13
### 🎉 New features
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
## [16.21.0](https://github.com/expo/eas-cli/releases/tag/v16.21.0) - 2025-10-13
### 🎉 New features
- Support `_expo/.routes.json` metadata file exported by `@expo/cli` for headers configuration on static deployments ([#3211](https://github.com/expo/eas-cli/pull/3211) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Add thorough tests for the env commands ([#3217](https://github.com/expo/eas-cli/pull/3217) by [@kadikraman](https://github.com/kadikraman))
## [16.20.4](https://github.com/expo/eas-cli/releases/tag/v16.20.4) - 2025-10-08
### 🎉 New features
- Allow workflow:run to run a workflow from a GitHub ref. ([#3203](https://github.com/expo/eas-cli/pull/3203) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Fix --environment flag in eas exec and pull ([#3216](https://github.com/expo/eas-cli/pull/3216) by [@kadikraman](https://github.com/kadikraman))
## [16.20.3](https://github.com/expo/eas-cli/releases/tag/v16.20.3) - 2025-10-07
### 🐛 Bug fixes
- Fix --environment flag in eas env commands ([#3213](https://github.com/expo/eas-cli/pull/3213) by [@kadikraman](https://github.com/kadikraman))
## [16.20.2](https://github.com/expo/eas-cli/releases/tag/v16.20.2) - 2025-10-07
### 🧹 Chores
- Create an `EnvironmentVariableEnvironment` enum and use it instead of the graphQL Enum ([#3208](https://github.com/expo/eas-cli/pull/3208) by [@kadikraman](https://github.com/kadikraman))
## [16.20.1](https://github.com/expo/eas-cli/releases/tag/v16.20.1) - 2025-10-03
### 🐛 Bug fixes
- skip syncing capabilities with settings if they don't contain the `enabled` attribute ([#3201](https://github.com/expo/eas-cli/pull/3201) by [@vonovak](https://github.com/vonovak))
## [16.20.0](https://github.com/expo/eas-cli/releases/tag/v16.20.0) - 2025-09-30
### 🎉 New features
- Improvements to workflow:validate. ([#3165](https://github.com/expo/eas-cli/pull/3165) by [@douglowder](https://github.com/douglowder))
- Add `eas:new` command behind `hidden` flag. ([#3196](https://github.com/expo/eas-cli/pull/3196) by [@mackenco](https://github.com/mackenco))
## [16.19.3](https://github.com/expo/eas-cli/releases/tag/v16.19.3) - 2025-09-17
### 🧹 Chores
- Temporarily remove warnings when no compatible build found, need to refine the message. ([#3182](https://github.com/expo/eas-cli/pull/3182) by [@brentvatne](https://github.com/brentvatne))
## [16.19.2](https://github.com/expo/eas-cli/releases/tag/v16.19.2) - 2025-09-15
### 🎉 New features
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
## [16.19.1](https://github.com/expo/eas-cli/releases/tag/v16.19.1) - 2025-09-12
## [16.19.0](https://github.com/expo/eas-cli/releases/tag/v16.19.0) - 2025-09-12
### 🎉 New features
- Allow any string as Android release `track`, not just the `production`, `internal`, `alpha`, `beta`. ([#3163](https://github.com/expo/eas-cli/pull/3163) by [@sjchmiela](https://github.com/sjchmiela))
## [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))
@@ -16,8 +221,6 @@ This is the log of notable changes to EAS CLI and related packages.
- 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))
### 🧹 Chores
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
### 🎉 New features
+14 -5
View File
@@ -1,12 +1,21 @@
# Development
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
## Getting Started
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
If this is your first time running `eas-cli` locally, make sure you install and build the packages. In the project root, run:
```sh
yarn install
yarn build
```
From there, set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
> The only exception is if there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
Open your `.zshrc` or other config file and add:
```
```sh
alias easd="/PATH/TO/eas-cli/bin/run"
```
@@ -14,7 +23,7 @@ Then use it with `easd` like `easd build`.
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
```
```sh
yarn start
```
@@ -33,7 +42,7 @@ If it is easier for you to work with the code locally with compiler options set
you can consider using:
```
```sh
yarn start-allow-unused
```
+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.16.0",
"version": "16.28.0",
"$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",
+406 -215
View File
File diff suppressed because it is too large. Load diff
+4304 -387
View File
File diff suppressed because it is too large. Load diff
+15 -13
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.16.0",
"version": "16.28.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -12,11 +12,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@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-build-job": "1.0.243",
"@expo/eas-json": "16.25.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.117",
"@expo/logger": "1.0.221",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.7.0",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "1.0.173",
"@expo/steps": "1.0.231",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
@@ -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",
@@ -85,13 +85,14 @@
"tar": "6.2.1",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1",
"ts-deepmerge": "6.2.0",
"tslib": "2.6.2",
"turndown": "7.1.2",
"untildify": "4.0.0",
"uuid": "9.0.1",
"wrap-ansi": "7.0.0",
"yaml": "2.6.0",
"zod": "^3.23.8"
"zod": "^4.1.3"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.0",
@@ -108,7 +109,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,14 +120,13 @@
"@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",
"mockdate": "3.0.5",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-deepmerge": "6.2.0",
"ts-mockito": "2.6.1",
"ts-node": "10.9.2",
"typescript": "5.3.3"
@@ -226,16 +226,18 @@
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json",
"build": "yarn typecheck-for-build && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"typecheck-for-build": "tsc --project tsconfig.build.json",
"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"
"clean": "rimraf dist build tmp node_modules yarn-error.log",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"volta": {
"node": "20.11.0",
+6 -1
View File
@@ -25,7 +25,12 @@ if cmp -s graphql.schema.json graphql.schema.json.bak; then
exit 1
fi
else
echo "GraphQL schema has changed on the server. Running `yarn tsc` to check for breaking changes..."
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
@@ -192,9 +192,16 @@ export function getMockWorkflowRunsFragment(
failures?: number;
pending?: number;
withJobs?: number;
withBuildJobs?: number;
}
): WorkflowRunFragment[] {
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
const {
successes = 0,
failures = 0,
pending = 0,
withJobs = 0,
withBuildJobs = 0,
} = params ?? {};
const runs: any[] = [];
for (let i = 0; i < successes; i++) {
runs.push({
@@ -247,10 +254,13 @@ export function getMockWorkflowRunsFragment(
for (let i = 0; i < withJobs; i++) {
runs.push(getMockWorkflowRunWithJobsFragment());
}
for (let i = 0; i < withBuildJobs; i++) {
runs.push(getMockWorkflowRunWithBuildJobsFragment());
}
return runs;
}
export function getMockWorkflowJobFragment(
export function getMockWorkflowCustomJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
@@ -258,7 +268,7 @@ export function getMockWorkflowJobFragment(
key: 'job1',
name: 'job1',
status: WorkflowJobStatus.Success,
type: WorkflowJobType.Build,
type: WorkflowJobType.Custom,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
outputs: {},
@@ -270,6 +280,19 @@ export function getMockWorkflowJobFragment(
id: 'job1',
logFileUrls: ['https://example.com/log1'],
errors: [],
artifacts: [],
},
};
}
export function getMockWorkflowBuildJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
...getMockWorkflowCustomJobFragment(runId),
type: WorkflowJobType.Build,
outputs: {
build_id: 'build1',
},
};
}
@@ -302,6 +325,38 @@ export function getMockWorkflowRunWithJobsFragment(
},
},
},
jobs: [getMockWorkflowJobFragment(id)],
jobs: [getMockWorkflowCustomJobFragment(id)],
};
}
export function getMockWorkflowRunWithBuildJobsFragment(
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: [getMockWorkflowBuildJobFragment(id)],
};
}
@@ -1,15 +1,20 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowJobFragment,
getMockWorkflowCustomJobFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import {
fetchRawLogsForBuildJobAsync,
fetchRawLogsForCustomJobAsync,
} from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
@@ -20,6 +25,7 @@ jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/BuildQuery');
jest.mock('../../graphql/queries/WorkflowJobQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
@@ -35,7 +41,7 @@ jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowCustomJobFragment());
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
@@ -98,7 +104,7 @@ describe(WorkflowLogView, () => {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -130,7 +136,64 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForJobAsync)
.mocked(fetchRawLogsForCustomJobAsync)
.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 build 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({ withBuildJobs: 1 }));
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
id: 'build1',
status: BuildStatus.Finished,
priority: BuildPriority.Normal,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
isForIosSimulator: false,
project: {
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
__typename: 'Account',
},
},
platform: AppPlatform.Android,
logFiles: ['https://example.com/log1'],
});
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(fetchRawLogsForBuildJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
+4
View File
@@ -108,3 +108,7 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
return new URL(projectId, `https://u.expo.dev`).href;
}
}
export function getExpoApiWorkflowSchemaURL(): string {
return getExpoApiBaseUrl() + '/v2/workflows/schema';
}
+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,
ScheduleBranchDeletionMutation,
ScheduleBranchDeletionMutationVariables,
} from '../graphql/generated';
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
export async function scheduleBranchDeletionAsync(
graphqlClient: ExpoGraphqlClient,
{
branchId,
}: {
branchId: string;
}
): Promise<BackgroundJobReceiptDataFragment> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<ScheduleBranchDeletionMutation, ScheduleBranchDeletionMutationVariables>(
gql`
mutation ScheduleBranchDeletion($branchId: ID!) {
updateBranch {
scheduleUpdateBranchDeletion(branchId: $branchId) {
id
...BackgroundJobReceiptData
}
}
}
${BackgroundJobReceiptNode}
`,
{ branchId }
)
.toPromise()
);
return result.updateBranch.scheduleUpdateBranchDeletion;
}
@@ -72,6 +72,7 @@ function createMockBuildFragment({
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
id: buildId ?? uuid(),
logFiles: [],
priority: BuildPriority.Normal,
project: {
__typename: 'App',
@@ -70,6 +70,7 @@ function createMockBuildFragment({
return {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
logFiles: [],
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
+3 -5
View File
@@ -1,4 +1,4 @@
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { Android, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk';
import getenv from 'getenv';
@@ -11,7 +11,7 @@ import { resolveRemoteVersionCodeAsync } from './version';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log, { learnMore } from '../../log';
import {
@@ -27,7 +27,6 @@ import {
prepareBuildRequestForPlatformAsync,
} from '../build';
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
import { transformMetadata } from '../graphql';
import { logCredentialsSource } from '../utils/credentials';
import {
checkGoogleServicesFileAsync,
@@ -124,10 +123,9 @@ export async function prepareAndroidBuildAsync(
sendBuildRequestAsync: async (
appId: string,
job: Android.Job,
metadata: Metadata,
graphqlMetadata: BuildMetadataInput,
buildParams: BuildParamsInput
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createAndroidBuildAsync(ctx.graphqlClient, {
appId,
@@ -8,7 +8,6 @@ import {
transformProjectArchive,
transformWorkflow,
} from '../graphql';
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
export function transformJob(job: Android.Job): AndroidJobInput {
return {
@@ -30,7 +29,7 @@ export function transformJob(job: Android.Job): AndroidJobInput {
experimental: job.experimental,
mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig,
environment: buildProfileEnvironmentToEnvironment(job.environment),
environment: job.environment,
loggerLevel: job.loggerLevel
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
: undefined,
+17 -9
View File
@@ -2,7 +2,6 @@ import {
ArchiveSource,
ArchiveSourceType,
BuildJob,
FingerprintSource,
Metadata,
Platform,
} from '@expo/eas-build-job';
@@ -50,9 +49,11 @@ import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
BuildFragment,
BuildMetadataInput,
BuildParamsInput,
BuildPriority,
BuildStatus,
FingerprintSourceInput,
UploadSessionType,
} from '../graphql/generated';
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
@@ -94,7 +95,7 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends BuildJob
sendBuildRequestAsync(
appId: string,
job: TJob,
metadata: Metadata,
metadata: BuildMetadataInput,
buildParams: BuildParamsInput
): Promise<BuildResult>;
}
@@ -193,13 +194,20 @@ export async function prepareBuildRequestForPlatformAsync<
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
buildId: nullthrows(process.env.EAS_BUILD_ID),
metadata: transformMetadata(metadata),
metadata: transformMetadata({
metadata,
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
}),
});
printJsonOnlyOutput({ job, metadata });
return undefined;
} else if (!ctx.localBuildOptions.localBuildMode) {
try {
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
const graphqlMetadata = transformMetadata({
metadata,
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
});
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
}
@@ -362,7 +370,7 @@ async function sendBuildRequestAsync<
>(
builder: Builder<TPlatform, Credentials, TJob>,
job: TJob,
metadata: Metadata,
graphqlMetadata: BuildMetadataInput,
buildParams: BuildParamsInput
): Promise<BuildFragment> {
const { ctx } = builder;
@@ -376,7 +384,7 @@ async function sendBuildRequestAsync<
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.projectId,
job,
metadata,
graphqlMetadata,
buildParams
);
@@ -654,7 +662,7 @@ async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends
): Promise<{
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
fingerprintSource?: FingerprintSourceInput | undefined;
}> {
const runtimeAndFingerprintMetadata =
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
@@ -676,7 +684,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
ctx: BuildContext<T>
): Promise<{
runtimeVersion?: string;
fingerprintSource?: FingerprintSource;
fingerprintSource?: FingerprintSourceInput;
fingerprintHash?: string;
}> {
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({
@@ -723,7 +731,7 @@ async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends
{ debug }: { debug?: boolean } = {}
): Promise<{
fingerprintHash?: string;
fingerprintSource?: FingerprintSource;
fingerprintSource?: FingerprintSourceInput;
}> {
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
workflow: ctx.workflow,
+1
View File
@@ -33,6 +33,7 @@ export interface IosBuildContext {
}
export interface BuildContext<T extends Platform> {
accountId: string;
accountName: string;
easJsonCliConfig: EasJson['cli'];
buildProfile: BuildProfile<T>;
@@ -125,6 +125,7 @@ export async function createBuildContextAsync<T extends Platform>({
);
const commonContext: CommonContext<T> = {
accountId,
accountName: account.name,
buildProfile,
buildProfileName,
@@ -1,9 +1,8 @@
import { Env } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { isEnvironment } from './utils/environment';
import { DefaultEnvironment } from './utils/environment';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
import Log, { learnMore } from '../log';
@@ -49,20 +48,9 @@ async function resolveEnvVarsAsync({
projectId: string;
}): Promise<Env> {
const environment =
buildProfile.environment?.toUpperCase() ??
buildProfile.environment ??
resolveSuggestedEnvironmentForBuildProfileConfiguration(buildProfile);
if (!isEnvironment(environment)) {
Log.log(
`Loaded "env" configuration for the "${buildProfileName}" profile: ${
buildProfile.env && Object.keys(buildProfile.env).length > 0
? Object.keys(buildProfile.env).join(', ')
: 'no environment variables specified'
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
);
return { ...buildProfile.env };
}
try {
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
graphqlClient,
@@ -129,16 +117,16 @@ async function resolveEnvVarsAsync({
function resolveSuggestedEnvironmentForBuildProfileConfiguration(
buildProfile: BuildProfile
): EnvironmentVariableEnvironment {
): DefaultEnvironment {
const environment =
buildProfile.distribution === 'store'
? EnvironmentVariableEnvironment.Production
? DefaultEnvironment.Production
: buildProfile.developmentClient
? EnvironmentVariableEnvironment.Development
: EnvironmentVariableEnvironment.Preview;
? DefaultEnvironment.Development
: DefaultEnvironment.Preview;
Log.log(
`Resolved "${environment.toLowerCase()}" environment for the build. ${learnMore(
`Resolved "${environment}" environment for the build. ${learnMore(
'https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds'
)}`
);
+8 -19
View File
@@ -3,7 +3,6 @@ import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
FingerprintSourceType,
Metadata,
Workflow,
} from '@expo/eas-build-job';
@@ -19,7 +18,6 @@ import {
FingerprintSourceInput,
BuildMode as GraphQLBuildMode,
BuildTrigger as GraphQLBuildTrigger,
FingerprintSourceType as GraphQLFingeprintSourceType,
ProjectArchiveSourceInput,
ProjectArchiveSourceType,
WorkerLoggerLevel,
@@ -42,11 +40,16 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
}
}
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
export function transformMetadata({
metadata,
fingerprintSource,
}: {
metadata: Metadata;
fingerprintSource: FingerprintSourceInput | undefined;
}): BuildMetadataInput {
return {
...metadata,
fingerprintSource:
metadata.fingerprintSource && transformFingerprintSource(metadata.fingerprintSource),
fingerprintSource,
credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution),
@@ -57,20 +60,6 @@ export function transformMetadata(metadata: Metadata): BuildMetadataInput {
};
}
export function transformFingerprintSource(
fingerprintSource: NonNullable<Metadata['fingerprintSource']>
): FingerprintSourceInput | null {
if (fingerprintSource.type !== FingerprintSourceType.GCS) {
return null;
}
return {
type: GraphQLFingeprintSourceType.Gcs,
bucketKey: fingerprintSource.bucketKey,
isDebugFingerprint: fingerprintSource.isDebugFingerprint,
};
}
function transformCredentialsSource(
credentialsSource: Metadata['credentialsSource']
): BuildCredentialsSource {
+3 -5
View File
@@ -1,4 +1,4 @@
import { Ios, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { Ios, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import { ensureIosCredentialsAsync } from './credentials';
@@ -7,7 +7,7 @@ import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteBuildNumberAsync } from './version';
import { IosCredentials } from '../../credentials/ios/types';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
@@ -15,7 +15,6 @@ import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommonContext, IosBuildContext } from '../context';
import { transformMetadata } from '../graphql';
import {
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
@@ -106,10 +105,9 @@ export async function prepareIosBuildAsync(
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
metadata: Metadata,
graphqlMetadata: BuildMetadataInput,
buildParams: BuildParamsInput
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createIosBuildAsync(ctx.graphqlClient, {
appId,
+1 -2
View File
@@ -9,7 +9,6 @@ import {
transformProjectArchive,
transformWorkflow,
} from '../graphql';
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
export function transformJob(job: Ios.Job): IosJobInput {
return {
@@ -32,7 +31,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
experimental: job.experimental,
mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig,
environment: buildProfileEnvironmentToEnvironment(job.environment),
environment: job.environment,
loggerLevel: job.loggerLevel
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
: undefined,
+4 -2
View File
@@ -1,13 +1,14 @@
import { Job, Metadata } from '@expo/eas-build-job';
import { Job, Metadata, version } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
import { getExpoApiBaseUrl } from '../api';
import Log from '../log';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.171';
const PLUGIN_PACKAGE_VERSION = version; // should match version of @expo/eas-build-job
export enum LocalBuildMode {
/**
@@ -60,6 +61,7 @@ export async function runLocalBuildAsync(
...env,
...process.env,
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
__API_SERVER_URL: getExpoApiBaseUrl(),
...(options.skipCleanup || options.skipNativeBuild
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
: {}),
+1 -3
View File
@@ -1,4 +1,4 @@
import { FingerprintSource, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
@@ -22,7 +22,6 @@ export async function collectMetadataAsync<T extends Platform>(
runtimeAndFingerprintMetadata: {
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
}
): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx);
@@ -36,7 +35,6 @@ export async function collectMetadataAsync<T extends Platform>(
sdkVersion: ctx.exp.sdkVersion,
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
fingerprintSource: runtimeAndFingerprintMetadata?.fingerprintSource,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
...channelObject,
distribution,
@@ -78,10 +78,12 @@ import { downloadAndMaybeExtractAppAsync } from '../utils/download';
import { truthy } from '../utils/expodash/filter';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { maybeWarnAboutUsageOveragesAsync } from '../utils/usage/checkForOverages';
import { Client } from '../vcs/vcs';
let metroConfigValidated = false;
let sdkVersionChecked = false;
let hasWarnedAboutUsageOverages = false;
export interface BuildFlags {
requestedPlatform: RequestedPlatform;
@@ -409,6 +411,15 @@ async function prepareAndStartBuildAsync({
env,
});
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
hasWarnedAboutUsageOverages = true;
Log.newLine();
await maybeWarnAboutUsageOveragesAsync({ graphqlClient, accountId: buildCtx.accountId });
if (!moreBuilds) {
Log.newLine();
}
}
if (moreBuilds) {
Log.newLine();
const appPlatform = toAppPlatform(buildProfile.platform);
@@ -1,26 +1,5 @@
import { BuildProfile } from '@expo/eas-json';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
type Environment = NonNullable<BuildProfile['environment']>;
const BuildProfileEnvironmentToEnvironment: Record<Environment, EnvironmentVariableEnvironment> = {
production: EnvironmentVariableEnvironment.Production,
preview: EnvironmentVariableEnvironment.Preview,
development: EnvironmentVariableEnvironment.Development,
};
export function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
return Object.values(EnvironmentVariableEnvironment).includes(
env as EnvironmentVariableEnvironment
);
}
export function buildProfileEnvironmentToEnvironment(
environment: BuildProfile['environment']
): EnvironmentVariableEnvironment | null {
if (!environment) {
return null;
}
return BuildProfileEnvironmentToEnvironment[environment];
export enum DefaultEnvironment {
Development = 'development',
Preview = 'preview',
Production = 'production',
}
@@ -6,8 +6,44 @@ import {
BuildFragment,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { link } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import formatFields from '../../utils/formatFields';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
export function formatGraphQLBuildArtifacts(build: BuildFragment): FormatFieldsItem[] {
const fields = [
{
label: 'Application Archive URL',
get value() {
switch (build.status) {
case GraphQLBuildStatus.InProgress:
return '<in progress>';
default: {
const url = build.artifacts?.buildUrl;
return url ? link(url) : 'null';
}
}
},
},
{
label: 'Build Artifacts URL',
get value() {
switch (build.status) {
case GraphQLBuildStatus.InProgress:
return '<in progress>';
default: {
const url = build.artifacts?.buildArtifactsUrl;
return url ? link(url) : 'null';
}
}
},
},
];
return fields.filter(({ value }) => value !== undefined && value !== null) as {
label: string;
value: string;
}[];
}
export function formatGraphQLBuild(build: BuildFragment): string {
const actor = getActorName(build);
@@ -81,10 +117,11 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Logs',
value: getBuildLogsUrl(build),
value: link(getBuildLogsUrl(build)),
},
...formatGraphQLBuildArtifacts(build),
{
label: 'Artifact',
label: 'Fingerprint',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
@@ -96,8 +133,8 @@ export function formatGraphQLBuild(build: BuildFragment): string {
case GraphQLBuildStatus.Errored:
return null;
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
const hash = build.fingerprint?.hash;
return hash ? chalk.green(hash) : chalk.red('not found');
}
default:
return null;
@@ -26,7 +26,6 @@ import {
CommandEvent,
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log, { link } from '../log';
import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs';
@@ -60,7 +59,7 @@ interface BaseGetContextAsyncArgs {
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
extends BaseGetContextAsyncArgs {
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
withServerSideEnvironment: string | null;
}
interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
@@ -1,5 +1,4 @@
import { Analytics } from '../../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import SessionManager from '../../user/SessionManager';
import { Client } from '../../vcs/vcs';
@@ -11,7 +10,7 @@ export interface ContextOptions {
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
withServerSideEnvironment?: string | null;
}
export default abstract class ContextField<T> {
@@ -1,23 +1,15 @@
import { ExpoGraphqlClient } from './createGraphqlClient';
import { EnvironmentVariableEnvironment } from '../../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
const cachedServerSideEnvironmentVariables: Record<
EnvironmentVariableEnvironment,
Record<string, string> | null
> = {
[EnvironmentVariableEnvironment.Development]: null,
[EnvironmentVariableEnvironment.Preview]: null,
[EnvironmentVariableEnvironment.Production]: null,
};
const cachedServerSideEnvironmentVariables: Record<string, Record<string, string> | undefined> = {};
export async function loadServerSideEnvironmentVariablesAsync({
environment,
projectId,
graphqlClient,
}: {
environment: EnvironmentVariableEnvironment;
environment: string;
projectId: string;
graphqlClient: ExpoGraphqlClient;
}): Promise<Record<string, string>> {
@@ -42,13 +34,13 @@ export async function loadServerSideEnvironmentVariablesAsync({
if (Object.keys(serverEnvVars).length > 0) {
Log.log(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment}" environment on EAS: ${Object.keys(
serverEnvVars
).join(', ')}.`
);
} else {
Log.log(
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment}" environment on EAS.`
);
}
Log.newLine();
+8 -26
View File
@@ -1,17 +1,5 @@
import { Flags } from '@oclif/core';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
// NOTE: not exactly true, but, provided mapToLowercase and upperCaseAsync
// are used in tandem, it saves on unnecessary typying in commands
async function upperCaseAsync<T>(input: string): Promise<T> {
return input.toUpperCase() as T;
}
function mapToLowercase<T extends string>(options: T[]): T[] {
return options.map(option => option.toLowerCase()) as T[];
}
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
@@ -23,21 +11,17 @@ export const EasNonInteractiveAndJsonFlags = {
};
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment",
parse: upperCaseAsync,
options: mapToLowercase([
EnvironmentVariableEnvironment.Development,
EnvironmentVariableEnvironment.Preview,
EnvironmentVariableEnvironment.Production,
]),
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
};
export const EASEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment>(EasEnvironmentFlagParameters),
environment: Flags.string({
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
}),
};
export const EASMultiEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment>({
environment: Flags.string({
...EasEnvironmentFlagParameters,
multiple: true,
}),
@@ -81,12 +65,10 @@ export const EasJsonOnlyFlag = {
};
export const EasUpdateEnvironmentFlag = {
environment: Flags.enum<EnvironmentVariableEnvironment | null>({
environment: Flags.string({
description:
'Environment to use for the server-side defined EAS environment variables during command execution.',
options: mapToLowercase(Object.values(EnvironmentVariableEnvironment)),
parse: upperCaseAsync,
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development"',
required: false,
default: null,
default: undefined,
}),
};
@@ -0,0 +1,131 @@
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../commands';
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../ora');
jest.mock('fs-extra');
describe('commands', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('cloneTemplateAsync', () => {
it('should clone the template project with ssh', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
const result = await cloneTemplateAsync(targetProjectDir);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
showOutput: false,
});
expect(result).toBe(finalTargetProjectDir);
});
it('should clone the template project with https', async () => {
const targetProjectDir = '/test/target-project';
const finalTargetProjectDir = '/test/final-target-project';
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir: finalTargetProjectDir,
});
const result = await cloneTemplateAsync(targetProjectDir);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
showOutput: false,
});
expect(result).toBe(finalTargetProjectDir);
});
});
describe('installProjectDependenciesAsync', () => {
it('should install the project dependencies', async () => {
const projectDir = '/test/project-directory';
jest.mocked(installDependenciesAsync).mockResolvedValue();
jest.mocked(runCommandAsync).mockResolvedValue();
await installProjectDependenciesAsync(projectDir, 'npm');
expect(installDependenciesAsync).toHaveBeenCalledWith({
outputLevel: 'none',
projectDir,
packageManager: 'npm',
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: false,
showSpinner: false,
});
});
});
describe('initializeGitRepositoryAsync', () => {
it('should initialize git repository', async () => {
const projectDir = '/test/project-dir';
await initializeGitRepositoryAsync(projectDir);
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['init'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['add', '.'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['commit', '-m', 'Initial commit'],
cwd: projectDir,
showOutput: false,
showSpinner: false,
});
});
});
});
@@ -0,0 +1,245 @@
import fs from 'fs-extra';
import { LogSpy } from './testUtils';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { promptAsync } from '../../../prompts';
import {
findAvailableProjectNameAsync,
generateProjectConfigAsync,
getAccountChoices,
promptForProjectAccountAsync,
} from '../configs';
jest.mock('../../../prompts');
jest.mock('../../../log');
jest.mock('fs-extra');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../utils', () => ({
printDirectory: jest.fn((dir: string) => dir),
}));
describe('configs', () => {
let logSpy: LogSpy;
beforeAll(() => {
logSpy = new LogSpy('withInfo');
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
logSpy.restore();
});
// Helper function to verify prompt was called with expected message
const expectPromptToHaveMessage = (expectedMessage: string): void => {
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expectedMessage,
})
);
};
const mockUserInput = (inputValue: any): void => {
jest.mocked(promptAsync).mockResolvedValue(inputValue);
};
describe('generateProjectConfigAsync', () => {
const mockOptions = {
graphqlClient: {} as ExpoGraphqlClient,
projectAccount: 'test-account',
};
describe('when no path is provided', () => {
it('should prompt for project name with default value', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'expo-project' });
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: 'expo-project',
projectDirectory: expect.stringContaining('/expo-project'),
});
expectPromptToHaveMessage('What would you like to name your project?');
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
initial: 'expo-project',
})
);
logSpy.expectLogToContain('Using project name: expo-project');
});
it('should prompt and use custom name entered by user', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'user-custom-name' });
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: 'user-custom-name',
projectDirectory: expect.stringContaining('/user-custom-name'),
});
expectPromptToHaveMessage('What would you like to name your project?');
logSpy.expectLogToContain('Using project name: user-custom-name');
});
it('should use shortid when prompted name exists', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true); // base name exists
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
mockUserInput({ name: 'existing-project' });
const result = await generateProjectConfigAsync(undefined, mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^existing-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining(result.projectName),
});
logSpy.expectLogToContain('Using project name:');
});
});
describe('when path is provided', () => {
it('should use absolute path and extract name from basename', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const absolutePath = '/absolute/path/to/my-project';
const result = await generateProjectConfigAsync(absolutePath, mockOptions);
expect(result).toEqual({
projectName: 'my-project',
projectDirectory: absolutePath,
});
expect(promptAsync).not.toHaveBeenCalled();
logSpy.expectLogToContain('Using project directory:');
});
it('should resolve relative path and extract name from basename', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const relativePath = 'some/relative/my-app';
const result = await generateProjectConfigAsync(relativePath, mockOptions);
expect(result).toEqual({
projectName: 'my-app',
projectDirectory: expect.stringContaining('some/relative/my-app'),
});
expect(promptAsync).not.toHaveBeenCalled();
logSpy.expectLogToContain('Using project directory:');
});
});
});
describe('promptForProjectAccountAsync', () => {
it('should prompt for a project account when there are multiple accounts', async () => {
const selectedAccount = 'jester';
mockUserInput({ account: { name: selectedAccount } });
const result = await promptForProjectAccountAsync(jester);
expect(result).toBe(selectedAccount);
expectPromptToHaveMessage('Which account should own this project?');
});
it('should automatically select single account with permissions', async () => {
const result = await promptForProjectAccountAsync(jester);
expect(result).toBe(jester.accounts[0].name);
});
});
describe('getAccountChoices', () => {
it('should return mapped account choices', () => {
const result = getAccountChoices(jester);
expect(result).toEqual([
{
title: 'other',
value: { name: 'other' },
disabled: true,
description:
'You do not have the required permissions to create projects on this account.',
},
{
title: 'jester',
value: { name: 'jester' },
},
]);
});
});
describe('findAvailableProjectNameAsync', () => {
const mockGraphqlClient = {} as ExpoGraphqlClient;
const mockOptions = {
graphqlClient: mockGraphqlClient,
projectAccount: 'jester',
};
it('should find first available name when checking both local and remote', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: 'test-project',
projectDirectory: '/base/path/test-project',
});
expect(fs.pathExists).toHaveBeenCalledTimes(1);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester',
'test-project'
);
});
it('should use shortid variant when base name exists locally', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true); // Base name exists locally
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
expect(fs.pathExists).toHaveBeenCalledTimes(1);
expect(fs.pathExists).toHaveBeenCalledWith('/base/path/test-project');
});
it('should use shortid variant when base name exists remotely', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id'); // Base name exists remotely
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(1);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester',
'test-project'
);
});
it('should use shortid variant when base name is taken both locally and remotely', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-'),
});
});
});
});
@@ -0,0 +1,369 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { getEASUpdateURL } from '../../../api';
import { AppFragment } from '../../../graphql/generated';
import { easCliVersion } from '../../../utils/easCli';
import {
cleanAndPrefix,
copyProjectTemplatesAsync,
generateAppConfigAsync,
generateEasConfigAsync,
updatePackageJsonAsync,
updateReadmeAsync,
} from '../projectFiles';
jest.mock('../../../api');
jest.mock('fs-extra');
describe('projectFiles', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readJson).mockImplementation(() => Promise.resolve({}));
jest.mocked(fs.writeJson).mockImplementation(() => Promise.resolve());
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
jest.mocked(fs.symlink).mockImplementation(() => Promise.resolve());
});
describe('cleanAndPrefix', () => {
it('should clean and prefix the string', () => {
expect(cleanAndPrefix('test-app-123', 'app')).toBe('testapp123');
expect(cleanAndPrefix('test@app#with$symbols', 'app')).toBe('testappwithsymbols');
expect(cleanAndPrefix('test-app-with-dashes', 'app')).toBe('testappwithdashes');
expect(cleanAndPrefix('TestApp', 'app')).toBe('appTestApp');
expect(cleanAndPrefix('100 App', 'app')).toBe('app100App');
});
it('should handle schemes', () => {
expect(cleanAndPrefix('test-app-123', 'scheme')).toBe('test-app-123');
expect(cleanAndPrefix('test@app#with$symbols', 'scheme')).toBe('testappwithsymbols');
expect(cleanAndPrefix('test+app.with-allowed.symbols', 'scheme')).toBe(
'test+app.with-allowed.symbols'
);
expect(cleanAndPrefix('100 App', 'scheme')).toBe('scheme100app');
expect(cleanAndPrefix('TestApp', 'scheme')).toBe('testapp');
expect(cleanAndPrefix('testApp2', 'scheme')).toBe('testapp2');
});
});
describe('generateAppConfigAsync', () => {
const projectDir = '/test/project-dir';
const mockUpdateUrl = 'https://u.expo.dev/test-project-id';
const createMockApp = (overrides: Partial<AppFragment> = {}): AppFragment => ({
id: 'test-project-id',
name: 'Test App',
fullName: '@testowner/test-app',
slug: 'test-app',
ownerAccount: {
id: 'testowner-account-id',
name: 'testowner',
users: [],
},
...overrides,
});
beforeEach(() => {
jest.mocked(getEASUpdateURL).mockReturnValue(mockUpdateUrl);
jest.mocked(fs.readJson).mockResolvedValue({
expo: {} as ExpoConfig,
});
});
it('should generate the app config', async () => {
const mockApp = createMockApp();
jest.mocked(fs.readJson).mockResolvedValue({
expo: {
name: 'value-to-override',
slug: 'value-to-override',
icon: 'value-to-keep',
} as ExpoConfig,
});
await generateAppConfigAsync(projectDir, mockApp);
expect(getEASUpdateURL).toHaveBeenCalledWith('test-project-id', null);
const expectedConfig = {
expo: {
name: 'Test App',
slug: 'test-app',
scheme: 'testapp',
icon: 'value-to-keep',
extra: {
eas: {
projectId: 'test-project-id',
},
},
owner: 'testowner',
updates: {
url: mockUpdateUrl,
},
runtimeVersion: {
policy: 'appVersion',
},
ios: {
bundleIdentifier: 'com.testowner.testapp',
},
android: {
package: 'com.testowner.testapp',
},
},
};
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
spaces: 2,
});
});
it('should handle invalid characters in the bundle identifier', async () => {
const mockApp = createMockApp({
slug: 'test-app-with-dashes',
ownerAccount: {
id: 'test-owner-account-id',
name: 'test-owner',
users: [],
},
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner.testappwithdashes',
}),
}),
}),
{ spaces: 2 }
);
});
it('should handle invalid characters in the owner account name', async () => {
const mockApp = createMockApp({
slug: 'testapp',
ownerAccount: {
id: 'test-owner-123-account-id',
name: 'test-owner-123',
users: [],
},
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner123.testapp',
}),
}),
}),
{ spaces: 2 }
);
});
it('should handle invalid characters in the slug', async () => {
const mockApp = createMockApp({
slug: 'test@app#with$symbols',
fullName: '@testowner/test@app#with$symbols',
});
await generateAppConfigAsync(projectDir, mockApp);
expect(fs.writeJson).toHaveBeenCalledWith(
`${projectDir}/app.json`,
expect.objectContaining({
expo: expect.objectContaining({
ios: expect.objectContaining({
bundleIdentifier: 'com.testowner.testappwithsymbols',
}),
}),
}),
{ spaces: 2 }
);
});
});
describe('generateEasConfigAsync', () => {
const projectDir = '/test/project-dir';
it('should generate the eas config', async () => {
const expectedEasConfig = {
cli: {
version: `>= ${easCliVersion}`,
appVersionSource: 'remote',
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
android: { image: 'latest' },
ios: { image: 'latest' },
},
'development-simulator': {
extends: 'development',
ios: { simulator: true },
},
preview: {
distribution: 'internal',
channel: 'main',
android: { image: 'latest' },
ios: { image: 'latest' },
},
production: {
channel: 'production',
autoIncrement: true,
android: { image: 'latest' },
ios: { image: 'latest' },
},
},
submit: {
production: {},
},
};
await generateEasConfigAsync(projectDir);
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
spaces: 2,
});
});
});
describe('updatePackageJsonAsync', () => {
it('should update the package.json', async () => {
jest.mocked(fs.readJson).mockResolvedValue({
name: 'test-app',
version: '1.0.0',
});
const projectDir = '/test/project-dir';
await updatePackageJsonAsync(projectDir);
const expectedPackageJson = {
name: 'test-app',
version: '1.0.0',
scripts: {
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
},
};
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/package.json`, expectedPackageJson, {
spaces: 2,
});
});
});
describe('copyProjectTemplatesAsync', () => {
it('should copy the project templates', async () => {
const projectDir = '/test/project-dir';
await copyProjectTemplatesAsync(projectDir);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('templates'),
projectDir,
expect.objectContaining({
errorOnExist: false,
overwrite: true,
filter: expect.any(Function),
})
);
});
});
describe('updateReadmeAsync', () => {
const projectDir = '/test/project-dir';
const readmeTemplatePath = expect.stringContaining('templates/readme-additions.md');
const projectReadmePath = `${projectDir}/README.md`;
const mockReadmeTemplate = `## Get started
To start the app, in your terminal run:
\`\`\`bash
npm run start
\`\`\`
## Workflows
`;
const mockExistingReadme = `# My App
## Get started
Follow these steps to get started with the app.
## Installation
Install the dependencies first.`;
beforeEach(() => {
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
const content = filePath.includes('readme-additions.md')
? mockReadmeTemplate
: filePath === projectReadmePath
? mockExistingReadme
: '';
return Promise.resolve(content);
});
});
it('should read the existing readme and merge the template content', async () => {
await updateReadmeAsync(projectDir, 'npm');
expect(fs.readFile).toHaveBeenCalledWith(readmeTemplatePath, 'utf8');
expect(fs.readFile).toHaveBeenCalledWith(projectReadmePath, 'utf8');
const headings = ['# My App', '## Get started', '## Workflows'];
expect(fs.writeFile).toHaveBeenCalledWith(
projectReadmePath,
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
);
});
it('should replace npm run with package manager specific commands', async () => {
await updateReadmeAsync(projectDir, 'yarn');
expect(fs.writeFile).toHaveBeenCalledWith(
projectReadmePath,
expect.stringContaining('yarn run start')
);
});
it("should handle the case when 'Get started' section does not exist", async () => {
const readmeWithoutGetStarted = `# My App
This is my awesome app.
## Installation
Install the dependencies first.`;
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
const content = filePath.includes('readme-additions.md')
? mockReadmeTemplate
: filePath === projectReadmePath
? readmeWithoutGetStarted
: '';
return Promise.resolve(content);
});
await updateReadmeAsync(projectDir, 'npm');
const headings = ['# My App', '## Installation', '## Get started', '## Workflows'];
expect(fs.writeFile).toHaveBeenCalledWith(
projectReadmePath,
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
);
});
});
});
@@ -0,0 +1,26 @@
import Log from '../../../log';
export class LogSpy {
private readonly logSpy: jest.SpyInstance;
constructor(logMethod: 'warn' | 'log' | 'withTick' | 'withInfo') {
this.logSpy = jest.spyOn(Log, logMethod);
}
getLogOutput(): string[] {
return this.logSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
}
expectLogToContain(message: string): void {
const output = this.getLogOutput();
// strip out ANSI codes and special characters like the tick
const outputWithoutAnsi = output.map(line =>
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
);
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
}
restore(): void {
this.logSpy.mockRestore();
}
}
@@ -0,0 +1,85 @@
import path from 'path';
import { printDirectory } from '../utils';
describe('utils', () => {
describe('printDirectory', () => {
let cwdSpy: jest.SpyInstance;
beforeEach(() => {
// Mock process.cwd() to return a predictable value
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/Users/test/projects');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should return "." for the current directory', () => {
const result = printDirectory('/Users/test/projects');
expect(result).toBe('.');
});
it('should return relative path with "./" prefix for subdirectory', () => {
const result = printDirectory('/Users/test/projects/my-app');
expect(result).toBe('./my-app');
});
it('should return relative path with "./" prefix for nested subdirectory', () => {
const result = printDirectory('/Users/test/projects/nested/my-app');
expect(result).toBe('./nested/my-app');
});
it('should return absolute path for directory outside cwd', () => {
const result = printDirectory('/Users/other/projects/my-app');
expect(result).toBe('/Users/other/projects/my-app');
});
it('should return absolute path for parent directory', () => {
const result = printDirectory('/Users/test');
expect(result).toBe('/Users/test');
});
it('should handle relative paths and convert them correctly', () => {
// Relative path resolves to absolute first, then converts back to relative
const result = printDirectory('my-app');
expect(result).toBe('./my-app');
});
it('should handle relative paths with "./" already', () => {
const result = printDirectory('./my-app');
expect(result).toBe('./my-app');
});
it('should handle relative paths with "../" (goes outside cwd)', () => {
const result = printDirectory('../other-project');
// This goes outside cwd, so should return absolute path
expect(result).toBe('/Users/test/other-project');
});
it('should handle complex relative paths', () => {
const result = printDirectory('./nested/deep/project');
expect(result).toBe('./nested/deep/project');
});
it('should normalize paths correctly', () => {
const result = printDirectory('/Users/test/projects/./my-app/../my-app');
expect(result).toBe('./my-app');
});
it('should handle paths in real cwd', () => {
// Restore mock to test with real cwd
cwdSpy.mockRestore();
const realCwd = process.cwd();
// Test with current directory
const result1 = printDirectory(realCwd);
expect(result1).toBe('.');
// Test with subdirectory
const subDir = path.join(realCwd, 'test-dir');
const result2 = printDirectory(subDir);
expect(result2).toBe('./test-dir');
});
});
});
@@ -0,0 +1,80 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { printDirectory } from './utils';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { ora } from '../../ora';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
const spinner = ora(
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
).start();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir,
cloneMethod,
showOutput: false,
});
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
return finalTargetProjectDirectory;
}
export async function installProjectDependenciesAsync(
projectDir: string,
packageManager: PackageManager
): Promise<void> {
const spinner = ora(`${chalk.bold('Installing project dependencies')}`).start();
await installDependenciesAsync({
outputLevel: 'none',
projectDir,
packageManager,
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
spinner.text = `Installing ${chalk.bold(dependency)}`;
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
showSpinner: false,
});
}
spinner.succeed(`Installed project dependencies`);
}
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
const spinner = ora(`${chalk.bold('Initializing Git repository')}`).start();
await fs.remove(path.join(projectDir, '.git'));
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
for (const args of commands) {
await runCommandAsync({
cwd: projectDir,
command: 'git',
args,
showOutput: false,
showSpinner: false,
});
}
spinner.succeed(`Initialized Git repository`);
}
@@ -0,0 +1,201 @@
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import { printDirectory } from './utils';
import { Role } from '../../graphql/generated';
import Log from '../../log';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
function validateProjectPath(resolvedPath: string): void {
const normalizedPath = path.normalize(resolvedPath);
// Check for path traversal attempts
if (normalizedPath.includes('..')) {
throw new Error(`Invalid project path: "${resolvedPath}". Path traversal is not allowed.`);
}
// Ensure we're not trying to create a project in system directories
const systemDirs = ['/bin', '/sbin', '/etc', '/usr', '/var', '/sys', '/proc', '/dev'];
const isSystemDir = systemDirs.some(
dir => normalizedPath === dir || normalizedPath.startsWith(dir + path.sep)
);
if (isSystemDir) {
throw new Error(
`Invalid project path: "${resolvedPath}". Cannot create projects in system directories.`
);
}
}
export async function generateProjectConfigAsync(
pathArg: string | undefined,
options: {
graphqlClient: ExpoGraphqlClient;
projectAccount: string;
}
): Promise<{
projectName: string;
projectDirectory: string;
}> {
let baseName = 'expo-project';
let parentDirectory = process.cwd();
if (pathArg) {
const resolvedPath = path.isAbsolute(pathArg) ? pathArg : path.resolve(process.cwd(), pathArg);
validateProjectPath(resolvedPath);
baseName = path.basename(resolvedPath);
parentDirectory = path.dirname(resolvedPath);
} else {
baseName = (
await promptAsync({
type: 'text',
name: 'name',
message: 'What would you like to name your project?',
initial: 'expo-project',
})
).name;
}
// Find an available name checking both local filesystem and remote server
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
baseName,
parentDirectory,
options
);
Log.withInfo(`Using project name: ${projectName}`);
Log.withInfo(`Using project directory: ${printDirectory(projectDirectory)}`);
return {
projectName,
projectDirectory,
};
}
function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
const permissionsMap = new Map<string, boolean>();
for (const account of actor.accounts) {
const hasPermission =
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
permissionsMap.set(account.name, hasPermission);
}
return permissionsMap;
}
export async function promptForProjectAccountAsync(actor: Actor): Promise<string> {
const permissionsMap = getAccountPermissionsMap(actor);
// If only one account, use it (if has permissions)
if (actor.accounts.length === 1) {
const account = actor.accounts[0];
if (permissionsMap.get(account.name)) {
return account.name;
}
throw new Error(
`You don't have permission to create projects on your only available account (${account.name}).`
);
}
// Multiple accounts - prompt user to select one with permissions
return (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices: getAccountChoices(actor, permissionsMap),
})
).account.name;
}
export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boolean>): Choice[] {
const permissions = permissionsMap ?? getAccountPermissionsMap(actor);
const sortedAccounts = [...actor.accounts].sort((a, _b) => (a.ownerUserActor ? 1 : -1));
return sortedAccounts.map(account => {
const isPersonalAccount = !!account.ownerUserActor && account.ownerUserActor.id === actor.id;
const isTeamAccount = !!account.ownerUserActor && account.ownerUserActor.id !== actor.id;
const accountDisplayName = isPersonalAccount
? `${account.name} (Limited - Personal Account)`
: isTeamAccount
? `${account.name} (Limited - Team Account)`
: account.name;
const disabled = !permissions.get(account.name);
return {
title: accountDisplayName,
value: { name: account.name },
...(disabled && {
disabled: true,
description: 'You do not have the required permissions to create projects on this account.',
}),
};
});
}
async function verifyProjectDoesNotExistAsync(
graphqlClient: ExpoGraphqlClient,
accountName: string,
projectName: string,
{ silent = false }: { silent?: boolean } = {}
): Promise<boolean> {
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
const doesNotExist = existingProjectId === null;
if (!doesNotExist && !silent) {
Log.warn(`Project @${accountName}/${projectName} already exists on the server.`);
}
return doesNotExist;
}
/**
* Finds an available project name that doesn't conflict with either:
* Local filesystem (directory already exists)
* Remote server (project already exists on Expo)
*/
export async function findAvailableProjectNameAsync(
baseName: string,
parentDirectory: string,
{
graphqlClient,
projectAccount,
}: {
graphqlClient: ExpoGraphqlClient;
projectAccount: string;
}
): Promise<{ projectName: string; projectDirectory: string }> {
let projectName = baseName;
let projectDirectory = path.join(parentDirectory, projectName);
const localExists = await fs.pathExists(projectDirectory);
const remoteAvailable = await verifyProjectDoesNotExistAsync(
graphqlClient,
projectAccount,
projectName
);
if (localExists || !remoteAvailable) {
projectName = `${baseName}-${nanoid(6)}`;
projectDirectory = path.join(parentDirectory, projectName);
}
return {
projectName,
projectDirectory,
};
}
@@ -0,0 +1,176 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../api';
import { AppFragment } from '../../graphql/generated';
import { PackageManager } from '../../onboarding/installDependencies';
import { easCliVersion } from '../../utils/easCli';
// Android package names must start with a lowercase letter
// schemes must start with a lowercase letter and can only contain lowercase letters, digits, "+", "." or "-"
export function cleanAndPrefix(_string: string, type: 'user' | 'app' | 'scheme'): string {
let string = _string;
let pattern = /[^A-Za-z0-9]/g;
if (type === 'scheme') {
string = _string.toLowerCase();
pattern = /[^a-z0-9+.-]/g;
}
const prefix = /^[^a-z]/.test(string) ? type : '';
const cleaned = string.replaceAll(pattern, '');
return prefix + cleaned;
}
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
const user = cleanAndPrefix(app.ownerAccount.name, 'user');
const slug = cleanAndPrefix(app.slug, 'app');
const scheme = cleanAndPrefix(app.name ?? app.slug, 'scheme');
const bundleIdentifier = `com.${user}.${slug}`;
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
const { expo: baseExpoConfig } = await fs.readJson(path.join(projectDir, 'app.json'));
const expoConfig: ExpoConfig = {
name: app.name ?? app.slug,
slug: app.slug,
scheme,
extra: {
eas: {
projectId: app.id,
},
},
owner: app.ownerAccount.name,
updates: {
url: updateUrl,
},
runtimeVersion: {
policy: 'appVersion',
},
ios: {
bundleIdentifier,
},
android: {
package: bundleIdentifier,
},
};
const mergedConfig = merge(baseExpoConfig, expoConfig);
const appJsonPath = path.join(projectDir, 'app.json');
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
}
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
const easBuildGitHubConfig = {
android: {
image: 'latest',
},
ios: {
image: 'latest',
},
};
const easJson: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
appVersionSource: AppVersionSource.REMOTE,
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
...easBuildGitHubConfig,
},
'development-simulator': {
extends: 'development',
ios: {
simulator: true,
},
},
preview: {
distribution: 'internal',
channel: 'main',
...easBuildGitHubConfig,
},
production: {
channel: 'production',
autoIncrement: true,
...easBuildGitHubConfig,
},
},
submit: {
production: {},
},
};
const easJsonPath = path.join(projectDir, 'eas.json');
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
}
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = await fs.readJson(packageJsonPath);
if (!packageJson.scripts) {
packageJson.scripts = {};
}
packageJson.scripts.draft = 'npx eas-cli@latest workflow:run create-draft.yml';
packageJson.scripts['development-builds'] =
'npx eas-cli@latest workflow:run create-development-builds.yml';
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates');
// Copy everything from templates to projectDir, skipping readme-additions.md
await fs.copy(templatesSourceDir, projectDir, {
overwrite: true,
errorOnExist: false,
filter: (src: string) => {
return !src.endsWith('readme-additions.md');
},
});
}
export async function updateReadmeAsync(
projectDir: string,
packageManager: PackageManager
): Promise<void> {
const readmeTemplatePath = path.join(__dirname, 'templates', 'readme-additions.md');
const projectReadmePath = path.join(projectDir, 'README.md');
const readmeAdditions = await fs.readFile(readmeTemplatePath, 'utf8');
const existingReadme = await fs.readFile(projectReadmePath, 'utf8');
const targetSection = '## Get started';
const sectionIndex = existingReadme.indexOf(targetSection);
let mergedReadme: string;
if (sectionIndex !== -1) {
// Find the next ## section after "## Get started"
const afterTargetSection = existingReadme.substring(sectionIndex);
const nextSectionMatch = afterTargetSection.match(/\n## /);
let endIndex = existingReadme.length;
if (nextSectionMatch?.index !== undefined) {
// Replace from "## Get started" to the next "##" section
endIndex = sectionIndex + nextSectionMatch.index;
}
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
const afterSection = existingReadme.substring(endIndex);
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
} else {
// No "Get started" section found, append the template to the existing README
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim();
}
mergedReadme = mergedReadme.replaceAll('npm run', `${packageManager} run`);
await fs.writeFile(projectReadmePath, mergedReadme);
}
@@ -0,0 +1,21 @@
name: Create development builds
jobs:
android_development_build:
name: Build Android
type: build
params:
platform: android
profile: development
ios_device_development_build:
name: Build iOS device
type: build
params:
platform: ios
profile: development
ios_simulator_development_build:
name: Build iOS simulator
type: build
params:
platform: ios
profile: development-simulator
@@ -0,0 +1,15 @@
name: Create draft
on:
push:
branches: ['*']
jobs:
publish_preview_update:
name: Publish preview update
type: update
params:
branch: ${{ github.ref_name || 'test' }}
deploy_website:
name: Deploy website
type: deploy
@@ -0,0 +1,68 @@
name: Deploy to production
on:
push:
branches: ['main']
jobs:
fingerprint:
name: Fingerprint
type: fingerprint
get_android_build:
name: Check for existing android build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
profile: production
get_ios_build:
name: Check for existing ios build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: production
build_android:
name: Build Android
needs: [get_android_build]
if: ${{ !needs.get_android_build.outputs.build_id }}
type: build
params:
platform: android
profile: production
build_ios:
name: Build iOS
needs: [get_ios_build]
if: ${{ !needs.get_ios_build.outputs.build_id }}
type: build
params:
platform: ios
profile: production
submit_android_build:
name: Submit Android Build
needs: [build_android]
type: submit
params:
build_id: ${{ needs.build_android.outputs.build_id }}
submit_ios_build:
name: Submit iOS Build
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
publish_android_update:
name: Publish Android update
needs: [get_android_build]
if: ${{ needs.get_android_build.outputs.build_id }}
type: update
params:
branch: production
platform: android
publish_ios_update:
name: Publish iOS update
needs: [get_ios_build]
if: ${{ needs.get_ios_build.outputs.build_id }}
type: update
params:
branch: production
platform: ios
@@ -0,0 +1,163 @@
# AGENTS.md
## Project Overview
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
## Documentation Resources
When working on this project, **always consult the official Expo documentation** available at:
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
- Expo APIs and best practices
- Expo Router navigation patterns
- EAS Build, Submit, and Update workflows
- Expo SDK modules and their usage
- Development and deployment processes
## Project Structure
```
/
├── app/ # Expo Router file-based routing
│ ├── (tabs)/ # Tab-based navigation screens
│ │ ├── index.tsx # Home screen
│ │ ├── explore.tsx # Explore screen
│ │ └── _layout.tsx # Tabs layout
│ ├── _layout.tsx # Root layout with theme provider
│ └── modal.tsx # Modal screen example
├── components/ # Reusable React components
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
│ └── ... # Feature components (themed, haptic, parallax)
├── constants/ # App-wide constants (theme, colors)
├── hooks/ # Custom React hooks (color scheme, theme)
├── assets/ # Static assets (images, fonts)
├── scripts/ # Utility scripts (reset-project)
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
├── app.json # Expo configuration
├── eas.json # EAS Build/Submit configuration
└── package.json # Dependencies and scripts
```
## Essential Commands
### Development
```bash
npx expo start # Start dev server
npx expo start --clear # Clear cache and start dev server
npx expo install <package> # Install packages with compatible versions
npx expo install --check # Check which installed packages need to be updated
npx expo install --fix # Automatically update any invalid package versions
npm run development-builds # Create development builds (workflow)
npm run reset-project # Reset to blank template
```
### Building & Testing
```bash
npx expo doctor # Check project health and dependencies
npx expo lint # Run ESLint
npm run draft # Publish preview update and website (workflow)
```
### Production
```bash
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
npm run deploy # Deploy to production (workflow)
```
## Development Guidelines
### Code Style & Standards
- **TypeScript First**: Use TypeScript for all new code with strict type checking
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
- **React 19 Patterns**: Follow modern React patterns including:
- Function components with hooks
- Enable React Compiler
- Proper dependency arrays in useEffect
- Memoization when appropriate (useMemo, useCallback)
- Error boundaries for better error handling
### Navigation & Routing
- Use **Expo Router** for all navigation
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
- Docs: https://docs.expo.dev/router/introduction/
### Recommended Libraries
- **Navigation**: `expo-router` for navigation
- **Images**: `expo-image` for optimized image handling and caching
- **Animations**: `react-native-reanimated` for performant animations on native thread
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
## Debugging & Development Tools
### DevTools Integration
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
- **Network Inspection**: Monitor API calls and network requests in DevTools
- **Element Inspector**: Debug component hierarchy and styles
- **Performance Profiler**: Identify performance bottlenecks
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
### Testing & Quality Assurance
#### Automated Testing with MCP Tools
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
- **Component Testing**: Add `testID` props to components for automation
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
## EAS Workflows CI/CD
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
When working with EAS Workflows, **always refer to**:
- https://docs.expo.dev/eas/workflows/ for workflow examples
- The `.eas/workflows/` directory for existing workflow configurations
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
### Build Profiles (eas.json)
- **development**: Development builds with dev client
- **development-simulator**: Development builds for iOS simulator
- **preview**: Internal distribution preview builds
- **production**: Production builds with auto-increment
## Troubleshooting
### Expo Go Errors & Development Builds
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
## AI Agent Instructions
When working on this project:
1. **Always start by consulting the appropriate documentation**:
- For general Expo questions: https://docs.expo.dev/llms-full.txt
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
2. **Understand before implementing**: Read the relevant docs section before writing code
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
@@ -0,0 +1,9 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Note**: This project uses `AGENTS.md` files for detailed guidance.
## Primary Reference
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
@@ -0,0 +1,36 @@
## Get started
To start the app, in your terminal run:
```bash
npm run start
```
In the output, you'll find options to open the app in:
- [a development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [an Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [an iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Workflows
This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) to automate some development and release processes. These commands are set up in [`package.json`](./package.json) and can be run using NPM scripts in your terminal.
### Previews
Run `npm run draft` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
### Development Builds
Run `npm run development-builds` to [create a development build](https://docs.expo.dev/eas/workflows/examples/create-development-builds/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/create-development-builds/#prerequisites) to ensure you have the correct emulator setup on your machine.
### Production Deployments
Run `npm run deploy` to [deploy to production](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/#prerequisites) to ensure you're set up to submit to the Apple and Google stores.
## Hosting
Expo offers hosting for websites and API functions via EAS Hosting. See the [Getting Started](https://docs.expo.dev/eas/hosting/get-started/) guide to learn more.
@@ -0,0 +1,20 @@
import path from 'path';
/**
* Formats a directory path for display.
* If the directory is within the current working directory, returns a relative path.
* Otherwise, returns the absolute path.
*/
export function printDirectory(directory: string): string {
const cwd = process.cwd();
const absoluteDir = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
const relativePath = path.relative(cwd, absoluteDir);
// If the relative path doesn't start with '..' it means it's within or at the cwd
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
return relativePath !== '' ? `./${relativePath}` : '.';
}
return absoluteDir;
}
@@ -0,0 +1,269 @@
import { ExpoConfig } from '@expo/config';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
import BuildConfigure from '../../commands/build/configure';
import UpdateConfigure from '../../commands/update/configure';
import Log from '../../log';
import { promptAsync } from '../../prompts';
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfileNames = new Set(
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
);
return buildProfileNames;
}
export async function getBuildProfileAsync(
projectDir: string,
platform: Platform,
profileName: string
): Promise<BuildProfile<Platform>> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
profileName
);
return buildProfile;
}
export async function buildProfilesFromProjectAsync(
projectDir: string
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
new Map();
for (const profileName of buildProfileNames) {
const iosBuildProfile = (await getBuildProfileAsync(
projectDir,
Platform.IOS,
profileName
)) as IosBuildProfile;
const androidBuildProfile = (await getBuildProfileAsync(
projectDir,
Platform.ANDROID,
profileName
)) as AndroidBuildProfile;
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
}
return buildProfiles;
}
export function isBuildProfileForDevelopment(
buildProfile: BuildProfile<Platform>,
platform: Platform
): boolean {
if (buildProfile.developmentClient) {
return true;
}
if (platform === Platform.ANDROID) {
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
}
if (platform === Platform.IOS) {
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
}
return false;
}
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
return buildProfile.simulator ?? false;
}
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
projectDir: string,
buildProfileName: string
): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build = {
...easJsonRawObject.build,
[buildProfileName]: {
developmentClient: true,
distribution: 'internal',
android: {
gradleCommand: ':app:assembleDebug',
},
},
};
return easJsonRawObject;
});
await easJsonAccessor.writeAsync();
}
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir: string,
buildProfileName: string,
simulator: boolean
): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build = {
...easJsonRawObject.build,
[buildProfileName]: {
developmentClient: true,
distribution: 'internal',
ios: {
buildConfiguration: 'Debug',
simulator,
},
},
};
return easJsonRawObject;
});
await easJsonAccessor.writeAsync();
}
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
projectDir: string
): Promise<boolean> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
let profileAdded = false;
easJsonAccessor.patch(easJsonRawObject => {
if (!easJsonRawObject.build?.production) {
profileAdded = true;
easJsonRawObject.build = {
...(easJsonRawObject.build ?? {}),
production: {},
};
// Also add the profile to submit
easJsonRawObject.submit = {
...(easJsonRawObject.submit ?? {}),
production: {},
};
}
return easJsonRawObject;
});
if (profileAdded) {
Log.log('Added missing production build profile to eas.json');
}
await easJsonAccessor.writeAsync();
return profileAdded;
}
export async function hasBuildConfigureBeenRunAsync({
projectDir,
expoConfig,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
// Is there a project ID in the Expo config?
if (!expoConfig.extra?.eas?.projectId) {
return false;
}
// Is there an eas.json?
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
try {
await easJsonAccessor.readAsync();
} catch {
return false;
}
return true;
}
export async function hasUpdateConfigureBeenRunAsync({
projectDir,
expoConfig,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
// Does the Expo config have an updates URL?
if (!expoConfig.updates?.url) {
return false;
}
// Does at least one build profile have a channel?
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
try {
const easJson = await easJsonAccessor.readAsync();
return Object.values(easJson.build ?? {}).some(buildProfile => !!buildProfile.channel);
} catch {
return false;
}
}
/**
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
*/
export async function runUpdateConfigureIfNeededAsync({
projectDir,
expoConfig,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
if (
await hasUpdateConfigureBeenRunAsync({
projectDir,
expoConfig,
})
) {
return true;
}
const nextStep = (
await promptAsync({
type: 'select',
name: 'nextStep',
message:
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
choices: [
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
{ title: 'Choose a different workflow template', value: 'repeat' },
],
})
).nextStep;
switch (nextStep) {
case 'configure':
Log.newLine();
await UpdateConfigure.run([]);
return true;
case 'proceed':
return true;
default:
return false;
}
}
/**
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
*/
export async function runBuildConfigureIfNeededAsync({
projectDir,
expoConfig,
}: {
projectDir: string;
expoConfig: ExpoConfig;
}): Promise<boolean> {
if (
await hasBuildConfigureBeenRunAsync({
projectDir,
expoConfig,
})
) {
return true;
}
const nextStep = (
await promptAsync({
type: 'select',
name: 'nextStep',
message:
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
choices: [
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
{ title: 'Choose a different workflow template', value: 'repeat' },
],
})
).nextStep;
switch (nextStep) {
case 'configure':
Log.newLine();
await BuildConfigure.run(['-p', 'all']);
return true;
case 'proceed':
return true;
default:
return false;
}
}
@@ -0,0 +1,536 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import {
addAndroidDevelopmentBuildProfileToEasJsonAsync,
addIosDevelopmentBuildProfileToEasJsonAsync,
addProductionBuildProfileToEasJsonIfNeededAsync,
buildProfilesFromProjectAsync,
hasBuildConfigureBeenRunAsync,
hasUpdateConfigureBeenRunAsync,
isBuildProfileForDevelopment,
isIosBuildProfileForSimulator,
} from './buildProfileUtils';
import Log, { link } from '../../log';
import { confirmAsync } from '../../prompts';
import { easCliVersion } from '../../utils/easCli';
export enum WorkflowStarterName {
BUILD = 'build',
UPDATE = 'update',
CUSTOM = 'custom',
DEPLOY = 'deploy',
}
export type WorkflowStarter = {
name: WorkflowStarterName;
displayName: string;
defaultFileName: string;
template: any;
header: string;
nextSteps?: string[];
};
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
const CUSTOM_TEMPLATE = {
name: 'Custom workflow',
on: {
push: {
branches: ['main'],
},
},
jobs: {
custom_build: {
name: 'Custom job',
steps: [
{
uses: 'eas/checkout',
},
{
name: 'Hello World',
id: 'hello_world',
run: '# Custom script\necho "Hello, World"\n',
},
],
},
},
};
const CUSTOM_TEMPLATE_HEADER = `
# Custom job
#
# This workflow shows how to write custom jobs.
# It contains a predefined workflow step and a shell command to print "Hello, World!".
#
# Key features:
# - Triggers on pushes to the main branch.
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run custom.yml
# - Runs eas/checkout then a custom "echo" command
#
# For detailed documentation on workflow syntax and available step types, visit:
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
#
${createdByEASCLI}
#
`;
const BUILD_TEMPLATE = {
name: 'Create development builds',
on: {
push: {
branches: ['main'],
},
},
jobs: {},
};
const BUILD_TEMPLATE_HEADER = `
# Create development builds
#
# This workflow shows how to create development builds.
#
# Key features:
# - Can be triggered manually with eas workflow:run create-development-builds.yml
# - Runs the pre-packaged build job to create Android and iOS development builds
# for Android emulators, Android and iOS devices, and iOS simulators
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/develop/development-builds/introduction/
#
${createdByEASCLI}
#
`;
const PUBLISH_UPDATE_TEMPLATE = {
name: 'Publish preview update',
on: {
push: {
branches: ['*'],
},
},
jobs: {
publish_preview_update: {
name: 'Publish preview update',
type: 'update',
params: {
branch: '${{ github.ref_name || "test" }}',
},
},
},
};
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
# Publish preview update
#
# This workflow shows how to publish preview updates.
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
#
# Key features:
# - Triggers on pushes to all branches
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
# - Runs the pre-packaged update job
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
#
${createdByEASCLI}
#
`;
const DEPLOY_TEMPLATE = {
name: 'Deploy to production',
on: {
push: {
branches: ['main'],
},
},
jobs: {
fingerprint: {
name: 'Fingerprint',
type: 'fingerprint',
},
get_android_build: {
name: 'Check for existing android build',
needs: ['fingerprint'],
type: 'get-build',
params: {
fingerprint_hash: '${{ needs.fingerprint.outputs.android_fingerprint_hash }}',
profile: 'production',
},
},
get_ios_build: {
name: 'Check for existing ios build',
needs: ['fingerprint'],
type: 'get-build',
params: {
fingerprint_hash: '${{ needs.fingerprint.outputs.ios_fingerprint_hash }}',
profile: 'production',
},
},
build_android: {
name: 'Build Android',
needs: ['get_android_build'],
if: '${{ !needs.get_android_build.outputs.build_id }}',
type: 'build',
params: {
platform: 'android',
profile: 'production',
},
},
build_ios: {
name: 'Build iOS',
needs: ['get_ios_build'],
if: '${{ !needs.get_ios_build.outputs.build_id }}',
type: 'build',
params: {
platform: 'ios',
profile: 'production',
},
},
submit_android_build: {
name: 'Submit Android Build',
needs: ['build_android'],
type: 'submit',
params: {
build_id: '${{ needs.build_android.outputs.build_id }}',
},
},
submit_ios_build: {
name: 'Submit iOS Build',
needs: ['build_ios'],
type: 'submit',
params: {
build_id: '${{ needs.build_ios.outputs.build_id }}',
},
},
publish_android_update: {
name: 'Publish Android update',
needs: ['get_android_build'],
if: '${{ needs.get_android_build.outputs.build_id }}',
type: 'update',
params: {
branch: 'production',
platform: 'android',
},
},
publish_ios_update: {
name: 'Publish iOS update',
needs: ['get_ios_build'],
if: '${{ needs.get_ios_build.outputs.build_id }}',
type: 'update',
params: {
branch: 'production',
platform: 'ios',
},
},
},
};
const DEPLOY_TEMPLATE_HEADER = `
# Deploy to production
#
# This workflow shows how to build and submit to app stores.
#
# Key features:
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
#
# For a detailed guide on using this workflow, visit:
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
`;
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
return `A build job in this workflow uses the build profile "${buildProfileName}"
to build your app for real devices, and may require credentials.
You can configure your credentials with the command "eas credentials".
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
`
.trim()
.trimStart();
}
function nextStepForAppSubmission(): string {
return `This workflow includes a job to submit your app to app stores.
You will need to configure your App Store/Play Store credentials
before you can run this workflow.
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
`
.trim()
.trimStart();
}
export function howToRunWorkflow(
workflowFileName: string,
workflowStarter: WorkflowStarter
): string {
const lines = [
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
];
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
const branches = workflowStarter.template.on.push.branches;
if (branches.length === 1 && branches[0] === '*') {
lines.push(
'This workflow is also configured to run automatically when code is pushed to any branch.'
);
} else if (branches.length === 1) {
lines.push(
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
);
} else {
lines.push(
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
', '
)}.`
);
}
}
return lines.join('\n');
}
export const workflowStarters: WorkflowStarter[] = [
{
displayName: 'Custom',
name: WorkflowStarterName.CUSTOM,
defaultFileName: 'custom.yml',
template: CUSTOM_TEMPLATE,
header: CUSTOM_TEMPLATE_HEADER,
},
{
displayName: 'Create development builds',
name: WorkflowStarterName.BUILD,
defaultFileName: 'create-development-builds.yml',
template: BUILD_TEMPLATE,
header: BUILD_TEMPLATE_HEADER,
},
{
displayName: 'Publish updates',
name: WorkflowStarterName.UPDATE,
defaultFileName: 'publish-updates.yml',
template: PUBLISH_UPDATE_TEMPLATE,
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
},
{
displayName: 'Deploy to production',
name: WorkflowStarterName.DEPLOY,
defaultFileName: 'deploy-to-production.yml',
template: DEPLOY_TEMPLATE,
header: DEPLOY_TEMPLATE_HEADER,
},
];
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<WorkflowStarter> {
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
// android_development_build
const nextSteps: Set<string> = new Set();
let androidDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.android;
if (!profile) {
continue;
}
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
androidDevelopmentBuildProfileName = profileName;
break;
}
}
if (!androidDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an Android development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an Android development build profile?',
initial: false,
});
if (add) {
let androidDevelopmentBuildProfileName = 'android_development';
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
}
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
projectDir,
androidDevelopmentBuildProfileName
);
} else {
Log.log('Skipping Android development build job...');
}
}
if (androidDevelopmentBuildProfileName) {
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
workflowStarter.template.jobs.android_development_build = {
name: `Build ${androidDevelopmentBuildProfileName} for android`,
type: 'build',
params: {
profile: androidDevelopmentBuildProfileName,
platform: 'android',
},
};
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
}
// ios_simulator_development_build
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.ios;
if (!profile) {
continue;
}
if (
isBuildProfileForDevelopment(profile, Platform.IOS) &&
isIosBuildProfileForSimulator(profile)
) {
iosSimulatorDevelopmentBuildProfileName = profileName;
break;
}
}
if (!iosSimulatorDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an iOS simulator development build profile?',
initial: false,
});
if (add) {
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
}
await addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir,
iosSimulatorDevelopmentBuildProfileName,
true
);
} else {
Log.log('Skipping iOS simulator development build job...');
}
}
if (iosSimulatorDevelopmentBuildProfileName) {
Log.log(
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
);
workflowStarter.template.jobs.ios_simulator_development_build = {
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
type: 'build',
params: {
profile: iosSimulatorDevelopmentBuildProfileName,
platform: 'ios',
},
};
}
// ios_device_development_build
let iosDeviceDevelopmentBuildProfileName: string | null = null;
for (const profileName of buildProfiles.keys()) {
const profile = buildProfiles.get(profileName)?.ios;
if (!profile) {
continue;
}
if (
isBuildProfileForDevelopment(profile, Platform.IOS) &&
!isIosBuildProfileForSimulator(profile)
) {
iosDeviceDevelopmentBuildProfileName = profileName;
break;
}
}
if (!iosDeviceDevelopmentBuildProfileName) {
Log.warn(
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
);
const add = await confirmAsync({
message: 'Do you want to add an iOS device development build profile?',
initial: false,
});
if (add) {
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
}
await addIosDevelopmentBuildProfileToEasJsonAsync(
projectDir,
iosDeviceDevelopmentBuildProfileName,
false
);
} else {
Log.log('Skipping iOS device development build job...');
}
}
if (iosDeviceDevelopmentBuildProfileName) {
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
workflowStarter.template.jobs.ios_development_build = {
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
type: 'build',
params: {
profile: iosDeviceDevelopmentBuildProfileName,
platform: 'ios',
},
};
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
}
workflowStarter.nextSteps = [...nextSteps];
return workflowStarter;
}
export async function ensureProductionBuildProfileExistsAsync(
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<WorkflowStarter> {
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
workflowStarter.nextSteps = [
nextStepForDeviceBuildProfile('production'),
nextStepForAppSubmission(),
];
return workflowStarter;
}
export async function customizeTemplateIfNeededAsync(
workflowStarter: WorkflowStarter,
projectDir: string,
expoConfig: ExpoConfig
): Promise<any> {
// Ensure EAS Build is configured
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE:
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
throw new Error(
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
);
}
break;
default:
break;
}
// Ensure EAS Update is configured
switch (workflowStarter.name) {
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE:
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
throw new Error(
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
);
}
break;
default:
break;
}
// Customize template
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
Log.debug('Adding build jobs to template...');
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
case WorkflowStarterName.DEPLOY:
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
default:
return workflowStarter;
}
}
@@ -1,8 +1,12 @@
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
export async function fetchRawLogsForCustomJobAsync(
job: WorkflowJobResult
): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
@@ -13,3 +17,25 @@ export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<s
const rawLogs = await response.text();
return rawLogs;
}
export async function fetchRawLogsForBuildJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
}
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
useCache: false,
});
const firstLogFileUrl = buildFragment.logFiles?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
@@ -0,0 +1,192 @@
import * as YAML from 'yaml';
import { z } from 'zod';
import { DefaultEnvironment } from '../../build/utils/environment';
import { booleanLike, stringLike } from '../../credentials/ios/types';
import Log from '../../log';
import { promptAsync } from '../../prompts';
const inputExtraProperties = {
description: stringLike.optional().describe('Description of the input'),
required: booleanLike.default(false).optional().describe('Whether the input is required.'),
};
// Adapted from the input definition on the server (https://github.com/expo/universe/pull/21950)
export const WorkflowDispatchInputZ = z.discriminatedUnion('type', [
z.object({
...inputExtraProperties,
type: z.literal('string').default('string').optional(),
default: stringLike.optional().describe('Default value for the input'),
}),
z.object({
...inputExtraProperties,
type: z.literal('boolean'),
default: booleanLike.optional().describe('Default value for the input'),
}),
z.object({
...inputExtraProperties,
type: z.literal('number'),
default: z.number().optional().describe('Default value for the input'),
}),
z.object({
...inputExtraProperties,
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({
...inputExtraProperties,
type: z.literal('environment'),
default: z
.enum(Object.values(DefaultEnvironment))
.optional()
.describe('Default value for the input'),
}),
]);
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 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 });
}
}
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 {};
}
}
export 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':
default: {
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;
}
}
}
@@ -5,7 +5,7 @@ import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processLogsFromJobAsync,
fetchAndProcessLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -268,7 +268,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
if (!job) {
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
}
const logs = await processLogsFromJobAsync(job);
const logs = await fetchAndProcessLogsFromJobAsync(prevState, job);
if (!logs) {
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
}
@@ -1,7 +1,7 @@
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
/*
* Utility types for workflow commands
* Utility types and Zod definitions for workflow commands
*/
export enum WorkflowTriggerType {
@@ -42,5 +42,12 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
logs?: string;
};
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
export type WorkflowLogLine = {
time: string;
msg: string;
result?: string;
marker?: string;
err?: any;
};
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
@@ -1,4 +1,7 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
@@ -7,11 +10,21 @@ import {
WorkflowTriggerType,
} from './types';
import {
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdQuery,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { sleepAsync } from '../../utils/promise';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
@@ -68,16 +81,23 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
};
}
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 choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
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}`,
name: step,
status: stepStatus,
value: step,
logLines,
};
})
.filter(step => step.status !== 'skipped');
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
@@ -100,29 +120,254 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
});
}
export async function processLogsFromJobAsync(
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const rawLogs = await fetchRawLogsForJobAsync(job);
let rawLogs: string | null;
switch (job.type) {
case WorkflowJobType.Build:
case WorkflowJobType.Repack:
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
break;
default:
rawLogs = await fetchRawLogsForCustomJobAsync(job);
break;
}
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach(line => {
rawLogs.split('\n').forEach((line, index) => {
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
try {
const parsedLine = JSON.parse(line);
const { result, marker } = parsedLine;
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
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 });
logs.get(stepId)?.push({ time, msg, result, marker, err });
}
} catch {}
});
return logs;
}
function descriptionForJobStatus(status: WorkflowJobStatus): string {
switch (status) {
case WorkflowJobStatus.New:
return 'Waiting for worker';
case WorkflowJobStatus.InProgress:
return 'In progress';
case WorkflowJobStatus.Success:
return 'Completed successfully';
case WorkflowJobStatus.Failure:
return 'Failed';
case WorkflowJobStatus.Canceled:
return 'Canceled';
case WorkflowJobStatus.Skipped:
return 'Skipped';
case WorkflowJobStatus.ActionRequired:
return 'Waiting for action';
case WorkflowJobStatus.PendingCancel:
return 'Pending cancel';
}
}
export async function infoForActiveWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
maxLogLines: number = 5 // -1 means no limit
): Promise<string> {
const statusLines = [];
const statusValues = [];
for (const job of workflowRun.jobs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Job', value: job.name });
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
if (steps.length > 0) {
const currentStep = steps[steps.length - 1];
statusValues.push({ label: ' Current step', value: currentStep.name });
if (currentStep?.logLines?.length) {
statusValues.push({ label: ' Current logs', value: '' });
const currentLogs =
currentStep.logLines
?.map(line => line.msg)
.filter((_, index) => {
if (maxLogLines === -1) {
return true;
}
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
}) ?? [];
for (const log of currentLogs) {
statusValues.push({ label: '', value: log });
}
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
}
export async function infoForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
maxLogLines: number = -1 // -1 means no limit
): Promise<string> {
const statusLines = [];
const statusValues = [];
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
for (const job of workflowRun.jobs) {
if (job.status !== WorkflowJobStatus.Failure) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({
label: ' Logs for failed step',
value: '',
});
for (const log of logs) {
statusValues.push({ label: '', value: log });
}
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
}
export async function fileExistsAsync(filePath: string): Promise<boolean> {
return await fs.promises
.access(filePath, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
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 async function showWorkflowStatusAsync(
graphqlClient: ExpoGraphqlClient,
{
workflowRunId,
spinnerUsesStdErr,
waitForCompletion = true,
}: { workflowRunId: string; spinnerUsesStdErr: boolean; waitForCompletion?: boolean }
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const spinner = ora({
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
break;
}
case WorkflowRunStatus.ActionRequired:
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.text = '';
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
}
export const workflowRunExitCodes = {
WORKFLOW_FAILED: 11,
WORKFLOW_CANCELED: 12,
WAIT_ABORTED: 13,
};
@@ -0,0 +1,208 @@
import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/errors';
import { CombinedError } from '@urql/core';
import { promises as fs } from 'fs';
import path from 'path';
import * as YAML from 'yaml';
import { buildProfileNamesFromProjectAsync } from './buildProfileUtils';
import { getExpoApiWorkflowSchemaURL } from '../../api';
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
import Log from '../../log';
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
import { WorkflowFile } from '../../utils/workflowFile';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
const buildProfileIsInterpolated = (profileName: string): boolean => {
return profileName.includes('${{') && profileName.includes('}}');
};
export async function validateWorkflowFileAsync(
workflowFileContents: { yamlConfig: string; filePath: string },
projectDir: string,
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<void> {
const parsedYaml = parsedYamlFromWorkflowContents(workflowFileContents);
Log.debug(`Parsed workflow: ${JSON.stringify(parsedYaml, null, 2)}`);
// Check if the parsed result is empty or null
Log.debug(`Validating workflow is not empty...`);
validateWorkflowIsNotEmpty(parsedYaml);
const workflowSchema = await fetchWorkflowSchemaAsync();
// Check that all job types are valid
Log.debug(`Validating workflow job types...`);
validateWorkflowJobTypes(parsedYaml, workflowSchema);
// Check for build jobs that do not match any EAS build profiles
Log.debug(`Validating workflow build jobs...`);
await validateWorkflowBuildJobsAsync(parsedYaml, projectDir);
// Check that result passes validation against workflow schema
Log.debug(`Validating workflow structure...`);
validateWorkflowStructure(parsedYaml, workflowSchema);
// Check for other errors using the server-side validation
Log.debug(`Validating workflow on server...`);
await validateWorkflowOnServerAsync(graphqlClient, projectId, workflowFileContents);
}
export function logWorkflowValidationErrors(error: unknown): void {
if (error instanceof MissingEasJsonError) {
throw new Error(
'Workflows require a valid eas.json. Please run "eas build:configure" to create it.'
);
} else if (error instanceof InvalidEasJsonError) {
throw new Error(
'Workflows require a valid eas.json. Please fix the errors in your eas.json and try again.\n\n' +
error.message
);
} else if (error instanceof YAML.YAMLParseError) {
Log.error(`YAML syntax error: ${error.message}`);
} else if (error instanceof CombinedError) {
WorkflowFile.maybePrintWorkflowFileValidationErrors({
error,
});
throw error;
} else if (error instanceof Error) {
Log.error(`Error: ${error.message}`);
} else {
Log.error(`Unexpected error: ${String(error)}`);
}
}
function validateWorkflowIsNotEmpty(parsedYaml: any): void {
if (
parsedYaml === null ||
parsedYaml === undefined ||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
) {
throw new Error('YAML file is empty or contains only comments.');
}
}
async function validateWorkflowOnServerAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
workflowFileContents: { yamlConfig: string }
): Promise<void> {
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
appId: projectId,
yamlConfig: workflowFileContents.yamlConfig,
});
}
async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: string): Promise<void> {
const jobs = jobsFromWorkflow(parsedYaml);
const buildJobs = jobs.filter(job => jobTypesWithBuildProfile.has(job.value.type));
if (buildJobs.length === 0) {
return;
}
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
const invalidBuildJobs = buildJobs.filter(
job =>
!buildProfileNames.has(job.value.params.profile) &&
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
!buildProfileIsInterpolated(job.value.params.profile)
);
if (invalidBuildJobs.length > 0) {
const invalidBuildProfiles = new Set(invalidBuildJobs.map(job => job.value.params.profile));
throw new Error(
`The build jobs in this workflow refer to the following build profiles that are not present in your eas.json file: ${[
...invalidBuildProfiles,
].join(', ')}`
);
}
}
function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): void {
const jobs = jobsFromWorkflow(parsedYaml);
const jobTypes = jobTypesFromWorkflowSchema(workflowJsonSchema);
const invalidJobs = jobs.filter(job => job.value.type && !jobTypes.includes(job.value.type));
if (invalidJobs.length > 0) {
throw new Error(
`The following jobs have invalid types: ${invalidJobs
.map(job => job.key)
.join(', ')}. Valid types are: ${jobTypes.join(', ')}`
);
}
}
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
delete workflowJsonSchema['$schema'];
const ajv = createValidator();
const validate = ajv.compile(workflowJsonSchema);
const result = validate(parsedYaml);
if (!result) {
Log.debug(
JSON.stringify(
{
errors: validate.errors,
},
null,
2
)
);
const readableErrors = getReadableErrors(validate.errors ?? []);
const processedErrors = new Set<string>();
for (const err of readableErrors) {
if (err.message) {
processedErrors.add(err.message);
}
}
throw new Error([...processedErrors].join('\n'));
}
}
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
return parsedYaml;
}
export function workflowContentsFromParsedYaml(parsedYaml: any): string {
return YAML.stringify(parsedYaml);
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async function fetchWorkflowSchemaAsync(): Promise<any> {
// EXPO_TESTING_WORKFLOW_SCHEMA_PATH is used only for testing against a different schema
if (process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH) {
const schemaPath = path.resolve(process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH);
Log.debug(`Loading workflow schema from ${schemaPath}`);
const jsonString = await fs.readFile(schemaPath, 'utf-8');
const jsonFromFile = JSON.parse(jsonString);
return jsonFromFile.data;
}
// Otherwise, we fetch from <ApiBaseUrl>/v2/workflows/schema
const schemaUrl = getExpoApiWorkflowSchemaURL();
Log.debug(`Fetching workflow schema from ${schemaUrl}`);
const response = await fetch(schemaUrl);
if (!response.ok) {
throw new Error(`Unable to fetch EAS Workflow schema, received status: ${response.status}`);
}
const jsonResponse: any = (await response.json()) as any;
return jsonResponse.data;
}
function jobsFromWorkflow(parsedYaml: any): any[] {
return Object.entries(parsedYaml?.jobs).flatMap(([key, value]: [string, any]) => {
return {
key,
value,
};
});
}
function jobTypesFromWorkflowSchema(workflowJsonSchema: any): string[] {
return workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf.map(
(props: any) => props.properties.type.const
);
}
+7 -36
View File
@@ -1,23 +1,19 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import { scheduleBranchDeletionAsync } from '../../branch/delete';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
DeleteUpdateBranchMutationVariables,
DeleteUpdateBranchResult,
GetBranchInfoQuery,
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
async function getBranchInfoAsync(
graphqlClient: ExpoGraphqlClient,
@@ -50,31 +46,6 @@ async function getBranchInfoAsync(
return data;
}
async function deleteBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ branchId }: DeleteUpdateBranchMutationVariables
): Promise<DeleteUpdateBranchResult> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
gql`
mutation DeleteUpdateBranch($branchId: ID!) {
updateBranch {
deleteUpdateBranch(branchId: $branchId) {
id
}
}
}
`,
{
branchId,
}
)
.toPromise()
);
return data.updateBranch.deleteUpdateBranch;
}
export default class BranchDelete extends EasCommand {
static override description = 'delete a branch';
@@ -146,12 +117,12 @@ export default class BranchDelete extends EasCommand {
}
}
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
branchId,
});
const receipt = await scheduleBranchDeletionAsync(graphqlClient, { branchId });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
printJsonOnlyOutput({ id: branchId });
} else {
Log.withTick(
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
@@ -10,10 +10,10 @@ import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import { ensureEASUpdateIsConfiguredInEasJsonAsync } from '../../update/configure';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { promptPlatformAsync } from '../../utils/prompts';
export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
@@ -113,24 +113,7 @@ export default class BuildConfigure extends EasCommand {
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
Log.addNewLineIfNone();
const { platform } = await promptAsync({
type: 'select',
return await promptPlatformAsync({
message: 'Which platforms would you like to configure for EAS Build?',
name: 'platform',
choices: [
{
title: 'All',
value: RequestedPlatform.All,
},
{
title: 'iOS',
value: RequestedPlatform.Ios,
},
{
title: 'Android',
value: RequestedPlatform.Android,
},
],
});
return platform;
}
@@ -22,7 +22,6 @@ import {
BuildStatus,
BuildWorkflow,
DistributionType,
EnvironmentVariableEnvironment,
IosJobOverridesInput,
ProjectArchiveSourceType,
StatuspageServiceName,
@@ -48,7 +47,7 @@ interface BuildResignFlags {
sourceProfile?: string;
maybeBuildId?: string;
wait: boolean;
environment?: EnvironmentVariableEnvironment;
environment?: string;
}
interface RawBuildResignFlags {
json: boolean;
@@ -60,7 +59,7 @@ interface RawBuildResignFlags {
'source-profile': string | undefined;
wait: boolean;
id: string | undefined;
environment: EnvironmentVariableEnvironment | undefined;
environment: string | undefined;
}
export default class BuildResign extends EasCommand {
@@ -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,140 @@
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();
}
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;
@@ -70,8 +73,6 @@ export default class WorkerAlias extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -0,0 +1,112 @@
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();
}
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'],
};
}
}
+10 -9
View File
@@ -8,7 +8,6 @@ import * as path from 'node:path';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
@@ -45,7 +44,7 @@ interface DeployFlags {
json: boolean;
isProduction: boolean;
aliasName?: string;
environment?: EnvironmentVariableEnvironment;
environment?: string;
deploymentIdentifier?: string;
exportDir: string;
dryRun: boolean;
@@ -53,7 +52,7 @@ interface DeployFlags {
interface RawDeployFlags {
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment;
environment?: string;
json: boolean;
prod: boolean;
alias?: string;
@@ -121,8 +120,6 @@ export default class WorkerDeploy extends EasCommand {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -138,6 +135,7 @@ export default class WorkerDeploy extends EasCommand {
logExportedProjectInfo(projectDist);
async function* emitWorkerTarballAsync(params: {
routesConfig: WorkerAssets.RoutesConfigEntry | null;
assetMap: WorkerAssets.AssetMap;
manifest: WorkerAssets.Manifest;
}): AsyncGenerator<WorkerAssets.FileEntry> {
@@ -148,6 +146,8 @@ export default class WorkerDeploy extends EasCommand {
for await (const workerFile of workerFiles) {
yield [`server/${workerFile.normalizedPath}`, workerFile.data];
}
} else if (projectDist.type === 'static' && params.routesConfig) {
yield ['routes.json', JSON.stringify(params.routesConfig)];
}
}
@@ -284,12 +284,13 @@ export default class WorkerDeploy extends EasCommand {
manifestResult.conflictingVariableNames.join(' ')
);
}
assetFiles = await WorkerAssets.collectAssetsAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
{ maxFileSize: MAX_UPLOAD_SIZE }
);
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
assetFiles = await WorkerAssets.collectAssetsAsync(assetPath, {
maxFileSize: MAX_UPLOAD_SIZE,
});
tarPath = await WorkerAssets.packFilesIterableAsync(
emitWorkerTarballAsync({
routesConfig: await WorkerAssets.getRoutesConfigAsync(assetPath),
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
manifest: manifestResult.manifest,
})
+51 -16
View File
@@ -1,11 +1,11 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
@@ -47,6 +47,7 @@ describe(EnvCreate, () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
@@ -56,6 +57,7 @@ describe(EnvCreate, () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Shared,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.updateAsync)
@@ -101,7 +103,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
@@ -138,7 +140,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
},
]);
@@ -166,7 +168,7 @@ describe(EnvCreate, () => {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
});
});
@@ -199,8 +201,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
isGlobal: true,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
@@ -235,7 +236,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: 'otherId',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
@@ -273,7 +274,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
@@ -284,7 +285,7 @@ describe(EnvCreate, () => {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
});
});
@@ -319,12 +320,8 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [
EnvironmentVariableEnvironment.Production,
EnvironmentVariableEnvironment.Development,
],
environments: [DefaultEnvironment.Production, DefaultEnvironment.Development],
visibility: EnvironmentVariableVisibility.Public,
isGlobal: true,
type: EnvironmentSecretType.String,
},
testAccountId
@@ -343,7 +340,7 @@ describe(EnvCreate, () => {
jest
.mocked(promptVariableEnvironmentAsync)
// @ts-expect-error
.mockImplementation(async () => [EnvironmentVariableEnvironment.Production]);
.mockImplementation(async () => [DefaultEnvironment.Production]);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
@@ -361,7 +358,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
@@ -369,4 +366,42 @@ describe(EnvCreate, () => {
);
});
});
it('accepts development environment when using positional argument', async () => {
const command = new EnvCreate(
[
'development',
'--name',
'TEST_VAR',
'--value',
'test-value',
'--visibility',
'plaintext',
'--scope',
'project',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'TEST_VAR',
value: 'test-value',
environments: [DefaultEnvironment.Development],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
});
@@ -1,8 +1,10 @@
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentVariableEnvironment,
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
@@ -35,7 +37,7 @@ describe(EnvDelete, () => {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
},
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
@@ -121,4 +123,40 @@ describe(EnvDelete, () => {
expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled();
});
it('accepts development environment when using positional argument', async () => {
const mockVariable = {
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValueOnce({ id: 'var1' });
jest.spyOn(Log, 'log').mockImplementation(() => {});
const command = new EnvDelete(
['development', '--variable-name', 'TEST_VAR_1', '--scope', 'project', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
environment: DefaultEnvironment.Development,
});
});
});
@@ -0,0 +1,85 @@
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import EnvExec from '../exec';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../log');
describe(EnvExec, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const mockEnvironmentVariables = [
{
id: 'var1',
name: 'EXPO_PUBLIC_API_URL',
value: 'https://api.example.com',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
];
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
// Mock GraphQL query
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValue(mockEnvironmentVariables);
});
it('accepts development environment when using positional argument', async () => {
const command = new EnvExec(['development', 'echo $EXPO_PUBLIC_API_URL'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: DefaultEnvironment.Development,
}
);
});
it('accepts custom environment when using positional argument', async () => {
const command = new EnvExec(['custom-environment', 'echo $EXPO_PUBLIC_API_URL'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: 'custom-environment',
}
);
});
});
+34 -4
View File
@@ -1,10 +1,10 @@
import { Config } from '@oclif/core';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
@@ -27,7 +27,7 @@ describe(EnvGet, () => {
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
@@ -82,7 +82,7 @@ describe(EnvGet, () => {
it('prompts for variable name and environment if the name is ambigous', async () => {
jest
.mocked(promptVariableEnvironmentAsync)
.mockResolvedValueOnce(EnvironmentVariableEnvironment.Production);
.mockResolvedValueOnce(DefaultEnvironment.Production);
jest.mocked(promptVariableNameAsync).mockResolvedValueOnce('TEST_VAR_1');
const command = new EnvGet([], mockConfig);
@@ -98,7 +98,7 @@ describe(EnvGet, () => {
id: 'var2',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Development],
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
@@ -111,4 +111,34 @@ describe(EnvGet, () => {
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
expect(promptVariableNameAsync).toHaveBeenCalled();
});
it('accepts development environment when using positional argument', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValueOnce(mockVariables);
jest.spyOn(Log, 'log').mockImplementation(() => {});
const command = new EnvGet(
['development', '--variable-name', 'TEST_VAR_1', '--scope', 'project'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: DefaultEnvironment.Development,
filterNames: ['TEST_VAR_1'],
includeFileContent: true,
}
);
});
});
+42 -28
View File
@@ -1,11 +1,11 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
@@ -13,10 +13,12 @@ import {
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptVariableEnvironmentAsync } from '../../../utils/prompts';
import EnvList from '../list';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../utils/prompts');
jest.mock('../../../log');
const mockVariables: EnvironmentVariableFragment[] = [
@@ -24,7 +26,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
createdAt: undefined,
@@ -35,7 +37,7 @@ const mockVariables: EnvironmentVariableFragment[] = [
id: 'var2',
name: 'TEST_VAR_2',
value: 'value2',
environments: [EnvironmentVariableEnvironment.Development],
environments: [DefaultEnvironment.Development],
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
createdAt: undefined,
@@ -51,27 +53,19 @@ describe(EnvList, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('lists project environment variables successfully', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
const command = new EnvList([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
(jest.mocked(promptVariableEnvironmentAsync) as jest.MockedFunction<any>).mockImplementation(
async (args: any) => {
if (args.nonInteractive) {
throw new Error(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
}
if (args.multiple) {
return [DefaultEnvironment.Production];
}
return DefaultEnvironment.Production;
}
);
});
it('lists project environment variables in specified environments', async () => {
@@ -88,13 +82,33 @@ describe(EnvList, () => {
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: EnvironmentVariableEnvironment.Production,
environment: DefaultEnvironment.Production,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2'));
});
it('accepts development environment when using positional argument', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce(mockVariables);
const command = new EnvList(['development'], mockConfig); // Using positional argument
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: DefaultEnvironment.Development,
includeFileContent: false,
});
});
it('lists project environment variables including sensitive values', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
@@ -113,7 +127,7 @@ describe(EnvList, () => {
graphqlClient,
{
appId: testProjectId,
environment: undefined,
environment: DefaultEnvironment.Production,
includeFileContent: false,
}
);
@@ -135,7 +149,7 @@ describe(EnvList, () => {
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
environment: DefaultEnvironment.Production,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
@@ -158,7 +172,7 @@ describe(EnvList, () => {
expect(EnvironmentVariablesQuery.sharedWithSensitiveAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: undefined,
environment: DefaultEnvironment.Production,
includeFileContent: false,
});
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1'));
@@ -0,0 +1,388 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import path from 'path';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import {
EnvironmentVariableWithFileContent,
EnvironmentVariablesQuery,
} from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import EnvPull from '../pull';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../prompts');
jest.mock('fs-extra');
jest.mock('../../../log');
describe(EnvPull, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testTargetPath = '.env.local';
const mockEnvironmentVariables: EnvironmentVariableWithFileContent[] = [
{
id: 'var1',
name: 'EXPO_PUBLIC_API_URL',
value: 'https://api.example.com',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
{
id: 'var2',
name: 'DATABASE_URL',
value: 'postgres://localhost:5432/mydb',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
},
{
id: 'var3',
name: 'SECRET_KEY',
value: 'super-secret-key',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
{
id: 'var4',
name: 'CONFIG_FILE',
value: 'base64-encoded-file-content',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.FileBase64,
valueWithFileContent: Buffer.from('{"key": "value"}').toString('base64'),
},
];
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
// Mock fs-extra methods
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(false));
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
jest.mocked(fs.mkdir).mockImplementation(() => Promise.resolve());
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
// Mock GraphQL query
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValue(mockEnvironmentVariables);
});
describe('environment validation', () => {
it('accepts development environment', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: 'development',
includeFileContent: true,
}
);
});
it('accepts custom environment', async () => {
const command = new EnvPull(['custom-environment'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: 'custom-environment',
includeFileContent: true,
}
);
});
});
describe('file operations', () => {
it('writes environment variables to .env.local file', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(fs.writeFile).toHaveBeenCalledWith(
testTargetPath,
expect.stringContaining('# Environment: development')
);
});
it('writes correct environment variable content', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Find the call that writes to .env.local (not the file variable)
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
expect(envFileCall).toBeDefined();
const fileContent = envFileCall![1] as string;
// Check that the file contains the expected content
expect(fileContent).toContain('# Environment: development');
expect(fileContent).toContain('EXPO_PUBLIC_API_URL=https://api.example.com');
expect(fileContent).toContain('DATABASE_URL=postgres://localhost:5432/mydb');
expect(fileContent).toContain('# SECRET_KEY=***** (secret)');
});
it('handles file variables correctly', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Should create .eas/.env directory for file variables
expect(fs.mkdir).toHaveBeenCalledWith(path.join(testProjectDir, '.eas', '.env'), {
recursive: true,
});
// Should write the file content (base64 encoded)
expect(fs.writeFile).toHaveBeenCalledWith(
path.join(testProjectDir, '.eas', '.env', 'CONFIG_FILE'),
Buffer.from('{"key": "value"}').toString('base64'),
'base64'
);
// Should include file path in env file
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
expect(envFileCall).toBeDefined();
const fileContent = envFileCall![1] as string;
expect(fileContent).toContain('CONFIG_FILE=');
expect(fileContent).toContain(path.join(testProjectDir, '.eas', '.env', 'CONFIG_FILE'));
});
it('prompts for overwrite when file exists in interactive mode', async () => {
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(confirmAsync).mockResolvedValue(true);
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: `File ${testTargetPath} already exists. Do you want to overwrite it?`,
});
});
it('aborts when user declines overwrite', async () => {
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(confirmAsync).mockResolvedValue(false);
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await expect(command.runAsync()).rejects.toThrow(`File ${testTargetPath} already exists.`);
});
});
describe('non-interactive mode', () => {
it('works in non-interactive mode', async () => {
const command = new EnvPull(['development', '--non-interactive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: testProjectId,
environment: 'development',
includeFileContent: true,
}
);
});
it('requires environment in non-interactive mode', async () => {
const command = new EnvPull(['--non-interactive'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await expect(command.runAsync()).rejects.toThrow(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
});
});
describe('custom target path', () => {
it('writes to custom path when specified', async () => {
const customPath = '.env.custom';
const command = new EnvPull(['development', '--path', customPath], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(fs.writeFile).toHaveBeenCalledWith(
customPath,
expect.stringContaining('# Environment: development')
);
});
});
describe('logging', () => {
it('logs success message with correct environment', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(
`Pulled plain text and sensitive environment variables from "development" environment to ${testTargetPath}.`
);
});
it('logs secret variables that were skipped', async () => {
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('The following variables have the secret visibility')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SECRET_KEY'));
});
});
describe('existing .env.local file handling', () => {
it('preserves existing secret values when they exist', async () => {
const existingEnvContent = 'SECRET_KEY=existing-secret-value\nOTHER_VAR=other-value';
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(existingEnvContent));
jest.mocked(confirmAsync).mockResolvedValue(true);
const command = new EnvPull(['development'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Find the call that writes to .env.local (not the file variable)
const writeFileCalls = jest.mocked(fs.writeFile).mock.calls;
const envFileCall = writeFileCalls.find(call => call[0] === testTargetPath);
expect(envFileCall).toBeDefined();
const fileContent = envFileCall![1] as string;
// Should use existing secret value instead of placeholder
expect(fileContent).toContain('SECRET_KEY=existing-secret-value');
expect(fileContent).not.toContain('# SECRET_KEY=***** (secret)');
// Should log that it reused the local value
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Reused local values for following secrets: SECRET_KEY')
);
});
});
});
@@ -0,0 +1,279 @@
import { Config } from '@oclif/core';
import * as fs from 'fs-extra';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import EnvPush from '../push';
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('fs-extra');
jest.mock('../../../log');
jest.mock('../../../prompts');
describe(EnvPush, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = {} as unknown as Config;
const testProjectDir = '/test/project';
const testEnvPath = '.env.test';
const mockEnvContent = `EXPO_PUBLIC_API_URL=https://api.example.com
VARIABLE_NAME=variable value
SECRET_KEY=super-secret-key`;
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'error').mockImplementation(() => {});
// Mock fs-extra methods
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(mockEnvContent));
// Mock GraphQL queries and mutations
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); // No existing variables
jest
.mocked(EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync)
.mockResolvedValue(true);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('accepts development environment when using positional argument', async () => {
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([]);
const command = new EnvPush(['development', '--path', testEnvPath], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: DefaultEnvironment.Development,
filterNames: ['EXPO_PUBLIC_API_URL', 'VARIABLE_NAME', 'SECRET_KEY'],
});
});
it('accepts custom environment', async () => {
const command = new EnvPush(['custom-environment', '--path', testEnvPath], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
environment: 'custom-environment',
filterNames: ['EXPO_PUBLIC_API_URL', 'VARIABLE_NAME', 'SECRET_KEY'],
});
});
describe('--force option', () => {
it('automatically overrides existing variables without confirmation when --force is used', async () => {
const existingVariable = {
id: 'var1',
name: 'VARIABLE_NAME',
value: 'old variable value',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Should not call confirmAsync for existing variables
expect(confirmAsync).not.toHaveBeenCalled();
// Should log that force flag is being used
expect(Log.log).toHaveBeenCalledWith(
'Using --force flag: automatically overriding existing variables.'
);
// Should call the mutation with overwrite flag
expect(
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
).toHaveBeenCalledWith(
graphqlClient,
expect.arrayContaining([
expect.objectContaining({
name: 'VARIABLE_NAME',
value: 'variable value',
environments: [DefaultEnvironment.Development],
overwrite: true,
}),
]),
testProjectId
);
});
it('automatically overrides sensitive variables without confirmation when --force is used', async () => {
const existingSensitiveVariable = {
id: 'var1',
name: 'SECRET_KEY',
value: 'old-secret-key',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
};
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
.mockResolvedValue([existingSensitiveVariable]);
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Should not call confirmAsync for sensitive variables
expect(confirmAsync).not.toHaveBeenCalled();
// Should log that force flag is being used for sensitive variables
expect(Log.log).toHaveBeenCalledWith(
'Using --force flag: automatically overriding sensitive variables.'
);
// Should call the mutation with overwrite flag
expect(
EnvironmentVariableMutation.createBulkEnvironmentVariablesForAppAsync
).toHaveBeenCalledWith(
graphqlClient,
expect.arrayContaining([
expect.objectContaining({
name: 'SECRET_KEY',
value: 'super-secret-key',
environments: [DefaultEnvironment.Development],
overwrite: true,
}),
]),
testProjectId
);
});
it('logs appropriate messages when using --force flag', async () => {
const existingVariable = {
id: 'var1',
name: 'VARIABLE_NAME',
value: 'old variable value',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
const command = new EnvPush(['development', '--path', testEnvPath, '--force'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Should log both force messages
expect(Log.log).toHaveBeenCalledWith(
'Using --force flag: automatically overriding existing variables.'
);
expect(Log.log).toHaveBeenCalledWith(
'Using --force flag: automatically overriding sensitive variables.'
);
});
it('still prompts for confirmation when --force is not used', async () => {
const existingVariable = {
id: 'var1',
name: 'VARIABLE_NAME',
value: 'old variable value',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([existingVariable]);
jest.mocked(confirmAsync).mockResolvedValue(true);
const command = new EnvPush(['development', '--path', testEnvPath], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
projectDir: testProjectDir,
});
await command.runAsync();
// Should call confirmAsync for both existing variables and sensitive variables
expect(confirmAsync).toHaveBeenCalledTimes(2);
expect(confirmAsync).toHaveBeenCalledWith({
message:
'The VARIABLE_NAME environment variable already exists in development environment. Do you want to override it?',
});
expect(confirmAsync).toHaveBeenCalledWith({
message:
'You are about to overwrite sensitive variables.\n- VARIABLE_NAME\n Do you want to continue?',
});
// Should not log force messages
expect(Log.log).not.toHaveBeenCalledWith(
'Using --force flag: automatically overriding existing variables.'
);
expect(Log.log).not.toHaveBeenCalledWith(
'Using --force flag: automatically overriding sensitive variables.'
);
});
});
});
+345 -7
View File
@@ -1,9 +1,11 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentVariableEnvironment,
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
@@ -11,14 +13,21 @@ import { EnvironmentVariableMutation } from '../../../graphql/mutations/Environm
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableVisibilityAsync,
} from '../../../utils/prompts';
import EnvUpdate from '../update';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../utils/prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
jest.mock('fs-extra');
describe(EnvUpdate, () => {
const projectId = 'test-project-id';
@@ -33,6 +42,41 @@ describe(EnvUpdate, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
(jest.mocked(promptVariableEnvironmentAsync) as jest.MockedFunction<any>).mockImplementation(
async (args: any) => {
if (args.nonInteractive) {
throw new Error(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
}
if (args.multiple) {
return ['production'];
}
return 'production';
}
);
(jest.mocked(promptVariableVisibilityAsync) as jest.MockedFunction<any>).mockResolvedValue(
EnvironmentVariableVisibility.Public
);
(jest.mocked(parseVisibility) as jest.MockedFunction<any>).mockImplementation(
(visibility: string) => {
switch (visibility) {
case 'plaintext':
return EnvironmentVariableVisibility.Public;
case 'sensitive':
return EnvironmentVariableVisibility.Sensitive;
case 'secret':
return EnvironmentVariableVisibility.Secret;
default:
throw new Error(`Invalid visibility: ${visibility}`);
}
}
);
(jest.mocked(selectAsync) as jest.MockedFunction<any>).mockResolvedValue({
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
});
});
it('updates a project variable by selected name in non-interactive mode', async () => {
@@ -41,7 +85,7 @@ describe(EnvUpdate, () => {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [EnvironmentVariableEnvironment.Development],
environments: [DefaultEnvironment.Development],
},
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
@@ -73,7 +117,7 @@ describe(EnvUpdate, () => {
id: variableId,
name: 'NEW_VARIABLE',
value: 'new-value',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
});
expect(Log.withTick).toHaveBeenCalledWith(
@@ -87,7 +131,7 @@ describe(EnvUpdate, () => {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
environments: [DefaultEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
@@ -119,7 +163,7 @@ describe(EnvUpdate, () => {
id: variableId,
name: 'NEW_VARIABLE',
value: 'new-value',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
});
expect(Log.withTick).toHaveBeenCalledWith(
`Updated variable ${chalk.bold('TEST_VARIABLE')} on account testuser.`
@@ -129,6 +173,7 @@ describe(EnvUpdate, () => {
it('prompts for variable selection when selected name is not provided', async () => {
const mockVariables = [
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
{ id: '2', name: 'ANOTHER_VARIABLE', scope: EnvironmentVariableScope.Project },
];
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
@@ -140,7 +185,7 @@ describe(EnvUpdate, () => {
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ id: variableId })
@@ -162,4 +207,297 @@ describe(EnvUpdate, () => {
'Variable with name NON_EXISTENT_VARIABLE does not exist on project @testuser/testpp.'
);
});
it('accepts development environment when using positional argument', async () => {
const mockVariable = {
id: 'var1',
name: 'TEST_VAR_1',
value: 'value1',
environments: [DefaultEnvironment.Development],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
};
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValueOnce(mockVariable);
jest.spyOn(Log, 'log').mockImplementation(() => {});
const command = new EnvUpdate(
[
'development',
'--variable-name',
'TEST_VAR_1',
'--value',
'new-value',
'--scope',
'project',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId,
});
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
environment: DefaultEnvironment.Development,
filterNames: ['TEST_VAR_1'],
});
});
it('processes file type variable in non-interactive mode with --type file and --value', async () => {
const testFilePath = '/path/to/test-file.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
const testFileName = 'test-file.json';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
};
const mockUpdatedVariable = {
...mockVariable,
type: EnvironmentSecretType.FileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
// Mock fs.pathExists to return true
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
// Mock fs.readFile to return base64 encoded content
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--type',
'file',
'--value',
testFilePath,
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify file was checked for existence
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
// Verify file was read as base64
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
// Verify update was called with FileBase64 type and base64-encoded value
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: testFileBase64,
environments: [DefaultEnvironment.Production],
type: EnvironmentSecretType.FileBase64,
fileName: testFileName,
});
});
it('throws error when updating FILE type variable with value that is not a valid file path', async () => {
const plainStringValue = 'some-new-value';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.FileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
// Mock fs.pathExists to return false (file doesn't exist)
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--value',
plainStringValue,
'--environment',
'production',
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
// Should throw error when FILE type variable is updated with non-file value
await expect(command.runAsync()).rejects.toThrow(
`Variable "TEST_VARIABLE" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
});
it('throws error when --type file is explicitly specified but file does not exist', async () => {
const nonExistentFilePath = '/path/to/nonexistent-file.json';
const mockVariable = {
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
// Mock fs.pathExists to return false (file doesn't exist)
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
const command = new EnvUpdate(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--non-interactive',
'--type',
'file',
'--value',
nonExistentFilePath,
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
// Should throw error when --type file is explicitly specified
await expect(command.runAsync()).rejects.toThrow(
`File "${nonExistentFilePath}" does not exist`
);
});
it('preserves existing STRING type when updating value without --type flag', async () => {
const stringValue = 'my-best-file.json';
const mockVariable = {
id: variableId,
name: 'MY_VAR',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.String,
value: 'old-value',
visibility: EnvironmentVariableVisibility.Public,
};
const mockUpdatedVariable = {
...mockVariable,
value: stringValue,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
const command = new EnvUpdate(
[
'--variable-name',
'MY_VAR',
'--variable-environment',
'development',
'--non-interactive',
'--value',
stringValue,
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify update was called with preserved STRING type
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: stringValue,
type: EnvironmentSecretType.String,
});
});
it('preserves existing FILE type when updating value without --type flag', async () => {
const testFilePath = '/path/to/test-file.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
const testFileName = 'test-file.json';
const mockVariable = {
id: variableId,
name: 'MY_FILE_VAR',
scope: EnvironmentVariableScope.Project,
environments: [DefaultEnvironment.Development],
type: EnvironmentSecretType.FileBase64,
value: 'old-base64-value',
visibility: EnvironmentVariableVisibility.Public,
};
const mockUpdatedVariable = {
...mockVariable,
value: testFileBase64,
};
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
// Mock fs.pathExists to return true
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
// Mock fs.readFile to return base64 encoded content
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
const command = new EnvUpdate(
[
'--variable-name',
'MY_FILE_VAR',
'--variable-environment',
'development',
'--non-interactive',
'--value',
testFilePath,
// Note: --type is NOT specified
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
// Verify file was checked for existence
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
// Verify file was read as base64
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
// Verify update was called with preserved FILE type
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: variableId,
value: testFileBase64,
type: EnvironmentSecretType.FileBase64,
fileName: testFileName,
});
});
});
+22 -25
View File
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
@@ -13,7 +14,6 @@ import {
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
@@ -33,7 +33,6 @@ import {
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
interface RawCreateFlags {
name?: string;
@@ -42,7 +41,7 @@ interface RawCreateFlags {
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EASEnvironmentVariableScopeFlagValue;
environment?: EnvironmentVariableEnvironment[];
environment?: string[];
'non-interactive': boolean;
}
@@ -53,7 +52,7 @@ interface CreateFlags {
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EnvironmentVariableScope;
environment?: EnvironmentVariableEnvironment[];
environment?: string[];
'non-interactive': boolean;
}
@@ -64,7 +63,7 @@ export default class EnvCreate extends EasCommand {
{
name: 'environment',
description:
"Environment to create the variable in. One of 'production', 'preview', or 'development'.",
"Environment to create the variable in. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -101,6 +100,13 @@ export default class EnvCreate extends EasCommand {
const validatedFlags = this.sanitizeFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvCreate, {
nonInteractive: validatedFlags['non-interactive'],
});
const {
name,
value,
@@ -111,14 +117,7 @@ export default class EnvCreate extends EasCommand {
force,
type,
fileName,
} = await this.promptForMissingFlagsAsync(validatedFlags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvCreate, {
nonInteractive,
});
} = await this.promptForMissingFlagsAsync(validatedFlags, args, { graphqlClient, projectId });
const [projectDisplayName, ownerAccount] = await Promise.all([
getDisplayNameForProjectIdAsync(graphqlClient, projectId),
@@ -220,7 +219,6 @@ export default class EnvCreate extends EasCommand {
value,
visibility,
environments,
isGlobal: true, // TODO: every account-wide variable is global for now so it's not user facing
type: type ?? EnvironmentSecretType.String,
},
ownerAccount.id
@@ -273,7 +271,8 @@ export default class EnvCreate extends EasCommand {
type,
...rest
}: CreateFlags,
{ environment }: { environment?: string }
{ environment }: { environment?: string },
{ graphqlClient, projectId }: { graphqlClient: ExpoGraphqlClient; projectId: string }
): Promise<
Required<
Omit<CreateFlags, 'type' | 'visibility'> & {
@@ -324,18 +323,16 @@ export default class EnvCreate extends EasCommand {
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
let newEnvironments = environments
? environments
: environment
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
: undefined;
let newEnvironments = environments ? environments : environment ? [environment] : undefined;
if (!newEnvironments) {
newEnvironments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
newEnvironments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
canEnterCustomEnvironment: true,
graphqlClient,
projectId,
});
if (!newEnvironments || newEnvironments.length === 0) {
throw new Error('No environments selected');
+6 -13
View File
@@ -9,23 +9,23 @@ import {
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
import { EnvironmentVariableScope } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
import { formatVariableName } from '../../utils/variableUtils';
interface DeleteFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
scope: EnvironmentVariableScope;
}
interface RawDeleteFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
scope: EASEnvironmentVariableScopeFlagValue;
}
@@ -37,7 +37,7 @@ export default class EnvDelete extends EasCommand {
'variable-name': Flags.string({
description: 'Name of the variable to delete',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to delete',
}),
@@ -49,7 +49,7 @@ export default class EnvDelete extends EasCommand {
{
name: 'environment',
description:
"Current environment of the variable to delete. One of 'production', 'preview', or 'development'.",
"Current environment of the variable to delete. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -165,13 +165,6 @@ export default class EnvDelete extends EasCommand {
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return { ...flags, 'variable-environment': environment, scope };
}
+9 -13
View File
@@ -4,21 +4,19 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
type ParsedFlags =
| {
nonInteractive: true;
environment: EnvironmentVariableEnvironment;
environment: string;
command: string;
}
| {
nonInteractive: false;
environment?: EnvironmentVariableEnvironment;
environment?: string;
command: string;
};
@@ -44,7 +42,7 @@ export default class EnvExec extends EasCommand {
name: 'environment',
required: true,
description:
"Environment to execute the command in. One of 'production', 'preview', or 'development'.",
"Environment to execute the command in. Default environments are 'production', 'preview', and 'development'.",
},
{
name: 'bash_command',
@@ -70,7 +68,11 @@ export default class EnvExec extends EasCommand {
const environment =
parsedFlags.environment ??
(await promptVariableEnvironmentAsync({ nonInteractive: parsedFlags.nonInteractive }));
(await promptVariableEnvironmentAsync({
nonInteractive: parsedFlags.nonInteractive,
graphqlClient,
projectId,
}));
const environmentVariables = await this.loadEnvironmentVariablesAsync({
graphqlClient,
projectId,
@@ -101,12 +103,6 @@ export default class EnvExec extends EasCommand {
);
}
environment = environment?.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const firstChar = bash_command[0];
const lastChar = bash_command[bash_command.length - 1];
const cleanCommand =
@@ -198,7 +194,7 @@ export default class EnvExec extends EasCommand {
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
environment: EnvironmentVariableEnvironment;
environment: string;
nonInteractive: boolean;
}): Promise<Record<string, string>> {
const environmentVariablesQueryResult =
+8 -13
View File
@@ -11,7 +11,6 @@ import {
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
@@ -19,11 +18,11 @@ import {
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { promptVariableEnvironmentAsync, promptVariableNameAsync } from '../../utils/prompts';
import { formatVariable, formatVariableValue, isEnvironment } from '../../utils/variableUtils';
import { formatVariable, formatVariableValue } from '../../utils/variableUtils';
interface RawGetFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
format?: string;
scope: EASEnvironmentVariableScopeFlagValue;
@@ -31,7 +30,7 @@ interface RawGetFlags {
interface GetFlags {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
format?: string;
scope: EnvironmentVariableScope;
@@ -49,7 +48,7 @@ export default class EnvGet extends EasCommand {
{
name: 'environment',
description:
"Current environment of the variable. One of 'production', 'preview', or 'development'.",
"Current environment of the variable. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -58,7 +57,7 @@ export default class EnvGet extends EasCommand {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable',
}),
@@ -93,6 +92,8 @@ export default class EnvGet extends EasCommand {
environment = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: false,
graphqlClient,
projectId,
});
}
@@ -156,12 +157,6 @@ export default class EnvGet extends EasCommand {
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
...flags,
'variable-environment': environment,
@@ -177,7 +172,7 @@ async function getVariablesAsync(
scope: string,
projectId: string,
name: string | undefined,
environment: EnvironmentVariableEnvironment | undefined
environment: string | undefined
): Promise<EnvironmentVariableFragment[]> {
if (!name) {
throw new Error(
+12 -12
View File
@@ -9,7 +9,7 @@ import {
EASMultiEnvironmentFlag,
EASVariableFormatFlag,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment, EnvironmentVariableScope } from '../../graphql/generated';
import { EnvironmentVariableScope } from '../../graphql/generated';
import {
EnvironmentVariableWithFileContent,
EnvironmentVariablesQuery,
@@ -19,7 +19,6 @@ import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import {
formatVariable,
formatVariableValue,
isEnvironment,
performForEnvironmentsAsync,
} from '../../utils/variableUtils';
@@ -35,7 +34,7 @@ async function getVariablesForScopeAsync(
scope: EnvironmentVariableScope;
includingSensitive: boolean;
includeFileContent: boolean;
environment?: EnvironmentVariableEnvironment;
environment?: string;
projectId: string;
}
): Promise<EnvironmentVariableWithFileContent[]> {
@@ -70,7 +69,7 @@ async function getVariablesForScopeAsync(
interface RawListFlags {
scope: EASEnvironmentVariableScopeFlagValue;
format: string;
environment: EnvironmentVariableEnvironment[] | undefined;
environment: string[] | undefined;
'include-sensitive': boolean;
'include-file-content': boolean;
'non-interactive'?: boolean;
@@ -79,7 +78,7 @@ interface RawListFlags {
interface ListFlags {
scope: EnvironmentVariableScope;
format: string;
environment: EnvironmentVariableEnvironment[] | undefined;
environment: string[] | undefined;
'include-sensitive': boolean;
'include-file-content': boolean;
'non-interactive': boolean;
@@ -111,7 +110,7 @@ export default class EnvList extends EasCommand {
{
name: 'environment',
description:
"Environment to list the variables from. One of 'production', 'preview', or 'development'.",
"Environment to list the variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -136,7 +135,12 @@ export default class EnvList extends EasCommand {
});
if (!environments) {
environments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
graphqlClient,
projectId,
});
}
await performForEnvironmentsAsync(environments, async environment => {
@@ -179,14 +183,10 @@ export default class EnvList extends EasCommand {
flags: RawListFlags,
{ environment }: { environment?: string }
): ListFlags {
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
? [environment]
: undefined;
return {
+10 -11
View File
@@ -13,7 +13,6 @@ import {
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
export default class EnvPull extends EasCommand {
static override description =
@@ -29,7 +28,7 @@ export default class EnvPull extends EasCommand {
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
"Environment to pull variables from. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -49,15 +48,7 @@ export default class EnvPull extends EasCommand {
flags: { environment: flagEnvironment, path: targetPath, 'non-interactive': nonInteractive },
} = await this.parse(EnvPull);
let environment = flagEnvironment?.toUpperCase() ?? argEnvironment?.toUpperCase();
if (!environment) {
environment = await promptVariableEnvironmentAsync({ nonInteractive });
}
if (!isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
let environment = flagEnvironment?.toLowerCase() ?? argEnvironment?.toLowerCase();
const {
projectId,
@@ -67,6 +58,14 @@ export default class EnvPull extends EasCommand {
nonInteractive,
});
if (!environment) {
environment = await promptVariableEnvironmentAsync({
nonInteractive,
graphqlClient,
projectId,
});
}
targetPath = targetPath ?? '.env.local';
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
+63 -55
View File
@@ -6,20 +6,16 @@ import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
import {
EnvironmentVariableEnvironment,
CreateEnvironmentVariableInput,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import {
EnvironmentVariableMutation,
EnvironmentVariablePushInput,
} from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { isEnvironment } from '../../utils/variableUtils';
export default class EnvPush extends EasCommand {
static override description =
@@ -36,13 +32,17 @@ export default class EnvPush extends EasCommand {
description: 'Path to the input `.env` file',
default: '.env.local',
}),
force: Flags.boolean({
description: 'Skip confirmation and automatically override existing variables',
default: false,
}),
};
static override args = [
{
name: 'environment',
description:
"Environment to push variables to. One of 'production', 'preview', or 'development'.",
"Environment to push variables to. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -50,7 +50,7 @@ export default class EnvPush extends EasCommand {
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvPush);
let { environment: environments, path: envPath } = this.parseFlagsAndArgs(flags, args);
let { environment: environments, path: envPath, force } = this.parseFlagsAndArgs(flags, args);
const {
projectId,
@@ -63,11 +63,13 @@ export default class EnvPush extends EasCommand {
environments = await promptVariableEnvironmentAsync({
nonInteractive: false,
multiple: true,
canEnterCustomEnvironment: true,
graphqlClient,
projectId,
});
}
const updateVariables: Record<string, EnvironmentVariablePushInput> =
await this.parseEnvFileAsync(envPath, environments);
const updateVariables = await this.parseEnvFileAsync(envPath, environments);
const variableNames = Object.keys(updateVariables);
@@ -117,40 +119,46 @@ export default class EnvPush extends EasCommand {
Log.warn(`Some variables already exist in the ${displayedEnvironment} environment.`);
const variableNames = existingDifferentVariables.map(variable => variable.name);
const confirmationMessage =
variableNames.length > 1
? `The ${variableNames.join(
', '
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
const confirm = await confirmAsync({
message: confirmationMessage,
});
let variablesToOverwrite: string[] = [];
if (!confirm && existingDifferentVariables.length === 0) {
throw new Error('No new variables to push.');
}
if (confirm) {
if (force) {
// When --force is used, automatically override all existing variables
Log.log('Using --force flag: automatically overriding existing variables.');
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
} else {
const promptResult = await promptAsync({
type: 'multiselect',
name: 'variablesToOverwrite',
message: 'Select variables to overwrite:',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: existingDifferentVariables.map(variable => ({
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
variable.value ?? '(secret)'
})`,
value: variable.name,
})),
const confirmationMessage =
variableNames.length > 1
? `The ${variableNames.join(
', '
)} environment variables already exist in ${displayedEnvironment} environment. Do you want to override them all?`
: `The ${variableNames[0]} environment variable already exists in ${displayedEnvironment} environment. Do you want to override it?`;
const confirm = await confirmAsync({
message: confirmationMessage,
});
variablesToOverwrite = promptResult.variablesToOverwrite;
if (!confirm && existingDifferentVariables.length === 0) {
throw new Error('No new variables to push.');
}
if (confirm) {
variablesToOverwrite = existingDifferentVariables.map(variable => variable.name);
} else {
const promptResult = await promptAsync({
type: 'multiselect',
name: 'variablesToOverwrite',
message: 'Select variables to overwrite:',
// @ts-expect-error property missing from `@types/prompts`
optionsPerPage: 20,
choices: existingDifferentVariables.map(variable => ({
title: `${variable.name}: ${updateVariables[variable.name].value} (was ${
variable.value ?? '(secret)'
})`,
value: variable.name,
})),
});
variablesToOverwrite = promptResult.variablesToOverwrite;
}
}
for (const existingVariable of existingVariables) {
@@ -168,7 +176,7 @@ export default class EnvPush extends EasCommand {
variable => variable.visibility !== EnvironmentVariableVisibility.Public
);
if (existingSensitiveVariables.length > 0) {
if (existingSensitiveVariables.length > 0 && !force) {
const existingSensitiveVariablesNames = existingSensitiveVariables.map(
variable => `- ${variable.name}`
);
@@ -180,6 +188,8 @@ export default class EnvPush extends EasCommand {
if (!confirm) {
throw new Error('Aborting...');
}
} else if (existingSensitiveVariables.length > 0 && force) {
Log.log('Using --force flag: automatically overriding sensitive variables.');
}
}
@@ -199,16 +209,14 @@ export default class EnvPush extends EasCommand {
}
parseFlagsAndArgs(
flags: { path: string; environment: EnvironmentVariableEnvironment[] | undefined },
flags: {
path: string;
environment: string[] | undefined;
force: boolean;
},
{ environment }: Record<string, string>
): { environment?: EnvironmentVariableEnvironment[]; path: string } {
if (environment && !isEnvironment(environment.toUpperCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments =
flags.environment ??
(environment ? [environment.toUpperCase() as EnvironmentVariableEnvironment] : undefined);
): { environment?: string[]; path: string; force: boolean } {
const environments = flags.environment ?? (environment ? [environment] : undefined);
return {
...flags,
@@ -218,12 +226,12 @@ export default class EnvPush extends EasCommand {
private async parseEnvFileAsync(
envPath: string,
environments: EnvironmentVariableEnvironment[]
): Promise<Record<string, EnvironmentVariablePushInput>> {
environments: string[]
): Promise<Record<string, CreateEnvironmentVariableInput & { environments: string[] }>> {
if (!(await fs.exists(envPath))) {
throw new Error(`File ${envPath} does not exist.`);
}
const pushInput: Record<string, EnvironmentVariablePushInput> = {};
const pushInput: Awaited<ReturnType<typeof this.parseEnvFileAsync>> = {};
const variables: Record<string, string> = dotenv.parse(await fs.readFile(envPath, 'utf8'));
@@ -246,9 +254,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,
};
}
+46 -36
View File
@@ -5,6 +5,7 @@ import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
@@ -15,7 +16,6 @@ import {
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
EnvironmentVariableEnvironment,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
@@ -36,17 +36,17 @@ import {
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
import { formatVariableName } from '../../utils/variableUtils';
interface RawUpdateFlags {
name?: string;
value?: string;
scope: EASEnvironmentVariableScopeFlagValue;
environment?: EnvironmentVariableEnvironment[];
environment?: string[];
visibility?: 'plaintext' | 'sensitive' | 'secret';
type?: 'string' | 'file';
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
}
@@ -54,11 +54,11 @@ interface UpdateFlags {
name?: string;
value?: string;
scope: EnvironmentVariableScope;
environment?: EnvironmentVariableEnvironment[];
environment?: string[];
visibility?: 'plaintext' | 'sensitive' | 'secret';
type?: 'string' | 'file';
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'variable-environment'?: string;
'non-interactive': boolean;
}
@@ -69,7 +69,7 @@ export default class EnvUpdate extends EasCommand {
'variable-name': Flags.string({
description: 'Current name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
'variable-environment': Flags.string({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to update',
}),
@@ -93,7 +93,7 @@ export default class EnvUpdate extends EasCommand {
{
name: 'environment',
description:
"Current environment of the variable to update. One of 'production', 'preview', or 'development'.",
"Current environment of the variable to update. Default environments are 'production', 'preview', and 'development'.",
required: false,
},
];
@@ -180,15 +180,19 @@ export default class EnvUpdate extends EasCommand {
visibility: newVisibility,
type: newType,
fileName,
} = await this.promptForMissingFlagsAsync(selectedVariable, {
name,
value: rawValue,
environment: environments,
visibility,
'non-interactive': nonInteractive,
type,
scope,
});
} = await this.promptForMissingFlagsAsync(
selectedVariable,
{
name,
value: rawValue,
environment: environments,
visibility,
'non-interactive': nonInteractive,
type,
scope,
},
{ graphqlClient, projectId }
);
const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: selectedVariable.id,
@@ -226,12 +230,6 @@ export default class EnvUpdate extends EasCommand {
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
...flags,
'variable-environment': environment,
@@ -252,7 +250,8 @@ export default class EnvUpdate extends EasCommand {
'non-interactive': nonInteractive,
type,
...rest
}: UpdateFlags
}: UpdateFlags,
{ graphqlClient, projectId }: { graphqlClient: ExpoGraphqlClient; projectId: string }
): Promise<
Omit<UpdateFlags, 'type' | 'visibility'> & {
type?: EnvironmentSecretType;
@@ -300,23 +299,14 @@ export default class EnvUpdate extends EasCommand {
}
}
let environmentFilePath: string | undefined;
if ((newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64 && value) {
environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
throw new Error(`File "${value}" does not exist`);
}
fileName = path.basename(environmentFilePath);
}
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (!environments || environments.length === 0) {
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
canEnterCustomEnvironment: true,
selectedEnvironments: selectedVariable.environments ?? [],
graphqlClient,
projectId,
});
if (
@@ -340,6 +330,26 @@ export default class EnvUpdate extends EasCommand {
}
}
// If value is provided but type is not explicitly set, preserve the existing type
if (value && !newType) {
newType = selectedVariable.type;
}
if (newType === EnvironmentSecretType.FileBase64 && value) {
const environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
if (type === 'file') {
throw new Error(`File "${value}" does not exist`);
} else {
throw new Error(
`Variable "${selectedVariable.name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
}
}
fileName = path.basename(environmentFilePath);
value = await fs.readFile(environmentFilePath, 'base64');
}
if (visibility) {
newVisibility = parseVisibility(visibility);
}
@@ -23,7 +23,6 @@ import {
AppPlatform,
BuildFragment,
BuildStatus,
EnvironmentVariableEnvironment,
FingerprintFragment,
UpdateFragment,
} from '../../graphql/generated';
@@ -97,7 +96,7 @@ export default class FingerprintCompare extends EasCommand {
open: Flags.boolean({
description: 'Open the fingerprint comparison in the browser',
}),
environment: Flags.enum<EnvironmentVariableEnvironment>({
environment: Flags.string({
...EasEnvironmentFlagParameters,
description:
'If generating a fingerprint from the local directory, use the specified environment.',
@@ -15,7 +15,7 @@ import {
getFingerprintInfoFromLocalProjectForPlatformsAsync,
stringToAppPlatform,
} from '../../fingerprint/utils';
import { AppPlatform, EnvironmentVariableEnvironment } from '../../graphql/generated';
import { AppPlatform } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { link } from '../../log';
import { promptAsync } from '../../prompts';
@@ -39,7 +39,7 @@ export default class FingerprintGenerate extends EasCommand {
options: ['android', 'ios'],
}),
...EASEnvironmentFlag,
environment: Flags.enum<EnvironmentVariableEnvironment>({
environment: Flags.string({
...EasEnvironmentFlagParameters,
exclusive: ['build-profile'],
}),
@@ -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,121 @@
import { ExpoConfig } from '@expo/config';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import New, { createProjectAsync, generateConfigsAsync } from '../new';
jest.mock('fs-extra', () => ({
...jest.requireActual('fs-extra'),
pathExists: jest.fn().mockResolvedValue(false),
}));
jest.mock('../../../prompts', () => ({
promptAsync: jest.fn(),
}));
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../project/expoConfig');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../user/User', () => ({
getActorUsername: jest.fn().mockReturnValue('jester'),
}));
jest.mock('../../../ora');
jest.mock('fs-extra');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../api');
jest.mock('../../../build/utils/url');
jest.mock('../../../utils/easCli', () => ({
easCliVersion: '5.0.0',
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync', () => ({
findProjectIdByAccountNameAndSlugNullableAsync: jest.fn().mockResolvedValue(null),
}));
jest.mock('../../../graphql/mutations/AppMutation', () => ({
AppMutation: {
createAppAsync: jest.fn().mockResolvedValue('test-project-id'),
},
}));
jest.mock('../../../project/expoConfig', () => ({
getPrivateExpoConfigAsync: jest.fn().mockResolvedValue({ extra: {} }),
createOrModifyExpoConfigAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../../prompts', () => ({
...jest.requireActual('../../../prompts'),
promptAsync: jest.fn().mockImplementation(async (options: any) => {
if (options.name === 'account') {
return { account: { name: 'jester' } };
}
if (options.name === 'name') {
return { name: options.initial || 'expo-project' };
}
return {};
}),
}));
describe(New.name, () => {
const mockGraphqlClient = {} as ExpoGraphqlClient;
describe('generateConfigsAsync', () => {
it('should orchestrate account selection and project configuration', async () => {
const result = await generateConfigsAsync({ path: undefined }, jester, mockGraphqlClient);
expect(result).toEqual({
projectAccount: 'jester',
projectDirectory: expect.stringContaining('/expo-project'),
projectName: 'expo-project',
});
});
it('should pass through path argument to config generation', async () => {
const customPath = 'my-custom-project';
const result = await generateConfigsAsync({ path: customPath }, jester, mockGraphqlClient);
expect(result).toEqual({
projectAccount: 'jester',
projectDirectory: expect.stringContaining('/my-custom-project'),
projectName: 'my-custom-project',
});
});
});
describe('createProjectAsync', () => {
it('should create a project', async () => {
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('test-project-id');
jest
.mocked(getPrivateExpoConfigAsync)
.mockResolvedValue({ name: 'name', slug: 'slug' } as ExpoConfig);
jest.mocked(createOrModifyExpoConfigAsync);
const result = await createProjectAsync({
graphqlClient: mockGraphqlClient,
actor: jester,
projectDirectory: 'test-project',
projectAccount: 'jester',
projectName: 'test',
});
expect(result).toBe('test-project-id');
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: jester.accounts[0].id,
projectName: 'test',
});
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
'test-project',
{
extra: { eas: { projectId: 'test-project-id' } },
},
{ skipSDKVersionRequirement: true }
);
});
});
});
@@ -0,0 +1,213 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
cloneTemplateAsync,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
} from '../../commandUtils/new/commands';
import {
generateProjectConfigAsync,
promptForProjectAccountAsync,
} from '../../commandUtils/new/configs';
import {
copyProjectTemplatesAsync,
generateAppConfigAsync,
generateEasConfigAsync,
updatePackageJsonAsync,
updateReadmeAsync,
} from '../../commandUtils/new/projectFiles';
import { printDirectory } from '../../commandUtils/new/utils';
import { AppFragment } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { PACKAGE_MANAGERS, PackageManager } from '../../onboarding/installDependencies';
import { ora } from '../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { Actor } from '../../user/User';
export async function generateConfigsAsync(
args: { path?: string },
actor: Actor,
graphqlClient: ExpoGraphqlClient
): Promise<{
projectName: string;
projectDirectory: string;
projectAccount: string;
}> {
const projectAccount = await promptForProjectAccountAsync(actor);
const { projectName, projectDirectory } = await generateProjectConfigAsync(args.path, {
graphqlClient,
projectAccount,
});
return {
projectAccount,
projectDirectory,
projectName,
};
}
export async function createProjectAsync({
graphqlClient,
actor,
projectDirectory,
projectAccount,
projectName,
}: {
graphqlClient: ExpoGraphqlClient;
actor: Actor;
projectDirectory: string;
projectAccount: string;
projectName: string;
}): Promise<string> {
const projectFullName = `@${projectAccount}/${projectName}`;
const projectDashboardUrl = getProjectDashboardUrl(projectAccount, projectName);
const projectLink = link(projectDashboardUrl, { text: projectFullName });
const account = nullthrows(actor.accounts.find(a => a.name === projectAccount));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let projectId: string;
try {
projectId = await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
projectName,
});
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
} catch (err) {
spinner.fail();
throw err;
}
const exp = await getPrivateExpoConfigAsync(projectDirectory, { skipPlugins: true });
await createOrModifyExpoConfigAsync(
projectDirectory,
{
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
},
{ skipSDKVersionRequirement: true }
);
Log.withInfo(`Project successfully linked (ID: ${chalk.bold(projectId)})`);
return projectId;
}
export async function generateProjectFilesAsync(
projectDir: string,
app: AppFragment,
packageManager: PackageManager
): Promise<void> {
const spinner = ora(`Generating project files`).start();
await generateAppConfigAsync(projectDir, app);
await generateEasConfigAsync(projectDir);
await updatePackageJsonAsync(projectDir);
await copyProjectTemplatesAsync(projectDir);
await updateReadmeAsync(projectDir, packageManager);
spinner.succeed(`Generated project files`);
Log.withInfo(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.withInfo(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
}
export default class New extends EasCommand {
static override aliases = ['new'];
static override description =
'Create a new project configured with Expo Application Services (EAS)';
static override args = [
{
name: 'path',
description: 'Path to create the project (defaults to current directory)',
required: false,
},
];
static override flags = {
'package-manager': Flags.enum<PackageManager>({
char: 'p',
description: 'Package manager to use for installing dependencies',
options: [...PACKAGE_MANAGERS],
default: 'npm',
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(New);
const {
loggedIn: { actor, graphqlClient },
} = 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}!`);
const {
projectName,
projectDirectory: targetProjectDirectory,
projectAccount,
} = await generateConfigsAsync(args, actor, graphqlClient);
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
const packageManager = flags['package-manager'];
await installProjectDependenciesAsync(projectDirectory, packageManager);
const projectId = await createProjectAsync({
projectDirectory,
projectAccount,
projectName,
actor,
graphqlClient,
});
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
await generateProjectFilesAsync(projectDirectory, app, packageManager);
await initializeGitRepositoryAsync(projectDirectory);
Log.log('🎉 We finished creating your new project.');
Log.newLine();
Log.log('Next steps:');
Log.withInfo(
`Run ${chalk.bold(`cd ${printDirectory(projectDirectory)}`)} to navigate to your project.`
);
Log.withInfo(
`Run ${chalk.bold(`${packageManager} run draft`)} to create a preview on EAS. ${learnMore(
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
)}`
);
Log.withInfo(
`Run ${chalk.bold(`${packageManager} run start`)} to start developing locally. ${learnMore(
'https://docs.expo.dev/get-started/start-developing/'
)}`
);
Log.withInfo(`See the ${chalk.bold('README.md')} for more information about your project.`);
}
}
@@ -152,7 +152,7 @@ export default class Onboarding extends EasCommand {
}))
? 'ssh'
: 'https';
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preffered git clone method`));
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
Log.log();
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
@@ -10,7 +10,6 @@ import {
AndroidSubmissionConfigInput,
IosSubmissionConfigInput,
SubmissionAndroidReleaseStatus,
SubmissionAndroidTrack,
} from '../../graphql/generated';
import { AppStoreConnectApiKeyQuery } from '../../graphql/queries/AppStoreConnectApiKeyQuery';
import { GoogleServiceAccountKeyQuery } from '../../graphql/queries/GoogleServiceAccountKeyQuery';
@@ -141,12 +140,11 @@ export default class SubmitInternal extends EasCommand {
graphqlClient,
})
);
const track = graphQlTrackToConfigTrack[androidConfig.track];
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> = {
changesNotSentForReview,
googleServiceAccountKeyJson,
track,
track: androidConfig.track,
...(releaseStatus === SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS
? {
releaseStatus: SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS,
@@ -217,10 +215,3 @@ const graphQlReleaseStatusToConfigReleaseStatus = {
[SubmissionAndroidReleaseStatus.Completed]: SubmissionConfig.Android.ReleaseStatus.COMPLETED,
[SubmissionAndroidReleaseStatus.Halted]: SubmissionConfig.Android.ReleaseStatus.HALTED,
};
const graphQlTrackToConfigTrack = {
[SubmissionAndroidTrack.Production]: SubmissionConfig.Android.ReleaseTrack.PRODUCTION,
[SubmissionAndroidTrack.Beta]: SubmissionConfig.Android.ReleaseTrack.BETA,
[SubmissionAndroidTrack.Alpha]: SubmissionConfig.Android.ReleaseTrack.ALPHA,
[SubmissionAndroidTrack.Internal]: SubmissionConfig.Android.ReleaseTrack.INTERNAL,
};
@@ -39,7 +39,7 @@ export default class UpdateConfigure extends EasCommand {
vcsClient,
} = await this.getContextAsync(UpdateConfigure, {
nonInteractive: flags['non-interactive'],
withServerSideEnvironment: flags['environment'],
withServerSideEnvironment: flags['environment'] ?? null,
});
Log.log(
+69 -62
View File
@@ -4,9 +4,8 @@ import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { getExpoWebsiteBaseUrl } from '../../api';
// import { getExpoWebsiteBaseUrl } from '../../api';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { transformFingerprintSource } from '../../build/graphql';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
@@ -15,7 +14,6 @@ import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
AssetMapSourceInput,
EnvironmentVariableEnvironment,
FingerprintInfoGroup as GraphqlFingerprintInfoGroup,
PublishUpdateGroupInput,
StatuspageServiceName,
@@ -24,7 +22,7 @@ import {
UpdateRolloutInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
// import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
@@ -33,12 +31,11 @@ import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprin
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
RawAsset,
UpdatePublishPlatform,
buildBundlesAsync,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
filterCollectedAssetsByRequestedPlatforms,
findCompatibleBuildsAsync,
// findCompatibleBuildsAsync,
generateEasMetadataAsync,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
@@ -53,7 +50,12 @@ import {
} from '../../project/publish';
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
import {
UpdatePublishPlatform,
getUpdateJsonInfosForUpdates,
isBundleDiffingEnabled,
prewarmDiffingAsync,
} from '../../update/utils';
import {
checkManifestBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
@@ -80,7 +82,7 @@ type RawUpdateFlags = {
'rollout-percentage'?: number;
'non-interactive': boolean;
json: boolean;
environment: EnvironmentVariableEnvironment | null;
environment?: string;
};
type UpdateFlags = {
@@ -97,7 +99,7 @@ type UpdateFlags = {
rolloutPercentage?: number;
json: boolean;
nonInteractive: boolean;
environment: EnvironmentVariableEnvironment | null;
environment?: string;
};
export default class UpdatePublish extends EasCommand {
@@ -194,7 +196,7 @@ export default class UpdatePublish extends EasCommand {
getServerSideEnvironmentVariablesAsync,
} = await this.getContextAsync(UpdatePublish, {
nonInteractive,
withServerSideEnvironment: environment,
withServerSideEnvironment: environment ?? null,
});
if (jsonFlag) {
@@ -488,10 +490,7 @@ export default class UpdatePublish extends EasCommand {
(prev, [platform, fingerprintInfo]) => {
return {
...prev,
[platform]: {
...fingerprintInfo,
fingerprintSource: transformFingerprintSource(fingerprintInfo.fingerprintSource),
},
[platform]: fingerprintInfo,
};
},
{} as GraphqlFingerprintInfoGroup
@@ -576,6 +575,10 @@ export default class UpdatePublish extends EasCommand {
throw e;
}
if (isBundleDiffingEnabled(exp)) {
await prewarmDiffingAsync(graphqlClient, projectId, newUpdates);
}
if (!skipBundler && emitMetadata) {
Log.log('Generating eas-update-metadata.json');
await generateEasMetadataAsync(distRoot, getUpdateJsonInfosForUpdates(newUpdates));
@@ -594,11 +597,11 @@ export default class UpdatePublish extends EasCommand {
Log.addNewLineIfNone();
const runtimeToCompatibleBuilds = await Promise.all(
runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
findCompatibleBuildsAsync(graphqlClient, projectId, obj)
)
);
// const runtimeToCompatibleBuilds = await Promise.all(
// runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
// findCompatibleBuildsAsync(graphqlClient, projectId, obj)
// )
// );
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
@@ -672,52 +675,56 @@ export default class UpdatePublish extends EasCommand {
Log.addNewLineIfNone();
}
const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
)?.fingerprintInfoGroupWithCompatibleBuilds;
if (fingerprintsWithoutCompatibleBuilds) {
const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
([_platform, fingerprintInfo]) => !fingerprintInfo.build
);
if (missingBuilds.length > 0) {
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
Log.warn('No compatible builds found for the following fingerprints:');
for (const [platform, fingerprintInfo] of missingBuilds) {
const fingerprintUrl = new URL(
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
getExpoWebsiteBaseUrl()
);
Log.warn(
formatFields(
[
{
label: `${this.prettyPlatform(platform)} fingerprint`,
value: fingerprintInfo.fingerprintHash,
},
{ label: 'URL', value: fingerprintUrl.toString() },
],
{
labelFormat: label => ` ${chalk.dim(label)}:`,
}
)
);
Log.addNewLineIfNone();
}
}
}
// NOTE(brentvatne): temporarily disable logging this until we can revisit the formatting
// and the logic for it - it's a bit too aggressive right now, and warns even if you're
// not using EAS Build
//
// const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
// ({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
// )?.fingerprintInfoGroupWithCompatibleBuilds;
// if (fingerprintsWithoutCompatibleBuilds) {
// const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
// ([_platform, fingerprintInfo]) => !fingerprintInfo.build
// );
// if (missingBuilds.length > 0) {
// const project = await AppQuery.byIdAsync(graphqlClient, projectId);
// Log.warn('No compatible builds found for the following fingerprints:');
// for (const [platform, fingerprintInfo] of missingBuilds) {
// const fingerprintUrl = new URL(
// `/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
// getExpoWebsiteBaseUrl()
// );
// Log.warn(
// formatFields(
// [
// {
// label: `${this.prettyPlatform(platform)} fingerprint`,
// value: fingerprintInfo.fingerprintHash,
// },
// { label: 'URL', value: fingerprintUrl.toString() },
// ],
// {
// labelFormat: label => ` ${chalk.dim(label)}:`,
// }
// )
// );
// Log.addNewLineIfNone();
// }
// }
// }
}
}
private prettyPlatform(updatePlatform: string): string {
switch (updatePlatform) {
case 'android':
return 'Android';
case 'ios':
return 'iOS';
default:
return updatePlatform;
}
}
// private prettyPlatform(updatePlatform: string): string {
// switch (updatePlatform) {
// case 'android':
// return 'Android';
// case 'ios':
// return 'iOS';
// default:
// return updatePlatform;
// }
// }
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
@@ -7,7 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { learnMore } from '../../log';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
import { getUpdateMessageForCommandAsync } from '../../project/publish';
import { confirmAsync, promptAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import {
@@ -18,6 +18,7 @@ import {
republishAsync,
} from '../../update/republish';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { UpdatePublishPlatform } from '../../update/utils';
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
@@ -9,7 +9,6 @@ import { StatuspageServiceName } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import {
UpdatePublishPlatform,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getUpdateMessageForCommandAsync,
@@ -17,6 +16,7 @@ import {
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { selectRuntimeOnBranchAsync } from '../../update/queries';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { UpdatePublishPlatform } from '../../update/utils';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
@@ -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,4 +1,8 @@
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
import {
parseInputs,
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../../commandUtils/workflow/inputs';
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
@@ -216,7 +220,6 @@ jobs:
it('should handle invalid YAML gracefully', () => {
const yamlConfig = 'invalid: yaml: content:';
const { parseWorkflowInputsFromYaml } = require('../run');
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
+184 -75
View File
@@ -1,28 +1,31 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs/promises';
import fsExtra from 'fs-extra';
import path from 'path';
import prompts from 'prompts';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
runBuildConfigureIfNeededAsync,
runUpdateConfigureIfNeededAsync,
} from '../../commandUtils/workflow/buildProfileUtils';
import {
WorkflowStarter,
WorkflowStarterName,
customizeTemplateIfNeededAsync,
howToRunWorkflow,
workflowStarters,
} from '../../commandUtils/workflow/creation';
import {
logWorkflowValidationErrors,
validateWorkflowFileAsync,
workflowContentsFromParsedYaml,
} from '../../commandUtils/workflow/validation';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { WorkflowFile } from '../../utils/workflowFile';
const DEFAULT_WORKFLOW_NAME = 'workflow.yml';
const HELLO_WORLD_TEMPLATE = `name: Hello World
on:
push:
branches: ['*']
jobs:
hello_world:
steps:
- uses: eas/checkout
- run: echo "Hello, World"
`;
export class WorkflowCreate extends EasCommand {
static override description = 'create a new workflow configuration YAML file';
@@ -35,11 +38,16 @@ export class WorkflowCreate extends EasCommand {
];
static override flags = {
...EASNonInteractiveFlag,
'skip-validation': Flags.boolean({
description: 'If set, the workflow file will not be validated before being created',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
@@ -48,74 +56,175 @@ export class WorkflowCreate extends EasCommand {
flags,
} = await this.parse(WorkflowCreate);
const { projectDir } = await this.getContextAsync(WorkflowCreate, {
nonInteractive: flags['non-interactive'],
});
try {
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
projectDir,
} = await this.getContextAsync(WorkflowCreate, {
nonInteractive: false,
withServerSideEnvironment: null,
});
let fileName = argFileName;
const { exp: originalExpoConfig, projectId } = await getDynamicPrivateProjectConfigAsync();
let expoConfig = originalExpoConfig;
if (!fileName) {
const response = await prompts({
let workflowStarter;
while (!workflowStarter) {
workflowStarter = await chooseTemplateAsync();
switch (workflowStarter.name) {
case WorkflowStarterName.BUILD:
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE: {
const shouldProceed = await runBuildConfigureIfNeededAsync({
projectDir,
expoConfig,
});
if (!shouldProceed) {
workflowStarter = undefined;
continue;
}
break;
}
default:
break;
}
switch (workflowStarter.name) {
case WorkflowStarterName.DEPLOY:
case WorkflowStarterName.UPDATE: {
const shouldProceed = await runUpdateConfigureIfNeededAsync({
projectDir,
expoConfig,
});
if (!shouldProceed) {
workflowStarter = undefined;
continue;
}
// Need to refetch the Expo config because it may have changed
expoConfig = (await getDynamicPrivateProjectConfigAsync()).exp;
break;
}
default:
break;
}
}
const { fileName, filePath } = await chooseFileNameAsync(
argFileName,
projectDir,
workflowStarter
);
// Customize the template if needed
workflowStarter = await customizeTemplateIfNeededAsync(
workflowStarter,
projectDir,
expoConfig
);
Log.debug(`Creating workflow file ${fileName} from template ${workflowStarter.name}`);
const yamlString = [
workflowStarter.header,
workflowContentsFromParsedYaml(workflowStarter.template),
].join('\n');
if (!flags['skip-validation']) {
await validateWorkflowFileAsync(
{ yamlConfig: yamlString, filePath: fileName },
projectDir,
graphqlClient,
projectId
);
}
await ensureWorkflowsDirectoryExistsAsync({ projectDir });
filePath && (await fs.writeFile(filePath, yamlString));
Log.withTick(`Created ${chalk.bold(filePath)}`);
Log.addNewLineIfNone();
Log.log(howToRunWorkflow(fileName, workflowStarter));
// Next steps
if (workflowStarter.nextSteps && workflowStarter.nextSteps.length > 0) {
Log.addNewLineIfNone();
Log.log('Next steps:');
Log.addNewLineIfNone();
Log.log(
formatFields(
workflowStarter.nextSteps.map((step: string, index: number) => ({
label: `${index + 1}.`,
value: step,
}))
)
);
}
} catch (error) {
logWorkflowValidationErrors(error);
Log.error('Failed to create workflow file.');
}
}
}
async function ensureWorkflowsDirectoryExistsAsync({
projectDir,
}: {
projectDir: string;
}): Promise<void> {
try {
await fs.access(path.join(projectDir, '.eas', 'workflows'));
} catch {
await fs.mkdir(path.join(projectDir, '.eas', 'workflows'), { recursive: true });
Log.withTick(`Created directory ${chalk.bold(path.join(projectDir, '.eas', 'workflows'))}`);
}
}
async function chooseTemplateAsync(): Promise<WorkflowStarter> {
const workflowStarter = (
await promptAsync({
type: 'select',
name: 'starter',
message: 'Select a workflow template:',
choices: workflowStarters.map(starter => ({
title: starter.displayName,
value: starter,
})),
})
).starter;
return workflowStarter;
}
async function chooseFileNameAsync(
initialValue: string | undefined,
projectDir: string,
workflowStarter: WorkflowStarter
): Promise<{ fileName: string; filePath: string }> {
let fileName = initialValue;
let filePath = '';
while ((fileName?.length ?? 0) === 0) {
fileName = (
await promptAsync({
type: 'text',
name: 'fileName',
message: 'What would you like to name your workflow file?',
initial: DEFAULT_WORKFLOW_NAME,
validate: value => {
try {
WorkflowFile.validateYamlExtension(value);
return true;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid file name';
}
},
});
if (!response.fileName) {
Log.warn('Workflow creation cancelled.');
process.exit(0);
}
fileName = response.fileName;
initial: workflowStarter.defaultFileName,
})
).fileName;
if (!fileName) {
fileName = undefined;
continue;
}
try {
await this.ensureWorkflowsDirectoryExistsAsync({ projectDir });
await this.createWorkflowFileAsync({ fileName, projectDir });
WorkflowFile.validateYamlExtension(fileName);
} catch (error) {
Log.error('Failed to create workflow file.');
throw error;
Log.error(error instanceof Error ? error.message : 'Invalid YAML file name extension');
fileName = undefined;
continue;
}
}
private async ensureWorkflowsDirectoryExistsAsync({
projectDir,
}: {
projectDir: string;
}): Promise<void> {
try {
await fs.access(path.join(projectDir, '.eas', 'workflows'));
} catch {
await fs.mkdir(path.join(projectDir, '.eas', 'workflows'), { recursive: true });
Log.withTick(`Created directory ${chalk.bold(path.join(projectDir, '.eas', 'workflows'))}`);
}
}
private async createWorkflowFileAsync({
fileName,
projectDir,
}: {
fileName: string;
projectDir: string;
}): Promise<void> {
WorkflowFile.validateYamlExtension(fileName);
const filePath = path.join(projectDir, '.eas', 'workflows', fileName);
filePath = path.join(projectDir, '.eas', 'workflows', fileName);
if (await fsExtra.pathExists(filePath)) {
throw new Error(`Workflow file already exists: ${filePath}`);
Log.error(`Workflow file already exists: ${filePath}`);
Log.error('Please choose a different file name.');
Log.newLine();
fileName = undefined;
}
await fs.writeFile(filePath, HELLO_WORLD_TEMPLATE);
Log.withTick(`Created ${chalk.bold(filePath)}`);
}
return { fileName: fileName ?? '', filePath };
}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more