mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10d44f0ed | ||
|
|
1e2a72982f | ||
|
|
5198a2cc9c | ||
|
|
ca5f0d2306 | ||
|
|
9eefd5a5c4 | ||
|
|
a797caaa8a | ||
|
|
11323be04b | ||
|
|
b1f886ef8d | ||
|
|
14e75df9e5 | ||
|
|
dd88335654 | ||
|
|
5183a6581a | ||
|
|
fb7c812e50 | ||
|
|
e690ac0060 | ||
|
|
c2190ca74f | ||
|
|
157b7cb79f | ||
|
|
0d2b1c8d88 | ||
|
|
788cb57ff1 | ||
|
|
1634a50b6d | ||
|
|
805b403ae3 | ||
|
|
1672bcdadd | ||
|
|
30b1eb2dda | ||
|
|
99ce876d50 | ||
|
|
602502e502 | ||
|
|
30a3924f43 | ||
|
|
d2e6b4c45e | ||
|
|
419efa485f | ||
|
|
3bb4240cb4 | ||
|
|
1677efc00c | ||
|
|
c7b3105f9f | ||
|
|
6fceead233 |
No files matched your search
@@ -5,6 +5,14 @@ on:
|
||||
|
||||
name: Release workflow
|
||||
|
||||
# Releases push commits to main, so they must never overlap. The group is a
|
||||
# constant rather than per-ref, so two different tags serialize. Queued runs
|
||||
# are never cancelled, because a half-finished release leaves npm, the git
|
||||
# history, and cli-versions.json out of sync.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
@@ -47,6 +55,10 @@ jobs:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
# The changelog is generated on the branch it is committed to, so the
|
||||
# job never switches branches with a dirty working tree.
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
@@ -57,23 +69,67 @@ jobs:
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Check whether the changelog is already updated
|
||||
run: |
|
||||
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
|
||||
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Update changelog
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
- name: Reuse the existing changelog entry
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "v$EAS_CLI_VERSION"
|
||||
echo
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d'
|
||||
} > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push changelog
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git add CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git push
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changelog changes to commit."
|
||||
else
|
||||
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
bump-cli-versions:
|
||||
name: Gate on npm and bump cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-changelog
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
availability_timeout_seconds=1800
|
||||
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
@@ -95,7 +151,7 @@ jobs:
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
@@ -109,11 +165,20 @@ jobs:
|
||||
- name: Commit and push version update
|
||||
run: |
|
||||
git add cli-versions.json
|
||||
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git push
|
||||
if git diff --cached --quiet; then
|
||||
echo "cli-versions.json already points at $EAS_CLI_VERSION."
|
||||
else
|
||||
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
|
||||
npx slackmd /tmp/changelog.md
|
||||
{
|
||||
echo 'SLACK_CHANGELOG<<EOF'
|
||||
|
||||
+37
-2
@@ -8,16 +8,51 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop spinners from printing their text over and over after the terminal is resized. ([#4473](https://github.com/expo/eas-cli/pull/4473) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
|
||||
|
||||
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
|
||||
|
||||
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.6.0",
|
||||
"PRODUCTION": "24.6.0"
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.7.0",
|
||||
"version": "24.8.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-3
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "20.19.43",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
@@ -32,8 +32,8 @@
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
"ts-jest": "29.4.12",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.7.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -41,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.5.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.8.13",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
@@ -101,10 +101,10 @@
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
+17
-24
@@ -1,11 +1,9 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../../context';
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: mockLogger,
|
||||
env: {},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(certificatePath, distributionCertificate.password);
|
||||
await keychain.importCertificate({
|
||||
logger: mockLogger,
|
||||
certPath: certificatePath,
|
||||
certPassword: distributionCertificate.password,
|
||||
});
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { runFastlane } from '../../fastlane';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
jest.mock('../../fastlane');
|
||||
const runFastlaneMock = jest.mocked(runFastlane);
|
||||
const logger = createLogger({ name: 'test' });
|
||||
const logError = jest.spyOn(logger, 'error').mockImplementation();
|
||||
let keychain: Keychain;
|
||||
const options = {
|
||||
logger,
|
||||
certPath: '/test.p12',
|
||||
certPassword: 'certificate-password',
|
||||
};
|
||||
const result = (stdout: string, stderr = ''): SpawnResult => ({
|
||||
stdout,
|
||||
stderr,
|
||||
output: [stdout, stderr],
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
runFastlaneMock.mockResolvedValueOnce(result(''));
|
||||
await keychain.create({ logger });
|
||||
runFastlaneMock.mockClear();
|
||||
});
|
||||
|
||||
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
|
||||
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
|
||||
await keychain.importCertificate(options);
|
||||
expect(runFastlaneMock).toHaveBeenCalledWith([
|
||||
'run',
|
||||
'import_certificate',
|
||||
'certificate_path:/test.p12',
|
||||
'certificate_password:certificate-password',
|
||||
`keychain_path:${keychain.data.path}`,
|
||||
`keychain_password:${keychain.data.password}`,
|
||||
]);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
|
||||
it.each([
|
||||
{
|
||||
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
|
||||
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Unknown format in import',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Some other error',
|
||||
codes: ['CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
|
||||
if (outcome === 'success') {
|
||||
runFastlaneMock.mockResolvedValueOnce(result(output));
|
||||
await keychain.importCertificate(options);
|
||||
} else {
|
||||
runFastlaneMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('import failed'), {
|
||||
stderr: output,
|
||||
})
|
||||
);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure.metadata.diagnosticCodes).toEqual(codes);
|
||||
}
|
||||
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
|
||||
});
|
||||
});
|
||||
|
||||
it('never forwards output, command arguments, private key data, or the original error', async () => {
|
||||
const secret = 'very-secret-password';
|
||||
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
|
||||
stdout: `private key data ${secret}`,
|
||||
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
|
||||
});
|
||||
runFastlaneMock.mockRejectedValueOnce(error);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(errors.UserError);
|
||||
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
|
||||
expect(failure.toExternalExpoError().message).toContain(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(failure.cause).toBeUndefined();
|
||||
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
|
||||
expect.stringContaining('private key')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ENOENT', 'EACCES'])(
|
||||
'classifies a %s process launch failure as a system error',
|
||||
async code => {
|
||||
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
|
||||
const error = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(error).toBeInstanceOf(errors.SystemError);
|
||||
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
|
||||
expect(error.cause).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
new Error('failure'),
|
||||
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
|
||||
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
|
||||
])('handles rejected values without usable process output: %p', async value => {
|
||||
runFastlaneMock.mockRejectedValueOnce(value);
|
||||
await expect(keychain.importCertificate(options)).rejects.toThrow(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ jest.unmock('fs');
|
||||
describe('ProvisioningProfile class', () => {
|
||||
describe('verifyCertificate method', () => {
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain<TJob extends Ios.Job> {
|
||||
// Do not log raw fastlane output or spawn errors: they can contain passwords,
|
||||
// command arguments, and private key attributes from set-key-partition-list.
|
||||
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
|
||||
{
|
||||
code: 'PKCS12_MAC_VERIFICATION_FAILED',
|
||||
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
|
||||
message:
|
||||
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
|
||||
},
|
||||
{
|
||||
code: 'PKCS12_UNKNOWN_FORMAT',
|
||||
pattern: /SecKeychainItemImport: Unknown format in import/i,
|
||||
message: 'macOS did not recognize the certificate import format.',
|
||||
},
|
||||
{
|
||||
code: 'CERTIFICATE_IMPORT_FAILED',
|
||||
pattern:
|
||||
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
|
||||
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
|
||||
},
|
||||
{
|
||||
code: 'PRIVATE_KEY_ACCESS_FAILED',
|
||||
pattern: /SecKeychainItemSetAccessWithPassword:/,
|
||||
message:
|
||||
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
|
||||
},
|
||||
{
|
||||
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
|
||||
pattern: /SecItemCopyMatching:/,
|
||||
message:
|
||||
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
|
||||
},
|
||||
];
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly ctx: BuildContext<TJob>) {
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
};
|
||||
}
|
||||
|
||||
public async create(): Promise<void> {
|
||||
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
public async create({ logger }: { logger: bunyan }): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
|
||||
public async importCertificate({
|
||||
logger,
|
||||
certPath,
|
||||
certPassword,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
certPath: string;
|
||||
certPassword: string;
|
||||
}): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
try {
|
||||
const result = await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
const output = [result.stdout, result.stderr].join('\n');
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processError =
|
||||
error instanceof Error
|
||||
? (error as Error &
|
||||
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
|
||||
: undefined;
|
||||
const output = [processError?.stdout, processError?.stderr]
|
||||
.filter(value => typeof value === 'string')
|
||||
.join('\n');
|
||||
const diagnosticCodes: string[] = [];
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
diagnosticCodes.push(diagnostic.code);
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Never attach the original error: its message includes passwords.
|
||||
|
||||
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
|
||||
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
|
||||
});
|
||||
}
|
||||
throw new errors.UserError(
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
|
||||
{
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
|
||||
metadata: { diagnosticCodes },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
public async ensureCertificateImported({
|
||||
teamId,
|
||||
fingerprint,
|
||||
}: {
|
||||
teamId: string;
|
||||
fingerprint: string;
|
||||
}): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(keychainPath?: string): Promise<void> {
|
||||
public async destroy({
|
||||
logger,
|
||||
keychainPath,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
keychainPath?: string;
|
||||
}): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
this.ctx.logger.warn('The keychain has been already destroyed');
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(): Promise<void> {
|
||||
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(turtleKeychainPath);
|
||||
await this.destroy({ logger, keychainPath: turtleKeychainPath });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Credentials {
|
||||
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
|
||||
|
||||
export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
private keychain?: Keychain<TJob>;
|
||||
private keychain?: Keychain;
|
||||
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
|
||||
private cleanedUp = false;
|
||||
|
||||
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info('Preparing credentials');
|
||||
|
||||
this.ctx.logger.info('Creating keychain');
|
||||
this.keychain = new Keychain(this.ctx);
|
||||
await this.keychain.create();
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create({ logger: this.ctx.logger });
|
||||
|
||||
const targets = Object.keys(buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy();
|
||||
await this.keychain.destroy({ logger: this.ctx.logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
);
|
||||
|
||||
this.ctx.logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
await this.keychain.importCertificate({
|
||||
logger: this.ctx.logger,
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
this.ctx.logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info(
|
||||
'Validating whether the distribution certificate has been imported successfully'
|
||||
);
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
this.ctx.logger.info(
|
||||
'Verifying whether the distribution certificate and provisioning profile match'
|
||||
|
||||
@@ -45,6 +45,7 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
jest.mock('node:os', () => {
|
||||
const actual = jest.requireActual('node:os');
|
||||
const actualPath = jest.requireActual('node:path');
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
|
||||
const DAEMON_ENTRY_PATH = path.join(
|
||||
TEST_HOME,
|
||||
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
|
||||
);
|
||||
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue(undefined as never);
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockEventCollectionStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://agent-device-abc.tunnel.example.com',
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@ import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
installAppiumAsync,
|
||||
resolveAppium3VersionSpec,
|
||||
resolveAppiumDeviceAsync,
|
||||
@@ -19,6 +22,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
@@ -151,3 +155,15 @@ describe(installAppiumAsync, () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+78
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
@@ -42,6 +43,7 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
@@ -157,6 +159,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -188,4 +191,79 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+63
-2
@@ -1,6 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -12,14 +13,25 @@ import {
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
@@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
@@ -88,6 +101,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
@@ -107,4 +123,49 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the web preview and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ function logApplicationLaunch(
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
@@ -194,7 +194,7 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
export function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
@@ -207,7 +207,7 @@ function parseLaunchArgsInput(value: unknown): string[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
export function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
|
||||
@@ -28,9 +28,12 @@ import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArti
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -59,6 +62,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -89,6 +93,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -121,12 +133,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
|
||||
@@ -30,9 +30,12 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -59,6 +62,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -77,6 +81,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
@@ -138,12 +150,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
|
||||
@@ -29,10 +29,13 @@ import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -71,6 +74,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -103,6 +107,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -156,7 +168,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
@@ -217,12 +229,19 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -29,6 +32,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -46,8 +50,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
@@ -60,6 +76,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -16,9 +18,12 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
parseServeSimLaunchInputs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -26,6 +31,7 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -123,6 +129,161 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(createServeSimLaunchInputProviders, () => {
|
||||
it('declares the launch inputs as optional', () => {
|
||||
const globalCtx = createGlobalContextMock();
|
||||
const inputs = createServeSimLaunchInputProviders().map(provider =>
|
||||
provider(globalCtx, 'Test step')
|
||||
);
|
||||
|
||||
expect(
|
||||
inputs.map(({ id, required, allowedValueTypeName }) => ({
|
||||
id,
|
||||
required,
|
||||
allowedValueTypeName,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: 'launch_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
{
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
},
|
||||
{
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseServeSimLaunchInputs, () => {
|
||||
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
|
||||
|
||||
it('reads the launch identifier, arguments and URL', () => {
|
||||
expect(
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toEqual({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to no launch when the step declares nothing', () => {
|
||||
expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not a list', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: 'oops',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not all strings', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', 1],
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments with no application to launch', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchArgs: ['-flag'] }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL with no application to open it in', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ openUrl: 'exp://127.0.0.1:8081' }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an identifier that resolved to an empty string', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(
|
||||
'must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL that is not a URL', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent', openUrl: 'not a url' },
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be a valid URL');
|
||||
});
|
||||
|
||||
it('raises a user error, not a platform error', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(UserError);
|
||||
try {
|
||||
parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin);
|
||||
} catch (error) {
|
||||
expect((error as UserError).errorCode).toBe('EAS_LAUNCH_APPLICATION_INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a launch on a session that does not run an iOS simulator', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent' },
|
||||
{ runtimePlatform: BuildRuntimePlatform.LINUX }
|
||||
)
|
||||
).toThrow('runs on linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe(describeServeSimLaunch, () => {
|
||||
it('says nothing when there is no application to launch', () => {
|
||||
expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('names only the application when there are no arguments or URL', () => {
|
||||
expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe(
|
||||
'serve-sim will launch host.exp.Exponent.'
|
||||
);
|
||||
});
|
||||
|
||||
it('names the application, its arguments and the URL', () => {
|
||||
expect(
|
||||
describeServeSimLaunch({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
).toBe(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
@@ -142,11 +303,11 @@ describe(createServeSimArgs, () => {
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'1600',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'10000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
@@ -154,16 +315,16 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends metrics CORS args after the TURN args when provided', () => {
|
||||
it('appends the website args after the TURN args when provided', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
metricsCorsArgs: ['--metrics-cors-origin', 'https://expo.dev'],
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev'],
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--metrics-cors-origin',
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
@@ -179,6 +340,45 @@ describe(createServeSimArgs, () => {
|
||||
'@expo/serve-sim@next'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends --share-url after the website args', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/abc',
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/abc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the launch flags after the streaming policy', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the launch flags when there is no application to launch', () => {
|
||||
const args = createServeSimArgs({ port: 4321 });
|
||||
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
|
||||
expect(args).not.toContain('--open-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
@@ -222,30 +422,45 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
it('returns no args when the origin is unset or empty', () => {
|
||||
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({ EAS_SIMULATOR_METRICS_CORS_ORIGIN: '' } as BuildStepEnv)
|
||||
).toEqual([]);
|
||||
describe(websiteOriginServeSimArgs, () => {
|
||||
it('names only the production website by default', () => {
|
||||
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds one flag per comma-separated origin', () => {
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual(['--metrics-cors-origin', 'https://expo.dev']);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev, https://staging.expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual([
|
||||
'--metrics-cors-origin',
|
||||
'https://expo.dev',
|
||||
'--metrics-cors-origin',
|
||||
it('names staging, its deploy previews and each website dev port on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args.slice(0, 4)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://staging.expo.dev',
|
||||
]);
|
||||
expect(args).toContain('https://*.expo.dev');
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).toContain('https://expo.test:13215');
|
||||
expect(args).not.toContain('https://expo.test:13216');
|
||||
expect(args).not.toContain('https://expo.dev');
|
||||
});
|
||||
|
||||
it('names only https origins', () => {
|
||||
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).not.toContain('https://*.expo.dev');
|
||||
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,10 +541,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
@@ -452,6 +666,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
|
||||
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
|
||||
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
|
||||
expect(simulatorPreviewPageUrl({ ...env, ...stage }, 'abc')).toBe(
|
||||
`${origin}/simulator-preview/abc`
|
||||
);
|
||||
});
|
||||
|
||||
it('points the preview URL at the website page for the tunnel', async () => {
|
||||
@@ -542,8 +759,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
@@ -605,8 +822,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
@@ -638,14 +855,58 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch options to serve-sim on Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
})
|
||||
).rejects.toThrow('Cannot launch host.exp.Exponent');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
// Those are in fact "real" tests in that they really execute the `fastlane` commands.
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
certificatePath,
|
||||
new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64'))
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(certificatePath);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(
|
||||
mockLogger,
|
||||
certificatePath,
|
||||
distributionCertificate.password
|
||||
);
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@expo/logger';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import Keychain from '../keychain';
|
||||
import Keychain from '../../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile from '../provisioningProfile';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
@@ -18,11 +18,11 @@ describe('ProvisioningProfile class', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
|
||||
get data(): { path: string; password: string } {
|
||||
return {
|
||||
path: this.keychainPath,
|
||||
password: this.keychainPassword,
|
||||
};
|
||||
}
|
||||
|
||||
public async create(logger: bunyan): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
`path:${this.keychainPath}`,
|
||||
`password:${this.keychainPassword}`,
|
||||
'unlock:true',
|
||||
'timeout:360000',
|
||||
]);
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(
|
||||
logger: bunyan,
|
||||
certPath: string,
|
||||
certPassword: string
|
||||
): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(logger: bunyan, keychainPath?: string): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(logger: bunyan): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
);
|
||||
const turtleKeychainList = keychainList.filter(keychain =>
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(logger, turtleKeychainPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
|
||||
const { output } = await spawn(
|
||||
'security',
|
||||
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
return output.join('');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import * as distributionCertificateUtils from './distributionCertificate';
|
||||
import Keychain from './keychain';
|
||||
import Keychain from '../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile, {
|
||||
DistributionType,
|
||||
ProvisioningProfileData,
|
||||
@@ -36,7 +36,7 @@ export default class IosCredentialsManager {
|
||||
|
||||
logger.info('Creating keychain');
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create(logger);
|
||||
await this.keychain.create({ logger });
|
||||
|
||||
const targets = Object.keys(this.buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -70,7 +70,7 @@ export default class IosCredentialsManager {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy(logger);
|
||||
await this.keychain.destroy({ logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -109,11 +109,11 @@ export default class IosCredentialsManager {
|
||||
);
|
||||
|
||||
logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
await this.keychain.importCertificate({
|
||||
logger,
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -125,10 +125,10 @@ export default class IosCredentialsManager {
|
||||
await provisioningProfile.init(logger);
|
||||
|
||||
logger.info('Validating whether distribution certificate has been imported successfully');
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
logger.info('Verifying whether the distribution certificate and provisioning profile match');
|
||||
provisioningProfile.verifyCertificate(certificateFingerprint);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -14,6 +20,11 @@ import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
parseLaunchArgsInput,
|
||||
parseNonEmptyStringInput,
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -27,12 +38,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
// Pinned while H.264 is known to stall at larger encode sizes without recovering.
|
||||
// Passing this explicitly also overrides serve-sim's own default H.264 cap, so raising it
|
||||
// loses that guard too.
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
@@ -628,19 +636,28 @@ export function websiteOrigin(env: BuildStepEnv): string {
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
|
||||
if (!origin) {
|
||||
return [];
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
const args: string[] = [];
|
||||
for (const value of origin.split(',')) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
args.push('--metrics-cors-origin', trimmed);
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
@@ -651,19 +668,96 @@ function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): str
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* serve-sim performs the launch so the application starts under its instrumentation.
|
||||
*/
|
||||
export function createServeSimLaunchInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: rawLaunchAppIdentifier,
|
||||
launchArgs: rawLaunchArgs,
|
||||
openUrl: rawOpenUrl,
|
||||
}: { launchAppIdentifier?: unknown; launchArgs?: unknown; openUrl?: unknown },
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): ServeSimLaunchOptions {
|
||||
const launchAppIdentifier =
|
||||
rawLaunchAppIdentifier === undefined
|
||||
? undefined
|
||||
: parseNonEmptyStringInput(rawLaunchAppIdentifier, 'launch_app_identifier');
|
||||
const launchArgs = parseLaunchArgsInput(rawLaunchArgs);
|
||||
const openUrl = rawOpenUrl === undefined ? undefined : parseOpenUrlInput(rawOpenUrl);
|
||||
if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Inputs "launch_args" and "open_url" only work with an application launch. Pass "launch_app_identifier", or remove them.'
|
||||
);
|
||||
}
|
||||
if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.`
|
||||
);
|
||||
}
|
||||
return { launchAppIdentifier, launchArgs, openUrl };
|
||||
}
|
||||
|
||||
export function describeServeSimLaunch({
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: ServeSimLaunchOptions): string | null {
|
||||
if (!launchAppIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const withArguments =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
const thenOpen = openUrl ? `, then open ${openUrl}` : '';
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
metricsCorsArgs = [],
|
||||
frameAncestorArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
metricsCorsArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
@@ -684,8 +778,11 @@ export function createServeSimArgs({
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...metricsCorsArgs,
|
||||
...frameAncestorArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -815,15 +912,17 @@ async function startWebPreviewWithTunnelAsync(
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs)
|
||||
createArgs(port, turnArgs, previewPageUrl)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
@@ -846,15 +945,13 @@ async function startWebPreviewWithTunnelAsync(
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewPageUrl: new URL(
|
||||
`/simulator-preview/${tunnel.subdomainId}`,
|
||||
websiteOrigin(env)
|
||||
).toString(),
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
@@ -888,16 +985,18 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const frameAncestorArgs = ['--frame-ancestor', websiteOrigin(env)];
|
||||
const websiteArgs = websiteOriginServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
@@ -905,8 +1004,17 @@ export async function startServeSimWithTunnelAsync(
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, frameAncestorArgs, packageVersion }),
|
||||
createArgs: (port, turnArgs, previewPageUrl) =>
|
||||
createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: previewPageUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
@@ -960,6 +1068,9 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
@@ -968,12 +1079,25 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
return await startServeSimWithTunnelAsync(ctx, {
|
||||
...options,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
});
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
// Unreachable from the three step functions, which reject a non-Darwin launch while
|
||||
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
@@ -987,6 +1111,7 @@ export type NgrokTunnelHandle = {
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
@@ -994,12 +1119,13 @@ export async function startNgrokTunnelAsync({
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.5.0",
|
||||
"version": "24.8.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,13 +35,13 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.5.0"
|
||||
"@expo/steps": "24.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"arg": "^5.0.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"update-check": "^1.5.4"
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/steps": "^1.0.119",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"typescript": "^5.5.3"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "node16",
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"strict": true,
|
||||
@@ -16,7 +16,7 @@
|
||||
"outDir": "build",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"composite": false
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"include": ["src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -31,10 +31,10 @@
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
@@ -8,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -51,7 +51,7 @@
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "24.5.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,19 +25,19 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
@@ -8,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
|
||||
+455
-246
File diff suppressed because it is too large.
Load diff
Generated
+931
-14
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.7.0",
|
||||
"version": "24.8.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/eas-json": "24.5.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/eas-json": "24.8.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.5.0",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -95,7 +95,7 @@
|
||||
"gradle-to-js": "2.0.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-tag": "2.12.6",
|
||||
"https-proxy-agent": "5.0.1",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "5.3.0",
|
||||
"indent-string": "4.0.0",
|
||||
"invariant": "^2.2.2",
|
||||
@@ -152,6 +152,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
"@types/node-forge": "1.3.1",
|
||||
"@types/pngjs": "6.0.4",
|
||||
@@ -172,7 +173,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"oclif": {
|
||||
"additionalHelpFlags": [
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
|
||||
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
|
||||
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
|
||||
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
|
||||
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md'];
|
||||
|
||||
async function fetchTemplateAsync(fileName) {
|
||||
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
jest.unmock('ora');
|
||||
|
||||
import { ora } from '../ora';
|
||||
|
||||
function createFakeTtyStream(columns: number): NodeJS.WriteStream & { clearedLines: number } {
|
||||
return {
|
||||
isTTY: true,
|
||||
columns,
|
||||
clearedLines: 0,
|
||||
write: jest.fn(),
|
||||
moveCursor: jest.fn(),
|
||||
cursorTo: jest.fn(),
|
||||
clearLine(this: { clearedLines: number }) {
|
||||
this.clearedLines++;
|
||||
},
|
||||
} as unknown as NodeJS.WriteStream & { clearedLines: number };
|
||||
}
|
||||
|
||||
describe(ora, () => {
|
||||
it('clears all wrapped lines after the terminal gets narrower', () => {
|
||||
const stream = createFakeTtyStream(200);
|
||||
const spinner = ora({ text: 'x'.repeat(100), stream, isEnabled: true });
|
||||
|
||||
spinner.render();
|
||||
spinner.render();
|
||||
expect(stream.clearedLines).toBe(1);
|
||||
|
||||
stream.columns = 40;
|
||||
// The frame on screen was written at the old width, so it is cleared with the old line count.
|
||||
spinner.render();
|
||||
stream.clearedLines = 0;
|
||||
spinner.render();
|
||||
// "- " + 100 characters wraps to 3 lines at 40 columns.
|
||||
expect(stream.clearedLines).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -87,8 +87,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
buildProfile.developmentClient ??
|
||||
(platform === Platform.ANDROID
|
||||
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
|
||||
false;
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug');
|
||||
|
||||
const requiredPackageManager = resolvePackageManager(projectDir);
|
||||
|
||||
|
||||
@@ -504,7 +504,10 @@ async function prepareAndStartBuildAsync({
|
||||
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
|
||||
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
|
||||
}
|
||||
if (buildCtx.workflow === Workflow.MANAGED) {
|
||||
// Custom builds run the steps from the profile's config file instead of the
|
||||
// standard managed pipeline, so the managed-workflow checks do not apply.
|
||||
const isCustomBuild = !!buildProfile.profile.config;
|
||||
if (buildCtx.workflow === Workflow.MANAGED && !isCustomBuild) {
|
||||
if (!sdkVersionChecked) {
|
||||
await checkExpoSdkIsSupportedAsync(buildCtx);
|
||||
sdkVersionChecked = true;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,12 +1,19 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
SupabaseAdvisorLintData,
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorType,
|
||||
SupabaseConnectionData,
|
||||
SupabaseOrganizationData,
|
||||
SupabaseProjectData,
|
||||
} from '../graphql/types/SupabaseConnection';
|
||||
import Log, { link } from '../log';
|
||||
|
||||
export const SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE =
|
||||
'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR';
|
||||
|
||||
export function getSupabaseProjectDashboardUrl(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
|
||||
): string {
|
||||
@@ -94,3 +101,72 @@ export function formatSupabaseProject(project: SupabaseProjectData): string {
|
||||
export function logNoSupabaseProject(projectName: string): void {
|
||||
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
|
||||
}
|
||||
|
||||
export function getSupabaseAdvisorsDashboardUrl(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
|
||||
type: SupabaseAdvisorType
|
||||
): string {
|
||||
return `${getSupabaseProjectDashboardUrl(project)}/advisors/${type.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function isSupabaseReauthorizationRequiredError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(
|
||||
graphQLError =>
|
||||
graphQLError.extensions?.errorCode === SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const ADVISOR_LINT_LEVEL_LABELS: Record<
|
||||
SupabaseAdvisorLintLevel,
|
||||
[singular: string, plural: string]
|
||||
> = {
|
||||
[SupabaseAdvisorLintLevel.Error]: ['error', 'errors'],
|
||||
[SupabaseAdvisorLintLevel.Warn]: ['warning', 'warnings'],
|
||||
[SupabaseAdvisorLintLevel.Info]: ['info', 'info'],
|
||||
};
|
||||
|
||||
const ADVISOR_LINT_LEVEL_MARKERS: Record<SupabaseAdvisorLintLevel, string> = {
|
||||
[SupabaseAdvisorLintLevel.Error]: chalk.red('✖'),
|
||||
[SupabaseAdvisorLintLevel.Warn]: chalk.yellow('▲'),
|
||||
[SupabaseAdvisorLintLevel.Info]: chalk.blue('●'),
|
||||
};
|
||||
|
||||
export function summarizeSupabaseAdvisorLints(lints: readonly SupabaseAdvisorLintData[]): string {
|
||||
if (lints.length === 0) {
|
||||
return 'No unresolved findings';
|
||||
}
|
||||
return (Object.keys(ADVISOR_LINT_LEVEL_LABELS) as SupabaseAdvisorLintLevel[])
|
||||
.map(level => [level, lints.filter(lint => lint.level === level).length] as const)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([level, count]) => `${count} ${ADVISOR_LINT_LEVEL_LABELS[level][count === 1 ? 0 : 1]}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatInlineCode(text: string): string {
|
||||
return text
|
||||
.replaceAll('\\`', '`')
|
||||
.split('`')
|
||||
.map((part, index) => (index % 2 === 1 ? chalk.cyan(part) : part))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function formatSupabaseAdvisorLints(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
|
||||
type: SupabaseAdvisorType,
|
||||
lints: readonly SupabaseAdvisorLintData[]
|
||||
): string {
|
||||
const heading = `${chalk.bold(type === SupabaseAdvisorType.Security ? 'Security' : 'Performance')} · ${summarizeSupabaseAdvisorLints(lints)}`;
|
||||
const dashboardUrl = getSupabaseAdvisorsDashboardUrl(project, type);
|
||||
const rows = lints.map(lint =>
|
||||
[
|
||||
` ${ADVISOR_LINT_LEVEL_MARKERS[lint.level]} ${lint.level === SupabaseAdvisorLintLevel.Warn ? 'WARNING' : lint.level} ${chalk.bold(lint.title)}`,
|
||||
...(lint.entity ? [` ${chalk.dim(lint.entity)}`] : []),
|
||||
` ${formatInlineCode(lint.detail)}`,
|
||||
` ${link(`${dashboardUrl}?id=${encodeURIComponent(lint.cacheKey)}`, { text: chalk.underline('View in Supabase ↗'), dim: false })}`,
|
||||
].join('\n')
|
||||
);
|
||||
return [heading, ...rows].join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { validateWorkflowStructure } from '../validation';
|
||||
|
||||
const workflowSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jobs: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', const: 'slack' },
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
required: ['webhook_url', 'message'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['type', 'params'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['jobs'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const workflowWithWebhookUrl = (webhookUrl: string): object => ({
|
||||
jobs: {
|
||||
notify: {
|
||||
type: 'slack',
|
||||
params: {
|
||||
webhook_url: webhookUrl,
|
||||
message: 'Build finished',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe(validateWorkflowStructure, () => {
|
||||
it('allows interpolated values for URI fields', () => {
|
||||
expect(() => {
|
||||
validateWorkflowStructure(
|
||||
workflowWithWebhookUrl('${{ env.SLACK_WEBHOOK_URL }}'),
|
||||
workflowSchema
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('still rejects invalid literal values for URI fields', () => {
|
||||
expect(() => {
|
||||
validateWorkflowStructure(workflowWithWebhookUrl('not a URL'), workflowSchema);
|
||||
}).toThrow('must be a valid URI string');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/e
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import { validateWorkflowLocalCompositeFunctionsAsync } from './compositeFunctions';
|
||||
@@ -15,9 +16,10 @@ import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
import { parsedYamlFromWorkflowContents } from './parse';
|
||||
|
||||
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
|
||||
const validateUri = addFormats.get('uri') as (value: string) => boolean;
|
||||
|
||||
const buildProfileIsInterpolated = (profileName: string): boolean => {
|
||||
return profileName.includes('${{') && profileName.includes('}}');
|
||||
const stringIsInterpolated = (value: string): boolean => {
|
||||
return value.includes('${{') && value.includes('}}');
|
||||
};
|
||||
|
||||
export async function validateWorkflowFileAsync(
|
||||
@@ -113,7 +115,7 @@ async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: strin
|
||||
job =>
|
||||
!buildProfileNames.has(job.value.params.profile) &&
|
||||
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
|
||||
!buildProfileIsInterpolated(job.value.params.profile)
|
||||
!stringIsInterpolated(job.value.params.profile)
|
||||
);
|
||||
|
||||
if (invalidBuildJobs.length > 0) {
|
||||
@@ -139,10 +141,12 @@ function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): voi
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
export function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
delete workflowJsonSchema['$schema'];
|
||||
|
||||
const ajv = createValidator();
|
||||
// Interpolated values cannot be format-checked until the workflow runs.
|
||||
ajv.addFormat('uri', value => stringIsInterpolated(value) || validateUri(value));
|
||||
const validate = ajv.compile(workflowJsonSchema);
|
||||
const result = validate(parsedYaml);
|
||||
|
||||
|
||||
@@ -62,12 +62,24 @@ export default class AccountView extends EasCommand {
|
||||
return 'Admin';
|
||||
case Role.Developer:
|
||||
return 'Developer';
|
||||
case Role.ReleaseManager:
|
||||
return 'Release Manager';
|
||||
case Role.ViewOnly:
|
||||
return 'Viewer';
|
||||
case Role.Custom:
|
||||
case Role.HasAdmin:
|
||||
case Role.NotAdmin:
|
||||
return 'Custom';
|
||||
// A role added to the API after this version of the CLI was published. TypeScript narrows
|
||||
// `role` to `never` here, but the server can still send one, so render its name rather than
|
||||
// returning undefined.
|
||||
default: {
|
||||
const unknownRole: string = role;
|
||||
return unknownRole
|
||||
.split('_')
|
||||
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -139,6 +140,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -207,6 +209,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -234,6 +237,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -255,6 +259,35 @@ describe(IntegrationsAscConnect, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when --api-key-id points at an individual key', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('is an individual key');
|
||||
expect(AscAppLinkQuery.discoverAccessibleAppsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when multiple keys match Apple key identifier', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
@@ -263,10 +296,12 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid-1',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
{
|
||||
id: 'eas-key-uuid-2',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -297,6 +332,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -329,6 +365,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ export default class IntegrationsAscConnect extends EasCommand {
|
||||
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
} else if (keysByAppleId.length === 1) {
|
||||
if (!keysByAppleId[0].issuerIdentifier) {
|
||||
throw new EasCommandError(
|
||||
`App Store Connect API key "${apiKeyId}" is an individual key (it has no Issuer ID). Individual keys are not supported for the App Store Connect connection. Use a team API key.`
|
||||
);
|
||||
}
|
||||
apiKeyId = keysByAppleId[0].id;
|
||||
} else {
|
||||
throw new EasCommandError(
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../../commandUtils/errors';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
|
||||
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
|
||||
import {
|
||||
SupabaseAdvisorLintData,
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
} from '../../../../graphql/types/SupabaseConnection';
|
||||
import { authorizeViaBrowserAsync } from '../../../../integrations/supabase/provision';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import Log from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../../../utils/json';
|
||||
import IntegrationsSupabaseAdvisors from '../advisors';
|
||||
|
||||
jest.mock('supports-hyperlinks', () => ({ stdout: false, stderr: false }));
|
||||
jest.mock('../../../../graphql/queries/SupabaseQuery');
|
||||
jest.mock('../../../../graphql/mutations/SupabaseMutation');
|
||||
jest.mock('../../../../log', () => {
|
||||
const actual = jest.requireActual('../../../../log');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: { ...actual.default, log: jest.fn(), warn: jest.fn(), newLine: jest.fn() },
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../integrations/supabase/provision');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(IntegrationsSupabaseAdvisors, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const runCommand = jest.fn();
|
||||
Object.assign(mockConfig, { runCommand });
|
||||
|
||||
const rlsLint: SupabaseAdvisorLintData = {
|
||||
name: 'rls_disabled_in_public',
|
||||
title: 'RLS Disabled in Public',
|
||||
level: SupabaseAdvisorLintLevel.Error,
|
||||
description: 'Detects cases where row level security (RLS) has not been enabled on a table.',
|
||||
detail: 'Table \\`public.todos\\` is public, but RLS has not been enabled.',
|
||||
entity: 'public.todos',
|
||||
remediation: 'https://supabase.com/docs/guides/database/database-linter?lint=0013',
|
||||
cacheKey: 'rls_disabled_in_public_public_todos',
|
||||
};
|
||||
const unindexedForeignKeyLint: SupabaseAdvisorLintData = {
|
||||
name: 'unindexed_foreign_keys',
|
||||
title: 'Unindexed foreign keys',
|
||||
level: SupabaseAdvisorLintLevel.Info,
|
||||
description: 'Identifies foreign key constraints without a covering index.',
|
||||
detail: 'Table `public.todos` has a foreign key without a covering index.',
|
||||
entity: 'public.todos',
|
||||
remediation: null,
|
||||
cacheKey: 'unindexed_foreign_keys_public_todos',
|
||||
};
|
||||
const mockResult: SupabaseAdvisorLintsData = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
supabaseProjectRef: 'abcdefghijklmnop',
|
||||
supabaseProjectName: 'Test App',
|
||||
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
|
||||
supabaseRegion: 'us-east-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
security: [rlsLint],
|
||||
performance: [unindexedForeignKeyLint],
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): IntegrationsSupabaseAdvisors {
|
||||
const command = new IntegrationsSupabaseAdvisors(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
function loggedOutput(): string {
|
||||
return jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([line]) => stripAnsi(String(line)))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.requireMock('supports-hyperlinks').stdout = false;
|
||||
jest
|
||||
.mocked(authorizeViaBrowserAsync)
|
||||
.mockResolvedValue({ supabaseOrganizationSlug: 'original-org' } as never);
|
||||
jest
|
||||
.mocked(getOwnerAccountForProjectIdAsync)
|
||||
.mockResolvedValue({ id: 'account-1', name: 'test' } as never);
|
||||
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue({
|
||||
updatedAt: '2024-01-01',
|
||||
supabaseOrganizationSlug: 'original-org',
|
||||
} as never);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockResult.project);
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(mockResult);
|
||||
});
|
||||
|
||||
function mockReauthorizationRequiredOnce(): void {
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('Expo is not allowed to read this Supabase project’s advisors.', {
|
||||
extensions: { errorCode: 'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR', errorType: 'USER' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('prints both advisors with severity summaries, details, and dashboard links', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
[SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance]
|
||||
);
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error');
|
||||
expect(output).toContain('View in Supabase ↗: https://supabase.com/dashboard/project/');
|
||||
expect(output).not.toContain('How to fix');
|
||||
expect(output).toContain('Table public.todos is public, but RLS has not been enabled.');
|
||||
expect(output).not.toContain(rlsLint.remediation);
|
||||
expect(output).toContain(
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
|
||||
);
|
||||
expect(output).toContain('Performance · 1 info');
|
||||
expect(output).toContain(
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance'
|
||||
);
|
||||
});
|
||||
|
||||
it('links below issue descriptions without visible URLs when hyperlinks are supported', async () => {
|
||||
jest.requireMock('supports-hyperlinks').stdout = true;
|
||||
await createCommand([]).runAsync();
|
||||
const rawOutput = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
const output = loggedOutput();
|
||||
expect(rawOutput).toContain(
|
||||
']8;;https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
|
||||
);
|
||||
expect(rawOutput).not.toContain(rlsLint.remediation);
|
||||
expect(output).toContain('✖ ERROR RLS Disabled in Public\n public.todos');
|
||||
expect(output).not.toContain('How to fix');
|
||||
expect(output).toContain(
|
||||
'✖ ERROR RLS Disabled in Public\n public.todos\n Table public.todos is public, but RLS has not been enabled.\n View in Supabase ↗'
|
||||
);
|
||||
expect(output).not.toContain('RLS Disabled in Public ↗');
|
||||
expect(output.split('\n').filter(line => line.includes('https://'))).toEqual([
|
||||
'Dashboard: https://supabase.com/dashboard/project/abcdefghijklmnop',
|
||||
]);
|
||||
expect(output).toMatch(
|
||||
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
|
||||
);
|
||||
});
|
||||
|
||||
it('separates multiple findings and keeps clean sections compact', async () => {
|
||||
jest.requireMock('supports-hyperlinks').stdout = true;
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue({
|
||||
...mockResult,
|
||||
security: [rlsLint, { ...unindexedForeignKeyLint, level: SupabaseAdvisorLintLevel.Warn }],
|
||||
performance: [],
|
||||
});
|
||||
await createCommand([]).runAsync();
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error, 1 warning');
|
||||
expect(output).toContain('View in Supabase ↗\n\n ▲ WARNING Unindexed foreign keys');
|
||||
expect(output).toContain('Performance · No unresolved findings');
|
||||
expect(output).toMatch(
|
||||
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
|
||||
);
|
||||
});
|
||||
|
||||
it('limits the output to one advisor with --type', async () => {
|
||||
await createCommand(['--type', 'security']).runAsync();
|
||||
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error');
|
||||
expect(output).not.toContain('Performance');
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
[SupabaseAdvisorType.Security]
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['--json', '--non-interactive'])('does not start a spinner with %s', async flag => {
|
||||
await createCommand([flag]).runAsync();
|
||||
expect(ora).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unsupported advisor type before making requests', async () => {
|
||||
await expect(createCommand(['--type', 'other']).runAsync()).rejects.toThrow();
|
||||
expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints structured findings with --json', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
project: {
|
||||
ref: 'abcdefghijklmnop',
|
||||
name: 'Test App',
|
||||
dashboardUrls: {
|
||||
security: 'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security',
|
||||
performance:
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance',
|
||||
},
|
||||
},
|
||||
security: [rlsLint],
|
||||
performance: [unindexedForeignKeyLint],
|
||||
});
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when no Supabase project is linked', async () => {
|
||||
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
|
||||
});
|
||||
|
||||
it('offers to re-authorize, refreshes OAuth in place, and retries when accepted', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('Re-authorize Supabase') })
|
||||
);
|
||||
expect(authorizeViaBrowserAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ id: 'account-1', name: 'test' },
|
||||
false,
|
||||
'2024-01-01'
|
||||
);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledTimes(2);
|
||||
expect(loggedOutput()).toContain('Security · 1 error');
|
||||
});
|
||||
|
||||
it('preserves the selected organization when OAuth defaults to a different one', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(authorizeViaBrowserAsync)
|
||||
.mockResolvedValue({ id: 'connection-1', supabaseOrganizationSlug: 'other-org' } as never);
|
||||
await createCommand([]).runAsync();
|
||||
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
supabaseConnectionId: 'connection-1',
|
||||
organizationSlug: 'original-org',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and returns when the user declines to re-authorize', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await expect(createCommand([]).runAsync()).resolves.toBeUndefined();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas integrations:supabase:advisors')
|
||||
);
|
||||
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with the reauth command in non-interactive mode', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(EasCommandError);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('distinguishes unavailable advisors from clean results', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync)
|
||||
.mockResolvedValue({ ...mockResult, security: null, performance: [] });
|
||||
await createCommand([]).runAsync();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security advisors are unavailable')
|
||||
);
|
||||
expect(loggedOutput()).toContain('Performance · No unresolved findings');
|
||||
expect(loggedOutput()).not.toContain('Security · No unresolved findings');
|
||||
});
|
||||
|
||||
it('does not reauthorize when the project is unlinked during the request', async () => {
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(null);
|
||||
await createCommand(['--json']).runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
project: null,
|
||||
security: null,
|
||||
performance: null,
|
||||
});
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
formatSupabaseAdvisorLints,
|
||||
formatSupabaseProjectLabel,
|
||||
getSupabaseAdvisorsDashboardUrl,
|
||||
getSupabaseProjectDashboardUrl,
|
||||
isSupabaseReauthorizationRequiredError,
|
||||
logNoSupabaseProject,
|
||||
} from '../../../commandUtils/supabase';
|
||||
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
|
||||
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
|
||||
import {
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
} from '../../../graphql/types/SupabaseConnection';
|
||||
import { authorizeViaBrowserAsync } from '../../../integrations/supabase/provision';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
const ADVISOR_TYPES = [SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance];
|
||||
const REAUTH_COMMAND = 'eas integrations:supabase:advisors';
|
||||
|
||||
export default class IntegrationsSupabaseAdvisors extends EasCommand {
|
||||
static override description =
|
||||
"list unresolved findings from the Supabase Security and Performance Advisors for this app's primary Supabase project";
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --type security',
|
||||
'<%= config.bin %> <%= command.id %> --json --non-interactive',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
type: Flags.option({
|
||||
description: 'Only show one advisor',
|
||||
options: ['security', 'performance'] as const,
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsSupabaseAdvisors);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const types =
|
||||
flags.type === 'security'
|
||||
? [SupabaseAdvisorType.Security]
|
||||
: flags.type === 'performance'
|
||||
? [SupabaseAdvisorType.Performance]
|
||||
: ADVISOR_TYPES;
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsSupabaseAdvisors, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
|
||||
if (!project) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ project: null, security: null, performance: null });
|
||||
} else {
|
||||
logNoSupabaseProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
|
||||
if (result === undefined) {
|
||||
if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
`Expo cannot read this project's advisors until the Supabase connection grants the database read permission. Run ${chalk.bold(REAUTH_COMMAND)} interactively to re-authorize.`
|
||||
);
|
||||
}
|
||||
Log.warn(
|
||||
'Expo cannot read the Supabase advisors until the Supabase connection is re-authorized with the database read permission.'
|
||||
);
|
||||
const confirmed = await confirmAsync({
|
||||
message: 'Re-authorize Supabase in your browser now?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.warn(`Run ${chalk.bold(REAUTH_COMMAND)} when you are ready to re-authorize.`);
|
||||
return;
|
||||
}
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id,
|
||||
{ useCache: false }
|
||||
);
|
||||
const authorized = await authorizeViaBrowserAsync(
|
||||
graphqlClient,
|
||||
account,
|
||||
false,
|
||||
connection?.updatedAt
|
||||
);
|
||||
if (
|
||||
connection &&
|
||||
authorized.supabaseOrganizationSlug !== connection.supabaseOrganizationSlug
|
||||
) {
|
||||
await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, {
|
||||
supabaseConnectionId: authorized.id,
|
||||
organizationSlug: connection.supabaseOrganizationSlug,
|
||||
});
|
||||
}
|
||||
Log.newLine();
|
||||
result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
|
||||
if (result === undefined) {
|
||||
throw new EasCommandError(
|
||||
`Supabase still denies access to the advisors after re-authorizing. Check that the Expo integration is allowed to read the database for ${chalk.bold(formatSupabaseProjectLabel(project))}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ project: null, security: null, performance: null });
|
||||
} else {
|
||||
logNoSupabaseProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
project: {
|
||||
ref: result.project.supabaseProjectRef,
|
||||
name: result.project.supabaseProjectName,
|
||||
dashboardUrls: Object.fromEntries(
|
||||
types.map(type => [
|
||||
type.toLowerCase(),
|
||||
getSupabaseAdvisorsDashboardUrl(result.project, type),
|
||||
])
|
||||
),
|
||||
},
|
||||
security: types.includes(SupabaseAdvisorType.Security) ? result.security : undefined,
|
||||
performance: types.includes(SupabaseAdvisorType.Performance)
|
||||
? result.performance
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(result.project), { dim: false })}`
|
||||
);
|
||||
|
||||
for (const type of types) {
|
||||
const lints = type === SupabaseAdvisorType.Security ? result.security : result.performance;
|
||||
Log.newLine();
|
||||
if (lints === null) {
|
||||
Log.warn(
|
||||
`${type === SupabaseAdvisorType.Security ? 'Security' : 'Performance'} advisors are unavailable. Try again or open ${getSupabaseAdvisorsDashboardUrl(result.project, type)}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(formatSupabaseAdvisorLints(result.project, type, lints));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLintsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
types: SupabaseAdvisorType[],
|
||||
nonInteractive: boolean
|
||||
): Promise<SupabaseAdvisorLintsData | null | undefined> {
|
||||
const spinner = nonInteractive ? null : ora('Fetching Supabase advisor findings').start();
|
||||
try {
|
||||
const result = await SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
types
|
||||
);
|
||||
if (result) {
|
||||
spinner?.succeed(
|
||||
`Fetched Supabase advisor findings for ${formatSupabaseProjectLabel(result.project)}`
|
||||
);
|
||||
} else {
|
||||
spinner?.stop();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isSupabaseReauthorizationRequiredError(error)) {
|
||||
spinner?.fail('Supabase needs to be re-authorized');
|
||||
return undefined;
|
||||
}
|
||||
spinner?.fail('Failed to fetch Supabase advisor findings');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -617,6 +618,40 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --build-fingerprint to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
' 4b1f2c9e7a3d ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts launch options with --build-fingerprint', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
'4b1f2c9e7a3d',
|
||||
'--open-url',
|
||||
'exp://example.test',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d', openUrl: 'exp://example.test' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --application-archive-url to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -813,6 +848,25 @@ describe(Simulator, () => {
|
||||
it.each([
|
||||
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
|
||||
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
|
||||
])('rejects passing --build-fingerprint with %s', async (sourceFlag, sourceValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
'4b1f2c9e7a3d',
|
||||
sourceFlag,
|
||||
sourceValue,
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
|
||||
['--build-fingerprint', '4b1f2c9e7a3d'],
|
||||
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
|
||||
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -828,6 +882,100 @@ describe(Simulator, () => {
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('waiting for concurrency', () => {
|
||||
let elapsedMs: number;
|
||||
|
||||
beforeEach(() => {
|
||||
elapsedMs = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => elapsedMs);
|
||||
jest.spyOn(promiseUtils, 'sleepAsync').mockImplementation(async () => {
|
||||
elapsedMs += 5 * 60 * 1_000;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([JobRunStatus.New, JobRunStatus.InQueue])(
|
||||
'allows more than 15 minutes in %s and then a full startup window',
|
||||
async status => {
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
if (elapsedMs < 20 * 60 * 1_000) {
|
||||
return makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status },
|
||||
});
|
||||
}
|
||||
expect(mockOra.mock.results[1].value.text).toContain(
|
||||
elapsedMs === 20 * 60 * 1_000
|
||||
? '⏳ Simulator session queued or waiting for available concurrency'
|
||||
: 'session to start'
|
||||
);
|
||||
return makeDeviceRunSession({
|
||||
...(elapsedMs < 30 * 60 * 1_000 ? { remoteConfig: null } : {}),
|
||||
});
|
||||
});
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(elapsedMs).toBe(30 * 60 * 1_000);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('times out and stops a session that does not start within 15 minutes after queuing', async () => {
|
||||
mockByIdAsync.mockImplementation(async () =>
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: {
|
||||
id: 'job-123',
|
||||
status: elapsedMs < 20 * 60 * 1_000 ? JobRunStatus.InQueue : JobRunStatus.InProgress,
|
||||
},
|
||||
})
|
||||
);
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'session to start (excluding time in the queue)'
|
||||
);
|
||||
|
||||
expect(elapsedMs).toBe(35 * 60 * 1_000);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([JobRunStatus.Canceled, JobRunStatus.Errored, JobRunStatus.Finished])(
|
||||
'reports %s while queued without waiting for a timeout',
|
||||
async status => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(status.toLowerCase());
|
||||
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the simulator session when interrupted before the session is ready', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorAvailability extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:availability'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '../../simulator/env';
|
||||
|
||||
export default class SimulatorEgress extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:egress'];
|
||||
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const POLL_INTERVAL_MS = 5_000;
|
||||
const POST_STOP_REFRESH_COUNT = 2;
|
||||
|
||||
export default class SimulatorEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:events'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show activity events from a remote simulator session';
|
||||
|
||||
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
|
||||
|
||||
export default class SimulatorExec extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:exec'];
|
||||
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
|
||||
static override strict = false;
|
||||
|
||||
@@ -28,7 +28,6 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
|
||||
type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:get'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
@@ -47,7 +47,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue
|
||||
const OUT_CONFIG_TYPE_VALUES = {
|
||||
Env: 'env',
|
||||
Dotenv: 'dotenv',
|
||||
@@ -61,8 +61,8 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
};
|
||||
|
||||
export default class Simulator extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['simulator:start', 'sim', 'sim:start'];
|
||||
static override aliases = ['simulator:start', 'sim:start'];
|
||||
static override hiddenAliases = ['sim'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
|
||||
|
||||
@@ -87,17 +87,22 @@ export default class Simulator extends EasCommand {
|
||||
}),
|
||||
'build-id': Flags.string({
|
||||
description: 'EAS Build to install and launch before the simulator session is ready.',
|
||||
exclusive: ['application-archive-url', 'expo-go'],
|
||||
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
|
||||
}),
|
||||
'build-fingerprint': Flags.string({
|
||||
description:
|
||||
'Fingerprint hash of an EAS Build to install and launch before the simulator session is ready. Uses the most recent finished build with this fingerprint that can be installed on the simulator.',
|
||||
exclusive: ['build-id', 'application-archive-url', 'expo-go'],
|
||||
}),
|
||||
'application-archive-url': Flags.string({
|
||||
description:
|
||||
'Application archive URL to download, install, and launch before the simulator session is ready.',
|
||||
exclusive: ['build-id', 'expo-go'],
|
||||
exclusive: ['build-id', 'build-fingerprint', 'expo-go'],
|
||||
}),
|
||||
'expo-go': Flags.boolean({
|
||||
description:
|
||||
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
|
||||
exclusive: ['build-id', 'application-archive-url'],
|
||||
exclusive: ['build-id', 'build-fingerprint', 'application-archive-url'],
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description:
|
||||
@@ -203,6 +208,7 @@ export default class Simulator extends EasCommand {
|
||||
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
|
||||
const launchArgs = flags['launch-arg'];
|
||||
@@ -217,11 +223,12 @@ export default class Simulator extends EasCommand {
|
||||
if (
|
||||
(launchArgs?.length || openUrl) &&
|
||||
!buildId &&
|
||||
!buildFingerprint &&
|
||||
!applicationArchiveUrlFromFlag &&
|
||||
!flags['expo-go']
|
||||
) {
|
||||
throw new EasCommandError(
|
||||
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
|
||||
'Launch options require an application source. Pass --build-id, --build-fingerprint, --application-archive-url, or --expo-go.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +288,7 @@ export default class Simulator extends EasCommand {
|
||||
: { android: { deviceIdentifier } }
|
||||
: {}),
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(buildFingerprint ? { buildFingerprint } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
|
||||
: {}),
|
||||
@@ -318,11 +326,11 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const pollSpinner = ora(`⏳ Waiting for ${flags.type} session to be ready`).start();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let startupDeadline: number | undefined = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
let remoteConfig: DeviceRunSessionRemoteConfig | undefined;
|
||||
|
||||
try {
|
||||
while (!sessionInterrupt.signal.aborted && Date.now() < deadline) {
|
||||
while (!sessionInterrupt.signal.aborted) {
|
||||
const session = await Promise.race([
|
||||
DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId),
|
||||
sessionInterrupt.abortPromise,
|
||||
@@ -358,6 +366,17 @@ export default class Simulator extends EasCommand {
|
||||
break;
|
||||
}
|
||||
|
||||
if (jobRunStatus === JobRunStatus.New || jobRunStatus === JobRunStatus.InQueue) {
|
||||
startupDeadline = undefined;
|
||||
pollSpinner.text = '⏳ Simulator session queued or waiting for available concurrency';
|
||||
} else {
|
||||
startupDeadline ??= Date.now() + STARTUP_TIMEOUT_MS;
|
||||
pollSpinner.text = `⏳ Waiting for ${flags.type} session to start`;
|
||||
if (Date.now() >= startupDeadline) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(POLL_INTERVAL_MS, sessionInterrupt.signal);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -379,11 +398,11 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
if (!remoteConfig) {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to start`);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
|
||||
sessionInterrupt.dispose();
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}`
|
||||
`Timed out after ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to start (excluding time in the queue). ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
|
||||
);
|
||||
|
||||
export default class SimulatorList extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:list'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] list remote simulator sessions for the current project';
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:stop'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getAppStoreConnectApiKeyJsonAsync } from '../internal';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
|
||||
jest.mock('../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(getAppStoreConnectApiKeyJsonAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('writes issuer_id for an inline team key', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {
|
||||
ascApiKey: {
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: 'ISSUER456',
|
||||
keyP8: 'p8-content',
|
||||
},
|
||||
} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(JSON.parse(json!)).toEqual({
|
||||
key_id: 'KEY123',
|
||||
issuer_id: 'ISSUER456',
|
||||
key: 'p8-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits issuer_id for an inline individual key', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {
|
||||
ascApiKey: {
|
||||
keyIdentifier: 'KEY123',
|
||||
keyP8: 'p8-content',
|
||||
},
|
||||
} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(json!);
|
||||
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
|
||||
expect('issuer_id' in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it('writes issuer_id for a stored team key', async () => {
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: 'ISSUER456',
|
||||
keyP8: 'p8-content',
|
||||
});
|
||||
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(JSON.parse(json!)).toEqual({
|
||||
key_id: 'KEY123',
|
||||
issuer_id: 'ISSUER456',
|
||||
key: 'p8-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits issuer_id for a stored individual key', async () => {
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: undefined,
|
||||
keyP8: 'p8-content',
|
||||
});
|
||||
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(json!);
|
||||
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
|
||||
expect('issuer_id' in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null without key input', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(json).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -183,17 +183,20 @@ async function getGoogleServiceAccountKeyJsonAsync({
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAppStoreConnectApiKeyJsonAsync({
|
||||
export async function getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig,
|
||||
graphqlClient,
|
||||
}: {
|
||||
iosConfig: IosSubmissionConfigInput;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}): Promise<string | null> {
|
||||
// Individual API keys have no issuer. fastlane detects them by the absence of the issuer_id field.
|
||||
if (iosConfig.ascApiKey) {
|
||||
return JSON.stringify({
|
||||
key_id: iosConfig.ascApiKey.keyIdentifier,
|
||||
issuer_id: iosConfig.ascApiKey.issuerIdentifier,
|
||||
...(iosConfig.ascApiKey.issuerIdentifier
|
||||
? { issuer_id: iosConfig.ascApiKey.issuerIdentifier }
|
||||
: null),
|
||||
key: iosConfig.ascApiKey.keyP8,
|
||||
});
|
||||
} else if (iosConfig.ascApiKeyId) {
|
||||
@@ -201,7 +204,7 @@ async function getAppStoreConnectApiKeyJsonAsync({
|
||||
|
||||
return JSON.stringify({
|
||||
key_id: key.keyIdentifier,
|
||||
issuer_id: key.issuerIdentifier,
|
||||
...(key.issuerIdentifier ? { issuer_id: key.issuerIdentifier } : null),
|
||||
key: key.keyP8,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function getAppstoreMock(): AppStoreApi {
|
||||
return {
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
ensureAuthenticatedAsync: jest.fn(),
|
||||
ensureProvisioningAuthenticatedAsync: jest.fn(),
|
||||
ensureBundleIdExistsAsync: jest.fn(),
|
||||
listDistributionCertificatesAsync: jest.fn(),
|
||||
createDistributionCertificateAsync: jest.fn(),
|
||||
|
||||
@@ -105,6 +105,17 @@ export class CredentialsContext {
|
||||
}
|
||||
|
||||
if (this.appStore.defaultAuthenticationMode === AuthenticationMode.API_KEY) {
|
||||
if (
|
||||
process.env.EXPO_ASC_API_KEY_PATH &&
|
||||
process.env.EXPO_ASC_KEY_ID &&
|
||||
!process.env.EXPO_ASC_ISSUER_ID
|
||||
) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${process.env.EXPO_ASC_KEY_ID} has no Issuer ID (individual key). Skipping authentication with Apple. Set EXPO_ASC_ISSUER_ID if this is a team key.`
|
||||
);
|
||||
this.shouldAskAuthenticateAppStore = false;
|
||||
return;
|
||||
}
|
||||
await this.appStore.ensureAuthenticatedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import env from '../../../env';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
@@ -34,9 +35,30 @@ export enum AppStoreApiKeyPurpose {
|
||||
ASC_APP_CONNECTION = 'EAS Connect',
|
||||
}
|
||||
|
||||
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
|
||||
export async function promptForAscApiKeyPathAsync(
|
||||
ctx: CredentialsContext,
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<AscApiKeyPath> {
|
||||
// Individual keys are valid only as submission keys. Every other purpose
|
||||
// requires a team key, so the key type question is not asked there.
|
||||
// The option stays behind an env var until every server-side submission
|
||||
// path runs a fastlane version that accepts keys without an issuer ID.
|
||||
const isIndividualKey =
|
||||
!!env.enableIndividualAscApiKeys &&
|
||||
purpose === AppStoreApiKeyPurpose.SUBMISSION_SERVICE &&
|
||||
!ctx.nonInteractive &&
|
||||
(await promptForAscApiKeyTypeIsIndividualAsync());
|
||||
|
||||
const { keyId, keyP8Path } = await promptForKeyP8AndIdAsync();
|
||||
|
||||
if (isIndividualKey) {
|
||||
Log.log(
|
||||
'Individual API keys can be used for submissions, TestFlight setup, and metadata only. ' +
|
||||
'They cannot manage certificates or provisioning profiles.'
|
||||
);
|
||||
return { keyId, keyP8Path };
|
||||
}
|
||||
|
||||
const bestEffortIssuerId = await getBestEffortIssuerIdAsync(ctx, keyId);
|
||||
if (bestEffortIssuerId) {
|
||||
Log.log(`Detected Issuer ID: ${bestEffortIssuerId}`);
|
||||
@@ -46,6 +68,13 @@ export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Prom
|
||||
return { keyId, issuerId, keyP8Path };
|
||||
}
|
||||
|
||||
async function promptForAscApiKeyTypeIsIndividualAsync(): Promise<boolean> {
|
||||
return await selectAsync<boolean>('Which type of App Store Connect API key do you want to use?', [
|
||||
{ title: 'Team key (recommended)', value: false },
|
||||
{ title: 'Individual key (submissions only)', value: true },
|
||||
]);
|
||||
}
|
||||
|
||||
export async function promptForIssuerIdAsync(): Promise<string> {
|
||||
Log.log(chalk.bold('An App Store Connect Issuer ID is required'));
|
||||
Log.log(
|
||||
@@ -79,7 +108,7 @@ export async function provideOrGenerateAscApiKeyAsync(
|
||||
return await generateAscApiKeyAsync(ctx, purpose);
|
||||
}
|
||||
|
||||
const userProvided = await promptForAscApiKeyAsync(ctx);
|
||||
const userProvided = await promptForAscApiKeyAsync(ctx, purpose);
|
||||
if (!userProvided) {
|
||||
return await generateAscApiKeyAsync(ctx, purpose);
|
||||
}
|
||||
@@ -133,12 +162,15 @@ export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
|
||||
return nameParts.join(' ');
|
||||
}
|
||||
|
||||
async function promptForAscApiKeyAsync(ctx: CredentialsContext): Promise<MinimalAscApiKey | null> {
|
||||
async function promptForAscApiKeyAsync(
|
||||
ctx: CredentialsContext,
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<MinimalAscApiKey | null> {
|
||||
const shouldAutoGenerateCredentials = await shouldAutoGenerateCredentialsAsync(ascApiKeyIdSchema);
|
||||
if (shouldAutoGenerateCredentials) {
|
||||
return null;
|
||||
}
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx, purpose);
|
||||
const { keyP8Path, keyId, issuerId } = ascApiKeyPath;
|
||||
return { keyP8: await fs.readFile(keyP8Path, 'utf-8'), keyId, issuerId };
|
||||
}
|
||||
@@ -260,6 +292,23 @@ function filterKeysFromDifferentAppleTeam(
|
||||
return keys.filter(key => !key.appleTeam || key.appleTeam?.appleTeamIdentifier === teamId);
|
||||
}
|
||||
|
||||
export function filterOutIndividualAscApiKeys(
|
||||
keys: AppStoreConnectApiKeyFragment[]
|
||||
): AppStoreConnectApiKeyFragment[] {
|
||||
const teamKeys = keys.filter(key => !!key.issuerIdentifier);
|
||||
const hiddenCount = keys.length - teamKeys.length;
|
||||
if (hiddenCount > 0) {
|
||||
Log.log(
|
||||
chalk.gray(
|
||||
`${hiddenCount} individual API ${
|
||||
hiddenCount === 1 ? 'key' : 'keys'
|
||||
} hidden: individual keys are valid only for submissions.`
|
||||
)
|
||||
);
|
||||
}
|
||||
return teamKeys;
|
||||
}
|
||||
|
||||
export function sortAscApiKeysByUpdatedAtDesc(
|
||||
keys: AppStoreConnectApiKeyFragment[]
|
||||
): AppStoreConnectApiKeyFragment[] {
|
||||
@@ -329,6 +378,16 @@ export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
}
|
||||
try {
|
||||
if (hasAscEnvVars()) {
|
||||
if (
|
||||
process.env.EXPO_ASC_API_KEY_PATH &&
|
||||
process.env.EXPO_ASC_KEY_ID &&
|
||||
!process.env.EXPO_ASC_ISSUER_ID
|
||||
) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${process.env.EXPO_ASC_KEY_ID} has no Issuer ID (individual key). Skipping authentication with Apple. Set EXPO_ASC_ISSUER_ID if this is a team key.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamType,
|
||||
@@ -342,6 +401,12 @@ export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
if (!resolvedKey) {
|
||||
return false;
|
||||
}
|
||||
if (!resolvedKey.ascApiKey.issuerId) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${resolvedKey.ascApiKey.keyId} has no Issuer ID (individual key). Skipping authentication with Apple. Run 'eas credentials' and upload the key again with its Issuer ID if this is a team key.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import env from '../../../../env';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../../../../log';
|
||||
import { promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
@@ -9,16 +12,33 @@ import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../../utils/promptForCredentials';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
|
||||
import { AppleTeamType } from '../../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
filterOutIndividualAscApiKeys,
|
||||
getAscApiKeyName,
|
||||
promptForAscApiKeyPathAsync,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
tryAuthenticateAppStoreWithEasAscApiKeyAsync,
|
||||
} from '../AscApiKeyUtils';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../api/GraphqlClient', () => ({
|
||||
...jest.requireActual('../../api/GraphqlClient'),
|
||||
getAscApiKeyForAppSubmissionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../appstore/resolveCredentials', () => ({
|
||||
hasAscEnvVars: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj) as K[];
|
||||
@@ -30,6 +50,27 @@ afterEach(() => {
|
||||
jest.mocked(getCredentialsFromUserAsync).mockClear();
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
jest.mocked(hasAscEnvVars).mockReset();
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockReset();
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockReset();
|
||||
});
|
||||
|
||||
describe(filterOutIndividualAscApiKeys, () => {
|
||||
it('removes keys without an issuer identifier and keeps the rest', () => {
|
||||
const teamKey = { id: 'team', issuerIdentifier: 'issuer-id' } as any;
|
||||
const individualKey = { id: 'individual', issuerIdentifier: null } as any;
|
||||
|
||||
expect(filterOutIndividualAscApiKeys([teamKey, individualKey])).toEqual([teamKey]);
|
||||
});
|
||||
|
||||
it('returns all keys when none is individual', () => {
|
||||
const keys = [
|
||||
{ id: 'a', issuerIdentifier: 'issuer-a' },
|
||||
{ id: 'b', issuerIdentifier: 'issuer-b' },
|
||||
] as any[];
|
||||
|
||||
expect(filterOutIndividualAscApiKeys(keys)).toEqual(keys);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getAscApiKeyName, () => {
|
||||
@@ -44,7 +85,15 @@ describe(getAscApiKeyName, () => {
|
||||
});
|
||||
|
||||
describe(promptForAscApiKeyPathAsync, () => {
|
||||
beforeEach(() => {
|
||||
env.enableIndividualAscApiKeys = '1';
|
||||
});
|
||||
afterEach(() => {
|
||||
env.enableIndividualAscApiKeys = undefined;
|
||||
});
|
||||
|
||||
it('prompts for keyId, keyP8Path and issuerId when user is not authenticated to Apple', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
@@ -62,16 +111,21 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).toHaveBeenCalledTimes(1); // key type
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
|
||||
});
|
||||
it('prompts for keyId, keyP8Path and detects issuerId when user is authenticated to Apple', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
@@ -87,7 +141,10 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
getAscApiKeyAsync: jest.fn(() => testAscApiKey),
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id-from-apple',
|
||||
@@ -97,6 +154,89 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId
|
||||
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
|
||||
});
|
||||
it('skips the issuer prompt for an individual key in the submission flow', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(true); // individual key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
}));
|
||||
const getAscApiKeyAsync = jest.fn(() => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
getAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId only
|
||||
expect(getAscApiKeyAsync).not.toHaveBeenCalled(); // no issuer detection
|
||||
});
|
||||
it('does not ask the key type outside the submission flow', async () => {
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
}));
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not ask the key type in the submission flow when individual keys are not enabled', async () => {
|
||||
env.enableIndividualAscApiKeys = undefined;
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
}));
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
|
||||
});
|
||||
});
|
||||
|
||||
describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
@@ -179,3 +319,95 @@ describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(tryAuthenticateAppStoreWithEasAscApiKeyAsync, () => {
|
||||
const app = {
|
||||
account: { name: 'test-account' },
|
||||
projectName: 'test-project',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXPO_ASC_API_KEY_PATH;
|
||||
delete process.env.EXPO_ASC_KEY_ID;
|
||||
delete process.env.EXPO_ASC_ISSUER_ID;
|
||||
});
|
||||
|
||||
function createNonInteractiveCtx(): ReturnType<typeof createCtxMock> {
|
||||
return createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
ensureAuthenticatedAsync: jest.fn(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('skips authentication without a warning when the environment key has no issuer ID', async () => {
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/asc-api-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'env-key-id';
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('authenticates with the environment key when it has an issuer ID', async () => {
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/asc-api-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'env-key-id';
|
||||
process.env.EXPO_ASC_ISSUER_ID = 'env-issuer-id';
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
jest.mocked(ctx.appStore.ensureAuthenticatedAsync).mockImplementation(async () => {
|
||||
(ctx.appStore as any).authCtx = testAuthCtx;
|
||||
return testAuthCtx;
|
||||
});
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips authentication without a warning when the stored submission key has no issuer ID', async () => {
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(false);
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
appleTeam: { appleTeamIdentifier: 'TEAM123', appleTeamName: 'Team Name' },
|
||||
} as any);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyP8: 'key-p8',
|
||||
keyIdentifier: 'stored-key-id',
|
||||
issuerIdentifier: null,
|
||||
} as any);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './ascApiKey';
|
||||
import {
|
||||
Options as AuthenticateOptions,
|
||||
assertProvisioningAuthCtx,
|
||||
assertUserAuthCtx,
|
||||
authenticateAsync,
|
||||
isUserAuthCtx,
|
||||
@@ -80,26 +81,38 @@ export default class AppStoreApi {
|
||||
return this.authCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use for operations against Apple's Provisioning endpoints (bundle IDs, certificates,
|
||||
* profiles, devices).
|
||||
*/
|
||||
public async ensureProvisioningAuthenticatedAsync(
|
||||
options?: AuthenticateOptions
|
||||
): Promise<AuthCtx> {
|
||||
const authCtx = await this.ensureAuthenticatedAsync(options);
|
||||
assertProvisioningAuthCtx(authCtx);
|
||||
return authCtx;
|
||||
}
|
||||
|
||||
public async ensureBundleIdExistsAsync(
|
||||
app: AppLookupParams,
|
||||
options?: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await ensureBundleIdExistsAsync(ctx, app, options);
|
||||
}
|
||||
|
||||
public async listDistributionCertificatesAsync(): Promise<DistributionCertificateStoreInfo[]> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await listDistributionCertificatesAsync(ctx);
|
||||
}
|
||||
|
||||
public async createDistributionCertificateAsync(): Promise<DistributionCertificate> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createDistributionCertificateAsync(ctx);
|
||||
}
|
||||
|
||||
public async revokeDistributionCertificateAsync(ids: string[]): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await revokeDistributionCertificateAsync(ctx, ids);
|
||||
}
|
||||
|
||||
@@ -123,7 +136,7 @@ export default class AppStoreApi {
|
||||
provisioningProfile: ProvisioningProfile,
|
||||
distCert: DistributionCertificate
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await useExistingProvisioningProfileAsync(
|
||||
ctx,
|
||||
bundleIdentifier,
|
||||
@@ -137,7 +150,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<ProvisioningProfileStoreInfo[]> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await listProvisioningProfilesAsync(ctx, bundleIdentifier, applePlatform, profileClass);
|
||||
}
|
||||
|
||||
@@ -148,7 +161,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createProvisioningProfileAsync(
|
||||
ctx,
|
||||
bundleIdentifier,
|
||||
@@ -164,7 +177,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await revokeProvisioningProfileAsync(ctx, bundleIdentifier, applePlatform, profileClass);
|
||||
}
|
||||
|
||||
@@ -174,7 +187,7 @@ export default class AppStoreApi {
|
||||
distCertSerialNumber: string,
|
||||
profileType: ProfileType
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createOrReuseAdhocProvisioningProfileAsync(
|
||||
ctx,
|
||||
udids,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import AppStoreApi from '../AppStoreApi';
|
||||
import { authenticateAsync } from '../authenticate';
|
||||
import { AuthCtx } from '../authenticateTypes';
|
||||
import { listDistributionCertificatesAsync } from '../distributionCertificate';
|
||||
|
||||
jest.mock('../authenticate', () => ({
|
||||
...jest.requireActual('../authenticate'),
|
||||
authenticateAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../distributionCertificate');
|
||||
|
||||
const individualKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const teamKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(authenticateAsync).mockReset();
|
||||
jest.mocked(listDistributionCertificatesAsync).mockReset();
|
||||
});
|
||||
|
||||
describe(AppStoreApi, () => {
|
||||
it('runs provisioning operations with a team API key', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(teamKeyAuthCtx);
|
||||
jest.mocked(listDistributionCertificatesAsync).mockResolvedValue([]);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
|
||||
await expect(appStoreApi.listDistributionCertificatesAsync()).resolves.toEqual([]);
|
||||
expect(listDistributionCertificatesAsync).toHaveBeenCalledWith(teamKeyAuthCtx);
|
||||
});
|
||||
|
||||
it('rejects provisioning operations with an individual API key before calling Apple', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(individualKeyAuthCtx);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
|
||||
await expect(appStoreApi.listDistributionCertificatesAsync()).rejects.toThrow(
|
||||
'individual API key'
|
||||
);
|
||||
expect(listDistributionCertificatesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects provisioning operations with an individual API key cached by an earlier flow', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(individualKeyAuthCtx);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
await appStoreApi.ensureAuthenticatedAsync();
|
||||
|
||||
await expect(appStoreApi.ensureProvisioningAuthenticatedAsync()).rejects.toThrow(
|
||||
'individual API key'
|
||||
);
|
||||
expect(authenticateAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Token } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
assertProvisioningAuthCtx,
|
||||
authenticateAsync,
|
||||
isIndividualAscApiKeyAuthCtx,
|
||||
} from '../authenticate';
|
||||
import { ApiKeyAuthCtx, AppleTeamType, AuthCtx, AuthenticationMode } from '../authenticateTypes';
|
||||
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
...jest.requireActual('@expo/apple-utils'),
|
||||
Token: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const apiKeyAuthOptions = {
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamId: 'team-id',
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
};
|
||||
|
||||
describe(authenticateAsync, () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_ASC_API_KEY_PATH;
|
||||
delete process.env.EXPO_ASC_KEY_ID;
|
||||
delete process.env.EXPO_ASC_ISSUER_ID;
|
||||
jest.mocked(Token).mockClear();
|
||||
});
|
||||
|
||||
it('authenticates with a team API key', async () => {
|
||||
const authCtx = (await authenticateAsync({
|
||||
...apiKeyAuthOptions,
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
})) as ApiKeyAuthCtx;
|
||||
|
||||
expect(authCtx.ascApiKey.issuerId).toBe('issuer-id');
|
||||
expect(Token).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ keyId: 'key-id', issuerId: 'issuer-id' })
|
||||
);
|
||||
});
|
||||
|
||||
it('authenticates with an individual (issuer-less) API key', async () => {
|
||||
const authCtx = (await authenticateAsync({
|
||||
...apiKeyAuthOptions,
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
})) as ApiKeyAuthCtx;
|
||||
|
||||
expect(authCtx.ascApiKey.issuerId).toBeUndefined();
|
||||
expect(Token).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ keyId: 'key-id', issuerId: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const individualKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const teamKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const userAuthCtx = {
|
||||
appleId: '[email protected]',
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
|
||||
describe(isIndividualAscApiKeyAuthCtx, () => {
|
||||
it('detects an individual API key auth context', () => {
|
||||
expect(isIndividualAscApiKeyAuthCtx(individualKeyAuthCtx)).toBe(true);
|
||||
expect(isIndividualAscApiKeyAuthCtx(teamKeyAuthCtx)).toBe(false);
|
||||
expect(isIndividualAscApiKeyAuthCtx(userAuthCtx)).toBe(false);
|
||||
expect(isIndividualAscApiKeyAuthCtx(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(assertProvisioningAuthCtx, () => {
|
||||
it('rejects an individual API key auth context', () => {
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(individualKeyAuthCtx);
|
||||
}).toThrow('individual API key');
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(individualKeyAuthCtx);
|
||||
}).toThrow('EXPO_ASC_ISSUER_ID');
|
||||
});
|
||||
|
||||
it('accepts team API key and user auth contexts', () => {
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(teamKeyAuthCtx);
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(userAuthCtx);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { JsonFileCache } from '@expo/apple-utils';
|
||||
import * as fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import Log from '../../../../log';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
@@ -67,6 +68,104 @@ describe(resolveAscApiKeyAsync, () => {
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toMatchObject(testAscApiKey);
|
||||
});
|
||||
it(`fills a missing issuer ID on a key passed in options from EXPO_ASC_ISSUER_ID and warns`, async () => {
|
||||
process.env.EXPO_ASC_ISSUER_ID = testAscApiKey.issuerId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
});
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: testAscApiKey.issuerId,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unset EXPO_ASC_ISSUER_ID if test-key-id is an individual API key')
|
||||
);
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it(`does not warn about EXPO_ASC_ISSUER_ID when the key comes from the environment too`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = testAscApiKey.keyId;
|
||||
process.env.EXPO_ASC_ISSUER_ID = testAscApiKey.issuerId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(resolveAscApiKeyAsync()).resolves.toEqual(testAscApiKey);
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it(`treats a key passed in options without an issuer ID as individual without prompting`, async () => {
|
||||
const ascApiKey = await resolveAscApiKeyAsync({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
});
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`treats an environment key without EXPO_ASC_ISSUER_ID as individual without prompting`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = testAscApiKey.keyId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toHaveBeenCalledWith(expect.stringContaining('individual API key'));
|
||||
logWarnSpy.mockRestore();
|
||||
logDebugSpy.mockRestore();
|
||||
});
|
||||
it(`accepts an empty issuer from the prompt as an individual key`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValueOnce({ ascApiKeyId: testAscApiKey.keyId })
|
||||
.mockResolvedValueOnce({ ascIssuerId: '' });
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
const question = jest.mocked(promptAsync).mock.calls[1][0] as unknown as {
|
||||
message: string;
|
||||
validate?: unknown;
|
||||
};
|
||||
expect(question.message).toContain('leave empty');
|
||||
expect(question.validate).toBeUndefined();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toHaveBeenCalledWith(expect.stringContaining('individual API key'));
|
||||
logWarnSpy.mockRestore();
|
||||
logDebugSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
const testTeam = {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
} from './resolveCredentials';
|
||||
import Log from '../../../log';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { MinimalAscApiKey } from '../credentials';
|
||||
|
||||
@@ -56,6 +56,33 @@ export function assertUserAuthCtx(authCtx: AuthCtx | undefined): UserAuthCtx {
|
||||
throw new Error('Expected user authentication context (login/password).');
|
||||
}
|
||||
|
||||
export function isIndividualAscApiKeyAuthCtx(authCtx: AuthCtx | undefined): boolean {
|
||||
return !!authCtx && 'ascApiKey' in authCtx && !!authCtx.ascApiKey && !authCtx.ascApiKey.issuerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple blocks individual (issuer-less) ASC API keys from the Provisioning endpoints
|
||||
* (certificates, profiles, bundle IDs, devices).
|
||||
*/
|
||||
export function assertProvisioningAuthCtx(authCtx: AuthCtx): void {
|
||||
if (!isIndividualAscApiKeyAuthCtx(authCtx)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'The App Store Connect API key in use has no Issuer ID, so it is an individual API key. ' +
|
||||
'Apple blocks individual API keys from managing certificates, provisioning profiles, and devices (Provisioning endpoints). ' +
|
||||
'If this is a team key, provide its Issuer ID: set EXPO_ASC_ISSUER_ID when the key comes from environment variables, ' +
|
||||
"or run 'eas credentials' and upload the key again with its Issuer ID when it is stored on EAS.\n" +
|
||||
'To continue, do one of the following:\n' +
|
||||
` - Use a team API key (one with an Issuer ID). Run 'eas credentials' to set one up, or set EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID and EXPO_ASC_ISSUER_ID. ${learnMore(
|
||||
'https://expo.fyi/creating-asc-api-key'
|
||||
)}\n` +
|
||||
` - Provide a distribution certificate and provisioning profile via credentials.json. ${learnMore(
|
||||
'https://docs.expo.dev/app-signing/local-credentials/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestContext(authCtx: AuthCtx): RequestContext {
|
||||
assert(authCtx.authState?.context, 'Apple request context must be defined');
|
||||
return authCtx.authState.context;
|
||||
|
||||
@@ -87,19 +87,40 @@ async function getAscKeyIdFromEnvironmentOrOptionsAsync(
|
||||
|
||||
async function getAscIssuerIdFromEnvironmentOrOptionsAsync(
|
||||
ascApiKey?: MinimalAscApiKey
|
||||
): Promise<string> {
|
||||
): Promise<string | undefined> {
|
||||
if (ascApiKey?.issuerId) {
|
||||
return ascApiKey?.issuerId;
|
||||
} else if (process.env.EXPO_ASC_ISSUER_ID) {
|
||||
return ascApiKey.issuerId;
|
||||
}
|
||||
|
||||
if (process.env.EXPO_ASC_ISSUER_ID) {
|
||||
if (ascApiKey?.keyId) {
|
||||
Log.warn(
|
||||
`App Store Connect API key ${ascApiKey.keyId} was configured without an Issuer ID, so EXPO_ASC_ISSUER_ID is used for it instead. App Store Connect rejects requests when the Issuer ID belongs to a different key. Unset EXPO_ASC_ISSUER_ID if ${ascApiKey.keyId} is an individual API key, or configure the Issuer ID that belongs to it.`
|
||||
);
|
||||
}
|
||||
return process.env.EXPO_ASC_ISSUER_ID;
|
||||
}
|
||||
|
||||
const keyId = ascApiKey?.keyId ?? process.env.EXPO_ASC_KEY_ID;
|
||||
const keyP8IsResolvable = !!ascApiKey?.keyP8 || !!process.env.EXPO_ASC_API_KEY_PATH;
|
||||
if (keyId && keyP8IsResolvable) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${keyId} has no Issuer ID, treating it as an individual API key.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { ascIssuerId } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'ascIssuerId',
|
||||
message: `ASC Issuer ID:`,
|
||||
validate: (val: string) => val !== '',
|
||||
message: `ASC Issuer ID (leave empty for an individual API key):`,
|
||||
});
|
||||
if (!ascIssuerId) {
|
||||
Log.debug(
|
||||
'No Issuer ID provided, treating the App Store Connect API key as an individual API key.'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return ascIssuerId;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,4 +54,41 @@ describe('print credentials', () => {
|
||||
.mock.calls.reduce((acc, mockValue) => acc + mockValue, '');
|
||||
expect(loggedSoFar).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('prints the key type for an individual (issuer-less) ASC API key', async () => {
|
||||
jest.mocked(Log.log).mockClear();
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const app: App = {
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'quinlanj',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
projectName: 'test52',
|
||||
};
|
||||
const testIosAppCredentialsData = JSON.parse(
|
||||
JSON.stringify(
|
||||
nullthrows(
|
||||
await IosAppCredentialsQuery.withCommonFieldsByAppIdentifierIdAsync(
|
||||
graphqlClient,
|
||||
'@quinlanj/test52',
|
||||
{
|
||||
appleAppIdentifierId: 'test-id',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
testIosAppCredentialsData.appStoreConnectApiKeyForSubmissions.issuerIdentifier = null;
|
||||
const targets: Target[] = [
|
||||
{ targetName: 'test52', bundleIdentifier: 'com.quinlanj.test52', entitlements: {} },
|
||||
];
|
||||
displayIosCredentials(app, { test52: testIosAppCredentialsData }, targets);
|
||||
const loggedSoFar = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.reduce((acc, mockValue) => acc + mockValue, '');
|
||||
expect(loggedSoFar).toContain('Key Type');
|
||||
expect(loggedSoFar).toContain('Individual (submissions only)');
|
||||
expect(loggedSoFar).not.toContain('Issuer ID');
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export async function runDeveloperPortalMethodAsync(
|
||||
account: AccountFragment,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
|
||||
): Promise<void> {
|
||||
const appleAuthCtx = await appStoreApi.ensureAuthenticatedAsync();
|
||||
const appleAuthCtx = await appStoreApi.ensureProvisioningAuthenticatedAsync();
|
||||
const unregisteredPortalDevices = await findUnregisteredPortalDevicesAsync(
|
||||
graphqlClient,
|
||||
appleAuthCtx,
|
||||
|
||||
@@ -21,4 +21,8 @@ export default {
|
||||
* Comma separated list of feature gate keys of feature gates to evaluate override to false.
|
||||
*/
|
||||
featureGateDisable: process.env.EAS_FG_DISABLE,
|
||||
/**
|
||||
* Enables the individual App Store Connect API key option in the interactive submission key prompt.
|
||||
*/
|
||||
enableIndividualAscApiKeys: process.env.EAS_ENABLE_INDIVIDUAL_ASC_API_KEYS,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Agent } from 'https';
|
||||
import createHttpsProxyAgent from 'https-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
|
||||
export { Headers, RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
@@ -18,7 +18,7 @@ function createHttpsAgent(): Agent | null {
|
||||
if (!httpsProxyUrl) {
|
||||
return null;
|
||||
}
|
||||
return createHttpsProxyAgent(httpsProxyUrl);
|
||||
return new HttpsProxyAgent(httpsProxyUrl);
|
||||
}
|
||||
|
||||
export const httpsProxyAgent: Agent | null = createHttpsAgent();
|
||||
|
||||
+145
-7
@@ -1101,9 +1101,9 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when a web preview is
|
||||
@@ -2949,6 +2949,8 @@ export type AppObserveErrorOccurrencesFilter = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -3131,6 +3133,8 @@ export type AppObserveErrorsBreakdownInput = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
dimension: AppObserveErrorBreakdownDimension;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -3150,6 +3154,8 @@ export type AppObserveErrorsGroupsInput = {
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Bucket size for each group's timeSeries. Defaults to daily. */
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Restrict to one error group. */
|
||||
@@ -3170,6 +3176,8 @@ export type AppObserveErrorsStatsInput = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -3186,6 +3194,8 @@ export type AppObserveErrorsTimeSeriesInput = {
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Restrict to one error group. */
|
||||
@@ -4721,9 +4731,9 @@ export type AppiumRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when a web preview is
|
||||
@@ -5689,12 +5699,14 @@ export type BuildFilterInput = {
|
||||
channel?: InputMaybe<Scalars['String']['input']>;
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
distributions?: InputMaybe<Array<DistributionType>>;
|
||||
expired?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
fingerprintHash?: InputMaybe<Scalars['String']['input']>;
|
||||
hasFingerprint?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
releaseChannel?: InputMaybe<Scalars['String']['input']>;
|
||||
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
simulator?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
statuses?: InputMaybe<Array<BuildStatus>>;
|
||||
};
|
||||
|
||||
export enum BuildIosEnterpriseProvisioning {
|
||||
@@ -6595,6 +6607,14 @@ export type CreatePostHogDeepLinkInput = {
|
||||
purpose?: InputMaybe<PostHogDeepLinkPurpose>;
|
||||
};
|
||||
|
||||
export type CreateSandboxInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
image?: InputMaybe<Scalars['String']['input']>;
|
||||
operatingSystem: SandboxOperatingSystem;
|
||||
projectArchive?: InputMaybe<SandboxProjectArchiveInput>;
|
||||
resourceClass: SandboxResourceClass;
|
||||
};
|
||||
|
||||
export type CreateSentryProjectInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
sentryProjectId: Scalars['String']['input'];
|
||||
@@ -10127,6 +10147,7 @@ export enum Permission {
|
||||
Admin = 'ADMIN',
|
||||
Own = 'OWN',
|
||||
Publish = 'PUBLISH',
|
||||
PublishProtected = 'PUBLISH_PROTECTED',
|
||||
View = 'VIEW'
|
||||
}
|
||||
|
||||
@@ -10280,7 +10301,6 @@ export type ProjectArchiveSourceInput = {
|
||||
bucketKey?: InputMaybe<Scalars['String']['input']>;
|
||||
gitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
metadataLocation?: InputMaybe<Scalars['String']['input']>;
|
||||
repositoryUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
type: ProjectArchiveSourceType;
|
||||
url?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -10335,6 +10355,7 @@ export type PublishUpdateGroupInput = {
|
||||
isGitWorkingTreeDirty?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
manifestHostOverride?: InputMaybe<Scalars['String']['input']>;
|
||||
message?: InputMaybe<Scalars['String']['input']>;
|
||||
previousRolloutUpdateToClobberIdGroup?: InputMaybe<UpdateIdGroup>;
|
||||
rollBackToEmbeddedInfoGroup?: InputMaybe<UpdateRollBackToEmbeddedGroup>;
|
||||
rolloutInfoGroup?: InputMaybe<UpdateRolloutInfoGroup>;
|
||||
runtimeVersion: Scalars['String']['input'];
|
||||
@@ -10534,6 +10555,7 @@ export enum Role {
|
||||
HasAdmin = 'HAS_ADMIN',
|
||||
NotAdmin = 'NOT_ADMIN',
|
||||
Owner = 'OWNER',
|
||||
ReleaseManager = 'RELEASE_MANAGER',
|
||||
ViewOnly = 'VIEW_ONLY'
|
||||
}
|
||||
|
||||
@@ -10660,6 +10682,7 @@ export type RootMutation = {
|
||||
realtimeLogs: RealtimeLogsMutation;
|
||||
/** Mutations that create, update, and delete Robots */
|
||||
robot: RobotMutation;
|
||||
sandbox: SandboxMutation;
|
||||
/** Mutations for Sentry installations */
|
||||
sentryInstallation: SentryInstallationMutation;
|
||||
/** Mutations for Sentry projects */
|
||||
@@ -10675,6 +10698,7 @@ export type RootMutation = {
|
||||
updateBranch: UpdateBranchMutation;
|
||||
updateChannel: UpdateChannelMutation;
|
||||
uploadSession: UploadSession;
|
||||
usageBudget: UsageBudgetMutation;
|
||||
/** Mutations that create, update, and delete pinned apps */
|
||||
userAppPins: UserAppPinMutation;
|
||||
userAuditLog: UserAuditLogMutation;
|
||||
@@ -11063,16 +11087,87 @@ export type Sandbox = {
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type SandboxConnection = {
|
||||
__typename?: 'SandboxConnection';
|
||||
edges: Array<SandboxEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type SandboxEdge = {
|
||||
__typename?: 'SandboxEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: Sandbox;
|
||||
};
|
||||
|
||||
export type SandboxFilterInput = {
|
||||
statuses?: InputMaybe<Array<SandboxStatus>>;
|
||||
};
|
||||
|
||||
export type SandboxMutation = {
|
||||
__typename?: 'SandboxMutation';
|
||||
createSandbox: Sandbox;
|
||||
markSandboxReady: Sandbox;
|
||||
stopSandbox: Sandbox;
|
||||
touchSandbox: Sandbox;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_CreateSandboxArgs = {
|
||||
input: CreateSandboxInput;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_MarkSandboxReadyArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_StopSandboxArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_TouchSandboxArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum SandboxOperatingSystem {
|
||||
Linux = 'LINUX',
|
||||
Macos = 'MACOS'
|
||||
}
|
||||
|
||||
export type SandboxProjectArchiveInput = {
|
||||
gitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
url?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type SandboxQuery = {
|
||||
__typename?: 'SandboxQuery';
|
||||
/** Project sandboxes, newest first. An omitted or empty status filter includes all statuses. */
|
||||
byAppIdPaginated: SandboxConnection;
|
||||
byId: Sandbox;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxQuery_ByAppIdPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
appId: Scalars['ID']['input'];
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<SandboxFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxQuery_ByIdArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum SandboxResourceClass {
|
||||
Large = 'LARGE',
|
||||
Medium = 'MEDIUM'
|
||||
}
|
||||
|
||||
export enum SandboxStatus {
|
||||
Errored = 'ERRORED',
|
||||
Running = 'RUNNING',
|
||||
@@ -11219,9 +11314,9 @@ export type ServeSimRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
previewToken?: Maybe<Scalars['String']['output']>;
|
||||
previewUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use previewUrl instead. */
|
||||
@@ -12080,7 +12175,7 @@ export type UpdateChannel = {
|
||||
embeddedUpdateCount: Scalars['Int']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
isPaused: Scalars['Boolean']['output'];
|
||||
/** Only account admins may release to a protected channel. */
|
||||
/** Only allowed publishers (Release Managers, Admins, Owners) may release to a protected channel. */
|
||||
isProtected: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
latestRuntimes: ChannelRuntimesConnection;
|
||||
@@ -12135,7 +12230,7 @@ export type UpdateChannelMutation = {
|
||||
editUpdateChannel: UpdateChannel;
|
||||
/** Pause updates for an EAS channel. */
|
||||
pauseUpdateChannel: UpdateChannel;
|
||||
/** Protect an EAS channel, so only account admins may release to it. */
|
||||
/** Protect an EAS channel, so only allowed publishers (Release Managers, Admins, Owners) may release to it. */
|
||||
protectUpdateChannel: UpdateChannel;
|
||||
/** Resume updates for an EAS channel. */
|
||||
resumeUpdateChannel: UpdateChannel;
|
||||
@@ -12388,6 +12483,12 @@ export type UpdateGroupsConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type UpdateIdGroup = {
|
||||
android?: InputMaybe<Scalars['ID']['input']>;
|
||||
ios?: InputMaybe<Scalars['ID']['input']>;
|
||||
web?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type UpdateInfoGroup = {
|
||||
android?: InputMaybe<PartialManifest>;
|
||||
ios?: InputMaybe<PartialManifest>;
|
||||
@@ -12585,6 +12686,31 @@ export enum UploadSessionType {
|
||||
EasUpdateFingerprint = 'EAS_UPDATE_FINGERPRINT'
|
||||
}
|
||||
|
||||
export type UsageBudget = {
|
||||
__typename?: 'UsageBudget';
|
||||
id: Scalars['ID']['output'];
|
||||
limits: UsageBudgetLimits;
|
||||
};
|
||||
|
||||
export type UsageBudgetLimits = {
|
||||
__typename?: 'UsageBudgetLimits';
|
||||
builds?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type UsageBudgetLimitsInput = {
|
||||
builds?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type UsageBudgetMutation = {
|
||||
__typename?: 'UsageBudgetMutation';
|
||||
createUsageBudget: UsageBudget;
|
||||
};
|
||||
|
||||
|
||||
export type UsageBudgetMutation_CreateUsageBudgetArgs = {
|
||||
limits: UsageBudgetLimitsInput;
|
||||
};
|
||||
|
||||
export type UsageMetricTotal = {
|
||||
__typename?: 'UsageMetricTotal';
|
||||
billingPeriod: BillingPeriod;
|
||||
@@ -13344,9 +13470,9 @@ export type WebPreviewOnlyRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewUrl: Scalars['String']['output'];
|
||||
};
|
||||
@@ -14575,6 +14701,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'WorkflowRun';
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
actor?: Maybe<Actor>;
|
||||
app: App;
|
||||
/**
|
||||
* Why the server canceled this run. Null for manually canceled runs and for
|
||||
* runs canceled before the reason was recorded.
|
||||
@@ -16716,6 +16843,15 @@ export type SupabaseProjectByAppIdQueryVariables = Exact<{
|
||||
|
||||
export type SupabaseProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } | null } } };
|
||||
|
||||
export type SupabaseAdvisorLintsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
security: Scalars['Boolean']['input'];
|
||||
performance: Scalars['Boolean']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type SupabaseAdvisorLintsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any, security?: Array<{ __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string }> | null, performance?: Array<{ __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string }> | null } | null } } };
|
||||
|
||||
export type ViewUpdateGroupInsightsQueryVariables = Exact<{
|
||||
groupId: Scalars['ID']['input'];
|
||||
timespan: InsightsTimespan;
|
||||
@@ -17101,6 +17237,8 @@ export type SupabaseConnectionFragment = { __typename?: 'SupabaseConnection', id
|
||||
|
||||
export type SupabaseProjectFragment = { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any };
|
||||
|
||||
export type SupabaseAdvisorLintFragment = { __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string };
|
||||
|
||||
export type UpdateFragment = { __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, runtime: { __typename?: 'Runtime', id: string, version: string }, actor?:
|
||||
| { __typename: 'PartnerActor', username: string, id: string }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string }
|
||||
|
||||
@@ -4,6 +4,13 @@ import gql from 'graphql-tag';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
SupabaseAdvisorLintsByAppIdQuery,
|
||||
SupabaseAdvisorLintsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
import {
|
||||
SupabaseAdvisorLintFragmentNode,
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
SupabaseConnectionData,
|
||||
SupabaseConnectionFragmentNode,
|
||||
SupabaseProjectData,
|
||||
@@ -94,4 +101,57 @@ export const SupabaseQuery = {
|
||||
);
|
||||
return data.app.byId.supabaseProject ?? null;
|
||||
},
|
||||
|
||||
async getSupabaseAdvisorLintsByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
types: readonly SupabaseAdvisorType[] = [
|
||||
SupabaseAdvisorType.Security,
|
||||
SupabaseAdvisorType.Performance,
|
||||
]
|
||||
): Promise<SupabaseAdvisorLintsData | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<SupabaseAdvisorLintsByAppIdQuery, SupabaseAdvisorLintsByAppIdQueryVariables>(
|
||||
gql`
|
||||
query SupabaseAdvisorLintsByAppId(
|
||||
$appId: String!
|
||||
$security: Boolean!
|
||||
$performance: Boolean!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
supabaseProject {
|
||||
id
|
||||
...SupabaseProjectFragment
|
||||
security: advisorLints(type: SECURITY) @include(if: $security) {
|
||||
...SupabaseAdvisorLintFragment
|
||||
}
|
||||
performance: advisorLints(type: PERFORMANCE) @include(if: $performance) {
|
||||
...SupabaseAdvisorLintFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(SupabaseProjectFragmentNode)}
|
||||
${print(SupabaseAdvisorLintFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
security: types.includes(SupabaseAdvisorType.Security),
|
||||
performance: types.includes(SupabaseAdvisorType.Performance),
|
||||
},
|
||||
{ additionalTypenames: ['App', 'SupabaseProject'], requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const project = data.app.byId.supabaseProject;
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const { security, performance, ...projectData } = project;
|
||||
return { project: projectData, security: security ?? null, performance: performance ?? null };
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError, withErrorHandlingAsync } from '../client';
|
||||
import { JobRunStatus, WorkflowJobStatus } from '../generated';
|
||||
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../generated';
|
||||
|
||||
const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
|
||||
WorkflowJobStatus.Success,
|
||||
@@ -11,6 +11,12 @@ const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
|
||||
WorkflowJobStatus.Skipped,
|
||||
]);
|
||||
|
||||
const FINAL_BUILD_STATUSES = new Set<BuildStatus>([
|
||||
BuildStatus.Errored,
|
||||
BuildStatus.Finished,
|
||||
BuildStatus.Canceled,
|
||||
]);
|
||||
|
||||
const FINAL_JOB_RUN_STATUSES = new Set<JobRunStatus>([
|
||||
JobRunStatus.Errored,
|
||||
JobRunStatus.Finished,
|
||||
@@ -53,6 +59,20 @@ type WorkflowJobSshPollQueryVariables = {
|
||||
workflowJobId: string;
|
||||
};
|
||||
|
||||
type BuildSshPollQuery = {
|
||||
builds: {
|
||||
byId: {
|
||||
id: string;
|
||||
status: BuildStatus;
|
||||
sshSession: WorkflowJobSshSession | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BuildSshPollQueryVariables = {
|
||||
buildId: string;
|
||||
};
|
||||
|
||||
type JobRunSshPollQuery = {
|
||||
jobRun: {
|
||||
byId: {
|
||||
@@ -154,6 +174,52 @@ export const WorkflowJobSshQuery = {
|
||||
return toConnectInfo(data.workflowJobs.byId);
|
||||
},
|
||||
|
||||
async connectInfoForBuildAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildId: string
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
let data: BuildSshPollQuery;
|
||||
try {
|
||||
data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<BuildSshPollQuery, BuildSshPollQueryVariables>(
|
||||
gql`
|
||||
query BuildSshPoll($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
status
|
||||
sshSession {
|
||||
id
|
||||
connectionConfig {
|
||||
host
|
||||
secret
|
||||
reconnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ buildId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const build = data.builds.byId;
|
||||
return {
|
||||
sshRequested: build.sshSession !== null,
|
||||
jobCompleted: FINAL_BUILD_STATUSES.has(build.status),
|
||||
session: build.sshSession,
|
||||
};
|
||||
},
|
||||
|
||||
async connectInfoForJobRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
jobRunId: string
|
||||
@@ -207,6 +273,7 @@ export const WorkflowJobSshQuery = {
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
return (
|
||||
(await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ??
|
||||
(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, resourceId)) ??
|
||||
(await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId))
|
||||
);
|
||||
},
|
||||
|
||||
@@ -98,4 +98,37 @@ describe('SupabaseQuery', () => {
|
||||
expect.objectContaining({ requestPolicy: 'network-only' })
|
||||
);
|
||||
});
|
||||
|
||||
it('getSupabaseAdvisorLintsByAppIdAsync splits the project from the aliased advisor lists', async () => {
|
||||
const lint = {
|
||||
name: 'rls_disabled_in_public',
|
||||
title: 'RLS Disabled in Public',
|
||||
level: 'ERROR',
|
||||
description: 'Detects tables without RLS.',
|
||||
detail: 'Table `public.todos` is public, but RLS has not been enabled.',
|
||||
entity: 'public.todos',
|
||||
remediation: null,
|
||||
cacheKey: 'rls_disabled_in_public_public_todos',
|
||||
};
|
||||
const client = makeQueryClient({
|
||||
app: { byId: { id: 'app-1', supabaseProject: { ...project, security: [lint] } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(client, 'app-1')
|
||||
).resolves.toEqual({ project, security: [lint], performance: null });
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ appId: 'app-1', security: true, performance: true },
|
||||
expect.objectContaining({ requestPolicy: 'network-only' })
|
||||
);
|
||||
});
|
||||
|
||||
it('getSupabaseAdvisorLintsByAppIdAsync returns null when no project is linked', async () => {
|
||||
const client = makeQueryClient({ app: { byId: { id: 'app-1', supabaseProject: null } } });
|
||||
|
||||
await expect(
|
||||
SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(client, 'app-1')
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError } from '../../client';
|
||||
import { WorkflowJobStatus } from '../../generated';
|
||||
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../../generated';
|
||||
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
|
||||
@@ -153,3 +153,207 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
|
||||
).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
|
||||
function makeClient(byId: unknown): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: { builds: { byId } } }),
|
||||
});
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
it('maps builds.byId into connect info', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
id: 'build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
sshSession: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
});
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId: 'build-1' },
|
||||
{ requestPolicy: 'network-only' }
|
||||
);
|
||||
});
|
||||
|
||||
it('is not ssh-requested when the build has no sshSession', async () => {
|
||||
const { graphqlClient } = makeClient({
|
||||
id: 'build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
sshSession: null,
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')).toEqual({
|
||||
sshRequested: false,
|
||||
jobCompleted: false,
|
||||
session: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([BuildStatus.Errored, BuildStatus.Finished, BuildStatus.Canceled])(
|
||||
'treats %s as completed',
|
||||
async status => {
|
||||
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).toMatchObject({ jobCompleted: true });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildStatus.New, BuildStatus.InQueue, BuildStatus.PendingCancel])(
|
||||
'treats %s as not completed',
|
||||
async status => {
|
||||
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).toMatchObject({ jobCompleted: false });
|
||||
}
|
||||
);
|
||||
|
||||
it('returns null when the build is not found', async () => {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: undefined,
|
||||
error: new GraphqlError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
|
||||
],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows GraphQL errors that are not not-found', async () => {
|
||||
const error = new GraphqlError({
|
||||
graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
});
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: undefined, error }),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
await expect(
|
||||
WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForResourceIdAsync.name, () => {
|
||||
const notFound = new GraphqlError({
|
||||
graphQLErrors: [{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } }],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
});
|
||||
const session = {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
};
|
||||
|
||||
function makeClient(found: { workflowJob?: unknown; build?: unknown; jobRun?: unknown }): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn((_document: unknown, variables: Record<string, string>) => ({
|
||||
toPromise: async () => {
|
||||
if ('workflowJobId' in variables) {
|
||||
return found.workflowJob
|
||||
? { data: { workflowJobs: { byId: found.workflowJob } } }
|
||||
: { data: undefined, error: notFound };
|
||||
}
|
||||
if ('buildId' in variables) {
|
||||
return found.build
|
||||
? { data: { builds: { byId: found.build } } }
|
||||
: { data: undefined, error: notFound };
|
||||
}
|
||||
return found.jobRun
|
||||
? { data: { jobRun: { byId: found.jobRun } } }
|
||||
: { data: undefined, error: notFound };
|
||||
},
|
||||
}));
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
function queriedVariables(query: jest.Mock): unknown[] {
|
||||
return query.mock.calls.map(([, variables]) => variables);
|
||||
}
|
||||
|
||||
it('returns the workflow job without querying builds or job runs', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
workflowJob: {
|
||||
id: 'id-1',
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } },
|
||||
turtleJobRun: { sshSession: session },
|
||||
turtleBuild: null,
|
||||
},
|
||||
build: { id: 'id-1', status: BuildStatus.Finished, sshSession: null },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([{ workflowJobId: 'id-1' }]);
|
||||
});
|
||||
|
||||
it('falls back to the build when the id is not a workflow job', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
build: { id: 'id-1', status: BuildStatus.InProgress, sshSession: session },
|
||||
jobRun: { id: 'id-1', status: JobRunStatus.Finished, sshSession: null },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([{ workflowJobId: 'id-1' }, { buildId: 'id-1' }]);
|
||||
});
|
||||
|
||||
it('falls back to the job run when the id is neither a workflow job nor a build', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
jobRun: { id: 'id-1', status: JobRunStatus.InProgress, sshSession: session },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([
|
||||
{ workflowJobId: 'id-1' },
|
||||
{ buildId: 'id-1' },
|
||||
{ jobRunId: 'id-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when no resource matches the id', async () => {
|
||||
const { graphqlClient } = makeClient({});
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'missing')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import {
|
||||
SupabaseAdvisorLint,
|
||||
SupabaseConnection,
|
||||
SupabaseOAuthStart,
|
||||
SupabaseOrganization,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from '../generated';
|
||||
|
||||
export {
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorType,
|
||||
BeginSupabaseOAuthInput,
|
||||
LinkSupabaseProjectInput,
|
||||
ProvisionAdditionalSupabaseProjectInput,
|
||||
@@ -35,6 +38,17 @@ export type SupabaseProjectData = Pick<
|
||||
|
||||
export type SupabaseOAuthStartData = Pick<SupabaseOAuthStart, 'url'>;
|
||||
|
||||
export type SupabaseAdvisorLintData = Pick<
|
||||
SupabaseAdvisorLint,
|
||||
'name' | 'title' | 'level' | 'description' | 'detail' | 'entity' | 'remediation' | 'cacheKey'
|
||||
>;
|
||||
|
||||
export type SupabaseAdvisorLintsData = {
|
||||
project: SupabaseProjectData;
|
||||
security: SupabaseAdvisorLintData[] | null;
|
||||
performance: SupabaseAdvisorLintData[] | null;
|
||||
};
|
||||
|
||||
export const SupabaseConnectionFragmentNode = gql`
|
||||
fragment SupabaseConnectionFragment on SupabaseConnection {
|
||||
id
|
||||
@@ -56,3 +70,16 @@ export const SupabaseProjectFragmentNode = gql`
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const SupabaseAdvisorLintFragmentNode = gql`
|
||||
fragment SupabaseAdvisorLintFragment on SupabaseAdvisorLint {
|
||||
name
|
||||
title
|
||||
level
|
||||
description
|
||||
detail
|
||||
entity
|
||||
remediation
|
||||
cacheKey
|
||||
}
|
||||
`;
|
||||
@@ -2,6 +2,7 @@ import { selectAsync } from '../../prompts';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
filterOutIndividualAscApiKeys,
|
||||
formatAscApiKey,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
sortAscApiKeysByUpdatedAtDesc,
|
||||
@@ -17,7 +18,8 @@ export async function selectOrCreateAscApiKeyIdAsync({
|
||||
existingKeys: AppStoreConnectApiKeyFragment[];
|
||||
ownerAccount: AccountFragment;
|
||||
}): Promise<string> {
|
||||
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys);
|
||||
// Individual API keys are not supported for the ASC connection yet.
|
||||
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(filterOutIndividualAscApiKeys(existingKeys));
|
||||
const createKeyOption = {
|
||||
title: '[Create or upload a new API key]',
|
||||
value: '__create_new_key__',
|
||||
|
||||
@@ -70,12 +70,14 @@ export function mergeEnvContent(rawContent: string, newVars: Record<string, stri
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(rawContent)) !== null) {
|
||||
const key = match[1];
|
||||
if (!Object.hasOwn(newVars, key) || !match.indices) {
|
||||
const indices = match.indices;
|
||||
const keyRange = indices?.[1];
|
||||
if (!Object.hasOwn(newVars, key) || !indices || !keyRange) {
|
||||
continue;
|
||||
}
|
||||
// match[0] runs past the value onto a following comment line, so spans come from the groups.
|
||||
const [keyStart, keyEnd] = match.indices[1];
|
||||
const valueRange = match.indices[2];
|
||||
const [keyStart, keyEnd] = keyRange;
|
||||
const valueRange = indices[2];
|
||||
const definitionEnd = valueRange
|
||||
? valueRange[1] - (/\s+$/.exec(match[2])?.[0].length ?? 0)
|
||||
: keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0);
|
||||
|
||||
@@ -289,6 +289,23 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC
|
||||
await expect(loadOrganizationsBestEffortAsync(client, 'acct-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the existing connection to change during reauthorization', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
const updated = { ...connection, updatedAt: '2024-01-02T00:00:00.000Z' };
|
||||
jest
|
||||
.mocked(SupabaseMutation.beginSupabaseOAuthAsync)
|
||||
.mockResolvedValue({ url: 'https://supabase.com/oauth' });
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(connection)
|
||||
.mockResolvedValueOnce(updated);
|
||||
jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue([]);
|
||||
const promise = authorizeViaBrowserAsync(client, account, false, connection.updatedAt);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await expect(promise).resolves.toEqual(updated);
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync retries after retriable query errors', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
|
||||
@@ -132,7 +132,8 @@ export async function pollProvisionReceiptAsync(
|
||||
export async function authorizeViaBrowserAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string },
|
||||
nonInteractive: boolean
|
||||
nonInteractive: boolean,
|
||||
previousUpdatedAt?: string
|
||||
): Promise<SupabaseConnectionData> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
@@ -154,7 +155,7 @@ export async function authorizeViaBrowserAsync(
|
||||
'Waiting for you to authorize in Supabase (up to 15 minutes; press Ctrl-C to cancel)'
|
||||
).start();
|
||||
try {
|
||||
const connection = await pollForConnectionAsync(graphqlClient, account.id);
|
||||
const connection = await pollForConnectionAsync(graphqlClient, account.id, previousUpdatedAt);
|
||||
const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
|
||||
spinner.succeed(
|
||||
`Connected Supabase organization ${chalk.bold(
|
||||
@@ -181,7 +182,8 @@ export async function loadOrganizationsBestEffortAsync(
|
||||
|
||||
export async function pollForConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
accountId: string,
|
||||
previousUpdatedAt?: string
|
||||
): Promise<SupabaseConnectionData> {
|
||||
const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS;
|
||||
let consecutiveErrors = 0;
|
||||
@@ -207,7 +209,11 @@ export async function pollForConnectionAsync(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (connection) {
|
||||
// Reauthorization updates the existing connection; its presence alone is not completion.
|
||||
if (
|
||||
connection &&
|
||||
(previousUpdatedAt === undefined || connection.updatedAt !== previousUpdatedAt)
|
||||
) {
|
||||
return connection;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
|
||||
@@ -130,6 +130,29 @@ describe(getAppStoreAuthAsync, () => {
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses individual API key from submit profile when ascApiKeyIssuerId is absent', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'KEY123',
|
||||
} as any;
|
||||
const args = createBaseArgs({ profile });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'mock-key-p8-content',
|
||||
keyId: 'KEY123',
|
||||
issuerId: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from EAS credentials service when available', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
|
||||
@@ -68,9 +68,9 @@ async function tryResolveAscApiKeyAsync({
|
||||
bundleId: string;
|
||||
}): Promise<ResolvedAscApiKey | null> {
|
||||
// 1. Check submit profile for ASC API key fields
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyId' in profile) {
|
||||
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
|
||||
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
|
||||
if (ascApiKeyPath && ascApiKeyId) {
|
||||
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
|
||||
// Also try to get teamId from the profile if available
|
||||
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
|
||||
@@ -186,8 +186,8 @@ export async function getAppStoreAuthAsync({
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID (omit the issuer ID for individual API keys)\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId (omit the issuer ID for individual API keys)\n' +
|
||||
' - EAS credentials service: run `eas credentials` to set up an API key'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,16 @@ export function ora(options?: Options | string): Ora {
|
||||
const oraStart = spinner.start.bind(spinner);
|
||||
const oraStop = spinner.stop.bind(spinner);
|
||||
const oraStopAndPersist = spinner.stopAndPersist.bind(spinner);
|
||||
const oraRender = spinner.render.bind(spinner);
|
||||
|
||||
spinner.render = (): Ora => {
|
||||
// ora only recalculates how many terminal lines the text wraps to when the text changes.
|
||||
// After the terminal is resized, the stale count makes every frame clear too few lines,
|
||||
// so the spinner text gets printed over and over. Setting the text recalculates the count.
|
||||
const { text } = spinner;
|
||||
spinner.text = text;
|
||||
return oraRender();
|
||||
};
|
||||
|
||||
const logWrap = (method: any, args: any[]): void => {
|
||||
oraStop();
|
||||
|
||||
@@ -189,7 +189,10 @@ async function handlePromptSourceAsync(
|
||||
ctx: SubmissionContext<Platform.IOS>,
|
||||
_source: AscApiKeyPromptSource
|
||||
): Promise<AscApiKeyPath> {
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx.credentialsCtx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx.credentialsCtx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
return await getAscApiKeyPathAsync(ctx, {
|
||||
sourceType: AscApiKeySourceType.path,
|
||||
path: ascApiKeyPath,
|
||||
|
||||
@@ -132,7 +132,7 @@ export default class IosSubmitCommand {
|
||||
private resolveAscApiKeySource(): Result<AscApiKeySource> {
|
||||
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = this.ctx.profile;
|
||||
|
||||
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
|
||||
if (ascApiKeyPath && ascApiKeyId) {
|
||||
return result({
|
||||
sourceType: AscApiKeySourceType.path,
|
||||
path: {
|
||||
@@ -145,7 +145,7 @@ export default class IosSubmitCommand {
|
||||
|
||||
// interpret this to mean the user had some intention of passing in ASC Api key
|
||||
if (ascApiKeyPath || ascApiKeyIssuerId || ascApiKeyId) {
|
||||
const message = `ascApiKeyPath, ascApiKeyIssuerId and ascApiKeyId must all be defined in eas.json`;
|
||||
const message = `ascApiKeyPath and ascApiKeyId must both be defined in eas.json (ascApiKeyIssuerId is also required unless the key is an individual API key)`;
|
||||
|
||||
// in non-interactive mode, we should fail
|
||||
if (this.ctx.nonInteractive) {
|
||||
|
||||
@@ -61,6 +61,7 @@ describe(IosSubmitCommand, () => {
|
||||
|
||||
const fakeFiles: Record<string, string> = {
|
||||
'/artifacts/fake.ipa': 'fake ipa',
|
||||
'/artifacts/asc-key.p8': 'fake asc key p8',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -211,6 +212,60 @@ describe(IosSubmitCommand, () => {
|
||||
|
||||
delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
|
||||
});
|
||||
|
||||
it('sends a request to EAS Submit with an individual (issuer-less) ASC API key', async () => {
|
||||
const projectId = uuidv4();
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const analytics = instance(mock<Analytics>());
|
||||
jest
|
||||
.mocked(getArchiveAsync)
|
||||
.mockImplementation(jest.requireActual('../../ArchiveSource').getArchiveAsync);
|
||||
|
||||
const ctx = await createSubmissionContextAsync({
|
||||
platform: Platform.IOS,
|
||||
projectDir: testProject.projectRoot,
|
||||
archiveFlags: {
|
||||
url: 'http://expo.dev/fake.ipa',
|
||||
},
|
||||
profile: {
|
||||
language: 'en-US',
|
||||
ascAppId: '12345678',
|
||||
ascApiKeyPath: '/artifacts/asc-key.p8',
|
||||
ascApiKeyId: 'KEY123',
|
||||
},
|
||||
nonInteractive: true,
|
||||
isVerboseFastlaneEnabled: false,
|
||||
groups: [],
|
||||
actor: mockJester,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
exp: testProject.appJSON.expo,
|
||||
projectId,
|
||||
vcsClient,
|
||||
});
|
||||
const command = new IosSubmitCommand(ctx);
|
||||
const submitter = await command.runAsync();
|
||||
await submitter.submitAsync();
|
||||
|
||||
expect(SubmissionMutation.createIosSubmissionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
archiveSource: { type: SubmissionArchiveSourceType.Url, url: 'http://expo.dev/fake.ipa' },
|
||||
config: {
|
||||
ascAppIdentifier: '12345678',
|
||||
isVerboseFastlaneEnabled: false,
|
||||
groups: [],
|
||||
changelog: undefined,
|
||||
appleIdUsername: undefined,
|
||||
ascApiKey: {
|
||||
keyP8: 'fake asc key p8',
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: undefined,
|
||||
},
|
||||
},
|
||||
submittedBuildId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe('build selected from EAS', () => {
|
||||
it('sends a request to EAS Submit with profile data matching selected build profile', async () => {
|
||||
const projectId = uuidv4();
|
||||
|
||||
@@ -118,6 +118,25 @@ describe(ensureTestFlightSetupForExistingAppAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets up TestFlight when environment credentials have no issuer ID (individual key)', async () => {
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'key-id';
|
||||
process.env.EXPO_APPLE_TEAM_ID = 'team-id';
|
||||
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
|
||||
|
||||
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
|
||||
|
||||
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamId: 'team-id',
|
||||
teamType: expect.any(String),
|
||||
});
|
||||
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
|
||||
nonInteractive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips setup when environment credentials are incomplete', async () => {
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
process.env.EXPO_ASC_KEY_ID = 'key-id';
|
||||
|
||||
@@ -49,12 +49,7 @@ export async function ensureTestFlightSetupForExistingAppAsync(
|
||||
resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
if (hasAscEnvVars()) {
|
||||
const teamId = process.env.EXPO_APPLE_TEAM_ID;
|
||||
if (
|
||||
!process.env.EXPO_ASC_API_KEY_PATH ||
|
||||
!process.env.EXPO_ASC_KEY_ID ||
|
||||
!process.env.EXPO_ASC_ISSUER_ID ||
|
||||
!teamId
|
||||
) {
|
||||
if (!process.env.EXPO_ASC_API_KEY_PATH || !process.env.EXPO_ASC_KEY_ID || !teamId) {
|
||||
Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ async function uploadWithSignedUrlWithProgressAsync(
|
||||
});
|
||||
|
||||
let currentSize = 0;
|
||||
readStream.addListener('data', (chunk: Buffer) => {
|
||||
readStream.addListener('data', (chunk: Buffer | string) => {
|
||||
currentSize += Buffer.byteLength(chunk);
|
||||
handleProgressEvent({
|
||||
progress: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cliProgress from 'cli-progress';
|
||||
import * as https from 'https';
|
||||
import createHttpsProxyAgent from 'https-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import fetch, { BodyInit, Headers, HeadersInit, RequestInit, Response } from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -119,7 +119,7 @@ const getAgent = (): https.Agent => {
|
||||
if (sharedAgent) {
|
||||
return sharedAgent;
|
||||
} else if (process.env.https_proxy) {
|
||||
return (sharedAgent = createHttpsProxyAgent(process.env.https_proxy));
|
||||
return (sharedAgent = new HttpsProxyAgent(process.env.https_proxy));
|
||||
} else {
|
||||
return (sharedAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user