mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bba882b1 | ||
|
|
4476c87962 | ||
|
|
18ecd0844a | ||
|
|
cc3e7cdbe4 | ||
|
|
aa7fecb9fe | ||
|
|
c73a3e3a1e | ||
|
|
37ca2ebd55 | ||
|
|
dead11964e | ||
|
|
b66d31d7d7 | ||
|
|
ed8170047b | ||
|
|
0e4056ba82 | ||
|
|
6182ea5a1d | ||
|
|
c9e7b649ad | ||
|
|
7a6b796242 | ||
|
|
804eb9a326 | ||
|
|
8da14f0101 | ||
|
|
dc62d22816 | ||
|
|
258c625611 | ||
|
|
e84340774d | ||
|
|
fbc23f7a7d | ||
|
|
97255ec90d | ||
|
|
cfccc87b82 | ||
|
|
09b4f8367e | ||
|
|
45481f9758 | ||
|
|
1119008317 | ||
|
|
123a0c1b7e | ||
|
|
2f21fa62b1 | ||
|
|
8ad449c6b7 | ||
|
|
fe8ec50806 | ||
|
|
86cefeb259 | ||
|
|
034e0a1e7f | ||
|
|
a6b776915f | ||
|
|
4ae52d0e3d | ||
|
|
f62720ef5a | ||
|
|
af09aaedb3 | ||
|
|
184616e874 | ||
|
|
6bb09c4e99 | ||
|
|
81add86c61 | ||
|
|
4bb1c784d8 | ||
|
|
322082c28b | ||
|
|
5db1d21aa5 | ||
|
|
0db5585b20 | ||
|
|
10ec54345a | ||
|
|
27b4710264 | ||
|
|
4132e92da5 | ||
|
|
264340a717 | ||
|
|
1d09c2c97d | ||
|
|
72cff1df1a | ||
|
|
6912e453ef | ||
|
|
839cbac47b | ||
|
|
5ba16dbc67 | ||
|
|
896b456dd3 | ||
|
|
f2c0202f06 | ||
|
|
497bac1cbf | ||
|
|
5b9734b60b | ||
|
|
3ac70d8977 | ||
|
|
32adc788d0 | ||
|
|
e465ed57cb | ||
|
|
df819fc232 | ||
|
|
539e201972 | ||
|
|
c14b3e179b |
No files matched your search
@@ -0,0 +1,64 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
staging_only:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
description: "Version number to move the tag to"
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: "Run the workflow in dry run mode, without making any NPM changes"
|
||||
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
|
||||
jobs:
|
||||
move-tag:
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
node-version: 22
|
||||
- name: Resolve version (fails if the version does not exist)
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm pack --dry-run eas-cli@$INPUT_VERSION
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['18', '20', '22']
|
||||
node: ['20', '22']
|
||||
include:
|
||||
- node: '20'
|
||||
coverage: true
|
||||
|
||||
+128
@@ -11,6 +11,134 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
|
||||
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
|
||||
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Multiple bugfixes and small features for the `eas:new` command: handle project name and directory generation more smoothly; support `bun` as a package manager; add `--package-manager` flag with a default to `npm`; ensure workflow files are in the correct location after build; update the generated project's README with the provided package manager; ensure `app.json`'s `scheme` is valid; silence lots of noisy output. ([#3205](https://github.com/expo/eas-cli/pull/3205) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.23.0](https://github.com/expo/eas-cli/releases/tag/v16.23.0) - 2025-10-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add --force flag to eas env:push command to bypass confirmation prompts. ([#3135](https://github.com/expo/eas-cli/pull/3135) by [@giorgiofellipe](https://github.com/giorgiofellipe))
|
||||
|
||||
## [16.22.0](https://github.com/expo/eas-cli/releases/tag/v16.22.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.21.0](https://github.com/expo/eas-cli/releases/tag/v16.21.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support `_expo/.routes.json` metadata file exported by `@expo/cli` for headers configuration on static deployments ([#3211](https://github.com/expo/eas-cli/pull/3211) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add thorough tests for the env commands ([#3217](https://github.com/expo/eas-cli/pull/3217) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.4](https://github.com/expo/eas-cli/releases/tag/v16.20.4) - 2025-10-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Allow workflow:run to run a workflow from a GitHub ref. ([#3203](https://github.com/expo/eas-cli/pull/3203) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas exec and pull ([#3216](https://github.com/expo/eas-cli/pull/3216) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.3](https://github.com/expo/eas-cli/releases/tag/v16.20.3) - 2025-10-07
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas env commands ([#3213](https://github.com/expo/eas-cli/pull/3213) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.2](https://github.com/expo/eas-cli/releases/tag/v16.20.2) - 2025-10-07
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Create an `EnvironmentVariableEnvironment` enum and use it instead of the graphQL Enum ([#3208](https://github.com/expo/eas-cli/pull/3208) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.1](https://github.com/expo/eas-cli/releases/tag/v16.20.1) - 2025-10-03
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "16.20.2",
|
||||
"version": "16.28.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+296
-212
File diff suppressed because it is too large.
Load diff
Generated
+3135
-13
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "16.20.2",
|
||||
"version": "16.28.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.243",
|
||||
"@expo/eas-json": "16.25.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "1.0.221",
|
||||
@@ -226,16 +226,18 @@
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log"
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
|
||||
@@ -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"}'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
ScheduleBranchDeletionMutation,
|
||||
ScheduleBranchDeletionMutationVariables,
|
||||
} from '../graphql/generated';
|
||||
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
|
||||
|
||||
export async function scheduleBranchDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
branchId,
|
||||
}: {
|
||||
branchId: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleBranchDeletionMutation, ScheduleBranchDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleBranchDeletion($branchId: ID!) {
|
||||
updateBranch {
|
||||
scheduleUpdateBranchDeletion(branchId: $branchId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ branchId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return result.updateBranch.scheduleUpdateBranchDeletion;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ function createMockBuildFragment({
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
__typename: 'App',
|
||||
|
||||
@@ -70,6 +70,7 @@ function createMockBuildFragment({
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
logFiles: [],
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
priority: BuildPriority.Normal,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface IosBuildContext {
|
||||
}
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
buildProfile: BuildProfile<T>;
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
);
|
||||
|
||||
const commonContext: CommonContext<T> = {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
|
||||
@@ -1,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'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -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,13 +1,14 @@
|
||||
import { Job, Metadata } from '@expo/eas-build-job';
|
||||
import { Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
|
||||
import { getExpoApiBaseUrl } from '../api';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.171';
|
||||
const PLUGIN_PACKAGE_VERSION = version; // should match version of @expo/eas-build-job
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
@@ -60,6 +61,7 @@ export async function runLocalBuildAsync(
|
||||
...env,
|
||||
...process.env,
|
||||
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
|
||||
__API_SERVER_URL: getExpoApiBaseUrl(),
|
||||
...(options.skipCleanup || options.skipNativeBuild
|
||||
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
|
||||
: {}),
|
||||
|
||||
@@ -78,10 +78,12 @@ import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { ProfileData, getProfilesAsync } from '../utils/profiles';
|
||||
import { maybeWarnAboutUsageOveragesAsync } from '../utils/usage/checkForOverages';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -409,6 +411,15 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
await maybeWarnAboutUsageOveragesAsync({ graphqlClient, accountId: buildCtx.accountId });
|
||||
if (!moreBuilds) {
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (moreBuilds) {
|
||||
Log.newLine();
|
||||
const appPlatform = toAppPlatform(buildProfile.platform);
|
||||
|
||||
@@ -1,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 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> {
|
||||
|
||||
+4
-12
@@ -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();
|
||||
|
||||
@@ -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,131 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('commands', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneTemplateAsync', () => {
|
||||
it('should clone the template project with ssh', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
|
||||
it('should clone the template project with https', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installProjectDependenciesAsync', () => {
|
||||
it('should install the project dependencies', async () => {
|
||||
const projectDir = '/test/project-directory';
|
||||
|
||||
jest.mocked(installDependenciesAsync).mockResolvedValue();
|
||||
jest.mocked(runCommandAsync).mockResolvedValue();
|
||||
|
||||
await installProjectDependenciesAsync(projectDir, 'npm');
|
||||
|
||||
expect(installDependenciesAsync).toHaveBeenCalledWith({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager: 'npm',
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', '@expo/metro-runtime'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeGitRepositoryAsync', () => {
|
||||
it('should initialize git repository', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await initializeGitRepositoryAsync(projectDir);
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['init'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['add', '.'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['commit', '-m', 'Initial commit'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { LogSpy } from './testUtils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import {
|
||||
findAvailableProjectNameAsync,
|
||||
generateProjectConfigAsync,
|
||||
getAccountChoices,
|
||||
promptForProjectAccountAsync,
|
||||
} from '../configs';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../utils', () => ({
|
||||
printDirectory: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe('configs', () => {
|
||||
let logSpy: LogSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
logSpy = new LogSpy('withInfo');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
logSpy.restore();
|
||||
});
|
||||
|
||||
// Helper function to verify prompt was called with expected message
|
||||
const expectPromptToHaveMessage = (expectedMessage: string): void => {
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expectedMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const mockUserInput = (inputValue: any): void => {
|
||||
jest.mocked(promptAsync).mockResolvedValue(inputValue);
|
||||
};
|
||||
|
||||
describe('generateProjectConfigAsync', () => {
|
||||
const mockOptions = {
|
||||
graphqlClient: {} as ExpoGraphqlClient,
|
||||
projectAccount: 'test-account',
|
||||
};
|
||||
|
||||
describe('when no path is provided', () => {
|
||||
it('should prompt for project name with default value', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'expo-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'expo-project',
|
||||
projectDirectory: expect.stringContaining('/expo-project'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initial: 'expo-project',
|
||||
})
|
||||
);
|
||||
logSpy.expectLogToContain('Using project name: expo-project');
|
||||
});
|
||||
|
||||
it('should prompt and use custom name entered by user', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'user-custom-name' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'user-custom-name',
|
||||
projectDirectory: expect.stringContaining('/user-custom-name'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
logSpy.expectLogToContain('Using project name: user-custom-name');
|
||||
});
|
||||
|
||||
it('should use shortid when prompted name exists', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // base name exists
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'existing-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^existing-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining(result.projectName),
|
||||
});
|
||||
logSpy.expectLogToContain('Using project name:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when path is provided', () => {
|
||||
it('should use absolute path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const absolutePath = '/absolute/path/to/my-project';
|
||||
const result = await generateProjectConfigAsync(absolutePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-project',
|
||||
projectDirectory: absolutePath,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
|
||||
it('should resolve relative path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const relativePath = 'some/relative/my-app';
|
||||
const result = await generateProjectConfigAsync(relativePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-app',
|
||||
projectDirectory: expect.stringContaining('some/relative/my-app'),
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForProjectAccountAsync', () => {
|
||||
it('should prompt for a project account when there are multiple accounts', async () => {
|
||||
const selectedAccount = 'jester';
|
||||
mockUserInput({ account: { name: selectedAccount } });
|
||||
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
|
||||
expect(result).toBe(selectedAccount);
|
||||
expectPromptToHaveMessage('Which account should own this project?');
|
||||
});
|
||||
|
||||
it('should automatically select single account with permissions', async () => {
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
expect(result).toBe(jester.accounts[0].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountChoices', () => {
|
||||
it('should return mapped account choices', () => {
|
||||
const result = getAccountChoices(jester);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'other',
|
||||
value: { name: 'other' },
|
||||
disabled: true,
|
||||
description:
|
||||
'You do not have the required permissions to create projects on this account.',
|
||||
},
|
||||
{
|
||||
title: 'jester',
|
||||
value: { name: 'jester' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAvailableProjectNameAsync', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockOptions = {
|
||||
graphqlClient: mockGraphqlClient,
|
||||
projectAccount: 'jester',
|
||||
};
|
||||
|
||||
it('should find first available name when checking both local and remote', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'test-project',
|
||||
projectDirectory: '/base/path/test-project',
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists locally', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // Base name exists locally
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(fs.pathExists).toHaveBeenCalledWith('/base/path/test-project');
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id'); // Base name exists remotely
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name is taken both locally and remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getEASUpdateURL } from '../../../api';
|
||||
import { AppFragment } from '../../../graphql/generated';
|
||||
import { easCliVersion } from '../../../utils/easCli';
|
||||
import {
|
||||
cleanAndPrefix,
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../projectFiles';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('projectFiles', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readJson).mockImplementation(() => Promise.resolve({}));
|
||||
jest.mocked(fs.writeJson).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
|
||||
jest.mocked(fs.symlink).mockImplementation(() => Promise.resolve());
|
||||
});
|
||||
|
||||
describe('cleanAndPrefix', () => {
|
||||
it('should clean and prefix the string', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'app')).toBe('testapp123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'app')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test-app-with-dashes', 'app')).toBe('testappwithdashes');
|
||||
expect(cleanAndPrefix('TestApp', 'app')).toBe('appTestApp');
|
||||
expect(cleanAndPrefix('100 App', 'app')).toBe('app100App');
|
||||
});
|
||||
|
||||
it('should handle schemes', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'scheme')).toBe('test-app-123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'scheme')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test+app.with-allowed.symbols', 'scheme')).toBe(
|
||||
'test+app.with-allowed.symbols'
|
||||
);
|
||||
expect(cleanAndPrefix('100 App', 'scheme')).toBe('scheme100app');
|
||||
expect(cleanAndPrefix('TestApp', 'scheme')).toBe('testapp');
|
||||
expect(cleanAndPrefix('testApp2', 'scheme')).toBe('testapp2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAppConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const mockUpdateUrl = 'https://u.expo.dev/test-project-id';
|
||||
|
||||
const createMockApp = (overrides: Partial<AppFragment> = {}): AppFragment => ({
|
||||
id: 'test-project-id',
|
||||
name: 'Test App',
|
||||
fullName: '@testowner/test-app',
|
||||
slug: 'test-app',
|
||||
ownerAccount: {
|
||||
id: 'testowner-account-id',
|
||||
name: 'testowner',
|
||||
users: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getEASUpdateURL).mockReturnValue(mockUpdateUrl);
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {} as ExpoConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate the app config', async () => {
|
||||
const mockApp = createMockApp();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {
|
||||
name: 'value-to-override',
|
||||
slug: 'value-to-override',
|
||||
icon: 'value-to-keep',
|
||||
} as ExpoConfig,
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(getEASUpdateURL).toHaveBeenCalledWith('test-project-id', null);
|
||||
|
||||
const expectedConfig = {
|
||||
expo: {
|
||||
name: 'Test App',
|
||||
slug: 'test-app',
|
||||
scheme: 'testapp',
|
||||
icon: 'value-to-keep',
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: 'test-project-id',
|
||||
},
|
||||
},
|
||||
owner: 'testowner',
|
||||
updates: {
|
||||
url: mockUpdateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: 'com.testowner.testapp',
|
||||
},
|
||||
android: {
|
||||
package: 'com.testowner.testapp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the bundle identifier', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test-app-with-dashes',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-account-id',
|
||||
name: 'test-owner',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithdashes',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the owner account name', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-123-account-id',
|
||||
name: 'test-owner-123',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner123.testapp',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the slug', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test@app#with$symbols',
|
||||
fullName: '@testowner/test@app#with$symbols',
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithsymbols',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEasConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
it('should generate the eas config', async () => {
|
||||
const expectedEasConfig = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
appVersionSource: 'remote',
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: { simulator: true },
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePackageJsonAsync', () => {
|
||||
it('should update the package.json', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const projectDir = '/test/project-dir';
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
|
||||
const expectedPackageJson = {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
|
||||
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
|
||||
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/package.json`, expectedPackageJson, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
it('should copy the project templates', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('templates'),
|
||||
projectDir,
|
||||
expect.objectContaining({
|
||||
errorOnExist: false,
|
||||
overwrite: true,
|
||||
filter: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReadmeAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const readmeTemplatePath = expect.stringContaining('templates/readme-additions.md');
|
||||
const projectReadmePath = `${projectDir}/README.md`;
|
||||
|
||||
const mockReadmeTemplate = `## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
\`\`\`bash
|
||||
npm run start
|
||||
\`\`\`
|
||||
|
||||
## Workflows
|
||||
`;
|
||||
const mockExistingReadme = `# My App
|
||||
|
||||
## Get started
|
||||
|
||||
Follow these steps to get started with the app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? mockExistingReadme
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
});
|
||||
|
||||
it('should read the existing readme and merge the template content', async () => {
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(readmeTemplatePath, 'utf8');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(projectReadmePath, 'utf8');
|
||||
|
||||
const headings = ['# My App', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace npm run with package manager specific commands', async () => {
|
||||
await updateReadmeAsync(projectDir, 'yarn');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringContaining('yarn run start')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle the case when 'Get started' section does not exist", async () => {
|
||||
const readmeWithoutGetStarted = `# My App
|
||||
|
||||
This is my awesome app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? readmeWithoutGetStarted
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
const headings = ['# My App', '## Installation', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import Log from '../../../log';
|
||||
|
||||
export class LogSpy {
|
||||
private readonly logSpy: jest.SpyInstance;
|
||||
|
||||
constructor(logMethod: 'warn' | 'log' | 'withTick' | 'withInfo') {
|
||||
this.logSpy = jest.spyOn(Log, logMethod);
|
||||
}
|
||||
|
||||
getLogOutput(): string[] {
|
||||
return this.logSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
|
||||
}
|
||||
|
||||
expectLogToContain(message: string): void {
|
||||
const output = this.getLogOutput();
|
||||
// strip out ANSI codes and special characters like the tick
|
||||
const outputWithoutAnsi = output.map(line =>
|
||||
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
|
||||
);
|
||||
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.logSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from '../utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('printDirectory', () => {
|
||||
let cwdSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock process.cwd() to return a predictable value
|
||||
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/Users/test/projects');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return "." for the current directory', () => {
|
||||
const result = printDirectory('/Users/test/projects');
|
||||
expect(result).toBe('.');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for nested subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/nested/my-app');
|
||||
expect(result).toBe('./nested/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for directory outside cwd', () => {
|
||||
const result = printDirectory('/Users/other/projects/my-app');
|
||||
expect(result).toBe('/Users/other/projects/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for parent directory', () => {
|
||||
const result = printDirectory('/Users/test');
|
||||
expect(result).toBe('/Users/test');
|
||||
});
|
||||
|
||||
it('should handle relative paths and convert them correctly', () => {
|
||||
// Relative path resolves to absolute first, then converts back to relative
|
||||
const result = printDirectory('my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "./" already', () => {
|
||||
const result = printDirectory('./my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "../" (goes outside cwd)', () => {
|
||||
const result = printDirectory('../other-project');
|
||||
// This goes outside cwd, so should return absolute path
|
||||
expect(result).toBe('/Users/test/other-project');
|
||||
});
|
||||
|
||||
it('should handle complex relative paths', () => {
|
||||
const result = printDirectory('./nested/deep/project');
|
||||
expect(result).toBe('./nested/deep/project');
|
||||
});
|
||||
|
||||
it('should normalize paths correctly', () => {
|
||||
const result = printDirectory('/Users/test/projects/./my-app/../my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle paths in real cwd', () => {
|
||||
// Restore mock to test with real cwd
|
||||
cwdSpy.mockRestore();
|
||||
const realCwd = process.cwd();
|
||||
|
||||
// Test with current directory
|
||||
const result1 = printDirectory(realCwd);
|
||||
expect(result1).toBe('.');
|
||||
|
||||
// Test with subdirectory
|
||||
const subDir = path.join(realCwd, 'test-dir');
|
||||
const result2 = printDirectory(subDir);
|
||||
expect(result2).toBe('./test-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
|
||||
const spinner = ora(
|
||||
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
|
||||
).start();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Installing project dependencies')}`).start();
|
||||
await installDependenciesAsync({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager,
|
||||
});
|
||||
|
||||
const dependencies = ['expo-updates', '@expo/metro-runtime'];
|
||||
for (const dependency of dependencies) {
|
||||
spinner.text = `Installing ${chalk.bold(dependency)}`;
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', dependency],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Installed project dependencies`);
|
||||
}
|
||||
|
||||
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Initializing Git repository')}`).start();
|
||||
await fs.remove(path.join(projectDir, '.git'));
|
||||
|
||||
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
|
||||
|
||||
for (const args of commands) {
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'git',
|
||||
args,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Initialized Git repository`);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
function validateProjectPath(resolvedPath: string): void {
|
||||
const normalizedPath = path.normalize(resolvedPath);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (normalizedPath.includes('..')) {
|
||||
throw new Error(`Invalid project path: "${resolvedPath}". Path traversal is not allowed.`);
|
||||
}
|
||||
|
||||
// Ensure we're not trying to create a project in system directories
|
||||
const systemDirs = ['/bin', '/sbin', '/etc', '/usr', '/var', '/sys', '/proc', '/dev'];
|
||||
const isSystemDir = systemDirs.some(
|
||||
dir => normalizedPath === dir || normalizedPath.startsWith(dir + path.sep)
|
||||
);
|
||||
|
||||
if (isSystemDir) {
|
||||
throw new Error(
|
||||
`Invalid project path: "${resolvedPath}". Cannot create projects in system directories.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateProjectConfigAsync(
|
||||
pathArg: string | undefined,
|
||||
options: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{
|
||||
projectName: string;
|
||||
projectDirectory: string;
|
||||
}> {
|
||||
let baseName = 'expo-project';
|
||||
let parentDirectory = process.cwd();
|
||||
|
||||
if (pathArg) {
|
||||
const resolvedPath = path.isAbsolute(pathArg) ? pathArg : path.resolve(process.cwd(), pathArg);
|
||||
validateProjectPath(resolvedPath);
|
||||
baseName = path.basename(resolvedPath);
|
||||
parentDirectory = path.dirname(resolvedPath);
|
||||
} else {
|
||||
baseName = (
|
||||
await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'What would you like to name your project?',
|
||||
initial: 'expo-project',
|
||||
})
|
||||
).name;
|
||||
}
|
||||
|
||||
// Find an available name checking both local filesystem and remote server
|
||||
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
|
||||
baseName,
|
||||
parentDirectory,
|
||||
options
|
||||
);
|
||||
|
||||
Log.withInfo(`Using project name: ${projectName}`);
|
||||
Log.withInfo(`Using project directory: ${printDirectory(projectDirectory)}`);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
|
||||
const permissionsMap = new Map<string, boolean>();
|
||||
|
||||
for (const account of actor.accounts) {
|
||||
const hasPermission =
|
||||
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
|
||||
permissionsMap.set(account.name, hasPermission);
|
||||
}
|
||||
|
||||
return permissionsMap;
|
||||
}
|
||||
|
||||
export async function promptForProjectAccountAsync(actor: Actor): Promise<string> {
|
||||
const permissionsMap = getAccountPermissionsMap(actor);
|
||||
|
||||
// If only one account, use it (if has permissions)
|
||||
if (actor.accounts.length === 1) {
|
||||
const account = actor.accounts[0];
|
||||
|
||||
if (permissionsMap.get(account.name)) {
|
||||
return account.name;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`You don't have permission to create projects on your only available account (${account.name}).`
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple accounts - prompt user to select one with permissions
|
||||
return (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices: getAccountChoices(actor, permissionsMap),
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
|
||||
export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boolean>): Choice[] {
|
||||
const permissions = permissionsMap ?? getAccountPermissionsMap(actor);
|
||||
const sortedAccounts = [...actor.accounts].sort((a, _b) => (a.ownerUserActor ? 1 : -1));
|
||||
|
||||
return sortedAccounts.map(account => {
|
||||
const isPersonalAccount = !!account.ownerUserActor && account.ownerUserActor.id === actor.id;
|
||||
const isTeamAccount = !!account.ownerUserActor && account.ownerUserActor.id !== actor.id;
|
||||
|
||||
const accountDisplayName = isPersonalAccount
|
||||
? `${account.name} (Limited - Personal Account)`
|
||||
: isTeamAccount
|
||||
? `${account.name} (Limited - Team Account)`
|
||||
: account.name;
|
||||
|
||||
const disabled = !permissions.get(account.name);
|
||||
|
||||
return {
|
||||
title: accountDisplayName,
|
||||
value: { name: account.name },
|
||||
...(disabled && {
|
||||
disabled: true,
|
||||
description: 'You do not have the required permissions to create projects on this account.',
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyProjectDoesNotExistAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
{ silent = false }: { silent?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
|
||||
const doesNotExist = existingProjectId === null;
|
||||
if (!doesNotExist && !silent) {
|
||||
Log.warn(`Project @${accountName}/${projectName} already exists on the server.`);
|
||||
}
|
||||
|
||||
return doesNotExist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an available project name that doesn't conflict with either:
|
||||
* Local filesystem (directory already exists)
|
||||
* Remote server (project already exists on Expo)
|
||||
*/
|
||||
export async function findAvailableProjectNameAsync(
|
||||
baseName: string,
|
||||
parentDirectory: string,
|
||||
{
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{ projectName: string; projectDirectory: string }> {
|
||||
let projectName = baseName;
|
||||
let projectDirectory = path.join(parentDirectory, projectName);
|
||||
|
||||
const localExists = await fs.pathExists(projectDirectory);
|
||||
|
||||
const remoteAvailable = await verifyProjectDoesNotExistAsync(
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
projectName
|
||||
);
|
||||
|
||||
if (localExists || !remoteAvailable) {
|
||||
projectName = `${baseName}-${nanoid(6)}`;
|
||||
projectDirectory = path.join(parentDirectory, projectName);
|
||||
}
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import merge from 'ts-deepmerge';
|
||||
|
||||
import { getEASUpdateURL } from '../../api';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { PackageManager } from '../../onboarding/installDependencies';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
// Android package names must start with a lowercase letter
|
||||
// schemes must start with a lowercase letter and can only contain lowercase letters, digits, "+", "." or "-"
|
||||
export function cleanAndPrefix(_string: string, type: 'user' | 'app' | 'scheme'): string {
|
||||
let string = _string;
|
||||
let pattern = /[^A-Za-z0-9]/g;
|
||||
if (type === 'scheme') {
|
||||
string = _string.toLowerCase();
|
||||
pattern = /[^a-z0-9+.-]/g;
|
||||
}
|
||||
|
||||
const prefix = /^[^a-z]/.test(string) ? type : '';
|
||||
const cleaned = string.replaceAll(pattern, '');
|
||||
|
||||
return prefix + cleaned;
|
||||
}
|
||||
|
||||
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
|
||||
const user = cleanAndPrefix(app.ownerAccount.name, 'user');
|
||||
const slug = cleanAndPrefix(app.slug, 'app');
|
||||
const scheme = cleanAndPrefix(app.name ?? app.slug, 'scheme');
|
||||
|
||||
const bundleIdentifier = `com.${user}.${slug}`;
|
||||
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
|
||||
|
||||
const { expo: baseExpoConfig } = await fs.readJson(path.join(projectDir, 'app.json'));
|
||||
const expoConfig: ExpoConfig = {
|
||||
name: app.name ?? app.slug,
|
||||
slug: app.slug,
|
||||
scheme,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: app.id,
|
||||
},
|
||||
},
|
||||
owner: app.ownerAccount.name,
|
||||
updates: {
|
||||
url: updateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier,
|
||||
},
|
||||
android: {
|
||||
package: bundleIdentifier,
|
||||
},
|
||||
};
|
||||
const mergedConfig = merge(baseExpoConfig, expoConfig);
|
||||
|
||||
const appJsonPath = path.join(projectDir, 'app.json');
|
||||
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
|
||||
const easBuildGitHubConfig = {
|
||||
android: {
|
||||
image: 'latest',
|
||||
},
|
||||
ios: {
|
||||
image: 'latest',
|
||||
},
|
||||
};
|
||||
|
||||
const easJson: EasJson = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: {
|
||||
simulator: true,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {};
|
||||
}
|
||||
packageJson.scripts.draft = 'npx eas-cli@latest workflow:run create-draft.yml';
|
||||
packageJson.scripts['development-builds'] =
|
||||
'npx eas-cli@latest workflow:run create-development-builds.yml';
|
||||
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
|
||||
|
||||
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates');
|
||||
|
||||
// Copy everything from templates to projectDir, skipping readme-additions.md
|
||||
await fs.copy(templatesSourceDir, projectDir, {
|
||||
overwrite: true,
|
||||
errorOnExist: false,
|
||||
filter: (src: string) => {
|
||||
return !src.endsWith('readme-additions.md');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateReadmeAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const readmeTemplatePath = path.join(__dirname, 'templates', 'readme-additions.md');
|
||||
const projectReadmePath = path.join(projectDir, 'README.md');
|
||||
|
||||
const readmeAdditions = await fs.readFile(readmeTemplatePath, 'utf8');
|
||||
const existingReadme = await fs.readFile(projectReadmePath, 'utf8');
|
||||
|
||||
const targetSection = '## Get started';
|
||||
const sectionIndex = existingReadme.indexOf(targetSection);
|
||||
|
||||
let mergedReadme: string;
|
||||
if (sectionIndex !== -1) {
|
||||
// Find the next ## section after "## Get started"
|
||||
const afterTargetSection = existingReadme.substring(sectionIndex);
|
||||
const nextSectionMatch = afterTargetSection.match(/\n## /);
|
||||
let endIndex = existingReadme.length;
|
||||
if (nextSectionMatch?.index !== undefined) {
|
||||
// Replace from "## Get started" to the next "##" section
|
||||
endIndex = sectionIndex + nextSectionMatch.index;
|
||||
}
|
||||
|
||||
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
|
||||
const afterSection = existingReadme.substring(endIndex);
|
||||
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
|
||||
} else {
|
||||
// No "Get started" section found, append the template to the existing README
|
||||
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim();
|
||||
}
|
||||
|
||||
mergedReadme = mergedReadme.replaceAll('npm run', `${packageManager} run`);
|
||||
|
||||
await fs.writeFile(projectReadmePath, mergedReadme);
|
||||
}
|
||||
File renamed without changes.
+4
-1
@@ -1,4 +1,4 @@
|
||||
name: Publish preview update
|
||||
name: Create draft
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,3 +10,6 @@ jobs:
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
deploy_website:
|
||||
name: Deploy website
|
||||
type: deploy
|
||||
File renamed without changes.
@@ -0,0 +1,163 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Documentation Resources
|
||||
|
||||
When working on this project, **always consult the official Expo documentation** available at:
|
||||
|
||||
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
|
||||
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
|
||||
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
|
||||
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
|
||||
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
|
||||
|
||||
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
|
||||
|
||||
- Expo APIs and best practices
|
||||
- Expo Router navigation patterns
|
||||
- EAS Build, Submit, and Update workflows
|
||||
- Expo SDK modules and their usage
|
||||
- Development and deployment processes
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/ # Expo Router file-based routing
|
||||
│ ├── (tabs)/ # Tab-based navigation screens
|
||||
│ │ ├── index.tsx # Home screen
|
||||
│ │ ├── explore.tsx # Explore screen
|
||||
│ │ └── _layout.tsx # Tabs layout
|
||||
│ ├── _layout.tsx # Root layout with theme provider
|
||||
│ └── modal.tsx # Modal screen example
|
||||
├── components/ # Reusable React components
|
||||
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
|
||||
│ └── ... # Feature components (themed, haptic, parallax)
|
||||
├── constants/ # App-wide constants (theme, colors)
|
||||
├── hooks/ # Custom React hooks (color scheme, theme)
|
||||
├── assets/ # Static assets (images, fonts)
|
||||
├── scripts/ # Utility scripts (reset-project)
|
||||
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build/Submit configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npx expo start # Start dev server
|
||||
npx expo start --clear # Clear cache and start dev server
|
||||
npx expo install <package> # Install packages with compatible versions
|
||||
npx expo install --check # Check which installed packages need to be updated
|
||||
npx expo install --fix # Automatically update any invalid package versions
|
||||
npm run development-builds # Create development builds (workflow)
|
||||
npm run reset-project # Reset to blank template
|
||||
```
|
||||
|
||||
### Building & Testing
|
||||
|
||||
```bash
|
||||
npx expo doctor # Check project health and dependencies
|
||||
npx expo lint # Run ESLint
|
||||
npm run draft # Publish preview update and website (workflow)
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
|
||||
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
|
||||
npm run deploy # Deploy to production (workflow)
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style & Standards
|
||||
|
||||
- **TypeScript First**: Use TypeScript for all new code with strict type checking
|
||||
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
|
||||
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
|
||||
- **React 19 Patterns**: Follow modern React patterns including:
|
||||
- Function components with hooks
|
||||
- Enable React Compiler
|
||||
- Proper dependency arrays in useEffect
|
||||
- Memoization when appropriate (useMemo, useCallback)
|
||||
- Error boundaries for better error handling
|
||||
|
||||
### Navigation & Routing
|
||||
|
||||
- Use **Expo Router** for all navigation
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
|
||||
- Docs: https://docs.expo.dev/router/introduction/
|
||||
|
||||
### Recommended Libraries
|
||||
|
||||
- **Navigation**: `expo-router` for navigation
|
||||
- **Images**: `expo-image` for optimized image handling and caching
|
||||
- **Animations**: `react-native-reanimated` for performant animations on native thread
|
||||
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
|
||||
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
|
||||
|
||||
## Debugging & Development Tools
|
||||
|
||||
### DevTools Integration
|
||||
|
||||
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
|
||||
- **Network Inspection**: Monitor API calls and network requests in DevTools
|
||||
- **Element Inspector**: Debug component hierarchy and styles
|
||||
- **Performance Profiler**: Identify performance bottlenecks
|
||||
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
|
||||
|
||||
### Testing & Quality Assurance
|
||||
|
||||
#### Automated Testing with MCP Tools
|
||||
|
||||
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
|
||||
|
||||
- **Component Testing**: Add `testID` props to components for automation
|
||||
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
|
||||
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
|
||||
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
|
||||
|
||||
## EAS Workflows CI/CD
|
||||
|
||||
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
|
||||
|
||||
When working with EAS Workflows, **always refer to**:
|
||||
|
||||
- https://docs.expo.dev/eas/workflows/ for workflow examples
|
||||
- The `.eas/workflows/` directory for existing workflow configurations
|
||||
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
|
||||
|
||||
### Build Profiles (eas.json)
|
||||
|
||||
- **development**: Development builds with dev client
|
||||
- **development-simulator**: Development builds for iOS simulator
|
||||
- **preview**: Internal distribution preview builds
|
||||
- **production**: Production builds with auto-increment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Expo Go Errors & Development Builds
|
||||
|
||||
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
|
||||
|
||||
## AI Agent Instructions
|
||||
|
||||
When working on this project:
|
||||
|
||||
1. **Always start by consulting the appropriate documentation**:
|
||||
|
||||
- For general Expo questions: https://docs.expo.dev/llms-full.txt
|
||||
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
|
||||
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
|
||||
|
||||
2. **Understand before implementing**: Read the relevant docs section before writing code
|
||||
|
||||
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
|
||||
@@ -0,0 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**Note**: This project uses `AGENTS.md` files for detailed guidance.
|
||||
|
||||
## Primary Reference
|
||||
|
||||
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
|
||||
+18
-1
@@ -1,10 +1,27 @@
|
||||
## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in:
|
||||
|
||||
- [a development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [an Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [an iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Workflows
|
||||
|
||||
This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) to automate some development and release processes. These commands are set up in [`package.json`](./package.json) and can be run using NPM scripts in your terminal.
|
||||
|
||||
### Previews
|
||||
|
||||
Run `npm run preview` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
|
||||
Run `npm run draft` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
|
||||
|
||||
### Development Builds
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Formats a directory path for display.
|
||||
* If the directory is within the current working directory, returns a relative path.
|
||||
* Otherwise, returns the absolute path.
|
||||
*/
|
||||
export function printDirectory(directory: string): string {
|
||||
const cwd = process.cwd();
|
||||
const absoluteDir = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
|
||||
|
||||
const relativePath = path.relative(cwd, absoluteDir);
|
||||
|
||||
// If the relative path doesn't start with '..' it means it's within or at the cwd
|
||||
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
||||
return relativePath !== '' ? `./${relativePath}` : '.';
|
||||
}
|
||||
|
||||
return absoluteDir;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
|
||||
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
|
||||
|
||||
import BuildConfigure from '../../commands/build/configure';
|
||||
import UpdateConfigure from '../../commands/update/configure';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
|
||||
const buildProfileNames = new Set(
|
||||
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
|
||||
);
|
||||
return buildProfileNames;
|
||||
}
|
||||
export async function getBuildProfileAsync(
|
||||
projectDir: string,
|
||||
platform: Platform,
|
||||
profileName: string
|
||||
): Promise<BuildProfile<Platform>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
profileName
|
||||
);
|
||||
return buildProfile;
|
||||
}
|
||||
export async function buildProfilesFromProjectAsync(
|
||||
projectDir: string
|
||||
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
|
||||
new Map();
|
||||
for (const profileName of buildProfileNames) {
|
||||
const iosBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.IOS,
|
||||
profileName
|
||||
)) as IosBuildProfile;
|
||||
const androidBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.ANDROID,
|
||||
profileName
|
||||
)) as AndroidBuildProfile;
|
||||
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
|
||||
}
|
||||
return buildProfiles;
|
||||
}
|
||||
export function isBuildProfileForDevelopment(
|
||||
buildProfile: BuildProfile<Platform>,
|
||||
platform: Platform
|
||||
): boolean {
|
||||
if (buildProfile.developmentClient) {
|
||||
return true;
|
||||
}
|
||||
if (platform === Platform.ANDROID) {
|
||||
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
|
||||
}
|
||||
if (platform === Platform.IOS) {
|
||||
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
|
||||
return buildProfile.simulator ?? false;
|
||||
}
|
||||
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: {
|
||||
gradleCommand: ':app:assembleDebug',
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string,
|
||||
simulator: boolean
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
buildConfiguration: 'Debug',
|
||||
simulator,
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
|
||||
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
|
||||
projectDir: string
|
||||
): Promise<boolean> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
let profileAdded = false;
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
if (!easJsonRawObject.build?.production) {
|
||||
profileAdded = true;
|
||||
easJsonRawObject.build = {
|
||||
...(easJsonRawObject.build ?? {}),
|
||||
production: {},
|
||||
};
|
||||
// Also add the profile to submit
|
||||
easJsonRawObject.submit = {
|
||||
...(easJsonRawObject.submit ?? {}),
|
||||
production: {},
|
||||
};
|
||||
}
|
||||
return easJsonRawObject;
|
||||
});
|
||||
if (profileAdded) {
|
||||
Log.log('Added missing production build profile to eas.json');
|
||||
}
|
||||
await easJsonAccessor.writeAsync();
|
||||
return profileAdded;
|
||||
}
|
||||
|
||||
export async function hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Is there a project ID in the Expo config?
|
||||
if (!expoConfig.extra?.eas?.projectId) {
|
||||
return false;
|
||||
}
|
||||
// Is there an eas.json?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
await easJsonAccessor.readAsync();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Does the Expo config have an updates URL?
|
||||
if (!expoConfig.updates?.url) {
|
||||
return false;
|
||||
}
|
||||
// Does at least one build profile have a channel?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
const easJson = await easJsonAccessor.readAsync();
|
||||
return Object.values(easJson.build ?? {}).some(buildProfile => !!buildProfile.channel);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
|
||||
export async function runUpdateConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await UpdateConfigure.run([]);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
export async function runBuildConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await BuildConfigure.run(['-p', 'all']);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
addAndroidDevelopmentBuildProfileToEasJsonAsync,
|
||||
addIosDevelopmentBuildProfileToEasJsonAsync,
|
||||
addProductionBuildProfileToEasJsonIfNeededAsync,
|
||||
buildProfilesFromProjectAsync,
|
||||
hasBuildConfigureBeenRunAsync,
|
||||
hasUpdateConfigureBeenRunAsync,
|
||||
isBuildProfileForDevelopment,
|
||||
isIosBuildProfileForSimulator,
|
||||
} from './buildProfileUtils';
|
||||
import Log, { link } from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
export enum WorkflowStarterName {
|
||||
BUILD = 'build',
|
||||
UPDATE = 'update',
|
||||
CUSTOM = 'custom',
|
||||
DEPLOY = 'deploy',
|
||||
}
|
||||
|
||||
export type WorkflowStarter = {
|
||||
name: WorkflowStarterName;
|
||||
displayName: string;
|
||||
defaultFileName: string;
|
||||
template: any;
|
||||
header: string;
|
||||
nextSteps?: string[];
|
||||
};
|
||||
|
||||
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
|
||||
|
||||
const CUSTOM_TEMPLATE = {
|
||||
name: 'Custom workflow',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
custom_build: {
|
||||
name: 'Custom job',
|
||||
steps: [
|
||||
{
|
||||
uses: 'eas/checkout',
|
||||
},
|
||||
{
|
||||
name: 'Hello World',
|
||||
id: 'hello_world',
|
||||
run: '# Custom script\necho "Hello, World"\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_TEMPLATE_HEADER = `
|
||||
# Custom job
|
||||
#
|
||||
# This workflow shows how to write custom jobs.
|
||||
# It contains a predefined workflow step and a shell command to print "Hello, World!".
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to the main branch.
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run custom.yml
|
||||
# - Runs eas/checkout then a custom "echo" command
|
||||
#
|
||||
# For detailed documentation on workflow syntax and available step types, visit:
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const BUILD_TEMPLATE = {
|
||||
name: 'Create development builds',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {},
|
||||
};
|
||||
|
||||
const BUILD_TEMPLATE_HEADER = `
|
||||
# Create development builds
|
||||
#
|
||||
# This workflow shows how to create development builds.
|
||||
#
|
||||
# Key features:
|
||||
# - Can be triggered manually with eas workflow:run create-development-builds.yml
|
||||
# - Runs the pre-packaged build job to create Android and iOS development builds
|
||||
# for Android emulators, Android and iOS devices, and iOS simulators
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/develop/development-builds/introduction/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE = {
|
||||
name: 'Publish preview update',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['*'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
publish_preview_update: {
|
||||
name: 'Publish preview update',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: '${{ github.ref_name || "test" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
|
||||
# Publish preview update
|
||||
#
|
||||
# This workflow shows how to publish preview updates.
|
||||
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to all branches
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
|
||||
# - Runs the pre-packaged update job
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const DEPLOY_TEMPLATE = {
|
||||
name: 'Deploy to production',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
fingerprint: {
|
||||
name: 'Fingerprint',
|
||||
type: 'fingerprint',
|
||||
},
|
||||
get_android_build: {
|
||||
name: 'Check for existing android build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.android_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
get_ios_build: {
|
||||
name: 'Check for existing ios build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.ios_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_android: {
|
||||
name: 'Build Android',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ !needs.get_android_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'android',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_ios: {
|
||||
name: 'Build iOS',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ !needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'ios',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
submit_android_build: {
|
||||
name: 'Submit Android Build',
|
||||
needs: ['build_android'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_android.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
submit_ios_build: {
|
||||
name: 'Submit iOS Build',
|
||||
needs: ['build_ios'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_ios.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
publish_android_update: {
|
||||
name: 'Publish Android update',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ needs.get_android_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'android',
|
||||
},
|
||||
},
|
||||
publish_ios_update: {
|
||||
name: 'Publish iOS update',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'ios',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEPLOY_TEMPLATE_HEADER = `
|
||||
# Deploy to production
|
||||
#
|
||||
# This workflow shows how to build and submit to app stores.
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
|
||||
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
|
||||
`;
|
||||
|
||||
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
|
||||
return `A build job in this workflow uses the build profile "${buildProfileName}"
|
||||
to build your app for real devices, and may require credentials.
|
||||
You can configure your credentials with the command "eas credentials".
|
||||
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
function nextStepForAppSubmission(): string {
|
||||
return `This workflow includes a job to submit your app to app stores.
|
||||
You will need to configure your App Store/Play Store credentials
|
||||
before you can run this workflow.
|
||||
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
export function howToRunWorkflow(
|
||||
workflowFileName: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): string {
|
||||
const lines = [
|
||||
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
|
||||
];
|
||||
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
|
||||
const branches = workflowStarter.template.on.push.branches;
|
||||
if (branches.length === 1 && branches[0] === '*') {
|
||||
lines.push(
|
||||
'This workflow is also configured to run automatically when code is pushed to any branch.'
|
||||
);
|
||||
} else if (branches.length === 1) {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const workflowStarters: WorkflowStarter[] = [
|
||||
{
|
||||
displayName: 'Custom',
|
||||
name: WorkflowStarterName.CUSTOM,
|
||||
defaultFileName: 'custom.yml',
|
||||
template: CUSTOM_TEMPLATE,
|
||||
header: CUSTOM_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Create development builds',
|
||||
name: WorkflowStarterName.BUILD,
|
||||
defaultFileName: 'create-development-builds.yml',
|
||||
template: BUILD_TEMPLATE,
|
||||
header: BUILD_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Publish updates',
|
||||
name: WorkflowStarterName.UPDATE,
|
||||
defaultFileName: 'publish-updates.yml',
|
||||
template: PUBLISH_UPDATE_TEMPLATE,
|
||||
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to production',
|
||||
name: WorkflowStarterName.DEPLOY,
|
||||
defaultFileName: 'deploy-to-production.yml',
|
||||
template: DEPLOY_TEMPLATE,
|
||||
header: DEPLOY_TEMPLATE_HEADER,
|
||||
},
|
||||
];
|
||||
|
||||
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
|
||||
// android_development_build
|
||||
|
||||
const nextSteps: Set<string> = new Set();
|
||||
|
||||
let androidDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.android;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
|
||||
androidDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!androidDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an Android development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an Android development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let androidDevelopmentBuildProfileName = 'android_development';
|
||||
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
|
||||
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
androidDevelopmentBuildProfileName
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping Android development build job...');
|
||||
}
|
||||
}
|
||||
if (androidDevelopmentBuildProfileName) {
|
||||
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.android_development_build = {
|
||||
name: `Build ${androidDevelopmentBuildProfileName} for android`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: androidDevelopmentBuildProfileName,
|
||||
platform: 'android',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
|
||||
}
|
||||
|
||||
// ios_simulator_development_build
|
||||
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosSimulatorDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS simulator development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
|
||||
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
|
||||
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosSimulatorDevelopmentBuildProfileName,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS simulator development build job...');
|
||||
}
|
||||
}
|
||||
if (iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.log(
|
||||
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
|
||||
);
|
||||
workflowStarter.template.jobs.ios_simulator_development_build = {
|
||||
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosSimulatorDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
}
|
||||
// ios_device_development_build
|
||||
let iosDeviceDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
!isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosDeviceDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS device development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
|
||||
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
|
||||
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosDeviceDevelopmentBuildProfileName,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS device development build job...');
|
||||
}
|
||||
}
|
||||
if (iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.ios_development_build = {
|
||||
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosDeviceDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
|
||||
}
|
||||
workflowStarter.nextSteps = [...nextSteps];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function ensureProductionBuildProfileExistsAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
|
||||
workflowStarter.nextSteps = [
|
||||
nextStepForDeviceBuildProfile('production'),
|
||||
nextStepForAppSubmission(),
|
||||
];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function customizeTemplateIfNeededAsync(
|
||||
workflowStarter: WorkflowStarter,
|
||||
projectDir: string,
|
||||
expoConfig: ExpoConfig
|
||||
): Promise<any> {
|
||||
// Ensure EAS Build is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Ensure EAS Update is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Customize template
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
Log.debug('Adding build jobs to template...');
|
||||
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
|
||||
default:
|
||||
return workflowStarter;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
// This function is in a separate module for testing purposes
|
||||
|
||||
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
|
||||
export async function fetchRawLogsForCustomJobAsync(
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
@@ -13,3 +17,25 @@ export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<s
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
|
||||
export async function fetchRawLogsForBuildJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
}
|
||||
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
|
||||
useCache: false,
|
||||
});
|
||||
const firstLogFileUrl = buildFragment.logFiles?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DefaultEnvironment } from '../../build/utils/environment';
|
||||
import { booleanLike, stringLike } from '../../credentials/ios/types';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const inputExtraProperties = {
|
||||
description: stringLike.optional().describe('Description of the input'),
|
||||
required: booleanLike.default(false).optional().describe('Whether the input is required.'),
|
||||
};
|
||||
|
||||
// Adapted from the input definition on the server (https://github.com/expo/universe/pull/21950)
|
||||
export const WorkflowDispatchInputZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('string').default('string').optional(),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('boolean'),
|
||||
default: booleanLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('choice'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('environment'),
|
||||
default: z
|
||||
.enum(Object.values(DefaultEnvironment))
|
||||
.optional()
|
||||
.describe('Default value for the input'),
|
||||
}),
|
||||
]);
|
||||
export function parseInputs(inputFlags: string[]): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const inputFlag of inputFlags) {
|
||||
const equalIndex = inputFlag.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
|
||||
}
|
||||
|
||||
const key = inputFlag.substring(0, equalIndex);
|
||||
const value = inputFlag.substring(equalIndex + 1);
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
|
||||
}
|
||||
|
||||
inputs[key] = value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export function parseJsonInputs(jsonString: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('JSON input must be an object.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON input.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWorkflowInputsFromYaml(
|
||||
yamlConfig: string
|
||||
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
|
||||
try {
|
||||
const parsed = YAML.parse(yamlConfig);
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
.parse(parsed?.on?.workflow_dispatch?.inputs);
|
||||
} catch (error) {
|
||||
Log.warn('Failed to parse workflow inputs from YAML:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export async function maybePromptForMissingInputsAsync({
|
||||
inputSpecs,
|
||||
inputs,
|
||||
}: {
|
||||
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
|
||||
inputs: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
|
||||
|
||||
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
|
||||
|
||||
if (missingRequiredInputs.length === 0) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Some required inputs are missing. Please provide them:');
|
||||
|
||||
const nextInputs = { ...inputs };
|
||||
|
||||
for (const [key, spec] of missingRequiredInputs) {
|
||||
const value = await promptForMissingInputAsync({ key, spec });
|
||||
nextInputs[key] = value;
|
||||
}
|
||||
|
||||
return nextInputs;
|
||||
}
|
||||
|
||||
async function promptForMissingInputAsync({
|
||||
key,
|
||||
spec,
|
||||
}: {
|
||||
key: string;
|
||||
spec: z.infer<typeof WorkflowDispatchInputZ>;
|
||||
}): Promise<unknown> {
|
||||
const message = spec.description ? `${key} (${spec.description})` : key;
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'number',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: number) => {
|
||||
if (isNaN(val)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'choice': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'value',
|
||||
message,
|
||||
choices: spec.options.map(option => ({
|
||||
title: option,
|
||||
value: option,
|
||||
})),
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'environment':
|
||||
default: {
|
||||
const { value } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: string) => {
|
||||
if (spec.required && (!val || val.trim() === '')) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
processLogsFromJobAsync,
|
||||
fetchAndProcessLogsFromJobAsync,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
@@ -268,7 +268,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
|
||||
if (!job) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
|
||||
}
|
||||
const logs = await processLogsFromJobAsync(job);
|
||||
const logs = await fetchAndProcessLogsFromJobAsync(prevState, job);
|
||||
if (!logs) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
|
||||
|
||||
/*
|
||||
* Utility types for workflow commands
|
||||
* Utility types and Zod definitions for workflow commands
|
||||
*/
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
@@ -42,5 +42,12 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
|
||||
export type WorkflowLogLine = {
|
||||
time: string;
|
||||
msg: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchRawLogsForJobAsync } from './fetchLogs';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
@@ -7,11 +10,21 @@ import {
|
||||
WorkflowTriggerType,
|
||||
} from './types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { Choice } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
|
||||
triggerType: WorkflowTriggerType;
|
||||
@@ -68,16 +81,23 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
|
||||
};
|
||||
}
|
||||
|
||||
export function choicesFromWorkflowLogs(logs: WorkflowLogs): Choice[] {
|
||||
return Array.from(logs.keys()).map(step => {
|
||||
const logLines = logs.get(step);
|
||||
const stepStatus =
|
||||
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
|
||||
return {
|
||||
title: `${step} - ${stepStatus}`,
|
||||
value: step,
|
||||
};
|
||||
});
|
||||
export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.keys())
|
||||
.map(step => {
|
||||
const logLines = logs.get(step);
|
||||
const stepStatus =
|
||||
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
|
||||
return {
|
||||
title: `${step} - ${stepStatus}`,
|
||||
name: step,
|
||||
status: stepStatus,
|
||||
value: step,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
@@ -100,29 +120,254 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
|
||||
});
|
||||
}
|
||||
|
||||
export async function processLogsFromJobAsync(
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
const rawLogs = await fetchRawLogsForJobAsync(job);
|
||||
let rawLogs: string | null;
|
||||
switch (job.type) {
|
||||
case WorkflowJobType.Build:
|
||||
case WorkflowJobType.Repack:
|
||||
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
|
||||
break;
|
||||
default:
|
||||
rawLogs = await fetchRawLogsForCustomJobAsync(job);
|
||||
break;
|
||||
}
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach(line => {
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { result, marker } = parsedLine;
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
|
||||
parsedLine;
|
||||
const stepId = buildStepDisplayName ?? buildStepInternalId;
|
||||
if (stepId) {
|
||||
if (!logKeys.has(stepId)) {
|
||||
logKeys.add(stepId);
|
||||
logs.set(stepId, []);
|
||||
}
|
||||
logs.get(stepId)?.push({ time, msg, result, marker });
|
||||
logs.get(stepId)?.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
function descriptionForJobStatus(status: WorkflowJobStatus): string {
|
||||
switch (status) {
|
||||
case WorkflowJobStatus.New:
|
||||
return 'Waiting for worker';
|
||||
case WorkflowJobStatus.InProgress:
|
||||
return 'In progress';
|
||||
case WorkflowJobStatus.Success:
|
||||
return 'Completed successfully';
|
||||
case WorkflowJobStatus.Failure:
|
||||
return 'Failed';
|
||||
case WorkflowJobStatus.Canceled:
|
||||
return 'Canceled';
|
||||
case WorkflowJobStatus.Skipped:
|
||||
return 'Skipped';
|
||||
case WorkflowJobStatus.ActionRequired:
|
||||
return 'Waiting for action';
|
||||
case WorkflowJobStatus.PendingCancel:
|
||||
return 'Pending cancel';
|
||||
}
|
||||
}
|
||||
|
||||
export async function infoForActiveWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = 5 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
for (const job of workflowRun.jobs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Job', value: job.name });
|
||||
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
|
||||
if (job.status !== WorkflowJobStatus.InProgress) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
if (steps.length > 0) {
|
||||
const currentStep = steps[steps.length - 1];
|
||||
statusValues.push({ label: ' Current step', value: currentStep.name });
|
||||
if (currentStep?.logLines?.length) {
|
||||
statusValues.push({ label: ' Current logs', value: '' });
|
||||
const currentLogs =
|
||||
currentStep.logLines
|
||||
?.map(line => line.msg)
|
||||
.filter((_, index) => {
|
||||
if (maxLogLines === -1) {
|
||||
return true;
|
||||
}
|
||||
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
|
||||
}) ?? [];
|
||||
for (const log of currentLogs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
|
||||
export async function infoForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = -1 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (job.status !== WorkflowJobStatus.Failure) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
if (steps.length > 0) {
|
||||
const failedStep = steps.find(step => step.status === 'fail');
|
||||
if (failedStep) {
|
||||
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
statusValues.push({
|
||||
label: ' Logs for failed step',
|
||||
value: '',
|
||||
});
|
||||
for (const log of logs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
export async function maybeReadStdinAsync(): Promise<string | null> {
|
||||
// Check if there's data on stdin
|
||||
if (process.stdin.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const trimmedData = data.trim();
|
||||
resolve(trimmedData || null);
|
||||
});
|
||||
|
||||
process.stdin.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function showWorkflowStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
waitForCompletion = true,
|
||||
}: { workflowRunId: string; spinnerUsesStdErr: boolean; waitForCompletion?: boolean }
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const spinner = ora({
|
||||
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
|
||||
text: '',
|
||||
}).start();
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
break;
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
|
||||
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
|
||||
spinner.stopAndPersist();
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure: {
|
||||
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
|
||||
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
|
||||
spinner.fail(failedInfo);
|
||||
return workflowRun;
|
||||
}
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
|
||||
spinner.text = '';
|
||||
spinner.succeed('');
|
||||
return workflowRun;
|
||||
}
|
||||
if (!waitForCompletion) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(workflowRunExitCodes.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowRunExitCodes = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { scheduleBranchDeletionAsync } from '../../branch/delete';
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateBranchMutation,
|
||||
DeleteUpdateBranchMutationVariables,
|
||||
DeleteUpdateBranchResult,
|
||||
GetBranchInfoQuery,
|
||||
GetBranchInfoQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
async function getBranchInfoAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -50,31 +46,6 @@ async function getBranchInfoAsync(
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteBranchOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ branchId }: DeleteUpdateBranchMutationVariables
|
||||
): Promise<DeleteUpdateBranchResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateBranch($branchId: ID!) {
|
||||
updateBranch {
|
||||
deleteUpdateBranch(branchId: $branchId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
branchId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.deleteUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchDelete extends EasCommand {
|
||||
static override description = 'delete a branch';
|
||||
|
||||
@@ -146,12 +117,12 @@ export default class BranchDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
|
||||
branchId,
|
||||
});
|
||||
const receipt = await scheduleBranchDeletionAsync(graphqlClient, { branchId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
printJsonOnlyOutput({ id: branchId });
|
||||
} else {
|
||||
Log.withTick(
|
||||
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
|
||||
|
||||
@@ -10,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 {
|
||||
|
||||
@@ -44,8 +44,6 @@ export default class WorkerAliasDelete extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -73,8 +73,6 @@ export default class WorkerAlias extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -45,8 +45,6 @@ export default class WorkerDelete extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
|
||||
@@ -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;
|
||||
@@ -121,8 +120,6 @@ export default class WorkerDeploy extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
Log.warn('EAS Hosting is still in preview and subject to changes.');
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -138,6 +135,7 @@ export default class WorkerDeploy extends EasCommand {
|
||||
logExportedProjectInfo(projectDist);
|
||||
|
||||
async function* emitWorkerTarballAsync(params: {
|
||||
routesConfig: WorkerAssets.RoutesConfigEntry | null;
|
||||
assetMap: WorkerAssets.AssetMap;
|
||||
manifest: WorkerAssets.Manifest;
|
||||
}): AsyncGenerator<WorkerAssets.FileEntry> {
|
||||
@@ -148,6 +146,8 @@ export default class WorkerDeploy extends EasCommand {
|
||||
for await (const workerFile of workerFiles) {
|
||||
yield [`server/${workerFile.normalizedPath}`, workerFile.data];
|
||||
}
|
||||
} else if (projectDist.type === 'static' && params.routesConfig) {
|
||||
yield ['routes.json', JSON.stringify(params.routesConfig)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +284,13 @@ export default class WorkerDeploy extends EasCommand {
|
||||
manifestResult.conflictingVariableNames.join(' ')
|
||||
);
|
||||
}
|
||||
assetFiles = await WorkerAssets.collectAssetsAsync(
|
||||
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
|
||||
{ maxFileSize: MAX_UPLOAD_SIZE }
|
||||
);
|
||||
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
|
||||
assetFiles = await WorkerAssets.collectAssetsAsync(assetPath, {
|
||||
maxFileSize: MAX_UPLOAD_SIZE,
|
||||
});
|
||||
tarPath = await WorkerAssets.packFilesIterableAsync(
|
||||
emitWorkerTarballAsync({
|
||||
routesConfig: await WorkerAssets.getRoutesConfigAsync(assetPath),
|
||||
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
|
||||
manifest: manifestResult.manifest,
|
||||
})
|
||||
|
||||
+49
-14
@@ -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
@@ -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
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+345
-7
@@ -1,9 +1,11 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { getMockAppFragment } from '../../../__tests__/commands/utils';
|
||||
import { EnvironmentVariableEnvironment } from '../../../build/utils/environment';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
@@ -11,14 +13,21 @@ import { EnvironmentVariableMutation } from '../../../graphql/mutations/Environm
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../../utils/prompts';
|
||||
import EnvUpdate from '../update';
|
||||
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../utils/prompts');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe(EnvUpdate, () => {
|
||||
const projectId = 'test-project-id';
|
||||
@@ -33,6 +42,41 @@ describe(EnvUpdate, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
(jest.mocked(promptVariableEnvironmentAsync) as jest.MockedFunction<any>).mockImplementation(
|
||||
async (args: any) => {
|
||||
if (args.nonInteractive) {
|
||||
throw new Error(
|
||||
'The `--environment` flag must be set when running in `--non-interactive` mode.'
|
||||
);
|
||||
}
|
||||
if (args.multiple) {
|
||||
return ['production'];
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
);
|
||||
(jest.mocked(promptVariableVisibilityAsync) as jest.MockedFunction<any>).mockResolvedValue(
|
||||
EnvironmentVariableVisibility.Public
|
||||
);
|
||||
(jest.mocked(parseVisibility) as jest.MockedFunction<any>).mockImplementation(
|
||||
(visibility: string) => {
|
||||
switch (visibility) {
|
||||
case 'plaintext':
|
||||
return EnvironmentVariableVisibility.Public;
|
||||
case 'sensitive':
|
||||
return EnvironmentVariableVisibility.Sensitive;
|
||||
case 'secret':
|
||||
return EnvironmentVariableVisibility.Secret;
|
||||
default:
|
||||
throw new Error(`Invalid visibility: ${visibility}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
(jest.mocked(selectAsync) as jest.MockedFunction<any>).mockResolvedValue({
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a project variable by selected name in non-interactive mode', async () => {
|
||||
@@ -41,7 +85,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -73,7 +117,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'NEW_VARIABLE',
|
||||
value: 'new-value',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
@@ -87,7 +131,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
environments: [EnvironmentVariableEnvironment.Development],
|
||||
environments: [DefaultEnvironment.Development],
|
||||
},
|
||||
];
|
||||
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
@@ -119,7 +163,7 @@ describe(EnvUpdate, () => {
|
||||
id: variableId,
|
||||
name: 'NEW_VARIABLE',
|
||||
value: 'new-value',
|
||||
environments: [EnvironmentVariableEnvironment.Production],
|
||||
environments: [DefaultEnvironment.Production],
|
||||
});
|
||||
expect(Log.withTick).toHaveBeenCalledWith(
|
||||
`Updated variable ${chalk.bold('TEST_VARIABLE')} on account testuser.`
|
||||
@@ -129,6 +173,7 @@ describe(EnvUpdate, () => {
|
||||
it('prompts for variable selection when selected name is not provided', async () => {
|
||||
const mockVariables = [
|
||||
{ id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project },
|
||||
{ id: '2', name: 'ANOTHER_VARIABLE', scope: EnvironmentVariableScope.Project },
|
||||
];
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
|
||||
@@ -140,7 +185,7 @@ describe(EnvUpdate, () => {
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalled();
|
||||
expect(selectAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ id: variableId })
|
||||
@@ -162,4 +207,297 @@ describe(EnvUpdate, () => {
|
||||
'Variable with name NON_EXISTENT_VARIABLE does not exist on project @testuser/testpp.'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts development environment when using positional argument', async () => {
|
||||
const mockVariable = {
|
||||
id: 'var1',
|
||||
name: 'TEST_VAR_1',
|
||||
value: 'value1',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValueOnce([mockVariable]);
|
||||
jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValueOnce(mockVariable);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'development',
|
||||
'--variable-name',
|
||||
'TEST_VAR_1',
|
||||
'--value',
|
||||
'new-value',
|
||||
'--scope',
|
||||
'project',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
environment: DefaultEnvironment.Development,
|
||||
filterNames: ['TEST_VAR_1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('processes file type variable in non-interactive mode with --type file and --value', async () => {
|
||||
const testFilePath = '/path/to/test-file.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'test-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
// Mock fs.pathExists to return true
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
// Mock fs.readFile to return base64 encoded content
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--type',
|
||||
'file',
|
||||
'--value',
|
||||
testFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify file was checked for existence
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
// Verify file was read as base64
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
|
||||
// Verify update was called with FileBase64 type and base64-encoded value
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: testFileBase64,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when updating FILE type variable with value that is not a valid file path', async () => {
|
||||
const plainStringValue = 'some-new-value';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
|
||||
// Mock fs.pathExists to return false (file doesn't exist)
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
plainStringValue,
|
||||
'--environment',
|
||||
'production',
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
|
||||
// Should throw error when FILE type variable is updated with non-file value
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`Variable "TEST_VARIABLE" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when --type file is explicitly specified but file does not exist', async () => {
|
||||
const nonExistentFilePath = '/path/to/nonexistent-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'TEST_VARIABLE',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
|
||||
// Mock fs.pathExists to return false (file doesn't exist)
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'TEST_VARIABLE',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--type',
|
||||
'file',
|
||||
'--value',
|
||||
nonExistentFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
|
||||
// Should throw error when --type file is explicitly specified
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`File "${nonExistentFilePath}" does not exist`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing STRING type when updating value without --type flag', async () => {
|
||||
const stringValue = 'my-best-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'MY_VAR',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.String,
|
||||
value: 'old-value',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
value: stringValue,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'MY_VAR',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
stringValue,
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify update was called with preserved STRING type
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: stringValue,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing FILE type when updating value without --type flag', async () => {
|
||||
const testFilePath = '/path/to/test-file.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'test-file.json';
|
||||
|
||||
const mockVariable = {
|
||||
id: variableId,
|
||||
name: 'MY_FILE_VAR',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
environments: [DefaultEnvironment.Development],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
value: 'old-base64-value',
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
};
|
||||
|
||||
const mockUpdatedVariable = {
|
||||
...mockVariable,
|
||||
value: testFileBase64,
|
||||
};
|
||||
|
||||
(EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue([mockVariable]);
|
||||
(EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockUpdatedVariable);
|
||||
|
||||
// Mock fs.pathExists to return true
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
// Mock fs.readFile to return base64 encoded content
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvUpdate(
|
||||
[
|
||||
'--variable-name',
|
||||
'MY_FILE_VAR',
|
||||
'--variable-environment',
|
||||
'development',
|
||||
'--non-interactive',
|
||||
'--value',
|
||||
testFilePath,
|
||||
// Note: --type is NOT specified
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
|
||||
await command.runAsync();
|
||||
|
||||
// Verify file was checked for existence
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
// Verify file was read as base64
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
|
||||
// Verify update was called with preserved FILE type
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: variableId,
|
||||
value: testFileBase64,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
-24
@@ -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.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
let newEnvironments = environments
|
||||
? environments
|
||||
: environment
|
||||
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
|
||||
: undefined;
|
||||
let newEnvironments = environments ? environments : environment ? [environment] : undefined;
|
||||
|
||||
if (!newEnvironments) {
|
||||
newEnvironments = await promptVariableEnvironmentAsync({ nonInteractive, multiple: true });
|
||||
newEnvironments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!newEnvironments || newEnvironments.length === 0) {
|
||||
throw new Error('No environments selected');
|
||||
|
||||
+5
-13
@@ -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,13 +165,6 @@ export default class EnvDelete extends EasCommand {
|
||||
: EnvironmentVariableScope.Project;
|
||||
|
||||
if (environment) {
|
||||
environment = environment.toUpperCase();
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return { ...flags, 'variable-environment': environment, scope };
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -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?.toUpperCase();
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const firstChar = bash_command[0];
|
||||
const lastChar = bash_command[bash_command.length - 1];
|
||||
const cleanCommand =
|
||||
@@ -198,7 +194,7 @@ export default class EnvExec extends EasCommand {
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
environment: EnvironmentVariableEnvironment;
|
||||
environment: string;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<Record<string, string>> {
|
||||
const environmentVariablesQueryResult =
|
||||
|
||||
+8
-13
@@ -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.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return {
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
@@ -177,7 +172,7 @@ async function getVariablesAsync(
|
||||
scope: string,
|
||||
projectId: string,
|
||||
name: string | undefined,
|
||||
environment: EnvironmentVariableEnvironment | undefined
|
||||
environment: string | undefined
|
||||
): Promise<EnvironmentVariableFragment[]> {
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
|
||||
+11
-12
@@ -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.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments = flags.environment
|
||||
? flags.environment
|
||||
: environment
|
||||
? [environment.toUpperCase() as EnvironmentVariableEnvironment]
|
||||
? [environment]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
+10
-11
@@ -13,7 +13,6 @@ import {
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
|
||||
import { isEnvironment } from '../../utils/variableUtils';
|
||||
|
||||
export default class EnvPull extends EasCommand {
|
||||
static override description =
|
||||
@@ -29,7 +28,7 @@ export default class EnvPull extends EasCommand {
|
||||
{
|
||||
name: 'environment',
|
||||
description:
|
||||
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
|
||||
"Environment to pull variables from. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
@@ -49,15 +48,7 @@ export default class EnvPull extends EasCommand {
|
||||
flags: { environment: flagEnvironment, path: targetPath, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(EnvPull);
|
||||
|
||||
let environment = flagEnvironment?.toUpperCase() ?? argEnvironment?.toUpperCase();
|
||||
|
||||
if (!environment) {
|
||||
environment = await promptVariableEnvironmentAsync({ nonInteractive });
|
||||
}
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
let environment = flagEnvironment?.toLowerCase() ?? argEnvironment?.toLowerCase();
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -67,6 +58,14 @@ export default class EnvPull extends EasCommand {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
environment = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
targetPath = targetPath ?? '.env.local';
|
||||
|
||||
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
|
||||
|
||||
+56
-50
@@ -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.toUpperCase())) {
|
||||
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
|
||||
}
|
||||
|
||||
const environments =
|
||||
flags.environment ??
|
||||
(environment ? [environment.toUpperCase() as EnvironmentVariableEnvironment] : undefined);
|
||||
): { environment?: string[]; path: string; force: boolean } {
|
||||
const environments = flags.environment ?? (environment ? [environment] : undefined);
|
||||
|
||||
return {
|
||||
...flags,
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
+46
-36
@@ -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.toUpperCase();
|
||||
if (!isEnvironment(environment)) {
|
||||
throw new Error(
|
||||
"Invalid environment. Use one of 'production', 'preview', or 'development'."
|
||||
);
|
||||
}
|
||||
return {
|
||||
...flags,
|
||||
'variable-environment': environment,
|
||||
@@ -252,7 +250,8 @@ export default class EnvUpdate extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
type,
|
||||
...rest
|
||||
}: UpdateFlags
|
||||
}: UpdateFlags,
|
||||
{ graphqlClient, projectId }: { graphqlClient: ExpoGraphqlClient; projectId: string }
|
||||
): Promise<
|
||||
Omit<UpdateFlags, 'type' | 'visibility'> & {
|
||||
type?: EnvironmentSecretType;
|
||||
@@ -300,23 +299,14 @@ export default class EnvUpdate extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let environmentFilePath: string | undefined;
|
||||
|
||||
if ((newType ?? selectedVariable.type) === EnvironmentSecretType.FileBase64 && value) {
|
||||
environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
}
|
||||
|
||||
value = environmentFilePath ? await fs.readFile(environmentFilePath, 'base64') : value;
|
||||
|
||||
if (!environments || environments.length === 0) {
|
||||
environments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
selectedEnvironments: selectedVariable.environments ?? [],
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -340,6 +330,26 @@ export default class EnvUpdate extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// If value is provided but type is not explicitly set, preserve the existing type
|
||||
if (value && !newType) {
|
||||
newType = selectedVariable.type;
|
||||
}
|
||||
|
||||
if (newType === EnvironmentSecretType.FileBase64 && value) {
|
||||
const environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
if (type === 'file') {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Variable "${selectedVariable.name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
}
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
value = await fs.readFile(environmentFilePath, 'base64');
|
||||
}
|
||||
|
||||
if (visibility) {
|
||||
newVisibility = parseVisibility(visibility);
|
||||
}
|
||||
|
||||
@@ -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,89 @@ 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().mockImplementation(async (options: any) => {
|
||||
if (options.name === 'account') {
|
||||
return { account: { name: 'jester' } };
|
||||
}
|
||||
if (options.name === 'name') {
|
||||
return { name: options.initial || 'expo-project' };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(New.name, () => {
|
||||
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('/expo-project'),
|
||||
projectName: '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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,178 +1,76 @@
|
||||
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 { printDirectory } from '../../commandUtils/new/utils';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { 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(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,27 +85,25 @@ 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 } },
|
||||
},
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
|
||||
Log.withInfo(`Project successfully linked (ID: ${chalk.bold(projectId)})`);
|
||||
|
||||
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> {
|
||||
const spinner = ora(`Generating project files`).start();
|
||||
await generateAppConfigAsync(projectDir, app);
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
@@ -216,206 +112,49 @@ export async function generateConfigFilesAsync(
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
await mergeReadmeAsync(projectDir);
|
||||
}
|
||||
|
||||
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(
|
||||
await updateReadmeAsync(projectDir, packageManager);
|
||||
spinner.succeed(`Generated project files`);
|
||||
Log.withInfo(
|
||||
`Generated ${chalk.bold('app.json')}. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/'
|
||||
)}`
|
||||
);
|
||||
Log.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(
|
||||
Log.withInfo(
|
||||
`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 hidden = true;
|
||||
|
||||
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
|
||||
static override flags = {
|
||||
'package-manager': Flags.enum<PackageManager>({
|
||||
char: 'p',
|
||||
description: 'Package manager to use for installing dependencies',
|
||||
options: [...PACKAGE_MANAGERS],
|
||||
default: 'npm',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args } = await this.parse(New);
|
||||
const { args, flags } = await this.parse(New);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
@@ -427,46 +166,48 @@ 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.'
|
||||
);
|
||||
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);
|
||||
|
||||
Log.log('🎉 We finished creating your new project.');
|
||||
Log.newLine();
|
||||
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 ${chalk.bold(`cd ${printDirectory(projectDirectory)}`)} to navigate to your project.`
|
||||
);
|
||||
Log.withInfo(
|
||||
`Run ${chalk.bold(`${packageManager} run draft`)} to create a preview on EAS. ${learnMore(
|
||||
'https://docs.expo.dev/eas/workflows/examples/publish-preview-update/'
|
||||
)}`
|
||||
);
|
||||
Log.withInfo(
|
||||
`Run \`${formatScriptCommand(
|
||||
'start',
|
||||
packageManager
|
||||
)}\` to start developing locally. ${learnMore(
|
||||
`Run ${chalk.bold(`${packageManager} run start`)} to start developing locally. ${learnMore(
|
||||
'https://docs.expo.dev/get-started/start-developing/'
|
||||
)}`
|
||||
);
|
||||
Log.withInfo(`See the README.md for more information about your project.`);
|
||||
Log.newLine();
|
||||
Log.withInfo(`See the ${chalk.bold('README.md')} for more information about your project.`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -32,7 +31,6 @@ import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprin
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
RawAsset,
|
||||
UpdatePublishPlatform,
|
||||
buildBundlesAsync,
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
@@ -52,7 +50,12 @@ import {
|
||||
} from '../../project/publish';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import {
|
||||
UpdatePublishPlatform,
|
||||
getUpdateJsonInfosForUpdates,
|
||||
isBundleDiffingEnabled,
|
||||
prewarmDiffingAsync,
|
||||
} from '../../update/utils';
|
||||
import {
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getCodeSigningInfoAsync,
|
||||
@@ -79,7 +82,7 @@ type RawUpdateFlags = {
|
||||
'rollout-percentage'?: number;
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
environment: EnvironmentVariableEnvironment | null;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
type UpdateFlags = {
|
||||
@@ -96,7 +99,7 @@ type UpdateFlags = {
|
||||
rolloutPercentage?: number;
|
||||
json: boolean;
|
||||
nonInteractive: boolean;
|
||||
environment: EnvironmentVariableEnvironment | null;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
export default class UpdatePublish extends EasCommand {
|
||||
@@ -193,7 +196,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
getServerSideEnvironmentVariablesAsync,
|
||||
} = await this.getContextAsync(UpdatePublish, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: environment,
|
||||
withServerSideEnvironment: environment ?? null,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -572,6 +575,10 @@ export default class UpdatePublish extends EasCommand {
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (isBundleDiffingEnabled(exp)) {
|
||||
await prewarmDiffingAsync(graphqlClient, projectId, newUpdates);
|
||||
}
|
||||
|
||||
if (!skipBundler && emitMetadata) {
|
||||
Log.log('Generating eas-update-metadata.json');
|
||||
await generateEasMetadataAsync(distRoot, getUpdateJsonInfosForUpdates(newUpdates));
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
|
||||
import { getUpdateMessageForCommandAsync } from '../../project/publish';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
republishAsync,
|
||||
} from '../../update/republish';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { UpdatePublishPlatform } from '../../update/utils';
|
||||
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
@@ -9,7 +9,6 @@ import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
UpdatePublishPlatform,
|
||||
defaultPublishPlatforms,
|
||||
getBranchNameForCommandAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { selectRuntimeOnBranchAsync } from '../../update/queries';
|
||||
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
|
||||
import { UpdatePublishPlatform } from '../../update/utils';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -40,45 +40,47 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
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 { boolish } from 'getenv';
|
||||
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,
|
||||
maybeReadStdinAsync,
|
||||
showWorkflowStatusAsync,
|
||||
workflowRunExitCodes,
|
||||
} from '../../commandUtils/workflow/utils';
|
||||
import {
|
||||
WorkflowProjectSourceType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRevision,
|
||||
WorkflowRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
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';
|
||||
|
||||
const EXIT_CODES = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description = 'run an EAS workflow';
|
||||
static override description =
|
||||
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
|
||||
|
||||
static override args = [{ name: 'file', description: 'Path to the workflow file to run' }];
|
||||
|
||||
@@ -88,7 +90,7 @@ 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.',
|
||||
}),
|
||||
input: Flags.string({
|
||||
char: 'F',
|
||||
@@ -98,6 +100,11 @@ export default class WorkflowRun extends EasCommand {
|
||||
'Add a parameter in key=value format. Use multiple instances of this flag to set multiple inputs.',
|
||||
summary: 'Set workflow inputs',
|
||||
}),
|
||||
ref: Flags.string({
|
||||
description:
|
||||
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
|
||||
summary: 'Git reference to run the workflow on',
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
@@ -125,26 +132,72 @@ export default class WorkflowRun extends EasCommand {
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
let yamlConfig: string;
|
||||
try {
|
||||
const workflowFileContents = await WorkflowFile.readWorkflowFileContentsAsync({
|
||||
projectDir,
|
||||
filePath: args.file,
|
||||
});
|
||||
Log.log(`Using workflow file from ${workflowFileContents.filePath}`);
|
||||
yamlConfig = workflowFileContents.yamlConfig;
|
||||
} catch (err) {
|
||||
Log.error('Failed to read workflow file.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
exp: { slug: projectName },
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
} = await getDynamicPrivateProjectConfigAsync({
|
||||
skipPlugins: true,
|
||||
});
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let yamlConfig: string;
|
||||
let workflowRunId: string;
|
||||
let workflowRevisionId: string | undefined;
|
||||
let gitRef: string | undefined;
|
||||
|
||||
if (flags.ref) {
|
||||
// Run from git ref
|
||||
const fileName = path.basename(args.file);
|
||||
// Find the real commit, make sure the ref is valid
|
||||
gitRef = (
|
||||
await spawnAsync('git', ['rev-parse', flags.ref], {
|
||||
cwd: projectDir,
|
||||
})
|
||||
).output[0].trim();
|
||||
if (!gitRef) {
|
||||
throw new Error('Failed to resolve git reference');
|
||||
}
|
||||
Log.log(`Using workflow file ${fileName} at ${gitRef}`);
|
||||
let revisionResult: WorkflowRevision | undefined;
|
||||
try {
|
||||
revisionResult = await WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: projectId,
|
||||
fileName,
|
||||
gitRef,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to find or create workflow revision for ${fileName} at ${flags.ref}: ${err}`
|
||||
);
|
||||
}
|
||||
Log.debug(`Workflow revision: ${JSON.stringify(revisionResult, null, 2)}`);
|
||||
if (!revisionResult) {
|
||||
throw new Error(
|
||||
`Failed to find or create workflow revision for ${fileName} at ${flags.ref}`
|
||||
);
|
||||
}
|
||||
yamlConfig = revisionResult.yamlConfig;
|
||||
workflowRevisionId = revisionResult.id;
|
||||
} else {
|
||||
// Run from local file
|
||||
try {
|
||||
const workflowFileContents = await WorkflowFile.readWorkflowFileContentsAsync({
|
||||
projectDir,
|
||||
filePath: args.file,
|
||||
});
|
||||
Log.log(`Using workflow file from ${workflowFileContents.filePath}`);
|
||||
yamlConfig = workflowFileContents.yamlConfig;
|
||||
} catch (err) {
|
||||
Log.error('Failed to read workflow file.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate workflow YAML
|
||||
try {
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
@@ -206,81 +259,95 @@ export default class WorkflowRun extends EasCommand {
|
||||
path.relative(await vcsClient.getRootPathAsync(), projectDir) || '.'
|
||||
);
|
||||
|
||||
try {
|
||||
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
graphqlClient,
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
}));
|
||||
|
||||
if (await fileExistsAsync(easJsonPath)) {
|
||||
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: easJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold('eas.json')} found in the project directory. Running ${chalk.bold(
|
||||
'type: build'
|
||||
)} jobs will not work. Run ${chalk.bold(
|
||||
'eas build:configure'
|
||||
)} to configure your project for builds.`
|
||||
);
|
||||
}
|
||||
|
||||
if (await fileExistsAsync(packageJsonPath)) {
|
||||
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: packageJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold(
|
||||
'package.json'
|
||||
)} found in the project directory. It is used to automatically infer best job configuration for your project. You may want to define ${chalk.bold(
|
||||
'image'
|
||||
)} property in your workflow to specify the image to use.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error('Failed to upload project sources.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
let workflowRunId: string;
|
||||
|
||||
try {
|
||||
({ id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
workflowRevisionInput: {
|
||||
fileName: path.basename(args.file),
|
||||
yamlConfig,
|
||||
},
|
||||
workflowRunInput: {
|
||||
if (gitRef) {
|
||||
// Run from git ref
|
||||
let runResult: { id: string };
|
||||
try {
|
||||
runResult = await WorkflowRunMutation.createWorkflowRunFromGitRefAsync(graphqlClient, {
|
||||
workflowRevisionId: workflowRevisionId ?? '',
|
||||
gitRef,
|
||||
inputs,
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
projectRootDirectory,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create workflow run: ${err}`);
|
||||
}
|
||||
workflowRunId = runResult.id;
|
||||
} else {
|
||||
// Run from local file
|
||||
try {
|
||||
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
|
||||
graphqlClient,
|
||||
vcsClient,
|
||||
accountId: account.id,
|
||||
}));
|
||||
|
||||
if (await fileExistsAsync(easJsonPath)) {
|
||||
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: easJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold('eas.json')} found in the project directory. Running ${chalk.bold(
|
||||
'type: build'
|
||||
)} jobs will not work. Run ${chalk.bold(
|
||||
'eas build:configure'
|
||||
)} to configure your project for builds.`
|
||||
);
|
||||
}
|
||||
|
||||
if (await fileExistsAsync(packageJsonPath)) {
|
||||
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
|
||||
graphqlClient,
|
||||
accountId: account.id,
|
||||
filePath: packageJsonPath,
|
||||
maxSizeBytes: 1024 * 1024,
|
||||
}));
|
||||
} else {
|
||||
Log.warn(
|
||||
`⚠ No ${chalk.bold(
|
||||
'package.json'
|
||||
)} found in the project directory. It is used to automatically infer best job configuration for your project. You may want to define ${chalk.bold(
|
||||
'image'
|
||||
)} property in your workflow to specify the image to use.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error('Failed to upload project sources.');
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
({ id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
workflowRevisionInput: {
|
||||
fileName: path.basename(args.file),
|
||||
yamlConfig,
|
||||
},
|
||||
},
|
||||
}));
|
||||
workflowRunInput: {
|
||||
inputs,
|
||||
projectSource: {
|
||||
type: WorkflowProjectSourceType.Gcs,
|
||||
projectArchiveBucketKey,
|
||||
easJsonBucketKey,
|
||||
packageJsonBucketKey,
|
||||
projectRootDirectory,
|
||||
},
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
Log.error('Failed to start the workflow with the API.');
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
} catch (err) {
|
||||
Log.error('Failed to start the workflow with the API.');
|
||||
|
||||
throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
|
||||
if (!flags.wait) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
@@ -292,9 +359,11 @@ export default class WorkflowRun extends EasCommand {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
const { status } = await waitForWorkflowRunToEndAsync(graphqlClient, {
|
||||
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
|
||||
|
||||
const { status } = await showWorkflowStatusAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
@@ -309,316 +378,9 @@ export default class WorkflowRun extends EasCommand {
|
||||
}
|
||||
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_FAILED);
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
process.exit(EXIT_CODES.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkflowRunToEndAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ workflowRunId }: { workflowRunId: string }
|
||||
): 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();
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.byIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.InProgress:
|
||||
spinner.start('Workflow run is in progress.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.warn('Workflow run is waiting for action.');
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.warn('Workflow run has been canceled.');
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure:
|
||||
spinner.fail('Workflow run has failed.');
|
||||
return workflowRun;
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.succeed('Workflow run completed successfully.');
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(EXIT_CODES.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* EAS Workflow Status Command
|
||||
*
|
||||
* This command shows the status of an existing workflow run.
|
||||
*
|
||||
* If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.
|
||||
*
|
||||
* If the selected run is still in progress, the command will show the progress of the run, with an option
|
||||
* to show periodic status updates while waiting for completion (similar to `eas workflow:run --wait`).
|
||||
*
|
||||
*/
|
||||
|
||||
import { Flags } from '@oclif/core';
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
choiceFromWorkflowRun,
|
||||
processWorkflowRuns,
|
||||
showWorkflowStatusAsync,
|
||||
workflowRunExitCodes,
|
||||
} from '../../commandUtils/workflow/utils';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class WorkflowStatus extends EasCommand {
|
||||
static override description =
|
||||
'show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent workflow runs for the current project.';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'WORKFLOW_RUN_ID',
|
||||
description: 'A workflow run ID.',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
wait: Flags.boolean({
|
||||
default: false,
|
||||
allowNo: true,
|
||||
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
|
||||
summary: 'Wait for workflow run to complete. Defaults to false.',
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.Vcs,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(WorkflowStatus);
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowStatus, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const {
|
||||
projectId,
|
||||
exp: { slug: projectName },
|
||||
} = await getDynamicPrivateProjectConfigAsync();
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let workflowRunId = args.WORKFLOW_RUN_ID;
|
||||
|
||||
if (!workflowRunId && flags['non-interactive']) {
|
||||
throw new Error('Workflow run ID is required in non-interactive mode');
|
||||
}
|
||||
if (!workflowRunId) {
|
||||
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
undefined,
|
||||
50
|
||||
);
|
||||
const runs = processWorkflowRuns(queryResult);
|
||||
if (runs.length === 0) {
|
||||
Log.warn('No workflow runs to show');
|
||||
return;
|
||||
}
|
||||
const answers = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedRun',
|
||||
message: 'Select a workflow run:',
|
||||
choices: runs.map(run => choiceFromWorkflowRun(run)),
|
||||
});
|
||||
workflowRunId = answers.selectedRun;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
const spinnerUsesStdErr = boolish('CI', false) || flags.json;
|
||||
|
||||
await showWorkflowStatusAsync(graphqlClient, {
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
waitForCompletion: flags.wait,
|
||||
});
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
const status = workflowRun.status;
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
...workflowRun,
|
||||
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
|
||||
});
|
||||
}
|
||||
|
||||
if (status === WorkflowRunStatus.Failure) {
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_FAILED);
|
||||
} else if (status === WorkflowRunStatus.Canceled) {
|
||||
process.exit(workflowRunExitCodes.WORKFLOW_CANCELED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
+506
-28
@@ -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 */
|
||||
@@ -158,6 +159,7 @@ export type Account = {
|
||||
pendingSentryInstallation?: Maybe<PendingSentryInstallation>;
|
||||
profileImageUrl: Scalars['String']['output'];
|
||||
pushSecurityEnabled: Scalars['Boolean']['output'];
|
||||
requireTwoFactor: Scalars['Boolean']['output'];
|
||||
/** @deprecated Legacy access tokens are deprecated */
|
||||
requiresAccessTokenForPushSecurity: Scalars['Boolean']['output'];
|
||||
sentryInstallation?: Maybe<SentryInstallation>;
|
||||
@@ -185,8 +187,13 @@ export type Account = {
|
||||
userActorOwner?: Maybe<UserActor>;
|
||||
/** Pending user invitations for this account */
|
||||
userInvitations: Array<UserInvitation>;
|
||||
userSpecifiedAccountUsage?: Maybe<UserSpecifiedAccountUsage>;
|
||||
/** Actors associated with this account and permissions they hold */
|
||||
users: Array<UserPermission>;
|
||||
/** Vexo account connection for this account */
|
||||
vexoAccountConnection?: Maybe<VexoAccountConnection>;
|
||||
/** 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 */
|
||||
@@ -538,6 +545,7 @@ export enum AccountAppsSortByField {
|
||||
export type AccountDataInput = {
|
||||
displayName?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
userSpecifiedAccountUsage?: InputMaybe<UserSpecifiedAccountUsage>;
|
||||
};
|
||||
|
||||
export type AccountGoogleServiceAccountKeysConnection = {
|
||||
@@ -576,6 +584,15 @@ export type AccountMutation = {
|
||||
setDisplayName: Account;
|
||||
/** Require authorization to send push notifications for experiences owned by this account */
|
||||
setPushSecurityEnabled: Account;
|
||||
/**
|
||||
* Set whether two-factor authentication is required for this account. When enabled:
|
||||
* - Existing members with 2FA cannot disable it
|
||||
* - New members must enable 2FA before joining
|
||||
* - Existing members without 2FA are not affected
|
||||
*/
|
||||
setRequireTwoFactor: Account;
|
||||
/** Set the user specified account usage for the account. */
|
||||
setUserSpecifiedAccountUsage: Account;
|
||||
};
|
||||
|
||||
|
||||
@@ -646,6 +663,34 @@ export type AccountMutationSetPushSecurityEnabledArgs = {
|
||||
pushSecurityEnabled: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AccountMutationSetRequireTwoFactorArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
requireTwoFactor: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AccountMutationSetUserSpecifiedAccountUsageArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
userSpecifiedAccountUsage: UserSpecifiedAccountUsage;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
@@ -1268,6 +1313,7 @@ export type App = Project & {
|
||||
appStoreUrl?: Maybe<Scalars['String']['output']>;
|
||||
assetLimitPerUpdateGroup: Scalars['Int']['output'];
|
||||
branchesPaginated: AppBranchesConnection;
|
||||
buildProfiles: Array<Scalars['String']['output']>;
|
||||
/** (EAS Build) Builds associated with this app */
|
||||
builds: Array<Build>;
|
||||
buildsPaginated: AppBuildsConnection;
|
||||
@@ -1285,6 +1331,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 +1461,9 @@ export type App = Project & {
|
||||
username: Scalars['String']['output'];
|
||||
/** @deprecated No longer supported */
|
||||
users?: Maybe<Array<Maybe<User>>>;
|
||||
vexoApp?: Maybe<VexoApp>;
|
||||
/** Notification preferences of the viewer for this app */
|
||||
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
|
||||
/** Webhooks for an app */
|
||||
webhooks: Array<Webhook>;
|
||||
workerCustomDomain?: Maybe<WorkerCustomDomain>;
|
||||
@@ -1428,6 +1478,7 @@ export type App = Project & {
|
||||
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
|
||||
workflowRunsPaginated: AppWorkflowRunsConnection;
|
||||
workflows: Array<Workflow>;
|
||||
workflowsInsights: AppWorkflowsInsights;
|
||||
};
|
||||
|
||||
|
||||
@@ -1916,6 +1967,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;
|
||||
@@ -2233,6 +2300,7 @@ export type AppWorkflowRunEdge = {
|
||||
export type AppWorkflowRunFilterInput = {
|
||||
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
status?: InputMaybe<WorkflowRunStatus>;
|
||||
timeRange?: InputMaybe<WorkflowRunTimeRangeInput>;
|
||||
};
|
||||
|
||||
export type AppWorkflowRunGitBranchEdge = {
|
||||
@@ -2253,12 +2321,45 @@ export type AppWorkflowRunGitBranchesConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppWorkflowRunTimeRangeInput = {
|
||||
earliest?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
latest?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
|
||||
export type AppWorkflowRunsConnection = {
|
||||
__typename?: 'AppWorkflowRunsConnection';
|
||||
edges: Array<AppWorkflowRunEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppWorkflowsInsights = {
|
||||
__typename?: 'AppWorkflowsInsights';
|
||||
overviewMetrics: WorkflowsInsightsOverviewMetrics;
|
||||
runsOverTime: WorkflowsInsightsRunsOverTimeData;
|
||||
workflows: WorkflowsInsightsWorkflowConnection;
|
||||
};
|
||||
|
||||
|
||||
export type AppWorkflowsInsightsOverviewMetricsArgs = {
|
||||
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppWorkflowsInsightsRunsOverTimeArgs = {
|
||||
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
|
||||
granularity: WorkflowsInsightsRunsOverTimeGranularity;
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppWorkflowsInsightsWorkflowsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
|
||||
first: Scalars['Int']['input'];
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
};
|
||||
|
||||
export type AppleAppIdentifier = {
|
||||
__typename?: 'AppleAppIdentifier';
|
||||
account: Account;
|
||||
@@ -3110,6 +3211,8 @@ export type BuildFilter = {
|
||||
};
|
||||
|
||||
export type BuildFilterInput = {
|
||||
buildModes?: InputMaybe<Array<BuildMode>>;
|
||||
buildProfile?: InputMaybe<Scalars['String']['input']>;
|
||||
channel?: InputMaybe<Scalars['String']['input']>;
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
distributions?: InputMaybe<Array<DistributionType>>;
|
||||
@@ -3670,6 +3773,21 @@ export type CreateSubmissionResult = {
|
||||
submission: Submission;
|
||||
};
|
||||
|
||||
export type CreateVexoAccountConnectionInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
code: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateVexoAppInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
domain?: InputMaybe<Scalars['String']['input']>;
|
||||
iconUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
owner: Scalars['String']['input'];
|
||||
slug: Scalars['String']['input'];
|
||||
vexoIdentifier: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CumulativeAverageMetrics = {
|
||||
__typename?: 'CumulativeAverageMetrics';
|
||||
averageUpdatePayloadBytes: Scalars['Int']['output'];
|
||||
@@ -5611,7 +5729,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 +5876,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 +6377,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;
|
||||
@@ -6251,6 +6403,12 @@ export type RootMutation = {
|
||||
userDashboardViewPins: UserDashboardViewPinMutation;
|
||||
/** Mutations that create, delete, and accept UserInvitations */
|
||||
userInvitation: UserInvitationMutation;
|
||||
/** Mutation interface for user preferences */
|
||||
userPreference: UserPreferenceMutation;
|
||||
/** Mutations for Vexo account connections */
|
||||
vexoAccountConnection: VexoAccountConnectionMutation;
|
||||
/** Mutations for Vexo apps */
|
||||
vexoApp: VexoAppMutation;
|
||||
/** Mutations that create, delete, update Webhooks */
|
||||
webhook: WebhookMutation;
|
||||
/** Mutations that modify a websiteNotification */
|
||||
@@ -6383,6 +6541,10 @@ export type RootQuery = {
|
||||
userByUsername?: Maybe<User>;
|
||||
/** Top-level query object for querying UserInvitationPublicData publicly. */
|
||||
userInvitationPublicData: UserInvitationPublicDataQuery;
|
||||
/** Query interface for user preferences */
|
||||
userPreference: UserPreferenceQuery;
|
||||
/** Top-level query object for querying Vexo Integration information. */
|
||||
vexoIntegration: VexoIntegrationQuery;
|
||||
/**
|
||||
* If authenticated as a typical end user, this is the appropriate top-level
|
||||
* query object
|
||||
@@ -6571,6 +6733,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 +7089,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']>;
|
||||
@@ -7165,6 +7329,7 @@ export type UpdateBranch = {
|
||||
name: Scalars['String']['output'];
|
||||
runtimes: RuntimesConnection;
|
||||
updateGroups: Array<Array<Update>>;
|
||||
updateGroupsPaginated: UpdateGroupsConnection;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
updates: Array<Update>;
|
||||
};
|
||||
@@ -7186,6 +7351,15 @@ export type UpdateBranchUpdateGroupsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type UpdateBranchUpdateGroupsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<UpdatesFilterV2>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type UpdateBranchUpdatesArgs = {
|
||||
filter?: InputMaybe<UpdatesFilter>;
|
||||
limit: Scalars['Int']['input'];
|
||||
@@ -7196,7 +7370,10 @@ export type UpdateBranchMutation = {
|
||||
__typename?: 'UpdateBranchMutation';
|
||||
/** Create an EAS branch for an app */
|
||||
createUpdateBranchForApp: UpdateBranch;
|
||||
/** Delete an EAS branch and all of its updates as long as the branch is not being used by any channels */
|
||||
/**
|
||||
* Delete an EAS branch and all of its updates as long as the branch is not being used by any channels
|
||||
* @deprecated Use scheduleUpdateBranchDeletion instead
|
||||
*/
|
||||
deleteUpdateBranch: DeleteUpdateBranchResult;
|
||||
/**
|
||||
* Edit an EAS branch. The branch can be specified either by its ID or
|
||||
@@ -7205,6 +7382,8 @@ export type UpdateBranchMutation = {
|
||||
editUpdateBranch: UpdateBranch;
|
||||
/** Publish an update group to a branch */
|
||||
publishUpdateGroups: Array<Update>;
|
||||
/** Delete an EAS branch and all of its updates in the background */
|
||||
scheduleUpdateBranchDeletion: BackgroundJobReceipt;
|
||||
};
|
||||
|
||||
|
||||
@@ -7228,6 +7407,11 @@ export type UpdateBranchMutationPublishUpdateGroupsArgs = {
|
||||
publishUpdateGroupsInput: Array<PublishUpdateGroupInput>;
|
||||
};
|
||||
|
||||
|
||||
export type UpdateBranchMutationScheduleUpdateBranchDeletionArgs = {
|
||||
branchId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type UpdateChannel = {
|
||||
__typename?: 'UpdateChannel';
|
||||
app: App;
|
||||
@@ -7393,6 +7577,18 @@ export type UpdateGitHubRepositorySettingsInput = {
|
||||
baseDirectory: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type UpdateGroupEdge = {
|
||||
__typename?: 'UpdateGroupEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: Array<Update>;
|
||||
};
|
||||
|
||||
export type UpdateGroupsConnection = {
|
||||
__typename?: 'UpdateGroupsConnection';
|
||||
edges: Array<UpdateGroupEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type UpdateInfoGroup = {
|
||||
android?: InputMaybe<PartialManifest>;
|
||||
ios?: InputMaybe<PartialManifest>;
|
||||
@@ -7483,10 +7679,22 @@ export type UpdateRolloutInfoGroup = {
|
||||
web?: InputMaybe<UpdateRolloutInfo>;
|
||||
};
|
||||
|
||||
export type UpdateVexoAppInput = {
|
||||
domain?: InputMaybe<Scalars['String']['input']>;
|
||||
iconUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
owner?: InputMaybe<Scalars['String']['input']>;
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type UpdatesFilter = {
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
sdkVersions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
export type UpdatesFilterV2 = {
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
export type UpdatesMetricsData = {
|
||||
@@ -7617,6 +7825,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 +7938,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;
|
||||
@@ -8020,6 +8230,8 @@ export type UserInvitation = {
|
||||
* @deprecated Use accountProfileImageUrl
|
||||
*/
|
||||
accountProfilePhoto?: Maybe<Scalars['String']['output']>;
|
||||
/** Whether the account requires two-factor authentication */
|
||||
accountRequiresTwoFactor: Scalars['Boolean']['output'];
|
||||
created: Scalars['DateTime']['output'];
|
||||
/** Email to which this invitation was sent */
|
||||
email: Scalars['String']['output'];
|
||||
@@ -8105,6 +8317,7 @@ export type UserInvitationPublicData = {
|
||||
accountName: Scalars['String']['output'];
|
||||
accountProfileImageUrl: Scalars['String']['output'];
|
||||
accountProfilePhoto?: Maybe<Scalars['String']['output']>;
|
||||
accountRequiresTwoFactor: Scalars['Boolean']['output'];
|
||||
created: Scalars['DateTime']['output'];
|
||||
email: Scalars['String']['output'];
|
||||
expires: Scalars['DateTime']['output'];
|
||||
@@ -8140,6 +8353,51 @@ export type UserPermission = {
|
||||
userActor?: Maybe<UserActor>;
|
||||
};
|
||||
|
||||
/** A single user preference key-value pair */
|
||||
export type UserPreference = {
|
||||
__typename?: 'UserPreference';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
value: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
/** Mutation interface for user preferences */
|
||||
export type UserPreferenceMutation = {
|
||||
__typename?: 'UserPreferenceMutation';
|
||||
/** Delete a user preference by key */
|
||||
delete?: Maybe<UserPreference>;
|
||||
/** Set a user preference value by key */
|
||||
set: UserPreference;
|
||||
};
|
||||
|
||||
|
||||
/** Mutation interface for user preferences */
|
||||
export type UserPreferenceMutationDeleteArgs = {
|
||||
key: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Mutation interface for user preferences */
|
||||
export type UserPreferenceMutationSetArgs = {
|
||||
key: Scalars['String']['input'];
|
||||
value: Scalars['JSON']['input'];
|
||||
};
|
||||
|
||||
/** Query interface for user preferences */
|
||||
export type UserPreferenceQuery = {
|
||||
__typename?: 'UserPreferenceQuery';
|
||||
/** Get a user preference by key */
|
||||
get?: Maybe<UserPreference>;
|
||||
};
|
||||
|
||||
|
||||
/** Query interface for user preferences */
|
||||
export type UserPreferenceQueryGetArgs = {
|
||||
key: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
__typename?: 'UserPreferences';
|
||||
onboarding?: Maybe<UserPreferencesOnboarding>;
|
||||
@@ -8220,6 +8478,86 @@ export type UserSecondFactorDevice = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export enum UserSpecifiedAccountUsage {
|
||||
Company = 'COMPANY',
|
||||
Personal = 'PERSONAL'
|
||||
}
|
||||
|
||||
export type VexoAccountAccessToken = {
|
||||
__typename?: 'VexoAccountAccessToken';
|
||||
access_token: Scalars['String']['output'];
|
||||
expires_at?: Maybe<Scalars['String']['output']>;
|
||||
token_type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type VexoAccountConnection = {
|
||||
__typename?: 'VexoAccountConnection';
|
||||
accessToken: VexoAccountAccessToken;
|
||||
account: Account;
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type VexoAccountConnectionMutation = {
|
||||
__typename?: 'VexoAccountConnectionMutation';
|
||||
/** Create a Vexo account connection for an Account */
|
||||
createVexoAccountConnection: VexoAccountConnection;
|
||||
/** Delete a Vexo account connection by ID and revoke the access token */
|
||||
revokeAndDeleteVexoAccountConnection: VexoAccountConnection;
|
||||
};
|
||||
|
||||
|
||||
export type VexoAccountConnectionMutationCreateVexoAccountConnectionArgs = {
|
||||
vexoAccountConnectionData: CreateVexoAccountConnectionInput;
|
||||
};
|
||||
|
||||
|
||||
export type VexoAccountConnectionMutationRevokeAndDeleteVexoAccountConnectionArgs = {
|
||||
vexoAccountConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type VexoApp = {
|
||||
__typename?: 'VexoApp';
|
||||
app: App;
|
||||
domain?: Maybe<Scalars['String']['output']>;
|
||||
iconUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
owner: Scalars['String']['output'];
|
||||
slug: Scalars['String']['output'];
|
||||
vexoIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type VexoAppMutation = {
|
||||
__typename?: 'VexoAppMutation';
|
||||
/** Create a Vexo app for an App */
|
||||
createVexoApp: VexoApp;
|
||||
/** Delete a Vexo app by ID */
|
||||
deleteVexoApp: VexoApp;
|
||||
/** Update a Vexo app by ID */
|
||||
updateVexoApp: VexoApp;
|
||||
};
|
||||
|
||||
|
||||
export type VexoAppMutationCreateVexoAppArgs = {
|
||||
vexoAppData: CreateVexoAppInput;
|
||||
};
|
||||
|
||||
|
||||
export type VexoAppMutationDeleteVexoAppArgs = {
|
||||
vexoAppId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type VexoAppMutationUpdateVexoAppArgs = {
|
||||
vexoAppData: UpdateVexoAppInput;
|
||||
vexoAppId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type VexoIntegrationQuery = {
|
||||
__typename?: 'VexoIntegrationQuery';
|
||||
clientIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WebNotificationUpdateReadStateInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
isRead: Scalars['Boolean']['input'];
|
||||
@@ -8759,6 +9097,7 @@ export type Workflow = {
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
fileName: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
insights: WorkflowsInsightsWorkflowMetrics;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
revisionsPaginated: WorkflowRevisionsConnection;
|
||||
runsPaginated: WorkflowRunsConnection;
|
||||
@@ -8766,6 +9105,12 @@ export type Workflow = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowInsightsArgs = {
|
||||
filters?: InputMaybe<WorkflowsInsightsFiltersInput>;
|
||||
timespan: WorkflowsInsightsTimespanInput;
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowRevisionsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -8806,6 +9151,7 @@ export type WorkflowJob = {
|
||||
approvals: Array<WorkflowJobApproval>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
errors: Array<WorkflowJobError>;
|
||||
id: Scalars['ID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
@@ -8948,6 +9294,7 @@ export type WorkflowRevisionInput = {
|
||||
export type WorkflowRevisionMutation = {
|
||||
__typename?: 'WorkflowRevisionMutation';
|
||||
getOrCreateWorkflowRevisionFromGitRef: WorkflowRevision;
|
||||
getWorkflowRevisionsFromGitRef: Array<WorkflowRevision>;
|
||||
validateWorkflowYamlConfig: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
@@ -8959,6 +9306,12 @@ export type WorkflowRevisionMutationGetOrCreateWorkflowRevisionFromGitRefArgs =
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowRevisionMutationGetWorkflowRevisionsFromGitRefArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
gitRef: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowRevisionMutationValidateWorkflowYamlConfigArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
@@ -8987,6 +9340,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
durationSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
errors: Array<WorkflowRunError>;
|
||||
finalizedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
gitCommitHash?: Maybe<Scalars['String']['output']>;
|
||||
gitCommitMessage?: Maybe<Scalars['String']['output']>;
|
||||
githubRepository?: Maybe<GitHubRepository>;
|
||||
@@ -9022,6 +9376,7 @@ export type WorkflowRunError = {
|
||||
export type WorkflowRunFilterInput = {
|
||||
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
status?: InputMaybe<WorkflowRunStatus>;
|
||||
timeRange?: InputMaybe<WorkflowRunTimeRangeInput>;
|
||||
};
|
||||
|
||||
export type WorkflowRunGitBranchFilterInput = {
|
||||
@@ -9085,6 +9440,11 @@ export enum WorkflowRunStatus {
|
||||
Success = 'SUCCESS'
|
||||
}
|
||||
|
||||
export type WorkflowRunTimeRangeInput = {
|
||||
earliest?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
latest?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
|
||||
export enum WorkflowRunTriggerEventType {
|
||||
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
|
||||
GithubPullRequestOpened = 'GITHUB_PULL_REQUEST_OPENED',
|
||||
@@ -9101,6 +9461,73 @@ export type WorkflowRunsConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsFiltersInput = {
|
||||
gitRefRequested?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
statuses?: InputMaybe<Array<WorkflowRunStatus>>;
|
||||
triggerEventTypes?: InputMaybe<Array<WorkflowRunTriggerEventType>>;
|
||||
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsMetric = {
|
||||
__typename?: 'WorkflowsInsightsMetric';
|
||||
currentValue: Scalars['Float']['output'];
|
||||
previousValue: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsOverviewMetrics = {
|
||||
__typename?: 'WorkflowsInsightsOverviewMetrics';
|
||||
activeWorkflows: WorkflowsInsightsMetric;
|
||||
failedRuns: WorkflowsInsightsMetric;
|
||||
successfulRuns: WorkflowsInsightsMetric;
|
||||
totalRuns: WorkflowsInsightsMetric;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsRunsOverTimeData = {
|
||||
__typename?: 'WorkflowsInsightsRunsOverTimeData';
|
||||
lineChart: LineChartData;
|
||||
};
|
||||
|
||||
export enum WorkflowsInsightsRunsOverTimeGranularity {
|
||||
Day = 'DAY',
|
||||
Hour = 'HOUR',
|
||||
Minute = 'MINUTE'
|
||||
}
|
||||
|
||||
export type WorkflowsInsightsTimespanInput = {
|
||||
end: Scalars['DateTime']['input'];
|
||||
start: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsWorkflowConnection = {
|
||||
__typename?: 'WorkflowsInsightsWorkflowConnection';
|
||||
edges: Array<WorkflowsInsightsWorkflowEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsWorkflowEdge = {
|
||||
__typename?: 'WorkflowsInsightsWorkflowEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: WorkflowsInsightsWorkflowNode;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsWorkflowMetrics = {
|
||||
__typename?: 'WorkflowsInsightsWorkflowMetrics';
|
||||
failedRuns: WorkflowsInsightsMetric;
|
||||
lastRunAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
successfulRuns: WorkflowsInsightsMetric;
|
||||
totalRuns: WorkflowsInsightsMetric;
|
||||
};
|
||||
|
||||
export type WorkflowsInsightsWorkflowNode = {
|
||||
__typename?: 'WorkflowsInsightsWorkflowNode';
|
||||
failedRuns: Scalars['Int']['output'];
|
||||
lastRunAt: Scalars['DateTime']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
successfulRuns: Scalars['Int']['output'];
|
||||
totalRuns: Scalars['Int']['output'];
|
||||
workflowId: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteAndroidAppBuildCredentialsResult = {
|
||||
__typename?: 'deleteAndroidAppBuildCredentialsResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -9121,6 +9548,13 @@ export type DeleteApplePushKeyResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type ScheduleBranchDeletionMutationVariables = Exact<{
|
||||
branchId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ScheduleBranchDeletionMutation = { __typename?: 'RootMutation', updateBranch: { __typename?: 'UpdateBranchMutation', scheduleUpdateBranchDeletion: { __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 CreateUpdateBranchForAppMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
name: Scalars['String']['input'];
|
||||
@@ -9146,13 +9580,6 @@ export type GetBranchInfoQueryVariables = Exact<{
|
||||
|
||||
export type GetBranchInfoQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, name: string } | null } } };
|
||||
|
||||
export type DeleteUpdateBranchMutationVariables = Exact<{
|
||||
branchId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteUpdateBranchMutation = { __typename?: 'RootMutation', updateBranch: { __typename?: 'UpdateBranchMutation', deleteUpdateBranch: { __typename?: 'DeleteUpdateBranchResult', id: string } } };
|
||||
|
||||
export type EditUpdateBranchMutationVariables = Exact<{
|
||||
input: EditUpdateBranchInput;
|
||||
}>;
|
||||
@@ -9651,7 +10078,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 +10088,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 +10096,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 +10104,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 +10188,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'];
|
||||
@@ -9859,6 +10286,23 @@ export type DeleteWebhookMutationVariables = Exact<{
|
||||
|
||||
export type DeleteWebhookMutation = { __typename?: 'RootMutation', webhook: { __typename?: 'WebhookMutation', deleteWebhook: { __typename?: 'DeleteWebhookResult', id: string } } };
|
||||
|
||||
export type GetWorkflowRevisionsFromGitRefMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
gitRef: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetWorkflowRevisionsFromGitRefMutation = { __typename?: 'RootMutation', workflowRevision: { __typename?: 'WorkflowRevisionMutation', getWorkflowRevisionsFromGitRef: Array<{ __typename?: 'WorkflowRevision', id: string, yamlConfig: string, blobSha: string, commitSha?: string | null, createdAt: any, workflow: { __typename?: 'Workflow', id: string } }> } };
|
||||
|
||||
export type GetOrCreateWorkflowRevisionFromGitRefMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
fileName: Scalars['String']['input'];
|
||||
gitRef: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOrCreateWorkflowRevisionFromGitRefMutation = { __typename?: 'RootMutation', workflowRevision: { __typename?: 'WorkflowRevisionMutation', getOrCreateWorkflowRevisionFromGitRef: { __typename?: 'WorkflowRevision', id: string, yamlConfig: string, blobSha: string, commitSha?: string | null, createdAt: any, workflow: { __typename?: 'Workflow', id: string } } } };
|
||||
|
||||
export type ValidateWorkflowYamlConfigMutationVariables = Exact<{
|
||||
appId: Scalars['ID']['input'];
|
||||
yamlConfig: Scalars['String']['input'];
|
||||
@@ -9876,6 +10320,15 @@ export type CreateWorkflowRunMutationVariables = Exact<{
|
||||
|
||||
export type CreateWorkflowRunMutation = { __typename?: 'RootMutation', workflowRun: { __typename?: 'WorkflowRunMutation', createWorkflowRun: { __typename?: 'WorkflowRun', id: string } } };
|
||||
|
||||
export type CreateWorkflowRunFromGitRefMutationVariables = Exact<{
|
||||
workflowRevisionId: Scalars['ID']['input'];
|
||||
gitRef: Scalars['String']['input'];
|
||||
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateWorkflowRunFromGitRefMutation = { __typename?: 'RootMutation', workflowRun: { __typename?: 'WorkflowRunMutation', createWorkflowRunFromGitRef: { __typename?: 'WorkflowRun', id: string } } };
|
||||
|
||||
export type CancelWorkflowRunMutationVariables = Exact<{
|
||||
workflowRunId: Scalars['ID']['input'];
|
||||
}>;
|
||||
@@ -9883,6 +10336,14 @@ export type CancelWorkflowRunMutationVariables = Exact<{
|
||||
|
||||
export type CancelWorkflowRunMutation = { __typename?: 'RootMutation', workflowRun: { __typename?: 'WorkflowRunMutation', cancelWorkflowRun: { __typename?: 'WorkflowRun', id: string } } };
|
||||
|
||||
export type AccountUsageForOverageWarningQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
currentDate: Scalars['DateTime']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountUsageForOverageWarningQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, value: number, limit: number }> } } } } };
|
||||
|
||||
export type AppByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -9902,7 +10363,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'];
|
||||
@@ -9929,6 +10390,14 @@ export type LatestAppVersionQueryVariables = Exact<{
|
||||
|
||||
export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } };
|
||||
|
||||
export type GetAssetSignedUrlsQueryVariables = Exact<{
|
||||
updateId: Scalars['ID']['input'];
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAssetSignedUrlsQuery = { __typename?: 'RootQuery', asset: { __typename?: 'AssetQuery', signedUrls: Array<{ __typename?: 'AssetSignedUrlResult', storageKey: string, url: string, headers?: any | null }> } };
|
||||
|
||||
export type BackgroundJobReceiptByIdQueryVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
@@ -9949,6 +10418,8 @@ export type ViewLatestUpdateOnBranchQueryVariables = Exact<{
|
||||
branchName: Scalars['String']['input'];
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: Scalars['String']['input'];
|
||||
offset: Scalars['Int']['input'];
|
||||
limit: Scalars['Int']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
@@ -9989,21 +10460,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'];
|
||||
@@ -10013,7 +10484,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'];
|
||||
@@ -10059,6 +10530,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']>;
|
||||
@@ -10230,7 +10708,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'];
|
||||
@@ -10244,7 +10722,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'];
|
||||
@@ -10262,11 +10740,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 };
|
||||
|
||||
@@ -10292,9 +10770,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 } };
|
||||
|
||||
|
||||
@@ -4,8 +4,13 @@ import { z } from 'zod';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutation,
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutationVariables,
|
||||
GetWorkflowRevisionsFromGitRefMutation,
|
||||
GetWorkflowRevisionsFromGitRefMutationVariables,
|
||||
ValidateWorkflowYamlConfigMutation,
|
||||
ValidateWorkflowYamlConfigMutationVariables,
|
||||
WorkflowRevision,
|
||||
} from '../generated';
|
||||
|
||||
export namespace WorkflowRevisionMutation {
|
||||
@@ -16,7 +21,108 @@ export namespace WorkflowRevisionMutation {
|
||||
fieldErrors: z.record(z.string(), z.array(z.string())),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function getWorkflowRevisionsFromGitRefAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
gitRef,
|
||||
}: {
|
||||
appId: string;
|
||||
gitRef: string;
|
||||
}
|
||||
): Promise<WorkflowRevision[] | undefined> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
GetWorkflowRevisionsFromGitRefMutation,
|
||||
GetWorkflowRevisionsFromGitRefMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation GetWorkflowRevisionsFromGitRef($appId: ID!, $gitRef: String!) {
|
||||
workflowRevision {
|
||||
getWorkflowRevisionsFromGitRef(appId: $appId, gitRef: $gitRef) {
|
||||
id
|
||||
yamlConfig
|
||||
blobSha
|
||||
commitSha
|
||||
createdAt
|
||||
workflow {
|
||||
id
|
||||
app {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
gitRef,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return (data.workflowRevision?.getWorkflowRevisionsFromGitRef as WorkflowRevision[]) ?? [];
|
||||
}
|
||||
export async function getOrCreateWorkflowRevisionFromGitRefAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
fileName,
|
||||
gitRef,
|
||||
}: {
|
||||
appId: string;
|
||||
fileName: string;
|
||||
gitRef: string;
|
||||
}
|
||||
): Promise<WorkflowRevision | undefined> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutation,
|
||||
GetOrCreateWorkflowRevisionFromGitRefMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation GetOrCreateWorkflowRevisionFromGitRef(
|
||||
$appId: ID!
|
||||
$fileName: String!
|
||||
$gitRef: String!
|
||||
) {
|
||||
workflowRevision {
|
||||
getOrCreateWorkflowRevisionFromGitRef(
|
||||
appId: $appId
|
||||
fileName: $fileName
|
||||
gitRef: $gitRef
|
||||
) {
|
||||
id
|
||||
yamlConfig
|
||||
blobSha
|
||||
commitSha
|
||||
createdAt
|
||||
workflow {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
fileName,
|
||||
gitRef,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return (
|
||||
(data.workflowRevision?.getOrCreateWorkflowRevisionFromGitRef as WorkflowRevision) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
export async function validateWorkflowYamlConfigAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
CancelWorkflowRunMutation,
|
||||
CancelWorkflowRunMutationVariables,
|
||||
CreateWorkflowRunFromGitRefMutation,
|
||||
CreateWorkflowRunFromGitRefMutationVariables,
|
||||
CreateWorkflowRunMutation,
|
||||
CreateWorkflowRunMutationVariables,
|
||||
WorkflowRevisionInput,
|
||||
@@ -54,6 +56,53 @@ export namespace WorkflowRunMutation {
|
||||
);
|
||||
return { id: data.workflowRun.createWorkflowRun.id };
|
||||
}
|
||||
|
||||
export async function createWorkflowRunFromGitRefAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
workflowRevisionId,
|
||||
gitRef,
|
||||
inputs,
|
||||
}: {
|
||||
workflowRevisionId: string;
|
||||
gitRef: string;
|
||||
inputs?: Record<string, any>;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
CreateWorkflowRunFromGitRefMutation,
|
||||
CreateWorkflowRunFromGitRefMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation CreateWorkflowRunFromGitRef(
|
||||
$workflowRevisionId: ID!
|
||||
$gitRef: String!
|
||||
$inputs: JSONObject
|
||||
) {
|
||||
workflowRun {
|
||||
createWorkflowRunFromGitRef(
|
||||
workflowRevisionId: $workflowRevisionId
|
||||
gitRef: $gitRef
|
||||
inputs: $inputs
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
workflowRevisionId,
|
||||
gitRef,
|
||||
inputs,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return { id: data.workflowRun.createWorkflowRunFromGitRef.id };
|
||||
}
|
||||
|
||||
export async function cancelWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountUsageForOverageWarningQuery,
|
||||
AccountUsageForOverageWarningQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const AccountUsageQuery = {
|
||||
async getUsageForOverageWarningAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<AccountUsageForOverageWarningQuery['account']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountUsageForOverageWarningQuery, AccountUsageForOverageWarningQueryVariables>(
|
||||
gql`
|
||||
query AccountUsageForOverageWarning($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
name
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
}
|
||||
usageMetrics {
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
planMetrics {
|
||||
id
|
||||
serviceMetric
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: ['Account', 'AccountUsageMetrics', 'UsageMetricTotal'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AssetSignedUrlResult,
|
||||
GetAssetSignedUrlsQuery,
|
||||
GetAssetSignedUrlsQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const AssetQuery = {
|
||||
async getSignedUrlsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
updateId: string,
|
||||
storageKeys: string[]
|
||||
): Promise<AssetSignedUrlResult[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<GetAssetSignedUrlsQuery, GetAssetSignedUrlsQueryVariables>(
|
||||
gql`
|
||||
query GetAssetSignedUrls($updateId: ID!, $storageKeys: [String!]!) {
|
||||
asset {
|
||||
signedUrls(updateId: $updateId, storageKeys: $storageKeys) {
|
||||
storageKey
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
updateId,
|
||||
storageKeys,
|
||||
},
|
||||
{ additionalTypenames: [] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.asset.signedUrls;
|
||||
},
|
||||
};
|
||||
@@ -63,15 +63,24 @@ export const BranchQuery = {
|
||||
}
|
||||
return updateBranchByName;
|
||||
},
|
||||
async getLatestUpdateIdOnBranchAsync(
|
||||
async getUpdateIdsOnBranchAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
platform,
|
||||
runtimeVersion,
|
||||
}: { appId: string; branchName: string; platform: AppPlatform; runtimeVersion: string }
|
||||
): Promise<string | null> {
|
||||
offset = 0,
|
||||
limit = 1,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const response = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewLatestUpdateOnBranchQuery, ViewLatestUpdateOnBranchQueryVariables>(
|
||||
@@ -81,6 +90,8 @@ export const BranchQuery = {
|
||||
$branchName: String!
|
||||
$platform: AppPlatform!
|
||||
$runtimeVersion: String!
|
||||
$offset: Int!
|
||||
$limit: Int!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
@@ -88,8 +99,8 @@ export const BranchQuery = {
|
||||
updateBranchByName(name: $branchName) {
|
||||
id
|
||||
updates(
|
||||
offset: 0
|
||||
limit: 1
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
filter: { platform: $platform, runtimeVersions: [$runtimeVersion] }
|
||||
) {
|
||||
id
|
||||
@@ -104,6 +115,8 @@ export const BranchQuery = {
|
||||
branchName,
|
||||
platform,
|
||||
runtimeVersion,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
{ additionalTypenames: ['UpdateBranch'] }
|
||||
)
|
||||
@@ -113,11 +126,26 @@ export const BranchQuery = {
|
||||
if (!updateBranchByName) {
|
||||
throw new BranchNotFoundError(`Could not find a branch named "${branchName}".`);
|
||||
}
|
||||
const latestUpdate = updateBranchByName.updates[0];
|
||||
if (!latestUpdate) {
|
||||
return null;
|
||||
}
|
||||
return latestUpdate.id;
|
||||
return updateBranchByName.updates.map(update => update.id);
|
||||
},
|
||||
async getLatestUpdateIdOnBranchAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
platform,
|
||||
runtimeVersion,
|
||||
}: { appId: string; branchName: string; platform: AppPlatform; runtimeVersion: string }
|
||||
): Promise<string | null> {
|
||||
const updateIds = await this.getUpdateIdsOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
branchName,
|
||||
platform,
|
||||
runtimeVersion,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
return updateIds[0] ?? null;
|
||||
},
|
||||
async listBranchesOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -9,11 +9,13 @@ export async function runGitCloneAsync({
|
||||
githubRepositoryName,
|
||||
githubUsername,
|
||||
cloneMethod,
|
||||
showOutput = true,
|
||||
}: {
|
||||
githubUsername: string;
|
||||
githubRepositoryName: string;
|
||||
targetProjectDir: string;
|
||||
cloneMethod: 'ssh' | 'https';
|
||||
showOutput?: boolean;
|
||||
}): Promise<{
|
||||
targetProjectDir: string;
|
||||
}> {
|
||||
@@ -29,6 +31,7 @@ export async function runGitCloneAsync({
|
||||
return line.includes('Cloning into');
|
||||
},
|
||||
showSpinner: false,
|
||||
showOutput,
|
||||
});
|
||||
return { targetProjectDir };
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -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,7 @@ export async function installDependenciesAsync({
|
||||
!(line === packageManager)
|
||||
);
|
||||
},
|
||||
showOutput: outputLevel !== 'none',
|
||||
showSpinner: outputLevel !== 'none',
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export async function runCommandAsync({
|
||||
shouldShowStderrLine,
|
||||
shouldPrintStderrLineAsStdout,
|
||||
showSpinner = true,
|
||||
showOutput = true,
|
||||
}: {
|
||||
cwd?: string;
|
||||
args: string[];
|
||||
@@ -18,43 +19,49 @@ 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(' ')}`)}...`);
|
||||
if (showOutput) {
|
||||
Log.log(`🏗️ Running ${chalk.bold(`${command} ${args.join(' ')}`)}...`);
|
||||
}
|
||||
let spinner: Ora | undefined;
|
||||
if (showSpinner) {
|
||||
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) {
|
||||
@@ -66,6 +73,10 @@ export async function runCommandAsync({
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!showOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
spinner?.succeed(`${chalk.bold(`${command} ${args.join(' ')}`)} succeeded`);
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { FingerprintOptions, createFingerprintsByKeyAsync } from '../fingerprint/cli';
|
||||
import {
|
||||
AppPlatform,
|
||||
AssetMetadataStatus,
|
||||
BuildFragment,
|
||||
FingerprintSourceInput,
|
||||
@@ -39,8 +38,10 @@ import { promptAsync } from '../prompts';
|
||||
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
|
||||
import {
|
||||
UpdateJsonInfo,
|
||||
UpdatePublishPlatform,
|
||||
formatUpdateMessage,
|
||||
truncateString as truncateUpdateMessage,
|
||||
updatePublishPlatformToAppPlatform,
|
||||
} from '../update/utils';
|
||||
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
import {
|
||||
@@ -56,9 +57,6 @@ import mapMapAsync from '../utils/expodash/mapMapAsync';
|
||||
import uniqBy from '../utils/expodash/uniqBy';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
// update publish does not currently support web
|
||||
export type UpdatePublishPlatform = 'ios' | 'android';
|
||||
|
||||
type Metadata = {
|
||||
version: number;
|
||||
bundler: 'metro';
|
||||
@@ -1049,11 +1047,6 @@ export const platformDisplayNames: Record<UpdatePublishPlatform, string> = {
|
||||
ios: 'iOS',
|
||||
};
|
||||
|
||||
export const updatePublishPlatformToAppPlatform: Record<UpdatePublishPlatform, AppPlatform> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
export async function getRuntimeToUpdateRolloutInfoGroupMappingAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@ import {
|
||||
App,
|
||||
DeploymentResult,
|
||||
Runtime,
|
||||
UpdateBranch,
|
||||
UpdateChannel,
|
||||
UpdateChannelRuntimeInsights,
|
||||
UpdateInsights,
|
||||
@@ -126,7 +125,7 @@ function mockUpdateChannel({
|
||||
};
|
||||
}
|
||||
|
||||
function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
|
||||
function mockUpdateBranches(branchNames: string[]): any[] {
|
||||
return branchNames.map(branchName => ({
|
||||
id: 'f9dc4dbb-663f-4a19-8cf2-a783b484d2db',
|
||||
latestActivity: '2022-12-07T02:24:29.786Z',
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
UpdatePublishPlatform,
|
||||
formatBranch,
|
||||
formatUpdateGroup,
|
||||
formatUpdateTitle,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
import { RuntimeQuery } from '../graphql/queries/RuntimeQuery';
|
||||
import { UpdateQuery } from '../graphql/queries/UpdateQuery';
|
||||
import Log from '../log';
|
||||
import { UpdatePublishPlatform } from '../project/publish';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync,
|
||||
selectUpdateGroupOnBranchAsync,
|
||||
} from './queries';
|
||||
import { truncateString as truncateUpdateMessage } from './utils';
|
||||
import { UpdatePublishPlatform, truncateString as truncateUpdateMessage } from './utils';
|
||||
import { selectBranchOnAppAsync } from '../branch/queries';
|
||||
import { getUpdateGroupUrl } from '../build/utils/url';
|
||||
import { selectChannelOnAppAsync } from '../channel/queries';
|
||||
@@ -20,7 +20,7 @@ import { UpdateQuery } from '../graphql/queries/UpdateQuery';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { UpdatePublishPlatform, getUpdateRolloutInfoGroupAsync } from '../project/publish';
|
||||
import { getUpdateRolloutInfoGroupAsync } from '../project/publish';
|
||||
import { promptAsync } from '../prompts';
|
||||
import {
|
||||
CodeSigningInfo,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getUpdateJsonInfosForUpdates } from './utils';
|
||||
import { UpdatePublishPlatform, getUpdateJsonInfosForUpdates } from './utils';
|
||||
import { getUpdateGroupUrl } from '../build/utils/url';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import fetch from '../fetch';
|
||||
@@ -12,7 +12,6 @@ import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import {
|
||||
RuntimeVersionInfo,
|
||||
UpdatePublishPlatform,
|
||||
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
|
||||
} from '../project/publish';
|
||||
import {
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user