Compare commits

...
19 Commits
Author SHA1 Message Date
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
81 changed files with 5660 additions and 2104 deletions

No files matched your search

+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
+42 -2
View File
@@ -8,12 +8,52 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🧹 Chores
## [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))
### 🧹 Chores
- 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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.20.4",
"version": "16.24.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+210 -209
View File
File diff suppressed because it is too large. Load diff
+614 -10
View File
@@ -1470,6 +1470,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environmentVariableEnvironments",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "EnvironmentVariableEnvironment",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environmentVariables",
"description": "Environment variables for an account",
@@ -2251,6 +2275,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "viewerNotificationPreferences",
"description": "Notification preferences of the viewer for this account",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "UNION",
"name": "NotificationPreferenceItem",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "viewerUserPermission",
"description": "Permission info for the viewer on this account",
@@ -3704,6 +3752,162 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AccountNotificationPreference",
"description": "Account-level notification preference",
"fields": [
{
"name": "account",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Account",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "enabled",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationEvent",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
{
"kind": "INTERFACE",
"name": "NotificationPreference",
"ofType": null
}
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AccountNotificationPreferenceInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "accountId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "enabled",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationEvent",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AccountNotificationSubscriptionInput",
@@ -8983,6 +9187,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environmentVariableEnvironments",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "EnvironmentVariableEnvironment",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environmentVariables",
"description": "Environment variables for an app",
@@ -10705,6 +10933,30 @@
"isDeprecated": true,
"deprecationReason": "No longer supported"
},
{
"name": "viewerNotificationPreferences",
"description": "Notification preferences of the viewer for this app",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "UNION",
"name": "NotificationPreferenceItem",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webhooks",
"description": "Webhooks for an app",
@@ -12463,6 +12715,162 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppNotificationPreference",
"description": "App-level notification preference",
"fields": [
{
"name": "app",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "App",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "enabled",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationEvent",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
{
"kind": "INTERFACE",
"name": "NotificationPreference",
"ofType": null
}
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AppNotificationPreferenceInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "appId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "enabled",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationEvent",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "AppNotificationSubscriptionInput",
@@ -39476,8 +39884,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "This method is no longer supported."
},
{
"name": "setPreferences",
@@ -39972,6 +40380,174 @@
}
]
},
{
"kind": "INTERFACE",
"name": "NotificationPreference",
"description": "Base interface for notification preferences",
"fields": [
{
"name": "enabled",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationEvent",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "NotificationType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": [
{
"kind": "OBJECT",
"name": "AccountNotificationPreference",
"ofType": null
},
{
"kind": "OBJECT",
"name": "AppNotificationPreference",
"ofType": null
}
]
},
{
"kind": "UNION",
"name": "NotificationPreferenceItem",
"description": "Union type representing either account-level or app-level notification preferences",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": [
{
"kind": "OBJECT",
"name": "AccountNotificationPreference",
"ofType": null
},
{
"kind": "OBJECT",
"name": "AppNotificationPreference",
"ofType": null
}
]
},
{
"kind": "OBJECT",
"name": "NotificationPreferenceMutation",
"description": null,
"fields": [
{
"name": "setAccountNotificationPreference",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AccountNotificationPreferenceInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "UNION",
"name": "NotificationPreferenceItem",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setAppNotificationPreference",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AppNotificationPreferenceInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "UNION",
"name": "NotificationPreferenceItem",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "NotificationSubscription",
@@ -43679,6 +44255,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "notificationPreference",
"description": "Notification preference management",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "NotificationPreferenceMutation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "notificationSubscription",
"description": "Mutations that modify a NotificationSubscription",
@@ -43692,8 +44284,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use notificationPreference mutation instead"
},
{
"name": "robot",
@@ -46183,8 +46775,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account/App.viewerNotificationPreferences instead"
},
{
"name": "pinnedApps",
@@ -48414,6 +49006,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "jobRun",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "JobRun",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "logFiles",
"description": null,
@@ -53618,8 +54222,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account/App.viewerNotificationPreferences instead"
},
{
"name": "pendingUserInvitations",
@@ -54446,8 +55050,8 @@
}
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use Account/App.viewerNotificationPreferences instead"
},
{
"name": "pinnedApps",
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "16.20.4",
"version": "16.24.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -12,8 +12,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.231",
"@expo/eas-json": "16.20.1",
"@expo/eas-build-job": "1.0.239",
"@expo/eas-json": "16.23.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.221",
@@ -71,7 +71,7 @@
"node-forge": "1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"ora": "5.1.0",
"ora": "9.0.0",
"pkg-dir": "4.2.0",
"pngjs": "7.0.0",
"promise-limit": "2.7.0",
@@ -226,7 +226,7 @@
"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": "tsc --project tsconfig.build.json && yarn copy-new-templates",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
@@ -235,7 +235,8 @@
"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",
@@ -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: {},
@@ -275,6 +285,18 @@ export function getMockWorkflowJobFragment(
};
}
export function getMockWorkflowBuildJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
...getMockWorkflowCustomJobFragment(runId),
type: WorkflowJobType.Build,
outputs: {
build_id: 'build1',
},
};
}
export function getMockWorkflowRunWithJobsFragment(
runID?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
@@ -303,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"}'
);
@@ -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,
@@ -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,
@@ -1,7 +1,7 @@
import { Env } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { EnvironmentVariableEnvironment, isEnvironment } from './utils/environment';
import { DefaultEnvironment } from './utils/environment';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
import Log, { learnMore } from '../log';
@@ -48,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,
@@ -128,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'
)}`
);
+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,
@@ -1,31 +1,5 @@
import { BuildProfile } from '@expo/eas-json';
type Environment = NonNullable<BuildProfile['environment']>;
// Default environments
export enum EnvironmentVariableEnvironment {
export enum DefaultEnvironment {
Development = 'development',
Preview = 'preview',
Production = 'production',
}
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.toLowerCase() as EnvironmentVariableEnvironment
);
}
export function buildProfileEnvironmentToEnvironment(
environment: BuildProfile['environment']
): EnvironmentVariableEnvironment | null {
if (!environment) {
return null;
}
return BuildProfileEnvironmentToEnvironment[environment];
}
@@ -26,7 +26,6 @@ import {
CommandEvent,
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
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 '../../build/utils/environment';
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 '../../../build/utils/environment';
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 -25
View File
@@ -1,17 +1,5 @@
import { Flags } from '@oclif/core';
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
// 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,20 +11,17 @@ export const EasNonInteractiveAndJsonFlags = {
};
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment",
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,
}),
@@ -80,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,138 @@
import { LogSpy } from './testUtils';
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('fs-extra');
describe('commands', () => {
let logSpy: LogSpy;
beforeAll(() => {
logSpy = new LogSpy('log');
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
logSpy.restore();
});
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);
logSpy.expectLogToContain(`📂 Cloning the project to ${targetProjectDir}`);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
});
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);
logSpy.expectLogToContain(`📂 Cloning the project to ${targetProjectDir}`);
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
});
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,
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
showOutput: 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,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['add', '.'],
cwd: projectDir,
showOutput: false,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['commit', '-m', 'Initial commit'],
cwd: projectDir,
showOutput: false,
});
});
});
});
@@ -0,0 +1,270 @@
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,
generateProjectNameVariations,
getAccountChoices,
promptForProjectAccountAsync,
} from '../configs';
jest.mock('../../../prompts');
jest.mock('../../../log');
jest.mock('fs-extra');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
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 generate name and directory when base name is available', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
expect(result).toEqual({
projectName: 'new-expo-project',
projectDirectory: expect.stringContaining('/new-expo-project'),
});
logSpy.expectLogToContain('Using project name: new-expo-project');
});
it('should use username-date when base name exists', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // base name exists
.mockResolvedValueOnce(false); // username-date doesn't exist
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(/^new-expo-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining(result.projectName),
});
logSpy.expectLogToContain('Using alternate project name:');
});
it('should use username-date-shortID when base name and username-date exist', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // base name exists
.mockResolvedValueOnce(true); // username-date exists
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await generateProjectConfigAsync(jester, undefined, mockOptions);
expect(result).toEqual({
projectName: expect.stringMatching(
/^new-expo-project-jester-\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_-]{6}$/
),
projectDirectory: expect.stringContaining(result.projectName),
});
logSpy.expectLogToContain('Using alternate 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(jester, absolutePath, mockOptions);
expect(result).toEqual({
projectName: 'my-project',
projectDirectory: absolutePath,
});
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(jester, relativePath, mockOptions);
expect(result).toEqual({
projectName: 'my-app',
projectDirectory: expect.stringContaining('some/relative/my-app'),
});
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: 'jester (personal account)',
value: { name: 'jester' },
},
{
title: 'other',
value: { name: 'other' },
disabled: true,
description:
'You do not have the required permissions to create projects on this account.',
},
]);
});
});
describe('generateProjectNameVariations', () => {
it('should generate name variations', () => {
const baseName = 'my-project';
const result = generateProjectNameVariations(jester, baseName);
expect(result).toEqual([
'my-project',
expect.stringMatching(/^my-project-jester-\d{4}-\d{2}-\d{2}$/),
expect.stringMatching(/^my-project-jester-\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_-]{6}$/),
]);
});
});
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(
jester,
'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 try alternatives when first name exists locally', async () => {
(fs.pathExists as jest.Mock)
.mockResolvedValueOnce(true) // First exists
.mockResolvedValueOnce(false); // Second available
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const result = await findAvailableProjectNameAsync(
jester,
'test-project',
'/base/path',
mockOptions
);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-jester-'),
});
expect(fs.pathExists).toHaveBeenCalledTimes(2);
});
it('should skip name if local is available but remote exists', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(false);
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValueOnce('project-id') // First name exists remotely
.mockResolvedValueOnce(null); // Second available remotely
const result = await findAvailableProjectNameAsync(
jester,
'test-project',
'/base/path',
mockOptions
);
expect(result).toEqual({
projectName: expect.stringMatching(/^test-project-jester-\d{4}-\d{2}-\d{2}$/),
projectDirectory: expect.stringContaining('/base/path/test-project-jester-'),
});
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(2);
});
it('should throw error when all variations taken', async () => {
(fs.pathExists as jest.Mock).mockResolvedValue(true);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
await expect(
findAvailableProjectNameAsync(jester, 'test-project', '/base/path', mockOptions)
).rejects.toThrow('Unable to find a unique project name');
});
it('should log when using alternative name', async () => {
(fs.pathExists as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
await findAvailableProjectNameAsync(jester, 'test-project', '/base/path', mockOptions);
logSpy.expectLogToContain('Using alternate project name:');
});
});
});
@@ -0,0 +1,385 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { LogSpy } from './testUtils';
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', () => {
let logSpy: LogSpy;
beforeAll(() => {
logSpy = new LogSpy('withTick');
});
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(''));
});
afterAll(() => {
logSpy.restore();
});
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,
});
logSpy.expectLogToContain('Generated app.json');
});
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,
});
logSpy.expectLogToContain('Generated eas.json');
});
});
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: {
preview: 'npx eas-cli@latest workflow:run publish-preview-update.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,
});
logSpy.expectLogToContain('Updated package.json with scripts');
});
});
describe('copyProjectTemplatesAsync', () => {
it('should copy the project templates', async () => {
const projectDir = '/test/project-dir';
await copyProjectTemplatesAsync(projectDir);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('templates/.eas/workflows'),
expect.stringContaining('.eas/workflows'),
{ errorOnExist: false, overwrite: true }
);
logSpy.expectLogToContain('Created EAS workflow files');
});
});
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]*')))
);
logSpy.expectLogToContain('Updated README.md with EAS configuration details');
});
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,68 @@
import fs from 'fs-extra';
import path from 'path';
import Log from '../../log';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
Log.log(`📂 Cloning the project to ${targetProjectDir}`);
Log.newLine();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir,
cloneMethod,
});
return finalTargetProjectDirectory;
}
export async function installProjectDependenciesAsync(
projectDir: string,
packageManager: PackageManager
): Promise<void> {
await installDependenciesAsync({
outputLevel: 'none',
projectDir,
packageManager,
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
showOutput: false,
});
}
}
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
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,
});
Log.log();
}
}
@@ -0,0 +1,214 @@
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import path from 'path';
import { Role } from '../../graphql/generated';
import Log from '../../log';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, promptAsync } from '../../prompts';
import { Actor, getActorUsername } 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(
actor: Actor,
pathArg: string | undefined,
options: {
graphqlClient: ExpoGraphqlClient;
projectAccount: string;
}
): Promise<{
projectName: string;
projectDirectory: string;
}> {
let baseName = 'new-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);
}
// Find an available name checking both local filesystem and remote server
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
actor,
baseName,
parentDirectory,
options
);
Log.withInfo(`Using project directory: ${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) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
return sortedAccounts.map(account => {
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
const accountDisplayName = isPersonalAccount
? `${account.name} (personal 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.',
}),
};
});
}
export function generateProjectNameVariations(actor: Actor, baseName: string): string[] {
const username = getActorUsername(actor);
const date = new Date().toISOString().split('T')[0];
return [
baseName,
`${baseName}-${username}-${date}`,
`${baseName}-${username}-${date}-${nanoid(6)}`,
];
}
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(
actor: Actor,
baseName: string,
parentDirectory: string,
{
graphqlClient,
projectAccount,
}: {
graphqlClient: ExpoGraphqlClient;
projectAccount: string;
}
): Promise<{ projectName: string; projectDirectory: string }> {
const nameVariations = generateProjectNameVariations(actor, baseName);
for (let i = 0; i < nameVariations.length; i++) {
const nameVariation = nameVariations[i];
const proposedDirectory = path.join(parentDirectory, nameVariation);
const usingVariant = i !== 0;
const localExists = await fs.pathExists(proposedDirectory);
if (localExists) {
continue;
}
const remoteAvailable = await verifyProjectDoesNotExistAsync(
graphqlClient,
projectAccount,
nameVariation,
{ silent: usingVariant }
);
if (!remoteAvailable) {
continue;
}
Log.withInfo(`Using ${usingVariant ? 'alternate ' : ''}project name: ${nameVariation}`);
return {
projectName: nameVariation,
projectDirectory: proposedDirectory,
};
}
throw new Error(
`Unable to find a unique project name for "${baseName}". All generated variations already exist.`
);
}
@@ -0,0 +1,195 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../api';
import { AppFragment } from '../../graphql/generated';
import Log, { learnMore } from '../../log';
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 });
Log.withTick(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.log();
}
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 });
Log.withTick(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
Log.log();
}
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.preview = 'npx eas-cli@latest workflow:run publish-preview-update.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 });
Log.withTick('Updated package.json with scripts');
Log.log();
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates', '.eas', 'workflows');
const easWorkflowsTargetDir = path.join(projectDir, '.eas', 'workflows');
await fs.copy(templatesSourceDir, easWorkflowsTargetDir, {
overwrite: true,
errorOnExist: false,
});
Log.withTick('Created EAS workflow files');
Log.log();
}
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);
Log.withTick('Updated README.md with EAS configuration details');
Log.log();
}
@@ -1,3 +1,20 @@
## 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.
@@ -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,6 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import * as fs from 'node:fs';
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
@@ -7,11 +9,17 @@ import {
WorkflowTriggerType,
} from './types';
import {
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import Log from '../../log';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
@@ -68,16 +76,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 +115,171 @@ 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']
): Promise<string> {
const statusLines = [];
const statusValues = [];
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) ?? [];
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);
});
});
}
@@ -1,10 +1,10 @@
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
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';
@@ -102,11 +102,8 @@ async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: strin
if (buildJobs.length === 0) {
return;
}
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
const buildProfileNames = new Set(
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
);
const invalidBuildJobs = buildJobs.filter(
job =>
!buildProfileNames.has(job.value.params.profile) &&
@@ -165,13 +162,15 @@ function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): vo
}
}
function parsedYamlFromWorkflowContents(workflowFileContents: {
yamlConfig: string;
}): Promise<any> {
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
@@ -10,7 +10,6 @@ import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEn
import { ensureIosCredentialsForBuildResignAsync } from '../../build/ios/credentials';
import { prepareCredentialsToResign } from '../../build/ios/prepareJob';
import { listAndSelectBuildOnAppAsync } from '../../build/queries';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -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 {
+10 -7
View File
@@ -5,7 +5,6 @@ import chalk from 'chalk';
import fs from 'node:fs';
import * as path from 'node:path';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
@@ -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;
@@ -138,6 +137,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 +148,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 +286,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,
})
+49 -14
View File
@@ -1,7 +1,7 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
@@ -103,7 +103,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
@@ -140,7 +140,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
},
]);
@@ -168,7 +168,7 @@ describe(EnvCreate, () => {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
});
});
@@ -201,7 +201,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
@@ -236,7 +236,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: 'otherId',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
@@ -274,7 +274,7 @@ describe(EnvCreate, () => {
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
@@ -285,7 +285,7 @@ describe(EnvCreate, () => {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [EnvironmentVariableEnvironment.Production],
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
});
});
@@ -320,10 +320,7 @@ describe(EnvCreate, () => {
{
name: 'VarName',
value: 'VarValue',
environments: [
EnvironmentVariableEnvironment.Production,
EnvironmentVariableEnvironment.Development,
],
environments: [DefaultEnvironment.Production, DefaultEnvironment.Development],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
@@ -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,7 +1,11 @@
import { Config } from '@oclif/core';
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
import { EnvironmentVariableScope } from '../../../graphql/generated';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
@@ -33,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);
@@ -119,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,6 +1,6 @@
import { Config } from '@oclif/core';
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
@@ -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,7 +1,7 @@
import { Config } from '@oclif/core';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
@@ -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.'
);
});
});
});
@@ -2,8 +2,9 @@ import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
import { DefaultEnvironment } from '../../../build/utils/environment';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
@@ -11,12 +12,18 @@ 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');
@@ -33,6 +40,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 +83,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 +115,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 +129,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 +161,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 +171,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 +183,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 +205,50 @@ 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'],
});
});
});
+22 -24
View File
@@ -3,8 +3,8 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
@@ -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),
@@ -272,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'> & {
@@ -323,18 +323,16 @@ export default class EnvCreate extends EasCommand {
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
if (environment && !isEnvironment(environment.toLowerCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
let newEnvironments = environments
? environments
: environment
? [environment.toLowerCase() 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');
+5 -12
View File
@@ -2,7 +2,6 @@ import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentVariableScopeFlag,
@@ -15,18 +14,18 @@ import { EnvironmentVariableMutation } from '../../graphql/mutations/Environment
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;
}
@@ -38,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',
}),
@@ -50,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,
},
];
@@ -166,12 +165,6 @@ export default class EnvDelete extends EasCommand {
: EnvironmentVariableScope.Project;
if (environment) {
environment = environment.toLowerCase();
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
@@ -1,24 +1,22 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
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?.toLowerCase();
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
@@ -1,7 +1,6 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -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.toLowerCase();
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(
+11 -12
View File
@@ -1,7 +1,6 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -20,7 +19,6 @@ import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import {
formatVariable,
formatVariableValue,
isEnvironment,
performForEnvironmentsAsync,
} from '../../utils/variableUtils';
@@ -36,7 +34,7 @@ async function getVariablesForScopeAsync(
scope: EnvironmentVariableScope;
includingSensitive: boolean;
includeFileContent: boolean;
environment?: EnvironmentVariableEnvironment;
environment?: string;
projectId: string;
}
): Promise<EnvironmentVariableWithFileContent[]> {
@@ -71,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;
@@ -80,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;
@@ -112,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,
},
];
@@ -137,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 => {
@@ -180,14 +183,10 @@ export default class EnvList extends EasCommand {
flags: RawListFlags,
{ environment }: { environment?: string }
): ListFlags {
if (environment && !isEnvironment(environment.toLowerCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment.toLowerCase() as EnvironmentVariableEnvironment]
? [environment]
: undefined;
return {
+9 -10
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,
},
];
@@ -51,14 +50,6 @@ export default class EnvPull extends EasCommand {
let environment = flagEnvironment?.toLowerCase() ?? argEnvironment?.toLowerCase();
if (!environment) {
environment = await promptVariableEnvironmentAsync({ nonInteractive });
}
if (!isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const {
projectId,
loggedIn: { graphqlClient },
@@ -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(
+56 -50
View File
@@ -3,7 +3,6 @@ import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag } from '../../commandUtils/flags';
import {
@@ -17,7 +16,6 @@ import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVari
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 =
@@ -34,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,
},
];
@@ -48,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,
@@ -61,6 +63,9 @@ export default class EnvPush extends EasCommand {
environments = await promptVariableEnvironmentAsync({
nonInteractive: false,
multiple: true,
canEnterCustomEnvironment: true,
graphqlClient,
projectId,
});
}
@@ -114,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) {
@@ -165,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}`
);
@@ -177,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.');
}
}
@@ -196,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.toLowerCase())) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments =
flags.environment ??
(environment ? [environment.toLowerCase() as EnvironmentVariableEnvironment] : undefined);
): { environment?: string[]; path: string; force: boolean } {
const environments = flags.environment ?? (environment ? [environment] : undefined);
return {
...flags,
@@ -215,13 +226,8 @@ export default class EnvPush extends EasCommand {
private async parseEnvFileAsync(
envPath: string,
environments: EnvironmentVariableEnvironment[]
): Promise<
Record<
string,
CreateEnvironmentVariableInput & { environments: EnvironmentVariableEnvironment[] }
>
> {
environments: string[]
): Promise<Record<string, CreateEnvironmentVariableInput & { environments: string[] }>> {
if (!(await fs.exists(envPath))) {
throw new Error(`File ${envPath} does not exist.`);
}
+26 -24
View File
@@ -4,8 +4,8 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
@@ -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.toLowerCase();
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;
@@ -316,7 +315,10 @@ export default class EnvUpdate extends EasCommand {
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
canEnterCustomEnvironment: true,
selectedEnvironments: selectedVariable.environments ?? [],
graphqlClient,
projectId,
});
if (
@@ -4,7 +4,6 @@ import chalk from 'chalk';
import { getExpoWebsiteBaseUrl } from '../../api';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds';
import { GetServerSideEnvironmentVariablesFn } from '../../commandUtils/context/ServerSideEnvironmentVariablesContextField';
@@ -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.',
@@ -4,7 +4,6 @@ import { Flags } from '@oclif/core';
import { getExpoWebsiteBaseUrl } from '../../api';
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
@@ -40,7 +39,7 @@ export default class FingerprintGenerate extends EasCommand {
options: ['android', 'ios'],
}),
...EASEnvironmentFlag,
environment: Flags.enum<EnvironmentVariableEnvironment>({
environment: Flags.string({
...EasEnvironmentFlagParameters,
exclusive: ['build-profile'],
}),
@@ -1,49 +1,29 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../../api';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester2 } from '../../../credentials/__tests__/fixtures-constants';
import { AppFragment, Role } from '../../../graphql/generated';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import {
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { Ora, ora } from '../../../ora';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { promptAsync } from '../../../prompts';
import { Actor, getActorUsername } from '../../../user/User';
import New, {
cloneTemplateAsync,
copyProjectTemplatesAsync,
createProjectAsync,
formatScriptCommand,
generateAppConfigAsync,
generateEasConfigAsync,
getAccountChoices,
initializeGitRepositoryAsync,
installProjectDependenciesAsync,
mergeReadmeAsync,
promptForTargetDirectoryAsync,
updatePackageJsonAsync,
} from '../new';
import New, { createProjectAsync, generateConfigsAsync } from '../new';
jest.mock('../../../prompts');
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(),
getActorUsername: jest.fn().mockReturnValue('jester'),
}));
jest.mock('../../../ora');
jest.mock('fs-extra');
@@ -53,662 +33,81 @@ jest.mock('../../../build/utils/url');
jest.mock('../../../utils/easCli', () => ({
easCliVersion: '5.0.0',
}));
// Remove Log module mocking entirely - use the actual module
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().mockResolvedValue({ account: { name: 'jester' } }),
}));
describe(New.name, () => {
let consoleLogSpy: jest.SpyInstance;
const mockGraphqlClient = {} as ExpoGraphqlClient;
describe('generateConfigsAsync', () => {
it('should orchestrate account selection and project configuration', async () => {
const result = await generateConfigsAsync({ path: undefined }, jester, mockGraphqlClient);
beforeEach(() => {
jest.clearAllMocks();
expect(result).toEqual({
projectAccount: 'jester',
projectDirectory: expect.stringContaining('/new-expo-project'),
projectName: 'new-expo-project',
});
});
// Capture console.log calls
consoleLogSpy = jest.spyOn(console, 'log');
it('should pass through path argument to config generation', async () => {
const customPath = 'my-custom-project';
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(''));
const result = await generateConfigsAsync({ path: customPath }, jester, mockGraphqlClient);
expect(result).toEqual({
projectAccount: 'jester',
projectDirectory: expect.stringContaining('/my-custom-project'),
projectName: 'my-custom-project',
});
});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
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);
// Helper function to get all console output as strings
const getConsoleOutput = (): string[] => {
return consoleLogSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
};
// Helper function to check if a specific message was logged
const expectConsoleToContain = (message: string): void => {
const output = getConsoleOutput();
// strip out ANSI codes and special characters like the tick
const outputWithoutAnsi = output.map(line =>
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
);
// throw new Error(message + '\n' + outputWithoutAnsi.join('\n'));
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
};
// Helper function to verify prompt was called with expected message
const expectPromptToHaveMessage = (expectedMessage: string): void => {
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
message: expectedMessage,
})
);
};
// Helper function to simulate user input via prompt
const mockUserInput = (inputValue: any): void => {
jest.mocked(promptAsync).mockResolvedValue(inputValue);
};
describe('helper functions', () => {
describe('promptForTargetDirectoryAsync', () => {
it('should prompt for target directory', async () => {
const promptedDirectory = '/test/prompted-project';
mockUserInput({ targetProjectDir: promptedDirectory });
const result = await promptForTargetDirectoryAsync();
expectConsoleToContain(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
expectPromptToHaveMessage('Where would you like to create your new project directory?');
expect(result).toBe(promptedDirectory);
const result = await createProjectAsync({
graphqlClient: mockGraphqlClient,
actor: jester,
projectDirectory: 'test-project',
projectAccount: 'jester',
projectName: 'test',
});
it('should handle the target directory from args', async () => {
const providedDirectory = '/test/provided-project';
const result = await promptForTargetDirectoryAsync(providedDirectory);
expectConsoleToContain(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(result).toBe(providedDirectory);
expect(result).toBe('test-project-id');
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: jester.accounts[0].id,
projectName: 'test',
});
});
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);
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
expectConsoleToContain('We detected that ssh is your preferred git clone method');
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'ssh',
});
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);
expectConsoleToContain(`📂 Cloning the project to ${targetProjectDir}`);
expectConsoleToContain('We detected that https is your preferred git clone method');
expect(runGitCloneAsync).toHaveBeenCalledWith({
githubUsername: 'expo',
githubRepositoryName: 'expo-template-default',
targetProjectDir,
cloneMethod: 'https',
});
expect(result).toBe(finalTargetProjectDir);
});
});
describe('installProjectDependenciesAsync', () => {
it('should install the project dependencies', async () => {
const projectDir = '/test/project-directory';
jest.mocked(promptForPackageManagerAsync).mockResolvedValue('npm');
jest.mocked(installDependenciesAsync).mockResolvedValue();
jest.mocked(runCommandAsync).mockResolvedValue();
const packageManager = await installProjectDependenciesAsync(projectDir);
expect(packageManager).toBe('npm');
expect(promptForPackageManagerAsync).toHaveBeenCalled();
expect(installDependenciesAsync).toHaveBeenCalledWith({
projectDir,
packageManager: 'npm',
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', 'expo-updates'],
});
expect(runCommandAsync).toHaveBeenCalledWith({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', '@expo/metro-runtime'],
});
});
});
describe('getAccountChoices', () => {
it('should return mapped account choices', () => {
// Use jester fixture which has multiple accounts with different permissions
const namesWithSufficientPermissions = new Set(['jester']); // Only personal account has permissions
const result = getAccountChoices(jester, namesWithSufficientPermissions);
expect(result).toEqual([
{
title: 'jester (personal account)',
value: { name: 'jester' },
},
{
title: 'other',
value: { name: 'other' },
disabled: true,
description:
'You do not have the required permissions to create projects on this account.',
},
]);
});
});
describe('createProjectAsync', () => {
const mockGraphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/test/project-dir';
const mockProjectId = 'test-project-id';
beforeEach(() => {
// use single-account jester for most of the tests
jest.mocked(getActorUsername).mockReturnValue('jester2');
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
extra: {},
} as ExpoConfig);
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
type: 'success' as const,
message: 'Config updated',
config: {
name: 'test-app',
slug: 'test-app',
extra: { eas: { projectId: 'test-project-id' } },
} as ExpoConfig,
});
const mockSpinner: Partial<Ora> = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
};
jest.mocked(ora).mockReturnValue(mockSpinner as Ora);
});
it('should create a project', async () => {
const mockActor = jester2 as Actor;
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester2',
'jester2-app'
);
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: 'jester2-account-id',
projectName: 'jester2-app',
});
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
projectDir,
{
extra: { eas: { projectId: mockProjectId } },
},
{ skipSDKVersionRequirement: true }
);
expect(result).toBe(mockProjectId);
});
it('should create a project when there are multiple accounts', async () => {
// Use jester fixture which has multiple accounts
const mockActor = jester;
jest.mocked(getActorUsername).mockReturnValue('jester');
jest.mocked(promptAsync).mockResolvedValue({
account: { name: 'jester' },
});
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue(mockProjectId);
const result = await createProjectAsync(mockGraphqlClient, mockActor, projectDir);
expect(promptAsync).toHaveBeenCalledWith({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices: expect.any(Array),
});
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
mockGraphqlClient,
'jester',
'jester-app'
);
expect(AppMutation.createAppAsync).toHaveBeenCalledWith(mockGraphqlClient, {
accountId: 'jester-account-id',
projectName: 'jester-app',
});
expect(result).toBe(mockProjectId);
});
it('should throw when the project already exists', async () => {
const mockActor = jester2 as Actor;
const existingProjectId = 'existing-project-id';
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValue(existingProjectId);
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
`Existing project found: @jester2/jester2-app (ID: ${existingProjectId}). Project ID configuration canceled. Re-run the command to select a different account/project.`
);
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
it('should throw when the user does not have permission to create projects on account', async () => {
// Create a custom actor with ViewOnly permissions for this test
const mockActor = merge(jester2, {
accounts: [{ users: [{ actor: { id: 'view-only-jester' }, role: Role.ViewOnly }] }],
}) as Actor;
jest.mocked(getActorUsername).mockReturnValue('view-only-jester');
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
await expect(createProjectAsync(mockGraphqlClient, mockActor, projectDir)).rejects.toThrow(
`You don't have permission to create a new project on the jester account and no matching project already exists on the account.`
);
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
});
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: [],
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
'test-project',
{
extra: { eas: { projectId: 'test-project-id' } },
},
...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,
});
expectConsoleToContain('Generated app.json');
});
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: '>= 5.0.0',
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,
});
expectConsoleToContain('Generated eas.json');
});
});
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: {
preview: 'npx eas-cli@latest workflow:run publish-preview-update.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 }
);
expectConsoleToContain('Updated package.json with scripts');
});
});
describe('copyProjectTemplatesAsync', () => {
it('should copy the project templates', async () => {
const projectDir = '/test/project-dir';
await copyProjectTemplatesAsync(projectDir);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('templates/.eas/workflows'),
expect.stringContaining('.eas/workflows'),
{ errorOnExist: false, overwrite: true }
);
expectConsoleToContain('Created EAS workflow files');
});
});
describe('mergeReadmeAsync', () => {
const projectDir = '/test/project-dir';
it('should merge readme when target section is found', async () => {
const existingReadme = `# My App
This is my app.
## Get a fresh project
Some content here.`;
const readmeAdditions = `## Workflows
This project uses EAS Workflows.`;
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
await mergeReadmeAsync(projectDir);
const expectedMergedReadme = `# My App
This is my app.
## Workflows
This project uses EAS Workflows.
## Get a fresh project
Some content here.`;
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
expectConsoleToContain('Updated README.md with EAS configuration details');
});
it('should append readme when target section is not found', async () => {
const existingReadme = `# My App
This is my app.`;
const readmeAdditions = `## Workflows
This project uses EAS Workflows.`;
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(readmeAdditions);
(jest.mocked(fs.readFile) as jest.Mock).mockResolvedValueOnce(existingReadme);
await mergeReadmeAsync(projectDir);
const expectedMergedReadme = `# My App
This is my app.
## Workflows
This project uses EAS Workflows.
`;
expect(fs.writeFile).toHaveBeenCalledWith(`${projectDir}/README.md`, expectedMergedReadme);
expectConsoleToContain('Updated README.md with EAS configuration details');
});
});
describe('initializeGitRepositoryAsync', () => {
it('should initialize git repository', async () => {
const projectDir = '/test/project-dir';
await initializeGitRepositoryAsync(projectDir);
expect(fs.remove).toHaveBeenCalledWith(`${projectDir}/.git`);
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['init'],
cwd: projectDir,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['add', '.'],
cwd: projectDir,
});
expect(runCommandAsync).toHaveBeenCalledWith({
command: 'git',
args: ['commit', '-m', 'Initial commit'],
cwd: projectDir,
});
});
});
describe('formatScriptCommand', () => {
it('should format script commands for package managers', () => {
expect(formatScriptCommand('start', 'npm')).toBe('npm run start');
expect(formatScriptCommand('start', 'yarn')).toBe('yarn start');
expect(formatScriptCommand('start', 'pnpm')).toBe('pnpm start');
});
{ skipSDKVersionRequirement: true }
);
});
});
});
+98 -366
View File
@@ -1,178 +1,75 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import merge from 'ts-deepmerge';
import { getEASUpdateURL } from '../../api';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppFragment, Role } from '../../graphql/generated';
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 { AppFragment } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import {
PackageManager,
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { PACKAGE_MANAGERS, PackageManager } from '../../onboarding/installDependencies';
import { ora } from '../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, promptAsync } from '../../prompts';
import { Actor, getActorUsername } from '../../user/User';
import { easCliVersion } from '../../utils/easCli';
import { Actor } from '../../user/User';
export async function promptForTargetDirectoryAsync(
targetProjectDirFromArgs?: string
): Promise<string> {
Log.log(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
Log.newLine();
if (targetProjectDirFromArgs) {
return targetProjectDirFromArgs;
}
const result = await promptAsync({
type: 'text',
name: 'targetProjectDir',
message: 'Where would you like to create your new project directory?',
initial: path.join(process.cwd(), 'new-expo-project'),
});
return result.targetProjectDir;
}
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
Log.log(`📂 Cloning the project to ${targetProjectDir}`);
Log.newLine();
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
githubUsername,
githubRepositoryName,
}))
? 'ssh'
: 'https';
Log.log(chalk.dim(`We detected that ${cloneMethod} is your preferred git clone method`));
Log.newLine();
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
githubUsername,
githubRepositoryName,
targetProjectDir,
cloneMethod,
});
return finalTargetProjectDirectory;
}
export async function installProjectDependenciesAsync(projectDir: string): Promise<PackageManager> {
const packageManager = await promptForPackageManagerAsync();
await installDependenciesAsync({
projectDir,
packageManager,
});
const dependencies = ['expo-updates', '@expo/metro-runtime'];
for (const dependency of dependencies) {
await runCommandAsync({
cwd: projectDir,
command: 'npx',
args: ['expo', 'install', dependency],
});
}
return packageManager;
}
export function getAccountChoices(
export async function generateConfigsAsync(
args: { path?: string },
actor: Actor,
namesWithSufficientPermissions: Set<string>
): Choice[] {
const sortedAccounts = actor.accounts.sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
return sortedAccounts.map(account => {
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
const accountDisplayName = isPersonalAccount
? `${account.name} (personal account)`
: account.name;
const disabled = !namesWithSufficientPermissions.has(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.',
}),
};
});
}
export async function createProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string
): Promise<string> {
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
let accountName = allAccounts[0].name;
if (allAccounts.length > 1) {
const choices = getAccountChoices(
actor,
accountNamesWhereUserHasSufficientPermissionsToCreateApp
);
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
const projectName = getActorUsername(actor) + '-app';
const projectFullName = `@${accountName}/${projectName}`;
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{
projectName: string;
projectDirectory: string;
projectAccount: string;
}> {
const projectAccount = await promptForProjectAccountAsync(actor);
const { projectName, projectDirectory } = await generateProjectConfigAsync(actor, args.path, {
graphqlClient,
accountName,
projectName
);
projectAccount,
});
if (existingProjectIdOnServer) {
throw new Error(
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Project ID configuration canceled. Re-run the command to select a different account/project.`
);
}
return {
projectAccount,
projectDirectory,
projectName,
};
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
throw new Error(
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
);
}
const projectDashboardUrl = getProjectDashboardUrl(accountName, 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(allAccounts.find(a => a.name === accountName));
const account = nullthrows(actor.accounts.find(a => a.name === projectAccount));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let projectId: string;
@@ -187,9 +84,9 @@ export async function createProjectAsync(
throw err;
}
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true });
const exp = await getPrivateExpoConfigAsync(projectDirectory, { skipPlugins: true });
await createOrModifyExpoConfigAsync(
projectDir,
projectDirectory,
{
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
},
@@ -200,13 +97,10 @@ export async function createProjectAsync(
return projectId;
}
export function stripInvalidCharactersForBundleIdentifier(string: string): string {
return string.replaceAll(/[^A-Za-z0-9]/g, '');
}
export async function generateConfigFilesAsync(
export async function generateProjectFilesAsync(
projectDir: string,
app: AppFragment
app: AppFragment,
packageManager: PackageManager
): Promise<void> {
await generateAppConfigAsync(projectDir, app);
@@ -216,206 +110,40 @@ export async function generateConfigFilesAsync(
await copyProjectTemplatesAsync(projectDir);
await mergeReadmeAsync(projectDir);
await updateReadmeAsync(projectDir, packageManager);
}
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
// Android package name requires each component to start with a lowercase letter.
const isUsernameValidSegment = /^[^a-z]/.test(app.ownerAccount.name);
const userPrefix = isUsernameValidSegment ? 'user' : '';
const isSlugValidSegment = /^[^a-z]/.test(app.slug);
const slugPrefix = isSlugValidSegment ? 'app' : '';
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
app.ownerAccount.name
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.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: stripInvalidCharactersForBundleIdentifier(app.name ?? app.slug),
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 });
Log.withTick(
`Generated ${chalk.bold('app.json')}. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/'
)}`
);
Log.log();
}
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 });
Log.withTick(
`Generated ${chalk.bold('eas.json')}. ${learnMore(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
Log.log();
}
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.preview = 'npx eas-cli@latest workflow:run publish-preview-update.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 });
Log.withTick('Updated package.json with scripts');
Log.log();
}
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
const templatesSourceDir = path.join(__dirname, 'templates', '.eas', 'workflows');
const easWorkflowsTargetDir = path.join(projectDir, '.eas', 'workflows');
await fs.copy(templatesSourceDir, easWorkflowsTargetDir, {
overwrite: true,
errorOnExist: false,
});
Log.withTick('Created EAS workflow files');
Log.log();
}
export async function mergeReadmeAsync(projectDir: string): 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 a fresh project';
const sectionIndex = existingReadme.indexOf(targetSection);
let mergedReadme: string;
if (sectionIndex !== -1) {
// Insert before "## Get a fresh project" section
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
const afterSection = existingReadme.substring(sectionIndex);
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
} else {
// Append to the end if section doesn't exist
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim() + '\n';
}
await fs.writeFile(projectReadmePath, mergedReadme);
Log.withTick('Updated README.md with EAS configuration details');
Log.log();
}
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
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,
});
Log.log();
}
}
export const formatScriptCommand = (script: string, packageManager: PackageManager): string => {
if (packageManager === 'npm') {
return `npm run ${script}`;
}
return `${packageManager} ${script}`;
};
export default class New extends EasCommand {
static override aliases = ['new'];
static override description = "create a new project set up with Expo's services.";
static override description =
'Create a new project configured with Expo Application Services (EAS)';
static override flags = {};
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 hidden = true;
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args } = await this.parse(New);
const { args, flags } = await this.parse(New);
const {
loggedIn: { actor, graphqlClient },
@@ -428,22 +156,32 @@ export default class New extends EasCommand {
}
Log.warn(
'This command is not yet implemented. It will create a new project, but it will not be fully configured.'
'This command is not yet implemented. It will create a new project, but it may not be fully configured.'
);
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
Log.newLine();
const targetProjectDirectory = await promptForTargetDirectoryAsync(
args.TARGET_PROJECT_DIRECTORY
);
const {
projectName,
projectDirectory: targetProjectDirectory,
projectAccount,
} = await generateConfigsAsync(args, actor, graphqlClient);
const projectDirectory = await cloneTemplateAsync(targetProjectDirectory);
const packageManager = await installProjectDependenciesAsync(projectDirectory);
const packageManager = flags['package-manager'];
await installProjectDependenciesAsync(projectDirectory, packageManager);
const projectId = await createProjectAsync(graphqlClient, actor, projectDirectory);
const projectId = await createProjectAsync({
projectDirectory,
projectAccount,
projectName,
actor,
graphqlClient,
});
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
await generateConfigFilesAsync(projectDirectory, app);
await generateProjectFilesAsync(projectDirectory, app, packageManager);
await initializeGitRepositoryAsync(projectDirectory);
@@ -451,18 +189,12 @@ export default class New extends EasCommand {
Log.log('Next steps:');
Log.withInfo(`Run \`cd ${projectDirectory}\` to navigate to your project.`);
Log.withInfo(
`Run \`${formatScriptCommand(
'preview',
packageManager
)}\` to create a preview build on EAS. ${learnMore(
`Run \`${packageManager} run preview\` to create a preview build on EAS. ${learnMore(
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
)}`
);
Log.withInfo(
`Run \`${formatScriptCommand(
'start',
packageManager
)}\` to start developing locally. ${learnMore(
`Run \`${packageManager} run start\` to start developing locally. ${learnMore(
'https://docs.expo.dev/get-started/start-developing/'
)}`
);
@@ -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(
@@ -6,7 +6,6 @@ import nullthrows from 'nullthrows';
// import { getExpoWebsiteBaseUrl } from '../../api';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
@@ -79,7 +78,7 @@ type RawUpdateFlags = {
'rollout-percentage'?: number;
'non-interactive': boolean;
json: boolean;
environment: EnvironmentVariableEnvironment | null;
environment?: string;
};
type UpdateFlags = {
@@ -96,7 +95,7 @@ type UpdateFlags = {
rolloutPercentage?: number;
json: boolean;
nonInteractive: boolean;
environment: EnvironmentVariableEnvironment | null;
environment?: string;
};
export default class UpdatePublish extends EasCommand {
@@ -193,7 +192,7 @@ export default class UpdatePublish extends EasCommand {
getServerSideEnvironmentVariablesAsync,
} = await this.getContextAsync(UpdatePublish, {
nonInteractive,
withServerSideEnvironment: environment,
withServerSideEnvironment: environment ?? null,
});
if (jsonFlag) {
@@ -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 };
}
+36 -269
View File
@@ -44,16 +44,25 @@ import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import * as fs from 'node:fs';
import * as path from 'node:path';
import slash from 'slash';
import * as YAML from 'yaml';
import { z } from 'zod';
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
maybePromptForMissingInputsAsync,
parseInputs,
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../commandUtils/workflow/inputs';
import {
fileExistsAsync,
infoForActiveWorkflowRunAsync,
infoForFailedWorkflowRunAsync,
maybeReadStdinAsync,
} from '../../commandUtils/workflow/utils';
import {
WorkflowProjectSourceType,
WorkflowRevision,
@@ -68,7 +77,6 @@ import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import { WorkflowFile } from '../../utils/workflowFile';
@@ -91,7 +99,8 @@ export default class WorkflowRun extends EasCommand {
default: false,
allowNo: true,
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
summary: 'Wait for workflow run to complete',
summary:
'Wait for workflow run to complete. Defaults to false. Cannot be used with the --json flag.',
}),
input: Flags.string({
char: 'F',
@@ -120,6 +129,9 @@ export default class WorkflowRun extends EasCommand {
const { flags, args } = await this.parse(WorkflowRun);
if (flags.json) {
if (flags.wait) {
throw new Error('Cannot use --json and --wait flags simultaneously.');
}
enableJsonOutput();
}
@@ -388,36 +400,45 @@ async function waitForWorkflowRunToEndAsync(
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const spinner = ora('Currently waiting for workflow run to start.').start();
const spinner = ora('').start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.byIdAsync(graphqlClient, workflowRunId, {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.InProgress:
spinner.start('Workflow run is in progress.');
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.warn('Workflow run is waiting for action.');
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.warn('Workflow run has been canceled.');
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure:
spinner.fail('Workflow run has failed.');
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.succeed('Workflow run completed successfully.');
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.succeed('');
return workflowRun;
}
} catch {
@@ -434,257 +455,3 @@ async function waitForWorkflowRunToEndAsync(
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
}
async function fileExistsAsync(filePath: string): Promise<boolean> {
return await fs.promises
.access(filePath, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
export function parseInputs(inputFlags: string[]): Record<string, string> {
const inputs: Record<string, string> = {};
for (const inputFlag of inputFlags) {
const equalIndex = inputFlag.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
}
const key = inputFlag.substring(0, equalIndex);
const value = inputFlag.substring(equalIndex + 1);
if (!key) {
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
}
inputs[key] = value;
}
return inputs;
}
export async function maybeReadStdinAsync(): Promise<string | null> {
// Check if there's data on stdin
if (process.stdin.isTTY) {
return null;
}
return await new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
const trimmedData = data.trim();
resolve(trimmedData || null);
});
process.stdin.on('error', err => {
reject(err);
});
});
}
export function parseJsonInputs(jsonString: string): Record<string, string> {
try {
const parsed = JSON.parse(jsonString);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON input must be an object.');
}
return parsed;
} catch (error) {
throw new Error(`Invalid JSON input.`, { cause: error });
}
}
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
// which is not what we want. See:
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
const booleanLike = z.union([
z.boolean(),
z.codec(z.number(), z.boolean(), {
decode: n => !!n,
encode: b => (b ? 1 : 0),
}),
z.stringbool({ truthy: ['true', 'True'], falsy: ['false', 'False'] }),
]);
const stringLike = z.codec(
z.union([
// We're going to coerce numbers and strings into strings.
z.number(),
z.string(),
// We do not allow other primitives, like:
// - bigints, symbols - because YAML does not support them,
// - booleans - because YAML accepts `True` and `true` as boolean input
// and parses both as JS `true` -- if we stringified that,
// we would lose the capital "T" which may not be what the user expects,
// - nulls - user should do `"null"` or not pass the property at all.
]),
z.string(),
{
decode: value => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
throw new Error(`Cannot convert ${typeof value} to string: ${value}`);
},
encode: value => value,
}
);
export const WorkflowDispatchInputZ = z
.object({
description: stringLike.optional().describe('Description of the input'),
required: booleanLike.default(false).describe('Whether the input is required.'),
})
.and(
z.union([
z.object({
type: z.literal('string').default('string'),
default: stringLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('boolean'),
default: booleanLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('number'),
default: z.number().optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('choice'),
default: stringLike.optional().describe('Default value for the input'),
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
}),
z.object({
type: z.literal('environment'),
default: z.string().optional().describe('Default value for the input'),
}),
])
);
export function parseWorkflowInputsFromYaml(
yamlConfig: string
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
try {
const parsed = YAML.parse(yamlConfig);
return z
.record(z.string(), WorkflowDispatchInputZ)
.default({})
.parse(parsed?.on?.workflow_dispatch?.inputs);
} catch (error) {
Log.warn('Failed to parse workflow inputs from YAML:', error);
return {};
}
}
async function maybePromptForMissingInputsAsync({
inputSpecs,
inputs,
}: {
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
inputs: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
if (missingRequiredInputs.length === 0) {
return inputs;
}
Log.addNewLineIfNone();
Log.log('Some required inputs are missing. Please provide them:');
const nextInputs = { ...inputs };
for (const [key, spec] of missingRequiredInputs) {
const value = await promptForMissingInputAsync({ key, spec });
nextInputs[key] = value;
}
return nextInputs;
}
async function promptForMissingInputAsync({
key,
spec,
}: {
key: string;
spec: z.infer<typeof WorkflowDispatchInputZ>;
}): Promise<unknown> {
const message = spec.description ? `${key} (${spec.description})` : key;
switch (spec.type) {
case 'boolean': {
const { value } = await promptAsync({
type: 'confirm',
name: 'value',
message,
initial: spec.default,
});
return value;
}
case 'number': {
const { value } = await promptAsync({
type: 'number',
name: 'value',
message,
initial: spec.default,
validate: (val: number) => {
if (isNaN(val)) {
return 'Please enter a valid number';
}
return true;
},
});
return value;
}
case 'choice': {
const { value } = await promptAsync({
type: 'select',
name: 'value',
message,
choices: spec.options.map(option => ({
title: option,
value: option,
})),
initial: spec.default,
});
return value;
}
case 'string':
case 'environment': {
const { value } = await promptAsync({
type: 'text',
name: 'value',
message,
initial: spec.default,
validate: (val: string) => {
if (spec.required && (!val || val.trim() === '')) {
return 'This field is required';
}
return true;
},
});
return value;
}
}
}
@@ -1,5 +1,6 @@
import { JSONObject } from '@expo/json-file';
import type { XCBuildConfiguration } from 'xcode';
import { z } from 'zod';
import {
AccountFragment,
@@ -33,3 +34,42 @@ export type IosCredentials = Record<string, TargetCredentials>;
export type IosAppBuildCredentialsMap = Record<string, IosAppBuildCredentialsFragment>;
export type IosAppCredentialsMap = Record<string, CommonIosAppCredentialsFragment | null>;
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
// which is not what we want. See:
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
export const booleanLike = z.union([
z.boolean(),
z.codec(z.number(), z.boolean(), {
decode: n => !!n,
encode: b => (b ? 1 : 0),
}),
z.stringbool({ truthy: ['true', 'True'], falsy: ['false', 'False'] }),
]);
export const stringLike = z.codec(
z.union([
// We're going to coerce numbers and strings into strings.
z.number(),
z.string(),
// We do not allow other primitives, like:
// - bigints, symbols - because YAML does not support them,
// - booleans - because YAML accepts `True` and `true` as boolean input
// and parses both as JS `true` -- if we stringified that,
// we would lose the capital "T" which may not be what the user expects,
// - nulls - user should do `"null"` or not pass the property at all.
]),
z.string(),
{
decode: value => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
throw new Error(`Cannot convert ${typeof value} to string: ${value}`);
},
encode: value => value,
}
);
+102 -19
View File
@@ -124,6 +124,7 @@ export type Account = {
displayName?: Maybe<Scalars['String']['output']>;
/** Environment secrets for an account */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
/** Environment variables for an account */
environmentVariables: Array<EnvironmentVariable>;
/** Environment variables for an account with decrypted secret values */
@@ -187,6 +188,8 @@ export type Account = {
userInvitations: Array<UserInvitation>;
/** Actors associated with this account and permissions they hold */
users: Array<UserPermission>;
/** Notification preferences of the viewer for this account */
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
/** Permission info for the viewer on this account */
viewerUserPermission: UserPermission;
/** @deprecated Build packs are no longer supported */
@@ -646,6 +649,22 @@ export type AccountMutationSetPushSecurityEnabledArgs = {
pushSecurityEnabled: Scalars['Boolean']['input'];
};
/** Account-level notification preference */
export type AccountNotificationPreference = NotificationPreference & {
__typename?: 'AccountNotificationPreference';
account: Account;
enabled: Scalars['Boolean']['output'];
event: NotificationEvent;
type: NotificationType;
};
export type AccountNotificationPreferenceInput = {
accountId: Scalars['ID']['input'];
enabled: Scalars['Boolean']['input'];
event: NotificationEvent;
type: NotificationType;
};
export type AccountNotificationSubscriptionInput = {
accountId: Scalars['ID']['input'];
event: NotificationEvent;
@@ -1285,6 +1304,7 @@ export type App = Project & {
devDomainName?: Maybe<AppDevDomainName>;
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
/** Environment variables for an app */
environmentVariables: Array<EnvironmentVariable>;
/** Environment variables for an app with decrypted secret values */
@@ -1414,6 +1434,8 @@ export type App = Project & {
username: Scalars['String']['output'];
/** @deprecated No longer supported */
users?: Maybe<Array<Maybe<User>>>;
/** Notification preferences of the viewer for this app */
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
/** Webhooks for an app */
webhooks: Array<Webhook>;
workerCustomDomain?: Maybe<WorkerCustomDomain>;
@@ -1916,6 +1938,22 @@ export type AppMutationSetResourceClassExperimentArgs = {
resourceClassExperiment?: InputMaybe<ResourceClassExperiment>;
};
/** App-level notification preference */
export type AppNotificationPreference = NotificationPreference & {
__typename?: 'AppNotificationPreference';
app: App;
enabled: Scalars['Boolean']['output'];
event: NotificationEvent;
type: NotificationType;
};
export type AppNotificationPreferenceInput = {
appId: Scalars['ID']['input'];
enabled: Scalars['Boolean']['input'];
event: NotificationEvent;
type: NotificationType;
};
export type AppNotificationSubscriptionInput = {
appId: Scalars['ID']['input'];
event: NotificationEvent;
@@ -5611,7 +5649,10 @@ export type MeMutation = {
scheduleCurrentUserDeletion: BackgroundJobReceipt;
/** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */
scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt;
/** Send SMS OTP to a second factor device for use during device setup or during change confirmation */
/**
* Send SMS OTP to a second factor device for use during device setup or during change confirmation
* @deprecated This method is no longer supported.
*/
sendSMSOTPToSecondFactorDevice: SecondFactorBooleanResult;
/**
* Sets user preferences. This is a key-value store for user-specific settings. Provided values are
@@ -5755,6 +5796,32 @@ export enum NotificationEvent {
export type NotificationMetadata = BuildLimitThresholdExceededMetadata | BuildPlanCreditThresholdExceededMetadata | TestNotificationMetadata;
/** Base interface for notification preferences */
export type NotificationPreference = {
enabled: Scalars['Boolean']['output'];
event: NotificationEvent;
type: NotificationType;
};
/** Union type representing either account-level or app-level notification preferences */
export type NotificationPreferenceItem = AccountNotificationPreference | AppNotificationPreference;
export type NotificationPreferenceMutation = {
__typename?: 'NotificationPreferenceMutation';
setAccountNotificationPreference: NotificationPreferenceItem;
setAppNotificationPreference: NotificationPreferenceItem;
};
export type NotificationPreferenceMutationSetAccountNotificationPreferenceArgs = {
input: AccountNotificationPreferenceInput;
};
export type NotificationPreferenceMutationSetAppNotificationPreferenceArgs = {
input: AppNotificationPreferenceInput;
};
export type NotificationSubscription = {
__typename?: 'NotificationSubscription';
account?: Maybe<Account>;
@@ -6230,7 +6297,12 @@ export type RootMutation = {
logRocketProject: LogRocketProjectMutation;
/** Mutations that modify the currently authenticated User */
me: MeMutation;
/** Mutations that modify a NotificationSubscription */
/** Notification preference management */
notificationPreference: NotificationPreferenceMutation;
/**
* Mutations that modify a NotificationSubscription
* @deprecated Use notificationPreference mutation instead
*/
notificationSubscription: NotificationSubscriptionMutation;
/** Mutations that create, update, and delete Robots */
robot: RobotMutation;
@@ -6571,6 +6643,7 @@ export type SsoUser = Actor & UserActor & {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
pinnedApps: Array<App>;
pinnedDashboardViews: Array<PinnedDashboardView>;
@@ -6926,6 +6999,7 @@ export type Submission = ActivityTimelineProjectActivity & {
id: Scalars['ID']['output'];
initiatingActor?: Maybe<Actor>;
iosConfig?: Maybe<IosSubmissionConfig>;
jobRun?: Maybe<JobRun>;
logFiles: Array<Scalars['String']['output']>;
/** @deprecated Use logFiles instead */
logsUrl?: Maybe<Scalars['String']['output']>;
@@ -7617,6 +7691,7 @@ export type User = Actor & UserActor & {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
/** Pending UserInvitations for this user. Only resolves for the viewer. */
pendingUserInvitations: Array<UserInvitation>;
@@ -7729,6 +7804,7 @@ export type UserActor = {
lastName?: Maybe<Scalars['String']['output']>;
/** @deprecated No longer supported */
location?: Maybe<Scalars['String']['output']>;
/** @deprecated Use Account/App.viewerNotificationPreferences instead */
notificationSubscriptions: Array<NotificationSubscription>;
pinnedApps: Array<App>;
preferences: UserPreferences;
@@ -9651,7 +9727,7 @@ export type CreateAndroidBuildMutationVariables = Exact<{
}>;
export type CreateAndroidBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createAndroidBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateAndroidBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createAndroidBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateIosBuildMutationVariables = Exact<{
appId: Scalars['ID']['input'];
@@ -9661,7 +9737,7 @@ export type CreateIosBuildMutationVariables = Exact<{
}>;
export type CreateIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createIosBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createIosBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type UpdateBuildMetadataMutationVariables = Exact<{
buildId: Scalars['ID']['input'];
@@ -9669,7 +9745,7 @@ export type UpdateBuildMetadataMutationVariables = Exact<{
}>;
export type UpdateBuildMetadataMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', updateBuildMetadata: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type UpdateBuildMetadataMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', updateBuildMetadata: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type RetryIosBuildMutationVariables = Exact<{
buildId: Scalars['ID']['input'];
@@ -9677,7 +9753,7 @@ export type RetryIosBuildMutationVariables = Exact<{
}>;
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
@@ -9761,7 +9837,7 @@ export type CreateLocalBuildMutationVariables = Exact<{
}>;
export type CreateLocalBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createLocalBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } } };
export type CreateLocalBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createLocalBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } } };
export type GetSignedUploadMutationVariables = Exact<{
contentTypes: Array<Scalars['String']['input']> | Scalars['String']['input'];
@@ -9920,7 +9996,7 @@ export type AppByIdWorkflowsQueryVariables = Exact<{
}>;
export type AppByIdWorkflowsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any }> } } };
export type AppByIdWorkflowsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, workflows: Array<{ __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any, revisionsPaginated: { __typename?: 'WorkflowRevisionsConnection', edges: Array<{ __typename?: 'WorkflowRevisionEdge', node: { __typename?: 'WorkflowRevision', id: string, blobSha: string, commitSha?: string | null, createdAt: any, yamlConfig: string } }> } }> } } };
export type AppByIdWorkflowRunsFilteredByStatusQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -10007,21 +10083,21 @@ export type BuildsByIdQueryVariables = Exact<{
}>;
export type BuildsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type BuildsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type BuildsWithSubmissionsByIdQueryVariables = Exact<{
buildId: Scalars['ID']['input'];
}>;
export type BuildsWithSubmissionsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type BuildsWithSubmissionsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type BuildsWithFingerprintByIdQueryVariables = Exact<{
buildId: Scalars['ID']['input'];
}>;
export type BuildsWithFingerprintByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type BuildsWithFingerprintByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type ViewBuildsOnAppQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -10031,7 +10107,7 @@ export type ViewBuildsOnAppQueryVariables = Exact<{
}>;
export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }> } } };
export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }> } } };
export type ViewUpdateChannelBasicInfoOnAppQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -10077,6 +10153,13 @@ export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
export type EnvironmentSecretsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentSecrets: Array<{ __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any }> }, environmentSecrets: Array<{ __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any }> } } };
export type AppEnvironmentVariableEnvironmentsQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type AppEnvironmentVariableEnvironmentsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, environmentVariableEnvironments: Array<any> } } };
export type EnvironmentVariablesIncludingSensitiveByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
filterNames?: InputMaybe<Array<Scalars['String']['input']> | Scalars['String']['input']>;
@@ -10248,7 +10331,7 @@ export type WorkflowJobByIdQueryVariables = Exact<{
}>;
export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
export type WorkflowRunByIdQueryVariables = Exact<{
workflowRunId: Scalars['ID']['input'];
@@ -10262,7 +10345,7 @@ export type WorkflowRunByIdWithJobsQueryVariables = Exact<{
}>;
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> }>, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }> } } };
export type WorkflowRunByIdWithJobsQuery = { __typename?: 'RootQuery', workflowRuns: { __typename?: 'WorkflowRunQuery', byId: { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, app: { __typename?: 'App', id: string, name: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } }, jobs: Array<{ __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> }>, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }> } } };
export type WorkflowRunsForAppIdFileNameAndStatusQueryVariables = Exact<{
appId: Scalars['ID']['input'];
@@ -10280,11 +10363,11 @@ export type AppFragment = { __typename?: 'App', id: string, name: string, fullNa
export type BackgroundJobReceiptDataFragment = { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any };
export type BuildFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type BuildFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type BuildWithFingerprintFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type BuildWithFingerprintFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any };
@@ -10310,9 +10393,9 @@ export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id:
export type WebhookFragment = { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any };
export type WorkflowFragment = { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any };
export type WorkflowFragment = { __typename?: 'Workflow', id: string, name?: string | null, fileName: string, createdAt: any, updatedAt: any, revisionsPaginated: { __typename?: 'WorkflowRevisionsConnection', edges: Array<{ __typename?: 'WorkflowRevisionEdge', node: { __typename?: 'WorkflowRevision', id: string, blobSha: string, commitSha?: string | null, createdAt: any, yamlConfig: string } }> } };
export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
export type WorkflowJobFragment = { __typename?: 'WorkflowJob', id: string, key: string, name: string, status: WorkflowJobStatus, type: WorkflowJobType, outputs: any, createdAt: any, updatedAt: any, workflowRun: { __typename?: 'WorkflowRun', id: string }, turtleJobRun?: { __typename?: 'JobRun', id: string, logFileUrls: Array<string>, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, contentType?: string | null, fileSizeBytes?: number | null, filename: string, downloadUrl?: string | null }>, errors: Array<{ __typename?: 'JobRunError', errorCode: string, message: string }> } | null, turtleBuild?: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> };
export type WorkflowRunFragment = { __typename?: 'WorkflowRun', id: string, status: WorkflowRunStatus, gitCommitMessage?: string | null, gitCommitHash?: string | null, requestedGitRef?: string | null, triggeringLabelName?: string | null, triggerEventType: WorkflowRunTriggerEventType, triggeringSchedule?: string | null, createdAt: any, updatedAt: any, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, errors: Array<{ __typename?: 'WorkflowRunError', title?: string | null, message: string }>, workflow: { __typename?: 'Workflow', id: string, name?: string | null, fileName: string } };
@@ -1,7 +1,6 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { EnvironmentVariableEnvironment } from '../../build/utils/environment';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
@@ -14,7 +13,7 @@ import { EnvironmentVariableFragmentNode } from '../types/EnvironmentVariable';
import { EnvironmentVariableWithSecretFragmentNode } from '../types/EnvironmentVariableWithSecret';
type EnvironmentVariableWithLinkedEnvironments = EnvironmentVariableFragment & {
linkedEnvironments?: EnvironmentVariableEnvironment[] | null;
linkedEnvironments?: string[] | null;
};
export type EnvironmentVariableWithFileContent = EnvironmentVariableFragment & {
@@ -22,6 +21,31 @@ export type EnvironmentVariableWithFileContent = EnvironmentVariableFragment & {
};
export const EnvironmentVariablesQuery = {
async environmentVariableEnvironmentsAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<string[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query(
gql`
query AppEnvironmentVariableEnvironments($appId: String!) {
app {
byId(appId: $appId) {
id
environmentVariableEnvironments
}
}
}
`,
{ appId },
{ additionalTypenames: ['App'] }
)
.toPromise()
);
return data.app?.byId.environmentVariableEnvironments ?? [];
},
async byAppIdWithSensitiveAsync(
graphqlClient: ExpoGraphqlClient,
{
@@ -31,7 +55,7 @@ export const EnvironmentVariablesQuery = {
includeFileContent = false,
}: {
appId: string;
environment?: EnvironmentVariableEnvironment;
environment?: string;
filterNames?: string[];
includeFileContent?: boolean;
}
@@ -80,7 +104,7 @@ export const EnvironmentVariablesQuery = {
includeFileContent = false,
}: {
appId: string;
environment?: EnvironmentVariableEnvironment;
environment?: string;
filterNames?: string[];
includeFileContent?: boolean;
}
@@ -128,7 +152,7 @@ export const EnvironmentVariablesQuery = {
}: {
appId: string;
filterNames?: string[];
environment?: EnvironmentVariableEnvironment;
environment?: string;
includeFileContent?: boolean;
}
): Promise<(EnvironmentVariableWithFileContent & EnvironmentVariableWithLinkedEnvironments)[]> {
@@ -178,7 +202,7 @@ export const EnvironmentVariablesQuery = {
}: {
appId: string;
filterNames?: string[];
environment?: EnvironmentVariableEnvironment;
environment?: string;
includeFileContent?: boolean;
}
): Promise<EnvironmentVariableWithFileContent[]> {
@@ -29,6 +29,7 @@ export const BuildFragmentNode = gql`
id
displayName
}
logFiles
project {
__typename
id
@@ -7,5 +7,16 @@ export const WorkflowFragmentNode = gql`
fileName
createdAt
updatedAt
revisionsPaginated(first: 1) {
edges {
node {
id
blobSha
commitSha
createdAt
yamlConfig
}
}
}
}
`;
@@ -9,22 +9,26 @@ describe('installDependencies', () => {
});
describe('promptForPackageManagerAsync', () => {
it.each(['npm', 'yarn', 'pnpm'])('returns %s when user selects %s', async packageManager => {
jest.mocked(selectAsync).mockResolvedValue(packageManager as PackageManager);
it.each(['npm', 'yarn', 'pnpm', 'bun'])(
'returns %s when user selects %s',
async packageManager => {
jest.mocked(selectAsync).mockResolvedValue(packageManager as PackageManager);
const result = await promptForPackageManagerAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Which package manager would you like to use?',
[
{ title: 'npm', value: 'npm' },
{ title: 'Yarn', value: 'yarn' },
{ title: 'pnpm', value: 'pnpm' },
],
{ initial: 'npm' }
);
const result = await promptForPackageManagerAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Which package manager would you like to use?',
[
{ title: 'bun', value: 'bun' },
{ title: 'npm', value: 'npm' },
{ title: 'pnpm', value: 'pnpm' },
{ title: 'yarn', value: 'yarn' },
],
{ initial: 'npm' }
);
expect(result).toBe(packageManager);
});
expect(result).toBe(packageManager);
}
);
it('defaults to npm as initial selection', async () => {
jest.mocked(selectAsync).mockResolvedValue('npm');
@@ -1,24 +1,23 @@
import { runCommandAsync } from './runCommand';
import { selectAsync } from '../prompts';
export type PackageManager = 'npm' | 'yarn' | 'pnpm';
export const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'] as const;
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
export async function promptForPackageManagerAsync(): Promise<PackageManager> {
return await selectAsync(
'Which package manager would you like to use?',
[
{ title: 'npm', value: 'npm' },
{ title: 'Yarn', value: 'yarn' },
{ title: 'pnpm', value: 'pnpm' },
],
(['bun', 'npm', 'pnpm', 'yarn'] as const).map(manager => ({ title: manager, value: manager })),
{ initial: 'npm' }
);
}
export async function installDependenciesAsync({
outputLevel = 'default',
projectDir,
packageManager = 'npm',
}: {
outputLevel?: 'default' | 'none';
projectDir: string;
packageManager?: PackageManager;
}): Promise<void> {
@@ -35,5 +34,6 @@ export async function installDependenciesAsync({
!(line === packageManager)
);
},
showOutput: outputLevel !== 'none',
});
}
+31 -26
View File
@@ -11,6 +11,7 @@ export async function runCommandAsync({
shouldShowStderrLine,
shouldPrintStderrLineAsStdout,
showSpinner = true,
showOutput = true,
}: {
cwd?: string;
args: string[];
@@ -18,6 +19,7 @@ export async function runCommandAsync({
shouldShowStderrLine?: (line: string) => boolean;
shouldPrintStderrLineAsStdout?: (line: string) => boolean;
showSpinner?: boolean;
showOutput?: boolean;
}): Promise<void> {
Log.log(`🏗️ Running ${chalk.bold(`${command} ${args.join(' ')}`)}...`);
let spinner: Ora | undefined;
@@ -25,36 +27,39 @@ export async function runCommandAsync({
spinner = ora(`${chalk.bold(`${command} ${args.join(' ')}`)}`).start();
}
const spawnPromise = spawnAsync(command, args, {
stdio: ['inherit', 'pipe', 'pipe'],
stdio: showOutput ? ['inherit', 'pipe', 'pipe'] : 'ignore',
cwd,
});
const {
child: { stdout, stderr },
} = spawnPromise;
if (!stdout || !stderr) {
throw new Error(`Failed to spawn ${command}`);
if (showOutput) {
const {
child: { stdout, stderr },
} = spawnPromise;
if (!stdout || !stderr) {
throw new Error(`Failed to spawn ${command}`);
}
stdout.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
Log.log(`${chalk.gray(`[${command}]`)} ${line}`);
}
});
stderr.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
if (shouldShowStderrLine && !shouldShowStderrLine(line)) {
continue;
}
const log = `${chalk.gray(`[${command}]`)} ${line}`;
if (shouldPrintStderrLineAsStdout?.(line)) {
Log.log(log);
} else {
Log.warn(`${chalk.gray(`[${command}]`)} ${line}`);
}
}
});
}
stdout.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
Log.log(`${chalk.gray(`[${command}]`)} ${line}`);
}
});
stderr.on('data', data => {
for (const line of data.toString().trim().split('\n')) {
if (shouldShowStderrLine && !shouldShowStderrLine(line)) {
continue;
}
const log = `${chalk.gray(`[${command}]`)} ${line}`;
if (shouldPrintStderrLineAsStdout?.(line)) {
Log.log(log);
} else {
Log.warn(`${chalk.gray(`[${command}]`)} ${line}`);
}
}
});
try {
await spawnPromise;
} catch (error) {
+104 -21
View File
@@ -1,10 +1,31 @@
import chalk from 'chalk';
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
import { DefaultEnvironment } from '../build/utils/environment';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../graphql/generated';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
import { RequestedPlatform } from '../platform';
import { promptAsync, selectAsync } from '../prompts';
const DEFAULT_ENVIRONMENTS = Object.values(DefaultEnvironment);
export async function getProjectEnvironmentVariableEnvironmentsAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string[]> {
try {
const environments = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync(
graphqlClient,
projectId
);
return environments;
} catch {
throw new Error('Failed to fetch available environments');
}
}
const CUSTOM_ENVIRONMENT_VALUE = '~~CUSTOM~~';
export async function promptVariableTypeAsync(
nonInteractive: boolean,
initialType?: EnvironmentSecretType
@@ -44,6 +65,24 @@ export function parseVisibility(
}
}
async function promptCustomEnvironmentAsync(): Promise<string> {
const { customEnvironment } = await promptAsync({
type: 'text',
name: 'customEnvironment',
message: 'Enter custom environment name:',
validate: (value: string) => {
if (!value || value.trim() === '') {
return 'Environment name cannot be empty';
}
if (!value.match(/^[a-zA-Z0-9_-]+$/)) {
return 'Environment name may only contain letters, numbers, underscores, and hyphens';
}
return true;
},
});
return customEnvironment;
}
export async function promptVariableVisibilityAsync(
nonInteractive: boolean,
selectedVisibility?: EnvironmentVariableVisibility | null
@@ -74,49 +113,93 @@ export async function promptVariableVisibilityAsync(
type EnvironmentPromptArgs = {
nonInteractive: boolean;
selectedEnvironments?: EnvironmentVariableEnvironment[];
availableEnvironments?: EnvironmentVariableEnvironment[];
selectedEnvironments?: string[];
graphqlClient?: ExpoGraphqlClient;
projectId?: string;
canEnterCustomEnvironment?: boolean;
};
export function promptVariableEnvironmentAsync(
input: EnvironmentPromptArgs & { multiple: true }
): Promise<EnvironmentVariableEnvironment[]>;
): Promise<string[]>;
export function promptVariableEnvironmentAsync(
input: EnvironmentPromptArgs & { multiple?: false }
): Promise<EnvironmentVariableEnvironment>;
): Promise<string>;
export async function promptVariableEnvironmentAsync({
nonInteractive,
selectedEnvironments,
multiple = false,
availableEnvironments,
}: EnvironmentPromptArgs & { multiple?: boolean }): Promise<
EnvironmentVariableEnvironment[] | EnvironmentVariableEnvironment
> {
canEnterCustomEnvironment = false,
graphqlClient,
projectId,
}: EnvironmentPromptArgs & { multiple?: boolean }): Promise<string[] | string> {
if (nonInteractive) {
throw new Error(
'The `--environment` flag must be set when running in `--non-interactive` mode.'
);
}
if (!multiple) {
return await selectAsync(
'Select environment:',
(availableEnvironments ?? Object.values(EnvironmentVariableEnvironment)).map(environment => ({
title: environment.toLocaleLowerCase(),
value: environment,
}))
let allEnvironments: string[] = DEFAULT_ENVIRONMENTS;
if (graphqlClient && projectId) {
const projectEnvironments = await getProjectEnvironmentVariableEnvironmentsAsync(
graphqlClient,
projectId
);
allEnvironments = [...new Set([...DEFAULT_ENVIRONMENTS, ...projectEnvironments])];
}
if (!multiple) {
const choices = allEnvironments.map(environment => ({
title: environment,
value: environment,
}));
if (canEnterCustomEnvironment) {
choices.push({
title: 'Other (enter custom environment)',
value: CUSTOM_ENVIRONMENT_VALUE,
});
}
const selectedEnvironment = await selectAsync('Select environment:', choices);
if (selectedEnvironment === CUSTOM_ENVIRONMENT_VALUE) {
return await promptCustomEnvironmentAsync();
}
return selectedEnvironment;
}
const choices = allEnvironments.map(environment => ({
title: environment,
value: environment,
selected: selectedEnvironments?.includes(environment),
}));
if (canEnterCustomEnvironment) {
choices.push({
title: 'Other (enter custom environment)',
value: CUSTOM_ENVIRONMENT_VALUE,
selected: false,
});
}
const { environments } = await promptAsync({
message: 'Select environment:',
name: 'environments',
type: 'multiselect',
choices: Object.values(EnvironmentVariableEnvironment).map(environment => ({
title: environment.toLocaleLowerCase(),
value: environment,
selected: selectedEnvironments?.includes(environment),
})),
choices,
});
if (environments?.includes(CUSTOM_ENVIRONMENT_VALUE)) {
const customEnvironment = await promptCustomEnvironmentAsync();
const filteredEnvironments = environments.filter(
(env: string) => env !== CUSTOM_ENVIRONMENT_VALUE
);
return [...filteredEnvironments, customEnvironment];
}
return environments;
}
+2 -9
View File
@@ -1,7 +1,6 @@
import dateFormat from 'dateformat';
import formatFields from './formatFields';
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
import {
EnvironmentSecretType,
EnvironmentVariableFragment,
@@ -10,12 +9,6 @@ import {
} from '../graphql/generated';
import { EnvironmentVariableWithFileContent } from '../graphql/queries/EnvironmentVariablesQuery';
export function isEnvironment(environment: string): environment is EnvironmentVariableEnvironment {
return Object.values(EnvironmentVariableEnvironment).includes(
environment as EnvironmentVariableEnvironment
);
}
export function formatVariableName(variable: EnvironmentVariableFragment): string {
const name = variable.name;
const scope = variable.scope === EnvironmentVariableScope.Project ? 'project' : 'shared';
@@ -53,8 +46,8 @@ export function formatVariableValue(variable: EnvironmentVariableWithFileContent
}
export async function performForEnvironmentsAsync(
environments: EnvironmentVariableEnvironment[] | null,
fun: (environment: EnvironmentVariableEnvironment | undefined) => Promise<any>
environments: string[] | null,
fun: (environment: string | undefined) => Promise<any>
): Promise<any[]> {
const selectedEnvironments = environments ?? [undefined];
return await Promise.all(selectedEnvironments.map(env => fun(env)));
+30 -2
View File
@@ -8,10 +8,11 @@ import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { pack } from 'tar-stream';
import { EnvironmentVariableEnvironment } from '../build/utils/environment';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
const EXPO_ROUTES_PATHS = new Set(['_expo/routes.json', '_expo/.routes.json', '.expo-routes.json']);
/** Returns whether a file or folder is ignored */
function isIgnoredName(name: string): boolean {
switch (name) {
@@ -114,6 +115,8 @@ export async function collectAssetsAsync(
throw new Error(
`Upload of "${file.normalizedPath}" aborted: File size is greater than the upload limit (>500MB)`
);
} else if (EXPO_ROUTES_PATHS.has(file.normalizedPath)) {
continue;
}
const sha512$ = computeSha512HashAsync(file.path);
const contentType$ = determineMimeTypeAsync(file.path);
@@ -129,6 +132,31 @@ export async function collectAssetsAsync(
return assets;
}
export interface RoutesConfigEntry {
headers?: Record<string, unknown>;
redirects?: Record<string, unknown>[];
}
export async function getRoutesConfigAsync(
assetPath: string | undefined
): Promise<RoutesConfigEntry | null> {
if (assetPath) {
for (const candidatePath of EXPO_ROUTES_PATHS) {
const targetPath = path.resolve(assetPath, candidatePath);
let json: unknown;
try {
json = JSON.parse(await fs.promises.readFile(targetPath, 'utf8'));
} catch {
continue;
}
if (typeof json === 'object' && json) {
return json as RoutesConfigEntry;
}
}
}
return null;
}
/** Mapping of normalized file paths to a SHA512 hash */
export type AssetMap = Record<
string,
@@ -162,7 +190,7 @@ export interface CreateManifestResult {
interface CreateManifestParams {
projectId: string;
projectDir: string;
environment?: EnvironmentVariableEnvironment;
environment?: string;
}
/** Creates a manifest configuration sent up for deployment */
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "16.20.1",
"version": "16.23.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
+2 -1
View File
@@ -214,7 +214,8 @@
"environment": {
"description": "Environment to use for the server-side defined EAS environment variables during build process and command execution. Learn more: https://docs.expo.dev/eas/environment-variables/",
"markdownDescription": "Environment to use for the server-side defined EAS environment variables during build process and command execution. [Learn more](https://docs.expo.dev/eas/environment-variables/)",
"enum": ["production", "preview", "development"]
"examples": ["production", "preview", "development"],
"type": "string"
},
"android": {
"$ref": "#/definitions/BuildProfileAndroid"
@@ -129,6 +129,35 @@ test('valid eas.json with specified environment', async () => {
});
});
test('supports custom environment names', async () => {
await fs.writeJson('/project/eas.json', {
build: {
development: {
environment: 'my-custom-environment',
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'development');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'development'
);
expect(androidProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
environment: 'my-custom-environment',
});
expect(iosProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
environment: 'my-custom-environment',
});
});
test('valid eas.json with specified keystoreName', async () => {
const keystoreName = 'this-is-a-keystore-name';
await fs.writeJson('/project/eas.json', {
+9 -1
View File
@@ -71,7 +71,15 @@ const CommonBuildProfileSchema = Joi.object({
// credentials
withoutCredentials: Joi.boolean(),
environment: Joi.string().valid('preview', 'production', 'development'),
environment: Joi.string()
.min(3)
.max(100)
.pattern(/^[a-z0-9_-]+$/)
.messages({
'string.min': 'Environment name must be at least 3 characters.',
'string.max': 'Environment name must not be longer than 100 characters.',
'string.pattern.base': 'Environment name can only contain lowercase letters, numbers, dashes and underscores.',
}),
});
const PlatformBuildProfileSchema = CommonBuildProfileSchema.concat(
+1 -1
View File
@@ -71,7 +71,7 @@ export interface CommonBuildProfile {
// credentials
withoutCredentials?: boolean;
environment?: 'preview' | 'production' | 'development';
environment?: string;
}
interface PlatformBuildProfile extends Omit<CommonBuildProfile, 'autoIncrement'> {
Generated
+110 -19
View File
@@ -1549,6 +1549,16 @@
semver "^7.6.2"
zod "^4.1.3"
"@expo/[email protected]":
version "1.0.239"
resolved "https://registry.yarnpkg.com/@expo/eas-build-job/-/eas-build-job-1.0.239.tgz#025afd9c3fb9086cbb053ccbc08b7683aa74bbc4"
integrity sha512-5P/dQL2S2iEyTvGKOPN95r7cNFW3HgnA4PNqXU0s5ctb10837sMX6/A5XgIg/Ow0SFGZRiP4cK9ZFAiK3m2Ong==
dependencies:
"@expo/logger" "1.0.221"
joi "^17.13.1"
semver "^7.6.2"
zod "^4.1.3"
"@expo/env@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@expo/env/-/env-1.0.0.tgz#8e22894fae04ba6dc41fe0f1351a4973d410273a"
@@ -5588,6 +5598,11 @@ chalk@^5.2.0:
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385"
integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==
chalk@^5.6.2:
version "5.6.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
[email protected]:
version "1.0.15"
resolved "https://registry.yarnpkg.com/change-case-all/-/change-case-all-1.0.15.tgz#de29393167fc101d646cd76b0ef23e27d09756ad"
@@ -5688,6 +5703,13 @@ cli-cursor@^2.1.0:
dependencies:
restore-cursor "^2.0.0"
cli-cursor@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38"
integrity sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==
dependencies:
restore-cursor "^5.0.0"
[email protected], cli-progress@^3.10.0, cli-progress@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.12.0.tgz#807ee14b66bcc086258e444ad0f19e7d42577942"
@@ -5705,10 +5727,10 @@ cli-spinners@^2.0.0:
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41"
integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==
cli-spinners@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.4.0.tgz#c6256db216b878cfba4720e719cec7cf72685d7f"
integrity sha512-sJAofoarcm76ZGpuooaO0eDy8saEy+YoZBLjC4h8srt4jeBnkYeOgqxgsJQTpyt2LjI5PTfLJHSL+41Yu4fEJA==
cli-spinners@^3.2.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-3.3.0.tgz#2ba7c98b4f4662e67315b5634365661be8574440"
integrity sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==
cli-table@^0.3.1:
version "0.3.11"
@@ -7897,6 +7919,11 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-east-asian-width@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6"
integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==
get-intrinsic@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.1.tgz#94a9768fcbdd0595a1c9273aacf4c89d075631be"
@@ -9095,6 +9122,11 @@ is-interactive@^1.0.0:
resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e"
integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==
is-interactive@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90"
integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==
is-lambda@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5"
@@ -9330,6 +9362,11 @@ is-unicode-supported@^0.1.0:
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
is-unicode-supported@^2.0.0, is-unicode-supported@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
is-upper-case@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649"
@@ -10531,6 +10568,14 @@ log-symbols@^4.0.0:
dependencies:
chalk "^4.0.0"
log-symbols@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-7.0.1.tgz#f52e68037d96f589fc572ff2193dc424d48c195b"
integrity sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==
dependencies:
is-unicode-supported "^2.0.0"
yoctocolors "^2.1.1"
log-update@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
@@ -10902,6 +10947,11 @@ mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
mimic-function@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076"
integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==
mimic-response@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
@@ -11901,6 +11951,13 @@ onetime@^5.1.0, onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
onetime@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60"
integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==
dependencies:
mimic-function "^5.0.0"
open@^8.0.4, open@^8.4.0:
version "8.4.0"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
@@ -11922,20 +11979,6 @@ optionator@^0.9.3:
prelude-ls "^1.2.1"
type-check "^0.4.0"
[email protected]:
version "5.1.0"
resolved "https://registry.yarnpkg.com/ora/-/ora-5.1.0.tgz#b188cf8cd2d4d9b13fd25383bc3e5cba352c94f8"
integrity sha512-9tXIMPvjZ7hPTbk8DFq1f7Kow/HU/pQYB60JbNq+QnGwcyhWVZaQ4hM9zQDEsPxw/muLpgiHSaumUZxCAmod/w==
dependencies:
chalk "^4.1.0"
cli-cursor "^3.1.0"
cli-spinners "^2.4.0"
is-interactive "^1.0.0"
log-symbols "^4.0.0"
mute-stream "0.0.8"
strip-ansi "^6.0.0"
wcwidth "^1.0.1"
[email protected]:
version "5.3.0"
resolved "https://registry.yarnpkg.com/ora/-/ora-5.3.0.tgz#fb832899d3a1372fe71c8b2c534bbfe74961bb6f"
@@ -11950,6 +11993,21 @@ [email protected]:
strip-ansi "^6.0.0"
wcwidth "^1.0.1"
[email protected]:
version "9.0.0"
resolved "https://registry.yarnpkg.com/ora/-/ora-9.0.0.tgz#945236f5ce78a024cf4c25df6c46ecd09ab6e685"
integrity sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==
dependencies:
chalk "^5.6.2"
cli-cursor "^5.0.0"
cli-spinners "^3.2.0"
is-interactive "^2.0.0"
is-unicode-supported "^2.1.0"
log-symbols "^7.0.1"
stdin-discarder "^0.2.2"
string-width "^8.1.0"
strip-ansi "^7.1.2"
ora@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/ora/-/ora-3.4.0.tgz#bf0752491059a3ef3ed4c85097531de9fdbcd318"
@@ -13100,6 +13158,14 @@ restore-cursor@^3.1.0:
onetime "^5.1.0"
signal-exit "^3.0.2"
restore-cursor@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7"
integrity sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==
dependencies:
onetime "^7.0.0"
signal-exit "^4.1.0"
[email protected]:
version "0.13.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
@@ -13483,7 +13549,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
signal-exit@^4.0.1:
signal-exit@^4.0.1, signal-exit@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
@@ -13779,6 +13845,11 @@ [email protected]:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
stdin-discarder@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be"
integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==
[email protected]:
version "2.2.0"
resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4"
@@ -13857,6 +13928,14 @@ string-width@^5.0.1, string-width@^5.1.2:
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
string-width@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.1.0.tgz#9e9fb305174947cf45c30529414b5da916e9e8d1"
integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==
dependencies:
get-east-asian-width "^1.3.0"
strip-ansi "^7.1.0"
string.prototype.matchall@^4.0.11:
version "4.0.11"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a"
@@ -14021,6 +14100,13 @@ strip-ansi@^7.0.1:
dependencies:
ansi-regex "^6.0.1"
strip-ansi@^7.1.0, strip-ansi@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba"
integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==
dependencies:
ansi-regex "^6.0.1"
strip-bom-buf@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"
@@ -15630,6 +15716,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yoctocolors@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.2.tgz#d795f54d173494e7d8db93150cec0ed7f678c83a"
integrity sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==
zod@^4.1.3:
version "4.1.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.8.tgz#54f024adc634dd185006a8415da2bdd495b9d92b"