Compare commits

...
Author SHA1 Message Date
Will Schurman 0b5a981e89 [14/n][eas-cli] Simplify credentials manager and device create for within-project-only running 2022-09-22 13:38:04 -07:00
Will Schurman ee1f333cd2 [13/n][eas-cli] Use projectId context in remaining commands 2022-09-22 13:37:37 -07:00
Will Schurman c79556ccfa [12/n][eas-cli] Use new context for project ID 2022-09-22 13:37:18 -07:00
Will Schurman d4984a7171 [11/n][eas-cli] Use new context for logged-in actor 2022-09-22 13:36:49 -07:00
Will Schurman 5c43819594 [10/n][eas-cli] Add new on-demand context concept for commands 2022-09-22 13:30:43 -07:00
Will Schurman fd4a64cc30 [9/n][eas-cli] Account -> AccountFragment (#1380) 2022-09-22 13:30:15 -07:00
Will Schurman a77cb45692 [8/n][eas-cli] Remove findAccountByName (#1379) 2022-09-22 13:27:58 -07:00
Will Schurman 863ef1eeec [7/n][eas-cli] Improve eas init command (#1376) 2022-09-22 13:27:09 -07:00
kirb bc04241e0f [eas-cli] extract app lookup params (#1382) 2022-09-22 07:45:04 -07:00
Szymon Dziedzic 9f94ddb606 [eas-json] add Xcode 14 image (#1365)
* [eas-json] add XCode 14 image

* bump eas-build-job version

* fix eas.schema.json

* update default and latest images

* update eas-build-job package

* update CHANGELOG.md
2022-09-21 13:43:02 +02:00
Will Schurman 6b101614ba [eas-cli] Update changelog for recent series of stacked commits (#1377) 2022-09-19 15:54:15 -07:00
Will Schurman 811568737b [6/n][eas-cli] Clean up some remaining project utils methods (#1373) 2022-09-19 15:31:07 -07:00
Will Schurman 36b5e0cee8 [5/n][eas-cli] Remove getProjectFullNameAsync (#1372) 2022-09-19 15:28:34 -07:00
Will Schurman 8681437081 [4/n][eas-cli] Remove getProjectAccountName (#1371) 2022-09-19 15:26:41 -07:00
Will Schurman 3e79916462 [2/n][eas-cli] Remove rest of getProjectAccountNameAsync callsites (#1369) 2022-09-19 15:24:13 -07:00
Will Schurman 0613b6c2cd [eas-cli][1/n] Remove some of getProjectAccountNameAsync (#1368) 2022-09-19 15:22:42 -07:00
kirb 63ed6f2b4e [eas-cli] Use confirmation when canceling builds (#1366) 2022-09-19 09:37:47 -07:00
Will Schurman 9985d618ef [eas-cli] Make optional nonInteractive arguments non-optional in utility functions (#1367) 2022-09-19 08:59:35 -07:00
Szymon Dziedzic 46c0ee6423 [eas-cli][ENG-6325] provide suggestions for developers when archive size is large (#1363)
* [eas-cli] provide suggestions for developers when archive size is large

* use learn more function

* apply suggestion from review

* apply suggestions from code review

* update CHANGELOG.md
2022-09-19 10:23:26 +02:00
Will Schurman 5585874c21 [eas-cli] Prompt before configuring EAS project (#1356) 2022-09-16 13:00:26 -07:00
kirb b288c31216 [eas-cli] add pagination for build queries (#1353) 2022-09-16 11:43:57 -07:00
kirb 738c8a21c8 [eas-cli] Add pagination to channel commands (#1352) 2022-09-16 11:32:57 -07:00
Dominik SokalandJuwan Wheatley ff5669ff30 [ENG-4730] support uploading files to EAS Secret (#1354)
* regenerate graphql code

* add support for uploading secret files to EAS Secret

* address PR feedback

* address PR feedback

* regenerate graphql code

* Update packages/eas-cli/src/commands/secret/create.ts

Co-authored-by: Juwan Wheatley <[email protected]>

* address PR feedback

* address PR feedback

* update CHANGELOG.md

Co-authored-by: Juwan Wheatley <[email protected]>
2022-09-16 15:47:22 +02:00
kirb fdf64a8673 [eas-cli] Standardize GQL Update type schema using Fragment (#1348) 2022-09-14 17:00:06 -07:00
kirb 2c7b442f06 [eas-cli] use paginated query API for update commands (#1323) 2022-09-14 16:49:46 -07:00
Dominik Sokal 9c09cb9034 Update CHANGELOG.md 2022-09-12 09:28:21 +02:00
Dominik Sokal e52d14586f [ENG-6129] support json5 in eas.json (#1350)
* support json5 in eas.json

* address PR feedback

* address more PR feedback

* fix test
2022-09-12 09:27:01 +02:00
Szymon Dziedzic 0f7646868f [eas-cli][ENG-6229] add -e as a shortcut for --profile flag (#1342)
* [eas-cli] add `-e` flag as a shortcut for `--profile` flag

* revert readme changes

* add description for profile flag on config command

* update CHANGELOG.md
2022-09-07 19:36:23 +02:00
Justin McBride 6fe4511280 Small updates to the language of the Device Create command (#1346) 2022-09-07 09:28:53 +02:00
Expo CI efc1551d62 update CHANGELOG.md 2022-09-05 12:11:53 +00:00
232 changed files with 6058 additions and 3395 deletions

No files matched your search

+23
View File
@@ -6,6 +6,29 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- Prompt before configuring EAS project. ([#1356](https://github.com/expo/eas-cli/pull/1356) by [@wschurman](https://github.com/wschurman))
### 🎉 New features
- Add `-e` as a shortcut for `--profile` flag. ([#1342](https://github.com/expo/eas-cli/pull/1342) by [@szdziedzic](https://github.com/szdziedzic))
- Support JSON5 in **eas.json**. ([#1350](https://github.com/expo/eas-cli/pull/1350) by [@dsokal](https://github.com/dsokal))
- Add pagination and interactivity to update commands. ([#1323](https://github.com/expo/eas-cli/pull/1323) by [@kgc00](https://github.com/kgc00))
- Add support for uploading files to EAS Secret. ([#1354](https://github.com/expo/eas-cli/pull/1354) by [@dsokal](https://github.com/dsokal))
- Add pagination to channel commands. ([#1352](https://github.com/expo/eas-cli/pull/1352) by [@kgc00](https://github.com/kgc00))
- Add pagination to build commands. ([#1353](https://github.com/expo/eas-cli/pull/1353) by [@kgc00](https://github.com/kgc00))
- Provide suggestions for developers when archive size is large. ([#1363](https://github.com/expo/eas-cli/pull/1363) by [@szdziedzic](https://github.com/szdziedzic))
- Improve `eas init` command. ([#1376](https://github.com/expo/eas-cli/pull/1376) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
### 🧹 Chores
- Standardize the GQL 'Update' schema using UpdateFragmentNode. ([#1348](https://github.com/expo/eas-cli/pull/1348) by [@kgc00](https://github.com/kgc00))
- Remove 'owner' app config field dependency. ([#1368](https://github.com/expo/eas-cli/pull/1368), [#1369](https://github.com/expo/eas-cli/pull/1369), [#1371](https://github.com/expo/eas-cli/pull/1371), [#1372](https://github.com/expo/eas-cli/pull/1372), [#1373](https://github.com/expo/eas-cli/pull/1373), by [@wschurman](https://github.com/wschurman))
- Add Xcode 14 image. ([#1365](https://github.com/expo/eas-cli/pull/1365) by [@szdziedzic](https://github.com/szdziedzic))
## [2.1.0](https://github.com/expo/eas-cli/releases/tag/v2.1.0) - 2022-09-05
### 🎉 New features
- Add tvOS credentials compatibility for Adhoc and App store builds. ([#1325](https://github.com/expo/eas-cli/pull/1325) by [@quinlanj](https://github.com/quinlanj))
+1093 -2
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -13,7 +13,7 @@
"@expo/config": "7.0.1",
"@expo/config-plugins": "5.0.1",
"@expo/config-types": "46.0.2",
"@expo/eas-build-job": "0.2.90",
"@expo/eas-build-job": "0.2.94",
"@expo/eas-json": "2.1.0",
"@expo/json-file": "8.2.36",
"@expo/multipart-body-parser": "1.1.0",
@@ -1,60 +0,0 @@
import { Actor } from '../user/User';
interface MockProject {
projectRoot: string;
projectTree: Record<string, string>;
appJSON: AppJSON;
packageJSON: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description: string;
main: string;
}
interface AppJSON {
expo: {
name: string;
version: string;
slug: string;
sdkVersion: string;
owner?: string;
[key: string]: any;
};
}
export function createTestProject(
user: Actor,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
const packageJSON: PackageJSON = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const owner = appJSONExtraData?.owner as string | undefined;
const appJSON: AppJSON = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
...appJSONExtraData,
},
};
return {
appJSON,
packageJSON,
projectRoot,
projectTree: {
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
};
}
+47 -37
View File
@@ -5,7 +5,7 @@ import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { UpdateBranchFragment } from '../graphql/generated';
import { BranchQuery } from '../graphql/queries/BranchQuery';
import Log from '../log';
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../update/utils';
import { UPDATE_COLUMNS, formatUpdateMessage, getPlatformsForGroup } from '../update/utils';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
@@ -14,24 +14,29 @@ import {
export const BRANCHES_LIMIT = 50;
export async function selectBranchFromPaginatedQueryAsync(
projectId: string,
promptTitle: string,
options: PaginatedQueryOptions
): Promise<UpdateBranchFragment> {
if (options.nonInteractive) {
export async function selectBranchOnAppAsync({
projectId,
promptTitle,
displayTextForListItem,
paginatedQueryOptions,
}: {
projectId: string;
displayTextForListItem: (queryItem: UpdateBranchFragment) => string;
promptTitle: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<UpdateBranchFragment> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a branch in non-interactive mode.');
}
const selectedBranch = await paginatedQueryWithSelectPromptAsync({
limit: options.limit ?? BRANCHES_LIMIT,
offset: options.offset,
queryToPerform: (limit, offset) => queryBranchesForProjectAsync(limit, offset, projectId),
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) => queryBranchesOnProjectAsync(limit, offset, projectId),
promptOptions: {
title: promptTitle,
getIdentifierForQueryItem: updateBranchFragment => updateBranchFragment.id,
createDisplayTextForSelectionPromptListItem: updateBranchFragment =>
updateBranchFragment.name,
createDisplayTextForSelectionPromptListItem: displayTextForListItem,
},
});
if (!selectedBranch) {
@@ -40,36 +45,39 @@ export async function selectBranchFromPaginatedQueryAsync(
return selectedBranch;
}
export async function listAndRenderPaginatedBranchesAsync(
projectId: string,
options: PaginatedQueryOptions
): Promise<void> {
if (options.nonInteractive) {
const branches = await queryBranchesForProjectAsync(
options.limit ?? BRANCHES_LIMIT,
options.offset,
export async function listAndRenderBranchesOnAppAsync({
projectId,
paginatedQueryOptions,
}: {
projectId: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesOnProjectAsync(
paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
paginatedQueryOptions.offset,
projectId
);
renderPageOfBranches(branches, options);
renderPageOfBranches(branches, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: options.limit ?? BRANCHES_LIMIT,
offset: options.offset,
queryToPerform: (limit, offset) => queryBranchesForProjectAsync(limit, offset, projectId),
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) => queryBranchesOnProjectAsync(limit, offset, projectId),
promptOptions: {
title: 'Load more branches?',
renderListItems: branches => renderPageOfBranches(branches, options),
renderListItems: branches => renderPageOfBranches(branches, paginatedQueryOptions),
},
});
}
}
async function queryBranchesForProjectAsync(
async function queryBranchesOnProjectAsync(
limit: number,
offset: number,
projectId: string
): Promise<UpdateBranchFragment[]> {
return await BranchQuery.listBranchesAsync({
return await BranchQuery.listBranchesOnAppAsync({
appId: projectId,
limit,
offset,
@@ -90,18 +98,20 @@ function renderPageOfBranches(
table.push(
...currentPage.map(branch => {
const update: UpdateBranchFragment['updates'][0] | undefined = branch.updates[0];
if (branch.updates.length === 0) {
return [branch.name, 'N/A', 'N/A', 'N/A', 'N/A'];
}
const latestUpdateOnBranch = branch.updates[0];
return [
branch.name,
formatUpdate(update),
update?.runtimeVersion ?? 'N/A',
update?.group ?? 'N/A',
update?.group
? getPlatformsForGroup({
updates: branch.updates,
group: update.group,
})
: 'N/A',
formatUpdateMessage(latestUpdateOnBranch),
latestUpdateOnBranch.runtimeVersion,
latestUpdateOnBranch.group,
getPlatformsForGroup({
group: latestUpdateOnBranch.group,
updates: branch.updates,
}),
];
})
);
+14
View File
@@ -0,0 +1,14 @@
import { getVcsClient } from '../vcs';
export async function getDefaultBranchNameAsync(): Promise<string> {
return (
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substring(2, 4)}`
);
}
export class BranchNotFoundError extends Error {
constructor(message?: string) {
super(message ?? 'Branch not found.');
}
}
@@ -0,0 +1,83 @@
import { v4 as uuid } from 'uuid';
import { selectBuildToCancelAsync } from '../../commands/build/cancel';
import { AppPlatform, BuildFragment, BuildPriority, BuildStatus } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { confirmAsync, selectAsync } from '../../prompts';
jest.mock('../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
jest.mock('../../prompts', () => {
return {
selectAsync: jest.fn(),
confirmAsync: jest.fn(),
};
});
jest.mock('../../graphql/queries/BuildQuery', () => {
const actual = jest.requireActual('../../graphql/queries/BuildQuery');
return {
BuildQuery: {
...actual.BuildQuery,
viewBuildsOnAppAsync: jest.fn(),
},
};
});
describe(selectBuildToCancelAsync.name, () => {
const selectedBuildId = uuid();
const projectId = uuid();
beforeEach(() => {
jest
.mocked(BuildQuery.viewBuildsOnAppAsync)
.mockImplementation(async () => [
createMockBuildFragment({ projectId, buildId: selectedBuildId }),
]);
});
it('does not return build id when confirmation is rejected', async () => {
jest.mocked(confirmAsync).mockResolvedValueOnce(false);
expect(selectBuildToCancelAsync(projectId, 'blah')).resolves.toEqual(null);
});
it('returns build id when confirmation is confirmed', async () => {
jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId);
jest.mocked(confirmAsync).mockResolvedValueOnce(true);
expect(selectBuildToCancelAsync(projectId, 'blah')).resolves.toEqual(selectedBuildId);
});
});
function createMockBuildFragment({
projectId,
buildId,
}: {
projectId: string;
buildId?: string;
}): BuildFragment {
return {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
project: {
__typename: 'App',
slug: 'test-project',
id: projectId,
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InQueue,
};
}
+3 -3
View File
@@ -15,7 +15,6 @@ import {
} from '../../project/android/applicationId';
import { resolveGradleBuildContextAsync } from '../../project/android/gradle';
import { toggleConfirmAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import {
BuildRequestSender,
CredentialsResult,
@@ -58,7 +57,7 @@ This means that it will most likely produce an AAB and you will not be able to i
const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp, ctx.user);
}
const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
@@ -92,6 +91,7 @@ export async function prepareAndroidBuildAsync(
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
});
},
prepareJobAsync: async (
@@ -136,7 +136,7 @@ async function ensureAndroidCredentialsAsync(
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
app: {
account: nullthrows(
findAccountByName(ctx.user.accounts, ctx.accountName),
ctx.user.accounts.find(a => a.name === ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
@@ -5,7 +5,6 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context';
@@ -24,7 +23,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
@@ -16,10 +16,12 @@ export async function syncProjectConfigurationAsync({
projectDir,
exp,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
localAutoIncrement?: AndroidVersionAutoIncrement;
projectId: string;
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
@@ -27,7 +29,7 @@ export async function syncProjectConfigurationAsync({
if (workflow === Workflow.GENERIC) {
await cleanUpOldEasBuildGradleScriptAsync(projectDir);
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
await syncUpdatesConfigurationAsync(projectDir, exp, projectId);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
+11
View File
@@ -177,6 +177,17 @@ async function uploadProjectAsync<TPlatform extends Platform>(
)}`
);
const projectTarball = await makeProjectTarballAsync();
if (projectTarball.size > 1024 * 1024 * 100) {
Log.warn(
`Your project archive is ${formatBytes(
projectTarball.size
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
'.easignore'
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
);
}
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadFileAtPathToS3Async(
+3 -3
View File
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonReader } from '@expo/eas-json';
import { EasJson, EasJsonAccessor } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -24,7 +24,7 @@ interface ConfigureParams {
export async function ensureProjectConfiguredAsync(
configureParams: ConfigureParams
): Promise<boolean> {
if (await fs.pathExists(EasJsonReader.formatEasJsonPath(configureParams.projectDir))) {
if (await fs.pathExists(EasJsonAccessor.formatEasJsonPath(configureParams.projectDir))) {
return false;
}
@@ -89,7 +89,7 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
};
async function createEasJsonAsync(projectDir: string): Promise<void> {
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir);
const hasAndroidNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
+12 -11
View File
@@ -10,10 +10,9 @@ import { Analytics, BuildEvent } from '../analytics/events';
import { CredentialsContext } from '../credentials/context';
import { BuildResourceClass } from '../graphql/generated';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { findAccountByName } from '../user/Account';
import { ensureLoggedInAsync } from '../user/actions';
import { Actor } from '../user/User';
import { createAndroidContextAsync } from './android/build';
import { BuildContext, CommonContext } from './context';
import { createIosContextAsync } from './ios/build';
@@ -31,6 +30,8 @@ export async function createBuildContextAsync<T extends Platform>({
projectDir,
resourceClass,
message,
actor,
projectId,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -43,22 +44,22 @@ export async function createBuildContextAsync<T extends Platform>({
projectDir: string;
resourceClass: BuildResourceClass;
message?: string;
actor: Actor;
projectId: string;
}): Promise<BuildContext<T>> {
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
const projectId = await getProjectIdAsync(exp, { env: buildProfile.env });
const account = await getOwnerAccountForProjectIdAsync(projectId);
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = findAccountByName(user.accounts, accountName)?.id;
const accountId = account.id;
const runFromCI = getenv.boolish('CI', false);
const credentialsCtx = new CredentialsContext({
exp,
projectInfo: { exp, projectId },
nonInteractive,
projectDir,
user,
user: actor,
env: buildProfile.env,
easJsonCliConfig,
});
@@ -81,7 +82,7 @@ export async function createBuildContextAsync<T extends Platform>({
Analytics.logEvent(BuildEvent.BUILD_COMMAND, trackingCtx);
const commonContext: CommonContext<T> = {
accountName,
accountName: account.name,
buildProfile,
buildProfileName,
resourceClass,
@@ -97,7 +98,7 @@ export async function createBuildContextAsync<T extends Platform>({
projectId,
projectName,
trackingCtx,
user,
user: actor,
workflow,
message,
runFromCI,
+2 -1
View File
@@ -23,7 +23,7 @@ export async function createIosContextAsync(
const { buildProfile } = ctx;
if (ctx.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp, ctx.user);
}
checkNodeEnvVariable(ctx);
@@ -80,6 +80,7 @@ export async function prepareIosBuildAsync(
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
});
},
prepareJobAsync: async (
@@ -1,7 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { getAppFromContextAsync } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
@@ -16,7 +16,7 @@ export async function ensureIosCredentialsAsync(
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: getAppFromContext(buildCtx.credentialsCtx),
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
targets,
distribution: buildCtx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
+1 -2
View File
@@ -4,7 +4,6 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { getUsername } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context';
@@ -26,7 +25,7 @@ export async function prepareJobAsync(
): Promise<Job> {
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const username = getUsername(ctx.exp, ctx.user);
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
if (jobData.credentials) {
const targetNames = Object.keys(jobData.credentials);
@@ -13,18 +13,20 @@ export async function syncProjectConfigurationAsync({
exp,
targets,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
targets: Target[];
localAutoIncrement?: IosVersionAutoIncrement;
projectId: string;
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
await syncUpdatesConfigurationAsync(projectDir, exp, projectId);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
} else {
+1 -2
View File
@@ -14,7 +14,6 @@ import {
readChannelSafelyAsync as readIosChannelSafelyAsync,
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
} from '../update/ios/UpdatesModule';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
@@ -49,7 +48,7 @@ export async function collectMetadataAsync<T extends Platform>(
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
),
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, await ensureLoggedInAsync()),
username: getUsername(ctx.exp, ctx.user),
message: ctx.message,
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
+70
View File
@@ -0,0 +1,70 @@
import chalk from 'chalk';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { BuildFilter, BuildFragment } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { printJsonOnlyOutput } from '../utils/json';
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
import { formatGraphQLBuild } from './utils/formatBuild';
export const BUILDS_LIMIT = 50;
export async function listAndRenderBuildsOnAppAsync({
projectId,
projectDisplayName,
filter,
paginatedQueryOptions,
}: {
projectId: string;
projectDisplayName: string;
filter?: BuildFilter;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const builds = await BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
filter,
});
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions });
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
limit,
offset,
filter,
}),
promptOptions: {
title: 'Load more builds?',
renderListItems: builds =>
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }),
},
});
}
}
function renderPageOfBuilds({
builds,
projectDisplayName,
paginatedQueryOptions,
}: {
builds: BuildFragment[];
projectDisplayName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): void {
if (paginatedQueryOptions.json) {
printJsonOnlyOutput(builds);
} else {
const list = builds.map(build => formatGraphQLBuild(build)).join(`\n\n${chalk.dim('———')}\n\n`);
Log.addNewLineIfNone();
Log.log(chalk.bold(`Builds for ${projectDisplayName}:`));
Log.log(`\n${list}`);
}
}
@@ -3,7 +3,8 @@ import {
AppVersionSource,
BuildProfile,
EasJson,
EasJsonReader,
EasJsonAccessor,
EasJsonUtils,
SubmitProfile,
} from '@expo/eas-json';
import chalk from 'chalk';
@@ -40,6 +41,7 @@ import {
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { Actor } from '../user/User';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { getVcsClient } from '../vcs';
@@ -86,7 +88,12 @@ const platformToGraphQLResourceClassMapping: Record<
},
};
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
export async function runBuildAndSubmitAsync(
projectDir: string,
flags: BuildFlags,
actor: Actor,
projectId: string
): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
@@ -94,13 +101,14 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
projectDir,
nonInteractive: flags.nonInteractive,
});
const easJsonReader = new EasJsonReader(projectDir);
const easJsonCliConfig: EasJson['cli'] = (await easJsonReader.getCliConfigAsync()) ?? {};
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
const platforms = toPlatforms(flags.requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonReader,
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
});
@@ -132,6 +140,8 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
flags.userInputResourceClass ?? UserInputResourceClass.DEFAULT
],
easJsonCliConfig,
actor,
projectId,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -150,7 +160,7 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
const submitProfiles = await getProfilesAsync({
easJsonReader,
easJsonAccessor,
platforms,
profileName: flags.submitProfile,
type: 'submit',
@@ -225,6 +235,8 @@ async function prepareAndStartBuildAsync({
buildProfile,
resourceClass,
easJsonCliConfig,
actor,
projectId,
}: {
projectDir: string;
flags: BuildFlags;
@@ -232,6 +244,8 @@ async function prepareAndStartBuildAsync({
buildProfile: ProfileData<'build'>;
resourceClass: BuildResourceClass;
easJsonCliConfig: EasJson['cli'];
actor: Actor;
projectId: string;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
@@ -245,6 +259,8 @@ async function prepareAndStartBuildAsync({
localBuildOptions: flags.localBuildOptions,
easJsonCliConfig,
message: flags.message,
actor,
projectId,
});
if (moreBuilds) {
@@ -316,6 +332,7 @@ async function prepareAndStartSubmissionAsync({
env: buildProfile.env,
credentialsCtx: buildCtx.credentialsCtx,
applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier,
actor: buildCtx.user,
});
if (moreBuilds) {
@@ -70,7 +70,11 @@ export async function commitPromptAsync({
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
await getVcsClient().commitAsync({ commitAllFiles, commitMessage: message });
await getVcsClient().commitAsync({
commitAllFiles,
commitMessage: message,
nonInteractive: false,
});
}
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
+194
View File
@@ -0,0 +1,194 @@
import chalk from 'chalk';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import {
ViewBranchesOnUpdateChannelQueryVariables,
ViewUpdateChannelsOnAppQueryVariables,
} from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { logChannelDetails } from './utils';
export const CHANNELS_LIMIT = 25;
export async function selectChannelOnAppAsync({
projectId,
selectionPromptTitle,
paginatedQueryOptions,
}: {
projectId: string;
selectionPromptTitle: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<UpdateChannelObject> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a channel in non-interactive mode.');
}
const updateChannel = await paginatedQueryWithSelectPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) => queryChannelsOnAppAsync({ appId: projectId, limit, offset }),
promptOptions: {
title: selectionPromptTitle,
createDisplayTextForSelectionPromptListItem: updateChannel => updateChannel.name,
getIdentifierForQueryItem: updateChannel => updateChannel.id,
},
});
if (!updateChannel) {
throw new Error(`Could not find any channels for app "${projectId}"`);
}
return updateChannel;
}
export async function listAndRenderChannelsOnAppAsync({
projectId,
paginatedQueryOptions,
}: {
projectId: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const channels = await queryChannelsOnAppAsync({
appId: projectId,
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
});
renderPageOfChannels(channels, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryChannelsOnAppAsync({ limit, offset, appId: projectId }),
promptOptions: {
title: 'Load more channels?',
renderListItems: channels => renderPageOfChannels(channels, paginatedQueryOptions),
},
});
}
}
export async function listAndRenderBranchesAndUpdatesOnChannelAsync({
projectId: appId,
channelName,
paginatedQueryOptions,
}: {
projectId: string;
channelName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<void> {
const channel = await ChannelQuery.viewUpdateChannelAsync({ appId, channelName });
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesAndUpdateGroupsOnChannelAsync({
appId,
channelName,
offset: paginatedQueryOptions.offset,
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
});
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesAndUpdateGroupsOnChannelAsync({
channelName,
appId,
offset,
limit,
}),
promptOptions: {
title: 'Load more channels?',
renderListItems: branches =>
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions),
},
});
}
}
async function queryChannelsOnAppAsync({
appId,
offset,
limit,
}: ViewUpdateChannelsOnAppQueryVariables): Promise<UpdateChannelObject[]> {
return await ChannelQuery.viewUpdateChannelsOnAppAsync({
appId,
offset,
limit,
});
}
async function queryBranchesAndUpdateGroupsOnChannelAsync(
args: ViewBranchesOnUpdateChannelQueryVariables
): Promise<UpdateBranchOnChannelObject[]> {
return await BranchQuery.listBranchesOnChannelAsync(args);
}
function renderPageOfChannels(
currentPage: UpdateChannelObject[],
{ json }: PaginatedQueryOptions
): void {
if (json) {
printJsonOnlyOutput({ currentPage });
} else {
for (const channel of currentPage) {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channel);
}
}
}
function renderPageOfBranchesOnChannel(
channel: UpdateChannelObject,
currentPage: UpdateBranchOnChannelObject[],
{ json }: PaginatedQueryOptions
): void {
const channelWithNewBranches = { ...channel, updateBranches: currentPage };
if (json) {
printJsonOnlyOutput({ currentPage: channelWithNewBranches });
} else {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channelWithNewBranches);
}
}
function renderChannelHeaderContent({
channelName,
channelId,
}: {
channelName: string;
channelId: string;
}): void {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
Log.log(
formatFields([
{ label: 'Name', value: channelName },
{ label: 'ID', value: channelId },
])
);
Log.addNewLineIfNone();
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
}
+112
View File
@@ -0,0 +1,112 @@
import assert from 'assert';
import CliTable from 'cli-table3';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { UPDATE_COLUMNS, getUpdateGroupDescriptionsWithBranch } from '../update/utils';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export function logChannelDetails(channel: UpdateChannelObject): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
const updateChannelsTable = new CliTable({
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
wordWrap: true,
});
if (branchMapping.data.length > 2) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
for (const currentBranch of channel.updateBranches) {
const updateGroupDescriptions = getUpdateGroupDescriptionsWithBranch(
currentBranch.updateGroups
);
const isRolloutBranch = isRollout && rolloutBranchIds.includes(currentBranch.id);
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === currentBranch.id;
updateGroupDescriptions.forEach(({ branch, message, runtimeVersion, group, platforms }) => {
updateChannelsTable.push([
branch,
...(isRolloutBranch
? [isBaseBranch ? `${rolloutPercent! * 100}%` : `${(1 - rolloutPercent!) * 100}%`]
: []),
message,
runtimeVersion,
group,
platforms,
]);
});
}
Log.log(updateChannelsTable.toString());
}
+106 -11
View File
@@ -1,4 +1,4 @@
import { EasJsonReader } from '@expo/eas-json';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import { Command } from '@oclif/core';
import chalk from 'chalk';
@@ -13,20 +13,113 @@ import {
logEvent,
} from '../analytics/rudderstackClient';
import { learnMore } from '../log';
import { findProjectRootAsync } from '../project/projectUtils';
import { getUserAsync } from '../user/User';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { Actor, getUserAsync } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { setVcsClient } from '../vcs';
import GitClient from '../vcs/clients/git';
export default abstract class EasCommand extends Command {
export interface CommandConfiguration {
/**
* When user data is unavailable locally, determines if the command will
* force the user to log in
* force the user to log in. By default, all commands require authentication.
*/
protected requiresAuthentication = true;
protected mustBeRunInsideProject = true;
allowUnauthenticated?: boolean;
/**
* Whether a command can be run outside of an Expo project directory.
* By default, all commands must be run inside an Expo project directory.
*/
canRunOutsideProject?: boolean;
}
export interface ContextOptions {
nonInteractive: boolean;
}
export abstract class ContextField<T> {
abstract getValueAsync(options: ContextOptions): Promise<T>;
}
export class ProjectIdContextField extends ContextField<string> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<string> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
return await getProjectIdAsync(exp, { nonInteractive });
}
}
export class OptionalProjectIdContextField extends ContextField<string | undefined> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<string | undefined> {
const projectDir = await findProjectRootAsync();
if (!projectDir) {
return undefined;
}
const exp = getExpoConfig(projectDir);
return await getProjectIdAsync(exp, { nonInteractive });
}
}
export class ActorContextField extends ContextField<Actor> {
async getValueAsync({ nonInteractive }: ContextOptions): Promise<Actor> {
return await ensureLoggedInAsync({ nonInteractive });
}
}
export const EASCommandProjectIdContext = {
projectId: new ProjectIdContextField(),
};
export const EASCommandProjectIdIfProjectDirContext = {
projectId: new OptionalProjectIdContextField(),
};
export const EASCommandLoggedInContext = {
actor: new ActorContextField(),
};
type ContextInput<
T extends {
[name: string]: any;
} = object
> = {
[P in keyof T]: ContextField<T[P]>;
};
type ContextOutput<
T extends {
[name: string]: any;
} = object
> = {
[P in keyof T]: T[P];
};
export default abstract class EasCommand extends Command {
static contextDefinition: ContextInput = {};
protected commandConfiguration: CommandConfiguration = {};
protected async getContextAsync<
C extends {
[name: string]: any;
} = object
>(
commandClass: { contextDefinition: ContextInput<C> },
{ nonInteractive }: { nonInteractive: boolean }
): Promise<ContextOutput<C>> {
const contextDefinition = commandClass.contextDefinition;
const contextValuePairs = await Promise.all(
Object.keys(contextDefinition).map(async contextKey => {
return [contextKey, await contextDefinition[contextKey].getValueAsync({ nonInteractive })];
})
);
return Object.fromEntries(contextValuePairs);
}
protected abstract runAsync(): Promise<any>;
@@ -34,24 +127,26 @@ export default abstract class EasCommand extends Command {
async run(): Promise<any> {
await initAnalyticsAsync();
if (this.mustBeRunInsideProject) {
if (!this.commandConfiguration.canRunOutsideProject) {
const projectDir = await findProjectRootAsync();
await this.applyCliConfigAsync(projectDir);
await this.ensureEasCliIsNotInDependenciesAsync(projectDir);
}
if (this.requiresAuthentication) {
if (!this.commandConfiguration.allowUnauthenticated) {
const { flags } = await this.parse();
const nonInteractive = (flags as any)['non-interactive'] ?? false;
await ensureLoggedInAsync({ nonInteractive });
} else {
await getUserAsync();
}
logEvent(AnalyticsEvent.ACTION, {
// id is assigned by oclif in constructor based on the filepath:
// commands/submit === submit, commands/build/list === build:list
action: `eas ${this.id}`,
});
return this.runAsync();
}
@@ -62,8 +157,8 @@ export default abstract class EasCommand extends Command {
}
private async applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonReader = new EasJsonReader(projectDir);
const config = await easJsonReader.getCliConfigAsync();
const easJsonAccessor = new EasJsonAccessor(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
throw new Error(
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint in eas.json (${config.version})`
@@ -2,7 +2,7 @@ import { flushAsync, initAsync, logEvent } from '../../analytics/rudderstackClie
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
import { getUserAsync } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
import EasCommand from '../EasCommand';
import EasCommand, { CommandConfiguration } from '../EasCommand';
jest.mock('../../user/User');
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn() }));
@@ -43,7 +43,9 @@ beforeEach(() => {
const createTestEasCommand = (authValue: boolean): typeof EasCommand => {
class TestEasCommand extends EasCommand {
override requiresAuthentication = authValue;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: !authValue,
};
async runAsync(): Promise<void> {}
}
@@ -0,0 +1,23 @@
import { Flags } from '@oclif/core';
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export const EASNonInteractiveFlag = {
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export const EasJsonOnlyFlag = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
};
+21 -15
View File
@@ -1,13 +1,17 @@
import { Flags } from '@oclif/core';
import { EasNonInteractiveAndJsonFlags } from './flags';
export const getPaginatedQueryOptions = (
flags: Record<keyof typeof EasPaginatedQueryFlags, any>
flags: Partial<
Record<keyof typeof EasPaginatedQueryFlags | keyof typeof EasNonInteractiveAndJsonFlags, any>
>
): PaginatedQueryOptions => {
return {
json: flags.json ?? false,
...('limit' in flags && { limit: flags.limit }),
offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false,
...('limit' in flags && { limit: flags.limit }),
json: flags.json ?? false,
};
};
@@ -27,6 +31,19 @@ const parseFlagInputStringAsInteger = (
return inputAsNumber;
};
export const getLimitFlagWithCustomValues = ({
defaultTo,
limit,
}: {
defaultTo: number;
limit: number;
}): any =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
});
export const EasPaginatedQueryFlags = {
offset: Flags.integer({
description: 'Start queries from specified index. Use for paginating results. Defaults to 0.',
@@ -34,18 +51,7 @@ export const EasPaginatedQueryFlags = {
parse: async input =>
parseFlagInputStringAsInteger(input, 'offset', 0, Number.MAX_SAFE_INTEGER),
}),
limit: Flags.integer({
description:
'The number of query items to list at once. The default value is 50 (the maximum is 100). Using a lower value may help increase command speed.',
// eslint-disable-next-line async-protect/async-suffix
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, 100),
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 }),
};
// options required to control a paginated query
@@ -1,4 +1,4 @@
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import Log from '../../log';
import { showLoginPromptAsync } from '../../user/actions';
@@ -6,8 +6,10 @@ export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
static override aliases = ['login'];
protected override mustBeRunInsideProject = false;
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
await showLoginPromptAsync();
@@ -1,4 +1,4 @@
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import Log from '../../log';
import { logoutAsync } from '../../user/User';
@@ -6,8 +6,10 @@ export default class AccountLogout extends EasCommand {
static override description = 'log out';
static override aliases = ['logout'];
protected override mustBeRunInsideProject = false;
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
await logoutAsync();
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../../commandUtils/EasCommand';
import Log from '../../log';
import { getActorDisplayName, getUserAsync } from '../../user/User';
@@ -8,8 +8,10 @@ export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
static override aliases = ['whoami'];
protected override mustBeRunInsideProject = false;
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
canRunOutsideProject: true,
};
async runAsync(): Promise<void> {
const user = await getUserAsync();
+4 -2
View File
@@ -1,4 +1,4 @@
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import Log from '../log';
import UserSettings from '../user/UserSettings';
@@ -7,7 +7,9 @@ export default class AnalyticsView extends EasCommand {
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
};
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
+22 -25
View File
@@ -1,8 +1,9 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
CreateUpdateBranchForAppMutation,
@@ -10,15 +11,9 @@ import {
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getVcsClient } from '../../vcs';
export async function createUpdateBranchOnAppAsync({
appId,
@@ -63,49 +58,51 @@ export default class BranchCreate extends EasCommand {
];
static override flags = {
json: Flags.boolean({
description: 'return a json with the new branch ID and name.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
args: { name },
flags,
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(BranchCreate);
if (flags.json) {
const { projectId } = await this.getContextAsync(BranchCreate, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
if (!name) {
const validationMessage = 'Branch name may not be empty.';
if (flags.json) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide a branch name:',
initial:
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substr(2, 4)}`,
initial: await getDefaultBranchNameAsync(),
validate: value => (value ? true : validationMessage),
}));
}
const newBranch = await createUpdateBranchOnAppAsync({ appId: projectId, name });
if (flags.json) {
if (jsonFlag) {
printJsonOnlyOutput(newBranch);
} else {
Log.withTick(
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(fullName)}.`
`️Created a new branch: ${chalk.bold(newBranch.name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
+34 -32
View File
@@ -1,8 +1,10 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateBranchMutation,
@@ -12,13 +14,8 @@ import {
GetBranchInfoQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function getBranchInfoAsync({
@@ -79,6 +76,10 @@ async function deleteBranchOnAppAsync({
export default class BranchDelete extends EasCommand {
static override description = 'delete a branch';
static override contextDefinition = {
...EASCommandProjectIdContext,
};
static override args = [
{
name: 'name',
@@ -86,56 +87,55 @@ export default class BranchDelete extends EasCommand {
description: 'Name of the branch to delete',
},
];
static override flags = {
json: Flags.boolean({
description: `return JSON with the edited branch's ID and name.`,
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
async runAsync(): Promise<void> {
let {
args: { name },
flags: { json: jsonFlag },
args: { name: branchName },
flags,
} = await this.parse(BranchDelete);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
const { projectId } = await this.getContextAsync(BranchDelete, { nonInteractive });
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
if (!name) {
if (!branchName) {
const validationMessage = 'branch name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide the name of the branch to delete:',
validate: value => (value ? true : validationMessage),
({ name: branchName } = await selectBranchOnAppAsync({
projectId,
displayTextForListItem: updateBranch => updateBranch.name,
promptTitle: 'Which branch would you like to delete?',
paginatedQueryOptions,
}));
}
const data = await getBranchInfoAsync({ appId: projectId, name });
const data = await getBranchInfoAsync({ appId: projectId, name: branchName });
const branchId = data.app?.byId.updateBranchByName?.id;
if (!branchId) {
throw new Error(`Could not find branch ${name} on ${fullName}`);
throw new Error(`Could not find branch ${branchName} on ${projectDisplayName}`);
}
if (!jsonFlag) {
if (!nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete branch: "${name}" and all of the updates published on it.` +
`You are about to permanently delete branch: "${branchName}" and all of the updates published on it.` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Cancelled deletion of branch: "${name}".`);
Log.error(`Cancelled deletion of branch: "${branchName}".`);
process.exit(1);
}
}
@@ -148,7 +148,9 @@ export default class BranchDelete extends EasCommand {
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(
`️Deleted branch "${name}" and all of its updates on project ${chalk.bold(fullName)}.`
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
+14 -10
View File
@@ -1,8 +1,7 @@
import { listAndRenderPaginatedBranchesAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { listAndRenderBranchesOnAppAsync } from '../../branch/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput } from '../../utils/json';
export default class BranchList extends EasCommand {
@@ -10,19 +9,24 @@ export default class BranchList extends EasCommand {
static override flags = {
...EasPaginatedQueryFlags,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const options = getPaginatedQueryOptions(flags);
const { projectId } = await this.getContextAsync(BranchList, {
nonInteractive: flags['non-interactive'],
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (options.json) {
if (paginatedQueryOptions.json) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
await listAndRenderPaginatedBranchesAsync(projectId, options);
await listAndRenderBranchesOnAppAsync({ projectId, paginatedQueryOptions });
}
}
+16 -19
View File
@@ -2,7 +2,8 @@ import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
EditUpdateBranchInput,
@@ -11,12 +12,7 @@ import {
UpdateBranch,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -63,28 +59,29 @@ export default class BranchRename extends EasCommand {
description: 'new name of the branch.',
required: false,
}),
json: Flags.boolean({
description: `return a json with the edited branch's ID and name.`,
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
flags: { json: jsonFlag, from: currentName, to: newName },
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
} = await this.parse(BranchRename);
const { projectId } = await this.getContextAsync(BranchRename, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
if (!currentName) {
const validationMessage = 'current name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ currentName } = await promptAsync({
@@ -97,7 +94,7 @@ export default class BranchRename extends EasCommand {
if (!newName) {
const validationMessage = 'new name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ newName } = await promptAsync({
@@ -120,7 +117,7 @@ export default class BranchRename extends EasCommand {
Log.withTick(
`️Renamed branch from ${currentName} to ${chalk.bold(
editedBranch.name
)} on project ${chalk.bold(fullName)}.`
)} on project ${chalk.bold(projectDisplayName)}.`
);
}
}
+37 -20
View File
@@ -1,9 +1,12 @@
import { selectBranchFromPaginatedQueryAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { listAndRenderUpdatesOnBranchByNameAsync } from '../../update/queries';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { listAndRenderUpdateGroupsOnBranchAsync } from '../../update/queries';
import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
@@ -19,6 +22,12 @@ export default class BranchView extends EasCommand {
static override flags = {
...EasPaginatedQueryFlags,
...EasNonInteractiveAndJsonFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
@@ -26,30 +35,38 @@ export default class BranchView extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchView);
const options = getPaginatedQueryOptions(flags);
const { 'non-interactive': nonInteractive } = flags;
const { projectId } = await this.getContextAsync(BranchView, {
nonInteractive,
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (options.json) {
if (paginatedQueryOptions.json) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
// provide help to a user if they ran the command with missing args
if (!branchName) {
if (options.nonInteractive) {
if (nonInteractive) {
throw new Error('Branch name may not be empty.');
}
({ name: branchName } = await selectBranchFromPaginatedQueryAsync(
({ name: branchName } = await selectBranchOnAppAsync({
projectId,
'Which branch would you like to view?',
// discard limit and offset because this query is not those flag's intended target
{ json: options.json, nonInteractive: options.nonInteractive, offset: 0 }
));
promptTitle: 'Which branch would you like to view?',
displayTextForListItem: updateBranch => updateBranch.name,
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: paginatedQueryOptions.json,
nonInteractive,
offset: 0,
},
}));
}
await listAndRenderUpdatesOnBranchByNameAsync(projectId, branchName, options);
await listAndRenderUpdateGroupsOnBranchAsync({
projectId,
branchName,
paginatedQueryOptions,
});
}
}
+58 -30
View File
@@ -1,7 +1,8 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
Build,
@@ -13,12 +14,7 @@ import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { appPlatformEmojis } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../prompts';
async function cancelBuildAsync(buildId: string): Promise<Pick<Build, 'id' | 'status'>> {
@@ -59,36 +55,45 @@ function formatUnfinishedBuild(
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
}
async function selectBuildToCancelAsync(
export async function selectBuildToCancelAsync(
projectId: string,
projectFullName: string
projectDisplayName: string
): Promise<string | null> {
const spinner = ora().start('Fetching the uncompleted builds…');
let builds;
try {
const [newBuilds, inQueueBuilds, inProgressBuilds] = await Promise.all([
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.New } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InQueue } }),
BuildQuery.allForAppAsync(projectId, { filter: { status: BuildStatus.InProgress } }),
BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.New },
}),
BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.InQueue },
}),
BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
offset: 0,
limit: 10,
filter: { status: BuildStatus.InProgress },
}),
]);
spinner.stop();
builds = [...newBuilds, ...inQueueBuilds, ...inProgressBuilds];
} catch (error) {
spinner.fail(
`Something went wrong and we couldn't fetch the builds for the project ${projectFullName}.`
`Something went wrong and we couldn't fetch the builds for the project ${projectDisplayName}.`
);
throw error;
}
if (builds.length === 0) {
Log.warn(`There aren't any uncompleted builds for the project ${projectFullName}.`);
Log.warn(`There aren't any uncompleted builds for the project ${projectDisplayName}.`);
return null;
} else if (builds.length === 1) {
Log.log('Found one build');
Log.log(formatUnfinishedBuild(builds[0]));
await confirmAsync({
message: 'Do you want to cancel it?',
});
return builds[0].id;
} else {
const buildId = await selectAsync<string>(
'Which build do you want to cancel?',
@@ -97,7 +102,12 @@ async function selectBuildToCancelAsync(
value: build.id,
}))
);
return buildId;
return (await confirmAsync({
message: 'Are you sure you want to cancel it?',
}))
? buildId
: null;
}
}
@@ -114,21 +124,39 @@ export default class BuildCancel extends EasCommand {
static override args = [{ name: 'BUILD_ID' }];
async runAsync(): Promise<void> {
const { BUILD_ID: buildIdFromArg } = (await this.parse(BuildCancel)).args;
static override flags = {
...EASNonInteractiveFlag,
};
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
args: { BUILD_ID: buildIdFromArg },
flags: { 'non-interactive': nonInteractive },
} = await this.parse(BuildCancel);
const { projectId } = await this.getContextAsync(BuildCancel, {
nonInteractive,
});
const displayName = await getDisplayNameForProjectIdAsync(projectId);
if (buildIdFromArg) {
await ensureBuildExistsAsync(buildIdFromArg);
}
const buildId = buildIdFromArg || (await selectBuildToCancelAsync(projectId, projectFullName));
let buildId: string | null = buildIdFromArg;
if (!buildId) {
return;
if (nonInteractive) {
throw new Error('BUILD_ID must not be empty in non-interactive mode');
}
buildId = await selectBuildToCancelAsync(projectId, displayName);
if (!buildId) {
return;
}
}
const spinner = ora().start('Canceling the build…');
@@ -4,7 +4,7 @@ import chalk from 'chalk';
import { cleanUpOldEasBuildGradleScriptAsync } from '../../build/android/syncProjectConfiguration';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
@@ -26,8 +26,15 @@ export default class BuildConfigure extends EasCommand {
}),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildConfigure);
const { projectId } = await this.getContextAsync(BuildConfigure, {
nonInteractive: false,
});
Log.log(
'💡 The following process will configure your iOS and/or Android project to be compatible with EAS Build. These changes only apply to your local project files and you can safely revert them at any time.'
@@ -60,14 +67,14 @@ export default class BuildConfigure extends EasCommand {
if ([RequestedPlatform.Android, RequestedPlatform.All].includes(platform)) {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
if (workflow === Workflow.GENERIC) {
await syncAndroidUpdatesConfigurationAsync(projectDir, exp);
await syncAndroidUpdatesConfigurationAsync(projectDir, exp, projectId);
}
}
if ([RequestedPlatform.Ios, RequestedPlatform.All].includes(platform)) {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) {
await syncIosUpdatesConfigurationAsync(projectDir, exp);
await syncIosUpdatesConfigurationAsync(projectDir, exp, projectId);
}
}
}
+33 -22
View File
@@ -1,14 +1,17 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonReader } from '@expo/eas-json';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import figures from 'figures';
import fs from 'fs-extra';
import path from 'path';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { UserInputResourceClass } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
@@ -47,23 +50,16 @@ export default class Build extends EasCommand {
default: false,
hidden: true,
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
default: false,
}),
'skip-project-configuration': Flags.boolean({
default: false,
hidden: true,
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
local: Flags.boolean({
default: false,
description: 'Run build locally [experimental]',
@@ -100,6 +96,12 @@ export default class Build extends EasCommand {
char: 'm',
description: 'A short message describing the build',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
@@ -110,6 +112,10 @@ export default class Build extends EasCommand {
}
const flags = this.sanitizeFlags(rawFlags);
const { actor, projectId } = await this.getContextAsync(Build, {
nonInteractive: flags.nonInteractive,
});
const projectDir = await findProjectRootAsync();
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
@@ -124,7 +130,7 @@ export default class Build extends EasCommand {
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
await runBuildAndSubmitAsync(projectDir, flagsWithPlatform);
await runBuildAndSubmitAsync(projectDir, flagsWithPlatform, actor, projectId);
}
private sanitizeFlags(
@@ -222,8 +228,8 @@ async function handleDeprecatedEasJsonAsync(
projectDir: string,
nonInteractive: boolean
): Promise<void> {
const reader = new EasJsonReader(projectDir);
const profileNames = await reader.getBuildProfileNamesAsync();
const easJsonAccessor = new EasJsonAccessor(projectDir);
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
const platformAndProfileNames: [Platform, string][] = profileNames.flatMap(profileName => [
[Platform.ANDROID, profileName],
[Platform.IOS, profileName],
@@ -232,7 +238,11 @@ async function handleDeprecatedEasJsonAsync(
const deprecatedProfiles: [Platform, string][] = [];
for (const [platform, profileName] of platformAndProfileNames) {
const buildProfile = await reader.getBuildProfileAsync(platform, profileName);
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
profileName
);
if (buildProfile.artifactPath) {
deprecatedProfiles.push([platform, profileName]);
}
@@ -275,15 +285,16 @@ async function handleDeprecatedEasJsonAsync(
Errors.exit(1);
}
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
const easJson = await fs.readJSON(easJsonPath);
await easJsonAccessor.readRawJsonAsync();
for (const [platform, profileName] of deprecatedProfiles) {
easJson.build[profileName][platform].applicationArchivePath =
easJson.build[profileName][platform].artifactPath;
delete easJson.build[profileName][platform].artifactPath;
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.build[profileName][platform].applicationArchivePath =
easJsonRawObject.build[profileName][platform].artifactPath;
delete easJsonRawObject.build[profileName][platform].artifactPath;
return easJsonRawObject;
});
}
await easJsonAccessor.writeAsync();
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
Log.withTick('Updated eas.json');
}
+35 -17
View File
@@ -5,7 +5,10 @@ import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
@@ -35,6 +38,7 @@ export default class BuildInspect extends EasCommand {
required: true,
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
@@ -61,8 +65,17 @@ export default class BuildInspect extends EasCommand {
}),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildInspect);
const { actor, projectId } = await this.getContextAsync(BuildInspect, {
nonInteractive: false,
});
const outputDirectory = path.resolve(process.cwd(), flags.output);
const tmpWorkingdir = path.join(getTmpDirectory(), uuidv4());
@@ -82,23 +95,28 @@ export default class BuildInspect extends EasCommand {
} else {
const projectDir = await findProjectRootAsync();
try {
await runBuildAndSubmitAsync(projectDir, {
nonInteractive: false,
wait: true,
clearCache: false,
json: false,
autoSubmit: false,
requestedPlatform: flags.platform,
profile: flags.profile,
localBuildOptions: {
enable: true,
...(flags.stage === InspectStage.PRE_BUILD ? { skipNativeBuild: true } : {}),
...(flags.stage === InspectStage.POST_BUILD ? { skipCleanup: true } : {}),
verbose: flags.verbose,
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
await runBuildAndSubmitAsync(
projectDir,
{
nonInteractive: false,
wait: true,
clearCache: false,
json: false,
autoSubmit: false,
requestedPlatform: flags.platform,
profile: flags.profile,
localBuildOptions: {
enable: true,
...(flags.stage === InspectStage.PRE_BUILD ? { skipNativeBuild: true } : {}),
...(flags.stage === InspectStage.POST_BUILD ? { skipCleanup: true } : {}),
verbose: flags.verbose,
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
},
},
});
actor,
projectId
);
if (!flags.verbose) {
Log.log(chalk.green('Build successful'));
}
+43 -70
View File
@@ -1,25 +1,22 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { BUILDS_LIMIT, listAndRenderBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import {
AppPlatform,
DistributionType,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput } from '../../utils/json';
export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
@@ -28,9 +25,6 @@ export default class BuildList extends EasCommand {
platform: Flags.enum({
options: [RequestedPlatform.All, RequestedPlatform.Android, RequestedPlatform.Ios],
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
}),
status: Flags.enum({
options: [
BuildStatus.NEW,
@@ -56,76 +50,55 @@ export default class BuildList extends EasCommand {
appIdentifier: Flags.string(),
buildProfile: Flags.string(),
gitCommitHash: Flags.string(),
limit: Flags.integer(),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: BUILDS_LIMIT }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const {
json,
json: jsonFlag,
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
limit = 10,
'non-interactive': nonInteractive,
} = flags;
if (json) {
const { projectId } = await this.getContextAsync(BuildList, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const platform = toAppPlatform(requestedPlatform);
const graphqlBuildStatus = toGraphQLBuildStatus(buildStatus);
const graphqlBuildDistribution = toGraphQLBuildDistribution(buildDistribution);
const displayName = await getDisplayNameForProjectIdAsync(projectId);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const spinner = ora().start('Fetching the build list for the project…');
try {
const builds = await BuildQuery.allForAppAsync(projectId, {
limit,
filter: {
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
});
if (builds.length) {
if (platform || graphqlBuildStatus) {
spinner.succeed(
`Showing ${builds.length} matching builds for the project ${projectName}`
);
} else {
spinner.succeed(`Showing last ${builds.length} builds for the project ${projectName}`);
}
if (json) {
printJsonOnlyOutput(builds);
} else {
const list = builds
.map(build => formatGraphQLBuild(build))
.join(`\n\n${chalk.dim('———')}\n\n`);
Log.log(`\n${list}`);
}
} else {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the build list ${projectName}`);
throw e;
}
await listAndRenderBuildsOnAppAsync({
projectId,
projectDisplayName: displayName,
filter: {
platform,
status: graphqlBuildStatus,
distribution: graphqlBuildDistribution,
channel: flags.channel,
appVersion: flags.appVersion,
appBuildVersion: flags.appBuildVersion,
sdkVersion: flags.sdkVersion,
runtimeVersion: flags.runtimeVersion,
appIdentifier: flags.appIdentifier,
buildProfile: flags.buildProfile,
gitCommitHash: flags.gitCommitHash,
},
paginatedQueryOptions,
});
}
}
@@ -1,10 +1,13 @@
import { getRuntimeVersionNullable } from '@expo/config-plugins/build/utils/Updates';
import { Platform } from '@expo/eas-build-job';
import { EasJsonReader } from '@expo/eas-json';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../../commandUtils/EasCommand';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
@@ -16,8 +19,7 @@ import { getExpoConfig } from '../../../project/expoConfig';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
getDisplayNameForProjectIdAsync,
} from '../../../project/projectUtils';
import {
ensureVersionSourceIsRemoteAsync,
@@ -35,32 +37,46 @@ export default class BuildVersionSetView extends EasCommand {
options: ['android', 'ios'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSetView);
const { actor, projectId } = await this.getContextAsync(BuildVersionSetView, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const platform = await selectPlatformAsync(flags.platform);
const easJsonReader = new EasJsonReader(projectDir);
await ensureVersionSourceIsRemoteAsync(projectDir, easJsonReader);
const profile = await easJsonReader.getBuildProfileAsync(platform, flags.profile ?? undefined);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const profile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
flags.profile ?? undefined
);
const exp = getExpoConfig(projectDir, { env: profile.env });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
const displayName = await getDisplayNameForProjectIdAsync(projectId);
validateAppConfigForRemoteVersionSource(exp, platform);
const applicationIdentifier = await getApplicationIdentifierAsync(
projectDir,
exp,
profile,
platform
platform,
actor
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
projectId,
@@ -68,12 +84,12 @@ export default class BuildVersionSetView extends EasCommand {
applicationIdentifier
);
const currentStateMessage = remoteVersions?.buildVersion
? `Project ${chalk.bold(projectFullName)} with ${getApplicationIdentifierName(
? `Project ${chalk.bold(displayName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" is configured with ${getBuildVersionName(platform)} ${
remoteVersions.buildVersion
}.`
: `Project ${chalk.bold(projectFullName)} with ${getApplicationIdentifierName(
: `Project ${chalk.bold(displayName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" does not have any ${getBuildVersionName(
platform
@@ -1,12 +1,15 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJsonReader } from '@expo/eas-json';
import { BuildProfile, EasJsonAccessor } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../../build/android/version';
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../../build/ios/version';
import EasCommand from '../../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../../commandUtils/EasCommand';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
@@ -22,7 +25,7 @@ import { getExpoConfig } from '../../../project/expoConfig';
import { resolveXcodeBuildContextAsync } from '../../../project/ios/scheme';
import { resolveTargetsAsync } from '../../../project/ios/target';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import { findProjectRootAsync, getProjectIdAsync } from '../../../project/projectUtils';
import { findProjectRootAsync } from '../../../project/projectUtils';
import {
ensureVersionSourceIsRemoteAsync,
getBuildVersionName,
@@ -49,30 +52,39 @@ export default class BuildVersionSyncView extends EasCommand {
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSyncView);
const { actor, projectId } = await this.getContextAsync(BuildVersionSyncView, {
nonInteractive: true,
});
const projectDir = await findProjectRootAsync();
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonReader = new EasJsonReader(projectDir);
await ensureVersionSourceIsRemoteAsync(projectDir, easJsonReader);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const platforms = toPlatforms(requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonReader,
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
});
for (const profileInfo of buildProfiles) {
const exp = getExpoConfig(projectDir, { env: profileInfo.profile.env });
const projectId = await getProjectIdAsync(exp);
validateAppConfigForRemoteVersionSource(exp, profileInfo.platform);
const platformDisplayName = appPlatformDisplayNames[toAppPlatform(profileInfo.platform)];
@@ -80,7 +92,8 @@ export default class BuildVersionSyncView extends EasCommand {
projectDir,
exp,
profileInfo.profile,
profileInfo.platform
profileInfo.platform,
actor
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
projectId,
+21 -21
View File
@@ -1,17 +1,11 @@
import { Flags } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { BuildFragment } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
@@ -20,9 +14,11 @@ export default class BuildView extends EasCommand {
static override args = [{ name: 'BUILD_ID' }];
static override flags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
}),
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
@@ -30,14 +26,14 @@ export default class BuildView extends EasCommand {
args: { BUILD_ID: buildId },
flags,
} = await this.parse(BuildView);
const { projectId } = await this.getContextAsync(BuildView, {
nonInteractive: true,
});
if (flags.json) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectName = await getProjectFullNameAsync(exp);
const displayName = await getDisplayNameForProjectIdAsync(projectId);
const spinner = ora().start('Fetching the build…');
@@ -47,18 +43,22 @@ export default class BuildView extends EasCommand {
if (buildId) {
build = await BuildQuery.byIdAsync(buildId);
} else {
const builds = await BuildQuery.allForAppAsync(projectId, { limit: 1 });
const builds = await BuildQuery.viewBuildsOnAppAsync({
appId: projectId,
offset: 0,
limit: 1,
});
if (builds.length === 0) {
spinner.fail(`Couldn't find any builds for the project ${projectName}`);
spinner.fail(`Couldn't find any builds for the project ${displayName}`);
return;
}
build = builds[0];
}
if (buildId) {
spinner.succeed(`Found a matching build for the project ${projectName}`);
spinner.succeed(`Found a matching build for the project ${displayName}`);
} else {
spinner.succeed(`Showing the last build for the project ${projectName}`);
spinner.succeed(`Showing the last build for the project ${displayName}`);
}
if (flags.json) {
@@ -71,7 +71,7 @@ export default class BuildView extends EasCommand {
spinner.fail(`Something went wrong and we couldn't fetch the build with id ${buildId}`);
} else {
spinner.fail(
`Something went wrong and we couldn't fetch the last build for the project ${projectName}`
`Something went wrong and we couldn't fetch the last build for the project ${displayName}`
);
}
+33 -32
View File
@@ -1,8 +1,9 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { BranchNotFoundError } from '../../branch/utils';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
CreateUpdateChannelOnAppMutation,
@@ -10,12 +11,7 @@ import {
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -71,29 +67,28 @@ export default class ChannelCreate extends EasCommand {
];
static override flags = {
json: Flags.boolean({
description:
'print output as a JSON object with the new channel ID, name and branch mapping.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags: { json: jsonFlag },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelCreate);
const { projectId } = await this.getContextAsync(ChannelCreate, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
if (!channelName) {
const validationMessage = 'Channel name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
@@ -106,21 +101,25 @@ export default class ChannelCreate extends EasCommand {
let branchId: string;
let branchMessage: string;
const existingBranch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: channelName,
});
if (existingBranch) {
branchId = existingBranch.id;
branchMessage = `We found a branch with the same name`;
} else {
const newBranch = await createUpdateBranchOnAppAsync({
try {
const branch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
branchId = branch.id;
branchMessage = `We found a branch with the same name`;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync({
appId: projectId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
} else {
throw error;
}
}
const {
@@ -133,7 +132,7 @@ export default class ChannelCreate extends EasCommand {
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on app with id ${projectId}`
`Could not create channel with name ${channelName} on project with id ${projectId}`
);
}
@@ -142,7 +141,9 @@ export default class ChannelCreate extends EasCommand {
} else {
Log.addNewLineIfNone();
Log.withTick(
`Created a new channel on project ${chalk.bold(await getProjectFullNameAsync(exp))}`
`Created a new channel on project ${chalk.bold(
await getDisplayNameForProjectIdAsync(projectId)
)}`
);
Log.log(
formatFields([
+37 -80
View File
@@ -1,23 +1,17 @@
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateChannelMutation,
DeleteUpdateChannelMutationVariables,
DeleteUpdateChannelResult,
GetChannelInfoQuery,
GetChannelInfoQueryVariables,
} from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelDelete extends EasCommand {
@@ -32,65 +26,59 @@ export default class ChannelDelete extends EasCommand {
},
];
static override flags = {
json: Flags.boolean({
description: 'print output as a JSON object',
default: false,
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
args: { name: nameArg },
flags: { json: jsonFlag, 'non-interactive': nonInteractiveFlag },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(ChannelDelete);
if (jsonFlag && !nonInteractiveFlag) {
throw new Error('--json is allowed only in non-interactive mode');
}
const { projectId } = await this.getContextAsync(ChannelDelete, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
let name;
let channelId, channelName;
if (nameArg) {
name = nameArg;
const { id, name } = await ChannelQuery.viewUpdateChannelAsync({
appId: projectId,
channelName: nameArg,
});
channelId = id;
channelName = name;
} else {
if (nonInteractiveFlag) {
if (nonInteractive) {
throw new Error('Channel name must be set when running in non-interactive mode');
}
name = (
await promptAsync({
type: 'text',
name: 'name',
message: 'Provide the name of the channel to delete:',
validate: (value: any) => (value ? true : 'Channel name may not be empty.'),
})
).name;
const { id, name } = await selectChannelOnAppAsync({
projectId,
selectionPromptTitle: 'Select a channel to delete',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelId = id;
channelName = name;
}
const data = await getChannelInfoAsync({ appId: projectId, name });
const channelId = data.app?.byId.updateChannelByName?.id;
if (!channelId) {
throw new Error(`Could not find channel ${name} on ${fullName}`);
}
if (!nonInteractiveFlag) {
if (!nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete channel: "${name}".\nThis action is irreversible.`
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of channel: "${name}".`);
Log.error(`Canceled deletion of channel: "${channelName}".`);
process.exit(1);
}
}
@@ -102,42 +90,11 @@ export default class ChannelDelete extends EasCommand {
if (jsonFlag) {
printJsonOnlyOutput(deletionResult);
} else {
Log.withTick(`️Deleted channel "${name}".`);
Log.withTick(`️Deleted channel "${channelName}".`);
}
}
}
async function getChannelInfoAsync({
appId,
name,
}: GetChannelInfoQueryVariables): Promise<GetChannelInfoQuery> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetChannelInfoQuery, GetChannelInfoQueryVariables>(
gql`
query GetChannelInfo($appId: String!, $name: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $name) {
id
name
}
}
}
}
`,
{
appId,
name,
},
{ additionalTypenames: ['UpdateChannel'] }
)
.toPromise()
);
return data;
}
async function deleteChannelOnAppAsync({
channelId,
}: DeleteUpdateChannelMutationVariables): Promise<DeleteUpdateChannelResult> {
+45 -101
View File
@@ -2,71 +2,26 @@ import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetChannelByNameToEditQuery,
GetChannelByNameToEditQueryVariables,
UpdateBranch,
UpdateChannel,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function getChannelByNameForAppAsync({
appId,
channelName,
}: GetChannelByNameToEditQueryVariables): Promise<
Pick<UpdateChannel, 'id' | 'name'> & {
updateBranches: Pick<UpdateBranch, 'id' | 'name'>[];
}
> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<GetChannelByNameToEditQuery, GetChannelByNameToEditQueryVariables>(
gql`
query GetChannelByNameToEdit($appId: String!, $channelName: String!) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
updateBranches(offset: 0, limit: 1) {
id
name
}
}
}
}
}
`,
{ appId, channelName },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch'] }
)
.toPromise()
);
const updateChannelByNameResult = data.app?.byId.updateChannelByName;
if (!updateChannelByNameResult) {
throw new Error(`Could not find a channel named ${channelName} on app with id ${appId}`);
}
return updateChannelByNameResult;
}
export async function updateChannelBranchMappingAsync({
channelId,
branchMapping,
}: UpdateChannelBranchMappingMutationVariables): Promise<{
id: string;
name: string;
branchMapping: string;
}> {
}: UpdateChannelBranchMappingMutationVariables): Promise<
UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']
> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
@@ -89,7 +44,7 @@ export async function updateChannelBranchMappingAsync({
if (!channel) {
throw new Error(`Could not find a channel with id: ${channelId}`);
}
return data.updateChannel.editUpdateChannel!;
return channel;
}
export default class ChannelEdit extends EasCommand {
@@ -107,42 +62,53 @@ export default class ChannelEdit extends EasCommand {
branch: Flags.string({
description: 'Name of the branch to point to',
}),
json: Flags.boolean({
description: 'Print output as a JSON object with the channel ID, name and branch mapping',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelEdit);
if (flags.json) {
const {
args,
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelEdit);
const { projectId } = await this.getContextAsync(ChannelEdit, {
nonInteractive,
});
if (json) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync({ appId: projectId, channelName: args.name })
: await selectChannelOnAppAsync({
projectId,
selectionPromptTitle: 'Select a channel to edit',
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
const channelName = args.name ?? (await promptForChannelAsync());
const existingChannel = await getChannelByNameForAppAsync({ appId: projectId, channelName });
if (existingChannel.updateBranches.length > 1) {
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
}
const branchName = flags.branch ?? (await promptForBranchAsync());
const branch = branchFlag
? await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: branchFlag,
})
: await selectBranchOnAppAsync({
projectId,
promptTitle: `Which branch would you like ${existingChannel.name} to point at?`,
displayTextForListItem: updateBranch => updateBranch.name,
paginatedQueryOptions: {
json,
nonInteractive,
offset: 0,
},
});
const branch = await BranchQuery.getBranchByNameAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Check what branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
const channel = await updateChannelBranchMappingAsync({
channelId: existingChannel.id,
// todo: move branch mapping logic to utility
@@ -152,7 +118,7 @@ export default class ChannelEdit extends EasCommand {
}),
});
if (flags.json) {
if (json) {
printJsonOnlyOutput(channel);
} else {
Log.withTick(
@@ -165,25 +131,3 @@ export default class ChannelEdit extends EasCommand {
}
}
}
async function promptForChannelAsync(): Promise<string> {
Log.addNewLineIfNone();
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide the name of the channel to edit:',
validate: value => (value ? true : 'The channel name may not be empty.'),
});
return name;
}
async function promptForBranchAsync(): Promise<string> {
Log.addNewLineIfNone();
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: `To which branch should the channel link?`,
validate: value => (value ? true : 'The branch name may not be empty.'),
});
return name;
}
+24 -98
View File
@@ -1,114 +1,40 @@
import { Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../channel/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
GetAllChannelsForAppQuery,
GetAllChannelsForAppQueryVariables,
} from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { logChannelDetails } from './view';
const CHANNEL_LIMIT = 10_000;
async function getAllUpdateChannelForAppAsync({
appId,
}: {
appId: string;
}): Promise<GetAllChannelsForAppQuery> {
return await withErrorHandlingAsync(
graphqlClient
.query<GetAllChannelsForAppQuery, GetAllChannelsForAppQueryVariables>(
gql`
query GetAllChannelsForApp($appId: String!, $offset: Int!, $limit: Int!) {
app {
byId(appId: $appId) {
id
updateChannels(offset: $offset, limit: $limit) {
id
name
branchMapping
updateBranches(offset: 0, limit: $limit) {
id
name
updates(offset: 0, limit: 10) {
id
group
message
runtimeVersion
createdAt
platform
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
}
}
}
}
}
}
`,
{ appId, offset: 0, limit: CHANNEL_LIMIT },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
);
}
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelList extends EasCommand {
static override description = 'list all channels';
static override flags = {
json: Flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: CHANNELS_LIMIT }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
flags: { json: jsonFlag },
} = await this.parse(ChannelList);
const { flags } = await this.parse(ChannelList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { projectId } = await this.getContextAsync(ChannelList, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const getAllUpdateChannelForAppResult = await getAllUpdateChannelForAppAsync({
appId: projectId,
await listAndRenderChannelsOnAppAsync({
projectId,
paginatedQueryOptions,
});
const channels = getAllUpdateChannelForAppResult.app?.byId.updateChannels;
if (!channels) {
throw new Error(`Could not find channels on project with id ${projectId}`);
}
if (jsonFlag) {
printJsonOnlyOutput(channels);
} else {
for (const channel of channels) {
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
logChannelDetails(channel);
}
}
}
}
@@ -1,21 +1,17 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { GetChannelByNameForAppQuery, UpdateBranch } from '../../graphql/generated';
import { BranchMapping, getBranchMapping } from '../../channel/utils';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { UpdateBranch } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { ChannelQuery, UpdateChannelByNameObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { updateChannelBranchMappingAsync } from './edit';
import { BranchMapping, getBranchMapping } from './view';
async function promptForRolloutPercentAsync({
promptMessage,
@@ -40,9 +36,7 @@ async function promptForRolloutPercentAsync({
return rolloutPercent;
}
function getRolloutInfo(
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>
): {
function getRolloutInfo(channel: UpdateChannelByNameObject): {
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
@@ -64,20 +58,20 @@ async function startRolloutAsync({
channelName,
branchName,
percent,
jsonFlag,
projectId,
fullName,
displayName,
currentBranchMapping,
channel,
nonInteractive,
}: {
channelName?: string;
branchName: string;
percent?: number;
jsonFlag: boolean;
projectId: string;
fullName: string;
displayName: string;
currentBranchMapping: BranchMapping;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
channel: UpdateChannelByNameObject;
nonInteractive: boolean;
}): Promise<{
newChannelInfo: {
id: string;
@@ -90,13 +84,6 @@ async function startRolloutAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Check which branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
const oldBranchId = currentBranchMapping.data[0].branchId;
if (branch.id === oldBranchId) {
@@ -106,9 +93,9 @@ async function startRolloutAsync({
}
if (percent == null) {
if (jsonFlag) {
if (nonInteractive) {
throw new Error(
'You must specify a percent with the --percent flag when initiating a rollout with the --json flag.'
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
);
}
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
@@ -137,7 +124,7 @@ async function startRolloutAsync({
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!oldBranch) {
throw new Error(
`Branch mapping is missing its only branch for channel "${channelName}" on app "${fullName}"`
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
);
}
@@ -155,15 +142,15 @@ async function startRolloutAsync({
async function editRolloutAsync({
channelName,
percent,
jsonFlag,
nonInteractive,
currentBranchMapping,
channel,
}: {
channelName?: string;
percent?: number;
jsonFlag: boolean;
nonInteractive: boolean;
currentBranchMapping: BranchMapping;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
channel: UpdateChannelByNameObject;
}): Promise<{
newChannelInfo: {
id: string;
@@ -175,7 +162,7 @@ async function editRolloutAsync({
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
if (percent == null) {
if (jsonFlag) {
if (nonInteractive) {
throw new Error(
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
);
@@ -210,15 +197,15 @@ async function editRolloutAsync({
async function endRolloutAsync({
channelName,
branchName,
jsonFlag,
nonInteractive,
projectId,
channel,
}: {
channelName?: string;
branchName?: string;
jsonFlag: boolean;
nonInteractive: boolean;
projectId: string;
channel: NonNullable<GetChannelByNameForAppQuery['app']['byId']['updateChannelByName']>;
channel: UpdateChannelByNameObject;
}): Promise<{
newChannelInfo: {
id: string;
@@ -236,13 +223,6 @@ async function endRolloutAsync({
appId: projectId,
name: branchName,
});
if (!branch) {
throw new Error(
`Could not find a branch named "${branchName}". Check which branches exist on this project with ${chalk.bold(
'eas branch:list'
)}.`
);
}
switch (branch.id) {
case newBranch.id:
@@ -257,9 +237,9 @@ async function endRolloutAsync({
);
}
} else {
if (jsonFlag) {
if (nonInteractive) {
throw new Error(
'Branch name must be specified with the --branch flag when both the --end and --json flag are true.'
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
);
}
endOnNewBranch = await selectAsync<boolean>(
@@ -328,27 +308,34 @@ export default class ChannelRollout extends EasCommand {
description: 'end the rollout',
default: false,
}),
json: Flags.boolean({
description: 'print output as a JSON object with the new channel ID, name and branch mapping',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
args: { channel: channelName },
flags: { json: jsonFlag, end: endFlag, branch: branchName, percent },
flags: {
json: jsonFlag,
end: endFlag,
branch: branchName,
percent,
'non-interactive': nonInteractive,
},
} = await this.parse(ChannelRollout);
const { projectId } = await this.getContextAsync(ChannelRollout, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const fullName = await getProjectFullNameAsync(exp);
const projectId = await getProjectIdAsync(exp);
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
const channel = await ChannelQuery.viewUpdateChannelAsync({
appId: projectId,
channelName: channelName!,
});
@@ -395,11 +382,11 @@ export default class ChannelRollout extends EasCommand {
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync({
channelName,
branchName: branchName ?? (await promptForBranchNameAsync(channelName)),
branchName: branchName ?? (await promptForBranchNameAsync(channelName, nonInteractive)),
percent,
jsonFlag,
nonInteractive,
projectId,
fullName,
displayName: projectDisplayName,
currentBranchMapping,
channel,
});
@@ -407,7 +394,7 @@ export default class ChannelRollout extends EasCommand {
rolloutMutationResult = await endRolloutAsync({
channelName,
branchName,
jsonFlag,
nonInteractive,
projectId,
channel,
});
@@ -415,7 +402,7 @@ export default class ChannelRollout extends EasCommand {
rolloutMutationResult = await editRolloutAsync({
channelName,
percent,
jsonFlag,
nonInteractive,
currentBranchMapping,
channel,
});
@@ -432,7 +419,13 @@ export default class ChannelRollout extends EasCommand {
}
}
async function promptForBranchNameAsync(channelName: string): Promise<string> {
async function promptForBranchNameAsync(
channelName: string,
nonInteractive: boolean
): Promise<string> {
if (nonInteractive) {
throw new Error('Must supply branch flag in non-interactive mode');
}
const { name } = await promptAsync({
type: 'text',
name: 'name',
+34 -172
View File
@@ -1,141 +1,13 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import Table from 'cli-table3';
import EasCommand from '../../commandUtils/EasCommand';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import {
FormatUpdateParameter,
UPDATE_COLUMNS,
formatUpdate,
getPlatformsForGroup,
} from '../../update/utils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0].branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
}
export function logChannelDetails(channel: {
branchMapping: string;
updateBranches: {
updates: (FormatUpdateParameter & {
runtimeVersion: string;
group: string;
platform: string;
})[];
name: string;
id: string;
}[];
}): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
const table = new Table({
head: ['branch', ...(isRollout ? ['rollout percent'] : []), ...UPDATE_COLUMNS],
wordWrap: true,
});
for (const index in branchMapping.data) {
if (parseInt(index, 10) > 1) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const { branchId } = branchMapping.data[index];
const branch = channel.updateBranches.filter(branch => branch.id === branchId)[0];
if (!branch) {
throw new Error('Branch mapping is pointing at a missing branch.');
}
const update = branch.updates[0];
table.push([
branch.name,
...(isRollout
? [
parseInt(index, 10) === 0
? `${rolloutPercent! * 100}%`
: `${(1 - rolloutPercent!) * 100}%`,
]
: []),
formatUpdate(update),
update?.runtimeVersion ?? 'N/A',
update?.group ?? 'N/A',
getPlatformsForGroup({
updates: branch.updates,
group: branch.updates[0]?.group,
}),
]);
}
Log.log(table.toString());
}
listAndRenderBranchesAndUpdatesOnChannelAsync,
selectChannelOnAppAsync,
} from '../../channel/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
@@ -149,60 +21,50 @@ export default class ChannelView extends EasCommand {
];
static override flags = {
json: Flags.boolean({
description: 'print output as a JSON object with the channel ID, name and branch mapping.',
default: false,
}),
...EasNonInteractiveAndJsonFlags,
...EasPaginatedQueryFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags: { json: jsonFlag },
flags,
} = await this.parse(ChannelView);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { projectId } = await this.getContextAsync(ChannelView, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
if (!channelName) {
const validationMessage = 'A channel name is required to view a specific channel.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name: channelName } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide a channel name:',
validate: value => (value ? true : validationMessage),
}));
const selectedUpdateChannel = await selectChannelOnAppAsync({
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelName = selectedUpdateChannel.name;
assert(channelName, `A channel must be selected.`);
}
const channel = await ChannelQuery.getUpdateChannelByNameForAppAsync({
appId: projectId,
await listAndRenderBranchesAndUpdatesOnChannelAsync({
projectId,
channelName,
paginatedQueryOptions,
});
if (!channel) {
throw new Error(`Could not find a channel with name: ${channelName}`);
}
if (jsonFlag) {
printJsonOnlyOutput(channel);
} else {
Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:'));
Log.log(
formatFields([
{ label: 'Name', value: channel.name },
{ label: 'ID', value: channel.id },
])
);
Log.addNewLineIfNone();
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
logChannelDetails(channel);
}
}
}
+14 -7
View File
@@ -1,10 +1,10 @@
import { getProjectConfigDescription } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { EasJsonReader } from '@expo/eas-json';
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
@@ -17,10 +17,17 @@ export default class Config extends EasCommand {
static override flags = {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
profile: Flags.string(),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
@@ -31,13 +38,13 @@ export default class Config extends EasCommand {
const projectDir = await findProjectRootAsync();
const reader = new EasJsonReader(projectDir);
const accessor = new EasJsonAccessor(projectDir);
const profileName =
maybeProfile ??
(await selectAsync(
'Select build profile',
(
await reader.getBuildProfileNamesAsync()
await EasJsonUtils.getBuildProfileNamesAsync(accessor)
).map(profileName => ({
title: profileName,
value: profileName,
@@ -56,7 +63,7 @@ export default class Config extends EasCommand {
},
]));
const profile = await reader.getBuildProfileAsync(platform, profileName);
const profile = await EasJsonUtils.getBuildProfileAsync(accessor, platform, profileName);
const config = getExpoConfig(projectDir, { env: profile.env, isPublicConfig: true });
Log.addNewLineIfNone();
+17 -2
View File
@@ -1,7 +1,12 @@
import { Flags } from '@oclif/core';
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../commandUtils/EasCommand';
import { SelectPlatform } from '../credentials/manager/SelectPlatform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync } from '../project/projectUtils';
export default class Credentials extends EasCommand {
static override description = 'manage credentials';
@@ -10,8 +15,18 @@ export default class Credentials extends EasCommand {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(Credentials);
await new SelectPlatform(flags.platform).runAsync();
const { actor, projectId } = await this.getContextAsync(Credentials, { nonInteractive: false });
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
await new SelectPlatform(actor, { projectId, exp }, flags.platform).runAsync();
}
}
+15 -5
View File
@@ -1,16 +1,26 @@
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import AppStoreApi from '../../credentials/ios/appstore/AppStoreApi';
import { createContextAsync } from '../../devices/context';
import DeviceManager from '../../devices/manager';
import { ensureLoggedInAsync } from '../../user/actions';
export default class DeviceCreate extends EasCommand {
static override description = 'register new Apple Devices to use for internal distribution';
async runAsync(): Promise<void> {
const user = await ensureLoggedInAsync();
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
const ctx = await createContextAsync({ appStore: new AppStoreApi(), user });
async runAsync(): Promise<void> {
// this command is interactive by design
const { actor, projectId } = await this.getContextAsync(DeviceCreate, {
nonInteractive: false,
});
const ctx = await createContextAsync({ appStore: new AppStoreApi(), user: actor, projectId });
const manager = new DeviceManager(ctx);
await manager.createAsync();
}
+12 -8
View File
@@ -2,7 +2,7 @@ import { Device, DeviceStatus } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { chooseDevicesToDeleteAsync } from '../../credentials/ios/actions/DeviceUtils';
import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import {
@@ -16,8 +16,7 @@ import formatDevice from '../../devices/utils/formatDevice';
import { AppleDevice, Maybe } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class DeviceDelete extends EasCommand {
@@ -28,22 +27,27 @@ export default class DeviceDelete extends EasCommand {
udid: Flags.string({ multiple: true }),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
flags: { 'apple-team-id': appleTeamIdentifier, udid: udids },
} = await this.parse(DeviceDelete);
const { projectId } = await this.getContextAsync(DeviceDelete, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const account = await getOwnerAccountForProjectIdAsync(projectId);
if (!appleTeamIdentifier) {
appleTeamIdentifier = await this.askForAppleTeamAsync(accountName);
appleTeamIdentifier = await this.askForAppleTeamAsync(account.name);
}
assert(appleTeamIdentifier, 'No team identifier is specified');
const appleDevicesResult = await this.getDevicesForTeamAsync(accountName, appleTeamIdentifier);
const appleDevicesResult = await this.getDevicesForTeamAsync(account.name, appleTeamIdentifier);
if (!appleDevicesResult) {
return;
+13 -9
View File
@@ -2,14 +2,13 @@ import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { AppleTeamQuery } from '../../credentials/ios/api/graphql/queries/AppleTeamQuery';
import formatDevice from '../../devices/utils/formatDevice';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
export default class BuildList extends EasCommand {
@@ -19,12 +18,17 @@ export default class BuildList extends EasCommand {
'apple-team-id': Flags.string(),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let appleTeamIdentifier = (await this.parse(BuildList)).flags['apple-team-id'];
const { projectId } = await this.getContextAsync(BuildList, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const account = await getOwnerAccountForProjectIdAsync(projectId);
let spinner: Ora;
@@ -32,7 +36,7 @@ export default class BuildList extends EasCommand {
spinner = ora().start('Fetching the list of teams for the project…');
try {
const teams = await AppleTeamQuery.getAllForAccountAsync(accountName);
const teams = await AppleTeamQuery.getAllForAccountAsync(account.name);
if (teams.length > 0) {
spinner.succeed();
@@ -55,7 +59,7 @@ export default class BuildList extends EasCommand {
appleTeamIdentifier = result.appleTeamIdentifier;
}
} else {
spinner.fail(`Couldn't find any teams for the account ${accountName}`);
spinner.fail(`Couldn't find any teams for the account ${account.name}`);
}
} catch (e) {
spinner.fail(`Something went wrong and we couldn't fetch the list of teams`);
@@ -69,7 +73,7 @@ export default class BuildList extends EasCommand {
try {
const result = await AppleDeviceQuery.getAllForAppleTeamAsync(
accountName,
account.name,
appleTeamIdentifier
);
+11 -8
View File
@@ -1,16 +1,19 @@
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { AppleDeviceQuery } from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import formatDevice from '../../devices/utils/formatDevice';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectAccountNameAsync } from '../../project/projectUtils';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
export default class DeviceView extends EasCommand {
static override description = 'view a device for your project';
static override args = [{ name: 'UDID' }];
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { UDID } = (await this.parse(DeviceView)).args;
@@ -27,15 +30,15 @@ If you are not sure what is the UDID of the device you are looking for, run:
);
throw new Error('Device UDID is missing');
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const { projectId } = await this.getContextAsync(DeviceView, {
nonInteractive: true,
});
const account = await getOwnerAccountForProjectIdAsync(projectId);
const spinner = ora().start(`Fetching device details for ${UDID}…`);
try {
const device = await AppleDeviceQuery.getByDeviceIdentifierAsync(accountName, UDID);
const device = await AppleDeviceQuery.getByDeviceIdentifierAsync(account.name, UDID);
if (device) {
spinner.succeed('Fetched device details');
+4 -2
View File
@@ -1,7 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import envinfo from 'envinfo';
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, { CommandConfiguration } from '../commandUtils/EasCommand';
import Log from '../log';
import { findProjectRootAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
@@ -10,7 +10,9 @@ import { easCliVersion } from '../utils/easCli';
export default class Diagnostics extends EasCommand {
static override description = 'display environment info';
protected override requiresAuthentication = false;
protected override commandConfiguration: CommandConfiguration = {
allowUnauthenticated: true,
};
async runAsync(): Promise<void> {
const info = await envinfo.run(
+19 -6
View File
@@ -4,38 +4,51 @@ import chalk from 'chalk';
import path from 'path';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { downloadMetadataAsync } from '../../metadata/download';
import { handleMetadataError } from '../../metadata/errors';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { findProjectRootAsync } from '../../project/projectUtils';
export default class MetadataPull extends EasCommand {
static override description = 'generate the local store configuration from the app stores';
static override flags = {
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandLoggedInContext,
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPull);
const { actor, projectId } = await this.getContextAsync(MetadataPull, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
await getProjectIdAsync(exp);
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false });
const credentialsCtx = new CredentialsContext({
exp,
projectInfo: { exp, projectId },
projectDir,
user: await ensureLoggedInAsync(),
user: actor,
nonInteractive: false,
});
+19 -6
View File
@@ -2,38 +2,51 @@ import { getConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { handleMetadataError } from '../../metadata/errors';
import { uploadMetadataAsync } from '../../metadata/upload';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { findProjectRootAsync } from '../../project/projectUtils';
export default class MetadataPush extends EasCommand {
static override description = 'sync the local store configuration to the app stores';
static override flags = {
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
...EASCommandLoggedInContext,
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPush);
const { actor, projectId } = await this.getContextAsync(MetadataPush, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
await getProjectIdAsync(exp);
// this command is interactive (all nonInteractive flags passed to utility functions are false)
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false });
const credentialsCtx = new CredentialsContext({
exp,
projectInfo: { exp, projectId },
projectDir,
user: await ensureLoggedInAsync(),
user: actor,
nonInteractive: false,
});
+12 -12
View File
@@ -1,31 +1,31 @@
import openBrowserAsync from 'better-opn';
import { getProjectDashboardUrl } from '../build/utils/url';
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../commandUtils/EasCommand';
import { ora } from '../ora';
import { getExpoConfig } from '../project/expoConfig';
import {
fetchProjectIdFromServerAsync,
findProjectRootAsync,
getProjectAccountName,
} from '../project/projectUtils';
import { ensureLoggedInAsync } from '../user/actions';
import { findProjectRootAsync, getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
export default class Open extends EasCommand {
static override description = 'open the project page in a web browser';
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
// this command is interactive by nature (only really run by humans in a terminal)
const { projectId } = await this.getContextAsync(Open, {
nonInteractive: false,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
// this ensures the project exists
await fetchProjectIdFromServerAsync(exp);
const account = await getOwnerAccountForProjectIdAsync(projectId);
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectDashboardUrl = getProjectDashboardUrl(account.name, projectName);
const failedMessage = `Unable to open a web browser. Project page is available at: ${projectDashboardUrl}`;
const spinner = ora(`Opening ${projectDashboardUrl}`).start();
try {
@@ -0,0 +1,236 @@
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import chalk from 'chalk';
import { vol } from 'memfs';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { findProjectRootAsync, saveProjectIdToAppConfigAsync } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { ensureLoggedInAsync } from '../../../user/actions';
import ProjectInit from '../init';
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('../../../project/projectUtils');
jest.mock('../../../prompts');
jest.mock('../../../user/actions');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
let originalProcessArgv: string[];
beforeAll(() => {
originalProcessArgv = process.argv;
process.argv = [];
});
afterAll(() => {
process.argv = originalProcessArgv;
});
function mockTestProject(options: {
configuredProjectId?: string;
configuredOwner?: string;
}): void {
const projectRoot = '/test-project';
const packageJSON: PackageJSONConfig = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const appJSON: AppJSONConfig = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: options.configuredOwner,
extra: {
eas: {
projectId: options.configuredProjectId,
},
},
},
};
vol.fromJSON(
{
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
'/'
);
const mockManifest = { exp: appJSON.expo };
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
jest.mocked(findProjectRootAsync).mockResolvedValue('/test-project');
}
const commandOptions = { root: '/test-project' } as any;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(ensureLoggedInAsync).mockResolvedValue(jester);
});
describe(ProjectInit.name, () => {
describe('when id flag is provided', () => {
describe('when it is already configured', () => {
beforeEach(() => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
});
describe('interactive', () => {
it('is no-op if already configured for id', async () => {
await new ProjectInit(['--id', '1234'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
it('prompts to overwrite when different', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
await new ProjectInit(['--id', '12345'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(confirmAsync).toHaveBeenCalled();
});
it('aborts when prompt to overwrite is declined', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await new ProjectInit(['--id', '12345'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalled();
});
});
describe('force', () => {
it('does not prompt to overwrite when different', async () => {
await new ProjectInit(['--id', '12345', '--force'], {
root: '/test-project',
} as any).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
expect(confirmAsync).not.toHaveBeenCalled();
});
});
describe('non-interactive', () => {
it('aborts when different', async () => {
await expect(
new ProjectInit(['--id', '12345', '--non-interactive'], {
root: '/test-project',
} as any).run()
).rejects.toThrowError(
`Project is already linked to a different ID: ${chalk.bold(
'1234'
)}. Use --force flag to overwrite.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
});
});
describe('when it is not yet configured', () => {
beforeEach(() => {
mockTestProject({});
});
it('configures', async () => {
await new ProjectInit(['--id', '1234'], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
});
});
});
describe('when no arguments are provided', () => {
describe('when it is already configured', () => {
beforeEach(() => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
});
it('does not configure', async () => {
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
});
describe('when it is not yet configured', () => {
describe('when owner is specified', () => {
beforeEach(() => {
mockTestProject({ configuredOwner: jester.accounts[0].name });
});
describe('when project exists on server', () => {
beforeEach(() => {
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
});
it('prompts for confirmation to link', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
it('does not save when confirmation to link is denied', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrow(
'Project ID configuration canceled. Re-run the command to select a different account/project.'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
});
describe('when project does not exist on server', () => {
it('asks to create it', async () => {
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
await new ProjectInit([], commandOptions).run();
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
it('does not create it if declined', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrowError(
'Project ID configuration canceled for @jester/testing-123.'
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
expect(confirmAsync).toHaveBeenCalledTimes(1);
});
});
});
describe('when owner is not specified', () => {
beforeEach(() => {
mockTestProject({});
});
it('prompts for an account', async () => {
jest.mocked(promptAsync).mockResolvedValue({ account: { name: 'other' } });
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrowError(
`You don't have permission to create a new project on the other account and no matching project already exists on the account.`
);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
});
it('throws when the user does not have permission to create projects on account', async () => {
mockTestProject({ configuredOwner: jester.accounts[1].name });
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
await expect(new ProjectInit([], commandOptions).run()).rejects.toThrowError(
`You don't have permission to create a new project on the other account and no matching project already exists on the account.`
);
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
});
});
});
});
+10 -7
View File
@@ -1,11 +1,9 @@
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { AppInfoQuery, AppInfoQueryVariables } from '../../graphql/generated';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import formatFields from '../../utils/formatFields';
async function projectInfoByIdAsync(appId: string): Promise<AppInfoQuery> {
@@ -34,13 +32,18 @@ async function projectInfoByIdAsync(appId: string): Promise<AppInfoQuery> {
export default class ProjectInfo extends EasCommand {
static override description = 'information about the current project';
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const { projectId } = await this.getContextAsync(ProjectInfo, {
nonInteractive: true,
});
const { app } = await projectInfoByIdAsync(projectId);
if (!app) {
throw new Error(`Could not find project with ID: ${projectId}`);
throw new Error(`Could not find project with id: ${projectId}`);
}
Log.addNewLineIfNone();
+206 -9
View File
@@ -1,32 +1,229 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import terminalLink from 'terminal-link';
import EasCommand from '../../commandUtils/EasCommand';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand, { EASCommandLoggedInContext } from '../../commandUtils/EasCommand';
import { AppPrivacy, Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import {
fetchProjectIdFromServerAsync,
findProjectRootAsync,
saveProjectIdToAppConfigAsync,
toAppPrivacy,
} from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
type InitializeMethodOptions = {
force: boolean;
nonInteractive: boolean;
};
export default class ProjectInit extends EasCommand {
static override description = 'create or link an EAS project';
static override aliases = ['init'];
async runAsync(): Promise<void> {
static override flags = {
id: Flags.string({
description: 'ID of the EAS project to link',
}),
force: Flags.boolean({
description: 'Whether to overwrite any existing project ID',
dependsOn: ['id'],
}),
// this is the same as EASNonInteractiveFlag but with the dependsOn
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
dependsOn: ['id'],
}),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
};
private static async saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
): Promise<void> {
await saveProjectIdToAppConfigAsync(projectDir, projectId);
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
}
private static async initializeWithExplicitIDAsync(
projectId: string,
{ force, nonInteractive }: InitializeMethodOptions
): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (exp.extra?.eas?.projectId) {
Log.error(
`app.json is already linked to project with ID: ${chalk.bold(exp.extra?.eas?.projectId)}`
if (projectId === existingProjectId) {
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
return;
}
if (!existingProjectId) {
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (projectId !== existingProjectId) {
if (force) {
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
return;
}
if (nonInteractive) {
throw new Error(
`Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Use --force flag to overwrite.`
);
}
const confirm = await confirmAsync({
message: `Project is already linked to a different ID: ${chalk.bold(
existingProjectId
)}. Do you wish to overwrite it?`,
});
if (!confirm) {
Log.log('Aborting');
return;
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
}
}
private static async initializeWithInteractiveSelectionAsync(actor: Actor): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const existingProjectId = exp.extra?.eas?.projectId;
if (existingProjectId) {
Log.succeed(
`Project already linked (ID: ${chalk.bold(
existingProjectId
)}). To re-configure, remove the extra.eas.projectId field from your app config.`
);
return;
}
const projectId = await fetchProjectIdFromServerAsync(exp);
await saveProjectIdToAppConfigAsync(projectDir, projectId);
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
);
Log.withTick(`Linked app.json to project with ID ${chalk.bold(projectId)}`);
// if no owner field, ask the user which account they want to use to create/link the project
let accountName = exp.owner;
if (!accountName) {
if (allAccounts.length === 1) {
accountName = allAccounts[0].name;
} else {
// if regular user, put primary account first
const sortedAccounts =
actor.__typename === 'Robot'
? allAccounts
: [...allAccounts].sort((a, _b) => (a.name === actor.username ? -1 : 1));
const choices = sortedAccounts.map(account => ({
title: account.name,
value: account,
description: !accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(account.name)
? '(Viewer Role)'
: undefined,
}));
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
}
if (!accountName) {
throw new Error('No account selected for project. Canceling.');
}
const projectName = exp.slug;
const projectFullName = `@${accountName}/${projectName}`;
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
accountName,
projectName
);
if (existingProjectIdOnServer) {
const affirmedLink = await confirmAsync({
message: `Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Link this project?`,
});
if (!affirmedLink) {
throw new Error(
`Project ID configuration canceled. Re-run the command to select a different account/project.`
);
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
return;
}
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
throw new Error(
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
);
}
const affirmedCreate = await confirmAsync({
message: `Would you like to create a project for ${projectFullName}?`,
});
if (!affirmedCreate) {
throw new Error(`Project ID configuration canceled for ${projectFullName}.`);
}
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = terminalLink(projectFullName, projectDashboardUrl, {
// https://github.com/sindresorhus/terminal-link/issues/18#issuecomment-1068020361
fallback: () => `${projectFullName} (${projectDashboardUrl})`,
});
const account = nullthrows(allAccounts.find(a => a.name === accountName));
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
let createdProjectId: string;
try {
createdProjectId = await AppMutation.createAppAsync({
accountId: account.id,
projectName,
privacy: toAppPrivacy(exp.privacy) ?? AppPrivacy.Public,
});
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
} catch (err) {
spinner.fail();
throw err;
}
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
}
async runAsync(): Promise<void> {
const {
flags: { id, force, 'non-interactive': nonInteractive },
} = await this.parse(ProjectInit);
const { actor } = await this.getContextAsync(ProjectInit, { nonInteractive });
if (id) {
await ProjectInit.initializeWithExplicitIDAsync(id, { force, nonInteractive });
} else {
await ProjectInit.initializeWithInteractiveSelectionAsync(actor);
}
}
}
+122 -48
View File
@@ -1,23 +1,26 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import {
EnvironmentSecretScope,
EnvironmentSecretsQuery,
} from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
SecretType,
SecretTypeToEnvironmentSecretType,
} from '../../graphql/types/EnvironmentSecret';
import Log from '../../log';
import {
getDisplayNameForProjectIdAsync,
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import { getActorDisplayName } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
import { promptAsync, selectAsync } from '../../prompts';
export default class EnvironmentSecretCreate extends EasCommand {
static override description =
@@ -33,29 +36,46 @@ export default class EnvironmentSecretCreate extends EasCommand {
description: 'Name of the secret',
}),
value: Flags.string({
description: 'Value of the secret',
description: 'Text value or path to a file to store in the secret',
}),
type: Flags.enum({
description: 'The type of secret',
options: [SecretType.STRING, SecretType.FILE],
}),
force: Flags.boolean({
description: 'Delete and recreate existing secrets',
default: false,
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const actor = await ensureLoggedInAsync();
let {
flags: { name, value: secretValue, scope, force },
flags: {
name,
value: secretValue,
scope,
force,
type: secretType,
'non-interactive': nonInteractive,
},
} = await this.parse(EnvironmentSecretCreate);
const { projectId } = await this.getContextAsync(EnvironmentSecretCreate, {
nonInteractive,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const accountName = await getProjectAccountNameAsync(exp);
const { slug } = exp;
const projectId = await getProjectIdAsync(exp);
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
const ownerAccount = await getOwnerAccountForProjectIdAsync(projectId);
if (!scope) {
const validationMessage = 'Secret scope may not be empty.';
if (nonInteractive) {
throw new Error(validationMessage);
}
({ scope } = await promptAsync({
type: 'select',
@@ -70,13 +90,18 @@ export default class EnvironmentSecretCreate extends EasCommand {
}
if (!name) {
const validationMessage = 'Secret name may not be empty.';
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name } = await promptAsync({
type: 'text',
name: 'name',
message: `Secret name:`,
validate: value => {
if (!value) {
return 'Secret name may not be empty.';
return validationMessage;
}
// this validation regex here is just to shorten the feedback loop
@@ -90,42 +115,82 @@ export default class EnvironmentSecretCreate extends EasCommand {
}));
if (!name) {
throw new Error('Secret name may not be empty.');
throw new Error(validationMessage);
}
}
if (!secretType) {
if (nonInteractive) {
throw new Error('Secret type may not be empty in non-interactive mode');
}
secretType = await selectAsync('Select secret type', [
{
title: 'string',
value: SecretType.STRING,
},
{
title: 'file',
value: SecretType.FILE,
},
]);
}
if (!secretValue) {
const validationMessage = 'Secret value may not be empty.';
if (nonInteractive) {
throw new Error(validationMessage);
}
({ secretValue } = await promptAsync({
type: 'text',
name: 'secretValue',
message: 'Secret value:',
validate: value => (value ? true : validationMessage),
message: secretType === SecretType.STRING ? 'Secret value:' : 'Local file path:',
// eslint-disable-next-line async-protect/async-suffix
validate: async secretValue => {
if (!secretValue) {
return 'Secret value may not be empty.';
}
if (secretType === SecretType.FILE) {
const secretFilePath = path.resolve(secretValue);
if (!(await fs.pathExists(secretFilePath))) {
return `File "${secretValue}" does not exist.`;
}
}
return true;
},
}));
}
if (!secretValue) {
throw new Error(validationMessage);
assert(secretValue);
let secretFilePath: string | undefined;
if (secretType === SecretType.FILE) {
secretFilePath = path.resolve(secretValue);
if (!(await fs.pathExists(secretFilePath))) {
throw new Error(`File "${secretValue}" does not exist`);
}
}
if (scope === EnvironmentSecretScope.PROJECT) {
if (force) {
const existingSecrets = await EnvironmentSecretsQuery.byAppIdAsync(projectId);
const { appSecrets: existingSecrets } = await EnvironmentSecretsQuery.byAppIdAsync(
projectId
);
const existingSecret = existingSecrets.find(secret => secret.name === name);
if (existingSecret) {
await EnvironmentSecretMutation.deleteAsync(existingSecret.id);
Log.withTick(
`Deleting existing secret ${chalk.bold(name)} on project ${chalk.bold(
`@${accountName}/${slug}`
projectDisplayName
)}.`
);
}
}
const secret = await EnvironmentSecretMutation.createForAppAsync(
{ name, value: secretValue },
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
projectId
);
if (!secret) {
@@ -134,25 +199,24 @@ export default class EnvironmentSecretCreate extends EasCommand {
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on project ${chalk.bold(
`@${accountName}/${slug}`
)}.`
);
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
const ownerAccount = findAccountByName(actor.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
accountName
)} account`
if (secretType === SecretType.STRING) {
Log.withTick(
`️Created a new secret ${chalk.bold(name)} with value ${chalk.bold(
secretValue
)} on project ${chalk.bold(projectDisplayName)}.`
);
} else {
Log.withTick(
`️Created a new secret ${chalk.bold(name)} from file ${chalk.bold(
secretFilePath
)} on project ${chalk.bold(projectDisplayName)}.`
);
return;
}
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
if (force) {
const existingSecrets = await EnvironmentSecretsQuery.byAccountNameAsync(ownerAccount.name);
const { accountSecrets: existingSecrets } = await EnvironmentSecretsQuery.byAppIdAsync(
projectId
);
const existingSecret = existingSecrets.find(secret => secret.name === name);
if (existingSecret) {
@@ -167,7 +231,7 @@ export default class EnvironmentSecretCreate extends EasCommand {
}
const secret = await EnvironmentSecretMutation.createForAccountAsync(
{ name, value: secretValue },
{ name, value: secretValue, type: SecretTypeToEnvironmentSecretType[secretType] },
ownerAccount.id
);
@@ -177,9 +241,19 @@ export default class EnvironmentSecretCreate extends EasCommand {
);
}
Log.withTick(
`️Created a new secret ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
if (secretType === SecretType.STRING) {
Log.withTick(
`️Created a new secret ${chalk.bold(name)} with value ${chalk.bold(
secretValue
)} on account ${chalk.bold(ownerAccount.name)}.`
);
} else {
Log.withTick(
`️Created a new secret ${chalk.bold(name)} from file ${chalk.bold(
secretFilePath
)} on account ${chalk.bold(ownerAccount.name)}.`
);
}
}
}
}
+35 -33
View File
@@ -1,6 +1,7 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentSecretMutation } from '../../graphql/mutations/EnvironmentSecretMutation';
import {
EnvironmentSecretScope,
@@ -8,12 +9,6 @@ import {
EnvironmentSecretsQuery,
} from '../../graphql/queries/EnvironmentSecretsQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
export default class EnvironmentSecretDelete extends EasCommand {
@@ -23,24 +18,29 @@ export default class EnvironmentSecretDelete extends EasCommand {
id: Flags.string({
description: 'ID of the secret to delete',
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectAccountName = await getProjectAccountNameAsync(exp);
let {
flags: { id },
flags: { id, 'non-interactive': nonInteractive },
} = await this.parse(EnvironmentSecretDelete);
const { projectId } = await this.getContextAsync(EnvironmentSecretDelete, {
nonInteractive,
});
let secret: EnvironmentSecretWithScope | undefined;
if (!id) {
const validationMessage = 'You must select which secret to delete.';
if (nonInteractive) {
throw new Error(validationMessage);
}
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectId);
const secrets = await EnvironmentSecretsQuery.allAsync(projectId);
({ secret } = await promptAsync({
type: 'autocomplete',
name: 'secret',
@@ -58,25 +58,27 @@ export default class EnvironmentSecretDelete extends EasCommand {
}
}
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete secret${
secret?.name ? ` "${secret?.name}"` : ''
} with id: "${id}".\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: `Are you sure you wish to proceed?${
secret?.scope === EnvironmentSecretScope.ACCOUNT
? ' This secret is applied across your whole account and may affect multiple apps.'
: ''
}`,
});
if (!confirmed) {
Log.error(
`Canceled deletion of secret${secret?.name ? ` "${secret?.name}"` : ''} with id: "${id}".`
if (!nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete secret${
secret?.name ? ` "${secret?.name}"` : ''
} with id: "${id}".\nThis action is irreversible.`
);
process.exit(1);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: `Are you sure you wish to proceed?${
secret?.scope === EnvironmentSecretScope.ACCOUNT
? ' This secret is applied across your whole account and may affect multiple apps.'
: ''
}`,
});
if (!confirmed) {
Log.error(
`Canceled deletion of secret${secret?.name ? ` "${secret?.name}"` : ''} with id: "${id}".`
);
process.exit(1);
}
}
await EnvironmentSecretMutation.deleteAsync(id);
+19 -19
View File
@@ -2,39 +2,39 @@ import chalk from 'chalk';
import Table from 'cli-table3';
import dateFormat from 'dateformat';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EnvironmentSecretsQuery } from '../../graphql/queries/EnvironmentSecretsQuery';
import { EnvironmentSecretTypeToSecretType } from '../../graphql/types/EnvironmentSecret';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
export default class EnvironmentSecretList extends EasCommand {
static override description = 'list environment secrets available for your current app';
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectAccountName = await getProjectAccountNameAsync(exp);
const { projectId } = await this.getContextAsync(EnvironmentSecretList, {
nonInteractive: true,
});
if (!projectDir) {
throw new Error("Run this command inside your project's directory");
}
const secrets = await EnvironmentSecretsQuery.allAsync(projectAccountName, projectId);
const secrets = await EnvironmentSecretsQuery.allAsync(projectId);
const table = new Table({
head: ['Name', 'Scope', 'ID', 'Updated at'],
head: ['Name', 'Type', 'Scope', 'ID', 'Updated at'],
wordWrap: true,
});
for (const secret of secrets) {
const { name, createdAt: updatedAt, scope, id } = secret;
table.push([name, scope, id, dateFormat(updatedAt, 'mmm dd HH:MM:ss')]);
const { name, createdAt: updatedAt, scope, id, type } = secret;
table.push([
name,
EnvironmentSecretTypeToSecretType[type],
scope,
id,
dateFormat(updatedAt, 'mmm dd HH:MM:ss'),
]);
}
Log.log(chalk`{bold Secrets for this account and project:}`);
+15 -7
View File
@@ -1,8 +1,11 @@
import { EasJsonReader } from '@expo/eas-json';
import { EasJsonAccessor } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../commandUtils/EasCommand';
import EasCommand, {
EASCommandLoggedInContext,
EASCommandProjectIdContext,
} from '../commandUtils/EasCommand';
import { StatuspageServiceName, SubmissionFragment } from '../graphql/generated';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
@@ -13,8 +16,7 @@ import {
selectRequestedPlatformAsync,
toPlatforms,
} from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { findProjectRootAsync } from '../project/projectUtils';
import { SubmitArchiveFlags, createSubmissionContextAsync } from '../submit/context';
import {
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
@@ -56,6 +58,7 @@ export default class Submit extends EasCommand {
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
@@ -90,14 +93,18 @@ export default class Submit extends EasCommand {
}),
};
static override contextDefinition = {
...EASCommandLoggedInContext,
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(Submit);
const { actor, projectId } = await this.getContextAsync(Submit, { nonInteractive: false });
const flags = this.sanitizeFlags(rawFlags);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
await maybeWarnAboutEasOutagesAsync([StatuspageServiceName.EasSubmit]);
@@ -106,7 +113,7 @@ export default class Submit extends EasCommand {
const platforms = toPlatforms(flagsWithPlatform.requestedPlatform);
const submissionProfiles = await getProfilesAsync({
type: 'submit',
easJsonReader: new EasJsonReader(projectDir),
easJsonAccessor: new EasJsonAccessor(projectDir),
platforms,
profileName: flagsWithPlatform.profile,
});
@@ -120,6 +127,7 @@ export default class Submit extends EasCommand {
profile: submissionProfile.profile,
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
actor,
});
if (submissionProfiles.length > 1) {
@@ -5,14 +5,13 @@ import assert from 'assert';
import chalk from 'chalk';
import { getEASUpdateURL } from '../../api';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { AppPlatform } from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import { RequestedPlatform, appPlatformDisplayNames } from '../../platform';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectIdAsync,
installExpoUpdatesAsync,
isExpoUpdatesInstalledOrAvailable,
} from '../../project/projectUtils';
@@ -35,11 +34,18 @@ export default class UpdateConfigure extends EasCommand {
}),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
Log.log(
'💡 The following process will configure your project to run EAS Update. These changes only apply to your local project files and you can safely revert them at any time.'
);
const { flags } = await this.parse(UpdateConfigure);
const { projectId } = await this.getContextAsync(UpdateConfigure, {
nonInteractive: true,
});
const platform = flags.platform as RequestedPlatform;
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
@@ -61,6 +67,7 @@ export default class UpdateConfigure extends EasCommand {
android: androidWorkflow,
ios: iosWorkflow,
},
projectId,
});
Log.withTick(`Configured ${chalk.bold('app.json')} for EAS Update`);
@@ -69,14 +76,14 @@ export default class UpdateConfigure extends EasCommand {
[RequestedPlatform.Android, RequestedPlatform.All].includes(platform) &&
androidWorkflow === Workflow.GENERIC
) {
await syncAndroidUpdatesConfigurationAsync(projectDir, updatedExp);
await syncAndroidUpdatesConfigurationAsync(projectDir, updatedExp, projectId);
Log.withTick(`Configured ${chalk.bold('AndroidManifest.xml')} for EAS Update`);
}
if (
[RequestedPlatform.Ios, RequestedPlatform.All].includes(platform) &&
iosWorkflow === Workflow.GENERIC
) {
await syncIosUpdatesConfigurationAsync(projectDir, updatedExp);
await syncIosUpdatesConfigurationAsync(projectDir, updatedExp, projectId);
Log.withTick(`Configured ${chalk.bold('Expo.plist')} for EAS Update`);
}
@@ -90,6 +97,7 @@ async function configureAppJSONForEASUpdateAsync({
exp,
platform,
workflows,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
@@ -97,8 +105,9 @@ async function configureAppJSONForEASUpdateAsync({
workflows: {
[key in RequestedPlatform.Android | RequestedPlatform.Ios]: Workflow;
};
projectId: string;
}): Promise<ExpoConfig> {
const projectId = await getProjectIdAsync(exp);
// this command is non-interactive in the way it was designed
const easUpdateURL = getEASUpdateURL(projectId);
const updates = { ...exp.updates, url: easUpdateURL };
@@ -1,8 +1,8 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateGroupMutation,
@@ -47,21 +47,20 @@ export default class UpdateDelete extends EasCommand {
];
static override flags = {
json: Flags.boolean({
description: `Return a json with the group ID of the deleted updates.`,
default: false,
}),
...EasNonInteractiveAndJsonFlags,
};
async runAsync(): Promise<void> {
const {
args: { groupId: group },
flags: { json: jsonFlag },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
} = await this.parse(UpdateDelete);
if (jsonFlag) {
enableJsonOutput();
} else {
}
if (!nonInteractive) {
const shouldAbort = await confirmAsync({
message:
`🚨${chalk.red('CAUTION')}🚨\n\n` +
+93 -249
View File
@@ -4,27 +4,23 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import dateFormat from 'dateformat';
import gql from 'graphql-tag';
import nullthrows from 'nullthrows';
import { getEASUpdateURL } from '../../api';
import { BRANCHES_LIMIT } from '../../branch/queries';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { BranchNotFoundError, getDefaultBranchNameAsync } from '../../branch/utils';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import {
GetUpdateGroupAsyncQuery,
PublishUpdateGroupInput,
Robot,
RootQueryUpdatesByGroupArgs,
StatuspageServiceName,
Update,
UpdateInfoGroup,
UpdatePublishMutation,
User,
ViewBranchUpdatesQuery,
ViewBranchQueryVariables,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
@@ -34,8 +30,7 @@ import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectAccountName,
getProjectIdAsync,
getOwnerAccountForProjectIdAsync,
installExpoUpdatesAsync,
isExpoUpdatesInstalledOrAvailable,
} from '../../project/projectUtils';
@@ -48,16 +43,15 @@ import {
uploadAssetsAsync,
} from '../../project/publish';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { formatUpdate } from '../../update/utils';
import { ensureLoggedInAsync } from '../../user/actions';
import { confirmAsync, promptAsync } from '../../prompts';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { formatUpdateMessage } from '../../update/utils';
import {
checkManifestBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getManifestBodyAsync,
signManifestBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
@@ -68,34 +62,6 @@ import { createUpdateChannelOnAppAsync } from '../channel/create';
export const defaultPublishPlatforms: PublishPlatform[] = ['android', 'ios'];
export type PublishPlatformFlag = PublishPlatform | 'all';
async function getUpdateGroupAsync({
group,
}: RootQueryUpdatesByGroupArgs): Promise<GetUpdateGroupAsyncQuery['updatesByGroup']> {
const { updatesByGroup } = await withErrorHandlingAsync(
graphqlClient
.query<GetUpdateGroupAsyncQuery, RootQueryUpdatesByGroupArgs>(
gql`
query getUpdateGroupAsync($group: ID!) {
updatesByGroup(group: $group) {
id
group
runtimeVersion
manifestFragment
platform
message
}
}
`,
{
group,
},
{ additionalTypenames: ['Update'] }
)
.toPromise()
);
return updatesByGroup;
}
async function ensureChannelExistsAsync({
appId,
branchId,
@@ -127,37 +93,32 @@ async function ensureChannelExistsAsync({
export async function ensureBranchExistsAsync({
appId,
name: branchName,
limit,
offset,
}: {
appId: string;
name: string;
limit: number;
offset: number;
}): Promise<{
id: string;
updates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'];
}: ViewBranchQueryVariables): Promise<{
branchId: string;
}> {
const { app } = await UpdateQuery.viewBranchAsync({
appId,
name: branchName,
limit,
offset,
});
const updateBranch = app?.byId.updateBranchByName;
if (updateBranch) {
const { id, updates } = updateBranch;
await ensureChannelExistsAsync({ appId, branchId: id, channelName: branchName });
return { id, updates };
}
try {
const updateBranch = await BranchQuery.getBranchByNameAsync({
appId,
name: branchName,
});
const newUpdateBranch = await createUpdateBranchOnAppAsync({ appId, name: branchName });
Log.withTick(`Created branch: ${chalk.bold(branchName)}`);
await ensureChannelExistsAsync({ appId, branchId: newUpdateBranch.id, channelName: branchName });
return { id: newUpdateBranch.id, updates: [] };
const { id } = updateBranch;
await ensureChannelExistsAsync({ appId, branchId: id, channelName: branchName });
return { branchId: id };
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newUpdateBranch = await createUpdateBranchOnAppAsync({ appId, name: branchName });
Log.withTick(`Created branch: ${chalk.bold(branchName)}`);
await ensureChannelExistsAsync({
appId,
branchId: newUpdateBranch.id,
channelName: branchName,
});
return { branchId: newUpdateBranch.id };
} else {
throw error;
}
}
}
export default class UpdatePublish extends EasCommand {
@@ -195,10 +156,6 @@ export default class UpdatePublish extends EasCommand {
default: 'all',
required: false,
}),
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr',
default: false,
}),
auto: Flags.boolean({
description:
'Use the current git branch and commit message for the EAS branch and update message',
@@ -208,28 +165,32 @@ export default class UpdatePublish extends EasCommand {
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
required: false,
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdatePublish);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
let {
flags: {
branch: branchName,
json: jsonFlag,
auto: autoFlag,
message,
republish,
group,
'input-dir': inputDir,
'skip-bundler': skipBundler,
platform,
'private-key-path': privateKeyPath,
'non-interactive': nonInteractive,
},
} = await this.parse(UpdatePublish);
branch: branchName,
auto: autoFlag,
message,
republish,
group,
'input-dir': inputDir,
'skip-bundler': skipBundler,
platform,
'private-key-path': privateKeyPath,
'non-interactive': nonInteractive,
json: jsonFlag,
} = flags;
const { projectId } = await this.getContextAsync(UpdatePublish, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
@@ -237,7 +198,7 @@ export default class UpdatePublish extends EasCommand {
const platformFlag = platform as PublishPlatformFlag;
// If a group was specified, that means we are republishing it.
republish = group ? true : republish;
republish = republish || !!group;
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir, {
@@ -279,54 +240,40 @@ export default class UpdatePublish extends EasCommand {
}
const runtimeVersions = await getRuntimeVersionObjectAsync(exp, platformFlag, projectDir);
const projectId = await getProjectIdAsync(exp);
await checkEASUpdateURLIsSetAsync(exp);
if (!branchName && autoFlag) {
branchName =
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substr(2, 4)}`;
}
await checkEASUpdateURLIsSetAsync(exp, projectId);
if (!branchName) {
if (nonInteractive) {
if (autoFlag) {
branchName = await getDefaultBranchNameAsync();
} else if (nonInteractive) {
throw new Error('Must supply --branch or use --auto when in non-interactive mode');
}
const validationMessage = 'Branch name may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
const branches = await BranchQuery.listBranchesAsync({
appId: projectId,
limit: BRANCHES_LIMIT,
offset: 0,
});
if (branches.length === 0) {
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Provide a branch name:',
initial:
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substr(2, 4)}`,
validate: value => (value ? true : validationMessage),
}));
} else {
branchName = await selectAsync<string>(
'Which branch would you like to publish on?',
branches.map(branch => {
return {
title: `${branch.name} ${chalk.grey(
`- current update: ${formatUpdate(branch.updates[0])}`
try {
const branch = await selectBranchOnAppAsync({
projectId,
promptTitle: `Which branch would you like to ${
republish ? 'republish' : 'publish'
} on?`,
displayTextForListItem: updateBranch =>
`${updateBranch.name} ${chalk.grey(
`- current update: ${formatUpdateMessage(updateBranch.updates[0])}`
)}`,
value: branch.name,
};
})
);
paginatedQueryOptions,
});
branchName = branch.name;
} catch {
// unable to select a branch (network error or no branches for project)
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Provide a branch name:',
initial: await getDefaultBranchNameAsync(),
validate: value => (value ? true : 'Branch name may not be empty.'),
}));
}
assert(branchName, 'Branch name must be specified.');
}
assert(branchName, 'Branch name must be specified.');
}
let unsortedUpdateInfoGroups: UpdateInfoGroup = {};
@@ -342,23 +289,18 @@ export default class UpdatePublish extends EasCommand {
'group' | 'message' | 'runtimeVersion' | 'manifestFragment' | 'platform'
>[];
if (group) {
updatesToRepublish = await getUpdateGroupAsync({ group });
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync({ groupId: group });
updatesToRepublish = updatesByGroup;
} else {
if (nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
// Drop into interactive mode if the user has not specified an update group to republish.
if (jsonFlag) {
throw new Error('You must specify the update group to republish.');
}
updatesToRepublish = await getUpdatesToRepublishInteractiveAsync(
updatesToRepublish = await selectUpdateGroupOnBranchAsync({
projectId,
branchName,
platformFlag,
50
);
paginatedQueryOptions,
});
}
const updatesToRepublishFilteredByPlatform = updatesToRepublish.filter(
// Only republish to the specified platforms
@@ -484,11 +426,9 @@ export default class UpdatePublish extends EasCommand {
.map(pair => pair[0]);
}
const { id: branchId } = await ensureBranchExistsAsync({
const { branchId } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
limit: 0,
offset: 0,
});
// Sort the updates into different groups based on their platform specific runtime versions
@@ -594,7 +534,7 @@ export default class UpdatePublish extends EasCommand {
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const accountName = (await getOwnerAccountForProjectIdAsync(projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
@@ -630,73 +570,6 @@ export default class UpdatePublish extends EasCommand {
}
}
export async function getUpdatesToRepublishInteractiveAsync(
projectId: string,
branchName: string,
platformFlag: string,
pageSize: number,
offset: number = 0,
cumulativeUpdates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] = []
): Promise<
Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates']
> {
const fetchMoreValue = '_fetchMore';
const { updates } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
limit: pageSize + 1, // fetch an extra item so we know if there are additional pages
offset,
});
cumulativeUpdates = [
...cumulativeUpdates,
// drop that extra item used for pagination from our render logic
...updates.slice(0, updates.length - 1),
];
const cumulativeUpdatesForTargetPlatforms =
platformFlag === 'all'
? cumulativeUpdates
: cumulativeUpdates.filter(update => {
// Only show groups that have updates on the specified platform(s).
return update.platform === platformFlag;
});
const updateGroups = uniqBy(cumulativeUpdatesForTargetPlatforms, u => u.group).map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (updateGroups.length === 0) {
throw new Error(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
}
if (updates.length > pageSize) {
updateGroups.push({ title: 'Next page...', value: fetchMoreValue });
}
const selectedUpdateGroup = await selectAsync<string>(
'Which update would you like to republish?',
updateGroups
);
if (selectedUpdateGroup === fetchMoreValue) {
return await getUpdatesToRepublishInteractiveAsync(
projectId,
branchName,
platformFlag,
pageSize,
offset + pageSize,
cumulativeUpdates
);
}
return cumulativeUpdates.filter(update => update.group === selectedUpdateGroup);
}
async function getRuntimeVersionObjectAsync(
exp: ExpoConfig,
platformFlag: PublishPlatformFlag,
@@ -727,37 +600,8 @@ async function getRuntimeVersionObjectAsync(
);
}
function formatUpdateTitle(
update: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'][number]
): string {
const { message, createdAt, actor, runtimeVersion } = update;
let actorName: string;
switch (actor?.__typename) {
case 'User': {
actorName = (actor as Pick<User, 'username' | 'id'>).username;
break;
}
case 'Robot': {
const { firstName, id } = actor as Pick<Robot, 'firstName' | 'id'>;
actorName = firstName ?? `robot: ${id.slice(0, 4)}...`;
break;
}
default:
actorName = 'unknown';
}
return `[${dateFormat(
createdAt,
'mmm dd HH:MM'
)} by ${actorName}, runtimeVersion: ${runtimeVersion}] ${message}`;
}
async function checkEASUpdateURLIsSetAsync(exp: ExpoConfig): Promise<void> {
async function checkEASUpdateURLIsSetAsync(exp: ExpoConfig, projectId: string): Promise<void> {
const configuredURL = exp.updates?.url;
const projectId = await getProjectIdAsync(exp);
const expectedURL = getEASUpdateURL(projectId);
if (configuredURL !== expectedURL) {
+56 -94
View File
@@ -1,124 +1,86 @@
import { Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import CliTable from 'cli-table3';
import EasCommand from '../../commandUtils/EasCommand';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { UPDATES_LIMIT } from '../../update/queries';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
UPDATE_COLUMNS,
UpdateGroupDescription,
formatUpdate,
getUpdateGroupsWithPlatforms,
} from '../../update/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getVcsClient } from '../../vcs';
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
getPaginatedQueryOptions,
} from '../../commandUtils/pagination';
import {
listAndRenderUpdateGroupsOnAppAsync,
listAndRenderUpdateGroupsOnBranchAsync,
} from '../../update/queries';
import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view the recent updates for a branch';
export default class UpdateList extends EasCommand {
static override description = 'view the recent updates';
static override flags = {
branch: Flags.string({
description: 'List all updates on this branch',
description: 'List updates only on this branch',
exclusive: ['all'],
}),
all: Flags.boolean({
description: 'List all updates associated with this project',
description: 'List updates on all branches',
exclusive: ['branch'],
default: false,
}),
json: Flags.boolean({
description: `Return a json with all of the recent update groups.`,
default: false,
}),
...EasPaginatedQueryFlags,
limit: getLimitFlagWithCustomValues({ defaultTo: 25, limit: 50 }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
flags: { branch: branchFlag, all, json: jsonFlag },
} = await this.parse(BranchView);
const { flags } = await this.parse(UpdateList);
const { branch: branchFlag, all, json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { projectId } = await this.getContextAsync(UpdateList, {
nonInteractive,
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
enableJsonOutput();
}
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
let updateGroupDescriptions: UpdateGroupDescription[];
if (all) {
const branchesAndUpdates = await UpdateQuery.viewAllAsync({
appId: projectId,
limit: UPDATES_LIMIT,
offset: 0,
});
updateGroupDescriptions = getUpdateGroupsWithPlatforms(
branchesAndUpdates.app.byId.updates.map(update => ({
...update,
branch: update.branch.name,
}))
).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
listAndRenderUpdateGroupsOnAppAsync({ projectId, paginatedQueryOptions });
} else {
let branchInteractive: string | undefined;
if (!branchFlag) {
if (branchFlag) {
listAndRenderUpdateGroupsOnBranchAsync({
projectId,
branchName: branchFlag,
paginatedQueryOptions,
});
} else {
const validationMessage = 'Branch name may not be empty.';
if (jsonFlag) {
if (nonInteractive) {
throw new Error(validationMessage);
}
({ name: branchInteractive } = await promptAsync({
type: 'text',
name: 'name',
message: 'Provide the name of the branch whose updates you wish to view:',
initial: (await getVcsClient().getBranchNameAsync()) ?? undefined,
validate: (value: any) => (value ? true : validationMessage),
}));
}
const branch = branchFlag ?? branchInteractive;
assert(branch, 'Branch name may not be empty.');
const branchesAndUpdates = await UpdateQuery.viewBranchAsync({
appId: projectId,
name: branch,
limit: UPDATES_LIMIT,
offset: 0,
});
const UpdateBranch = branchesAndUpdates.app?.byId.updateBranchByName;
if (!UpdateBranch) {
throw new Error(`Could not find branch "${branch}"`);
const selectedBranch = await selectBranchOnAppAsync({
projectId,
promptTitle: 'Which branch would you like to view?',
displayTextForListItem: updateBranch => updateBranch.name,
paginatedQueryOptions:
// discard limit and offset because this query is not those flag's intended target
{
json: paginatedQueryOptions.json,
nonInteractive: paginatedQueryOptions.nonInteractive,
offset: 0,
},
});
listAndRenderUpdateGroupsOnBranchAsync({
projectId,
branchName: selectedBranch.name,
paginatedQueryOptions,
});
}
updateGroupDescriptions = getUpdateGroupsWithPlatforms(
UpdateBranch.updates.map(update => ({ ...update, branch }))
).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
if (jsonFlag) {
printJsonOnlyOutput(updateGroupDescriptions);
} else {
logAsTable(updateGroupDescriptions);
}
}
}
function logAsTable(updateGroupDescriptions: UpdateGroupDescription[]): void {
const table = new CliTable({
head: ['Branch', ...UPDATE_COLUMNS],
wordWrap: true,
});
table.push(
...updateGroupDescriptions.map(updateGroupDescription => [
updateGroupDescription.branch,
formatUpdate(updateGroupDescription),
updateGroupDescription.runtimeVersion,
updateGroupDescription.group,
updateGroupDescription.platforms,
])
);
Log.addNewLineIfNone();
Log.log(chalk.bold('Recently published update groups:'));
Log.log(table.toString());
}
+15 -61
View File
@@ -1,55 +1,14 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import Table from 'cli-table3';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
import { UpdatesByGroupQuery, UpdatesByGroupQueryVariables } from '../../graphql/generated';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { UPDATE_COLUMNS, formatUpdate, getPlatformsForGroup } from '../../update/utils';
import { UPDATE_COLUMNS, getUpdateGroupDescriptions } from '../../update/utils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function viewUpdateAsync({
groupId,
}: {
groupId: string;
}): Promise<UpdatesByGroupQuery> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<UpdatesByGroupQuery, UpdatesByGroupQueryVariables>(
gql`
query UpdatesByGroup($groupId: ID!) {
updatesByGroup(group: $groupId) {
id
group
runtimeVersion
platform
message
actor {
id
... on User {
username
}
... on Robot {
firstName
}
}
createdAt
}
}
`,
{
groupId,
},
{ additionalTypenames: ['Update'] }
)
.toPromise()
);
if (data.updatesByGroup.length === 0) {
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
}
return data;
}
export default class UpdateView extends EasCommand {
static override description = 'update group details';
@@ -62,10 +21,7 @@ export default class UpdateView extends EasCommand {
];
static override flags = {
json: Flags.boolean({
description: `Return a json with the updates belonging to the group.`,
default: false,
}),
...EasJsonOnlyFlag,
};
async runAsync(): Promise<void> {
@@ -77,27 +33,25 @@ export default class UpdateView extends EasCommand {
enableJsonOutput();
}
const { updatesByGroup } = await viewUpdateAsync({ groupId });
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync({ groupId });
const [updateGroupDescription] = getUpdateGroupDescriptions([updatesByGroup]);
if (jsonFlag) {
printJsonOnlyOutput(updatesByGroup);
printJsonOnlyOutput(updateGroupDescription);
} else {
const groupTable = new Table({
head: [...UPDATE_COLUMNS],
wordWrap: true,
});
const representativeUpdate = updatesByGroup[0];
Log.log(chalk.bold('Update group:'));
Log.log(formatFields([{ label: 'ID', value: updateGroupDescription.group }]));
groupTable.push([
formatUpdate(representativeUpdate),
representativeUpdate.runtimeVersion,
representativeUpdate.group,
getPlatformsForGroup({
updates: updatesByGroup,
group: updatesByGroup[0]?.group,
}),
updateGroupDescription.message,
updateGroupDescription.runtimeVersion,
updateGroupDescription.group,
updateGroupDescription.platforms,
]);
Log.log(groupTable.toString());
}
}
@@ -1,11 +1,10 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { WebhookType } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { prepareInputParamsAsync } from '../../webhooks/input';
export default class WebhookCreate extends EasCommand {
@@ -23,16 +22,20 @@ export default class WebhookCreate extends EasCommand {
description:
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
}),
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(WebhookCreate);
const { projectId } = await this.getContextAsync(WebhookCreate, {
nonInteractive: flags['non-interactive'],
});
const webhookInputParams = await prepareInputParamsAsync(flags);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const spinner = ora('Creating a webhook').start();
try {
await WebhookMutation.createWebhookAsync(projectId, webhookInputParams);
+29 -15
View File
@@ -2,14 +2,13 @@ import assert from 'assert';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { WebhookFragment } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, toggleConfirmAsync } from '../../prompts';
import { formatWebhook } from '../../webhooks/formatWebhook';
@@ -24,14 +23,22 @@ export default class WebhookDelete extends EasCommand {
},
];
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
let {
args: { ID: webhookId },
flags: { 'non-interactive': nonInteractive },
} = await this.parse(WebhookDelete);
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const { projectId } = await this.getContextAsync(WebhookDelete, {
nonInteractive,
});
let webhook: WebhookFragment | undefined =
webhookId && (await WebhookQuery.byIdAsync(webhookId));
@@ -40,6 +47,11 @@ export default class WebhookDelete extends EasCommand {
if (webhooks.length === 0) {
process.exit(1);
}
if (nonInteractive) {
throw new Error('Must supply ID argument in non-interactive mode');
}
({ webhook } = await promptAsync({
type: 'autocomplete',
name: 'webhook',
@@ -57,15 +69,17 @@ export default class WebhookDelete extends EasCommand {
Log.addNewLineIfNone();
Log.log(formatWebhook(webhook));
Log.newLine();
Log.warn(`You are about to permanently delete this webhook.\nThis action is irreversible.`);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error(`Canceled deletion of the webhook`);
process.exit(1);
if (!nonInteractive) {
Log.warn(`You are about to permanently delete this webhook.\nThis action is irreversible.`);
Log.newLine();
const confirmed = await toggleConfirmAsync({
message: 'Are you sure you wish to proceed?',
});
if (!confirmed) {
Log.error(`Canceled deletion of the webhook`);
process.exit(1);
}
}
const spinner = ora('Deleting the webhook').start();
+14 -15
View File
@@ -1,17 +1,12 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import EasCommand, { EASCommandProjectIdContext } from '../../commandUtils/EasCommand';
import { WebhookType } from '../../graphql/generated';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { formatWebhook } from '../../webhooks/formatWebhook';
export default class WebhookList extends EasCommand {
@@ -24,23 +19,27 @@ export default class WebhookList extends EasCommand {
}),
};
static override contextDefinition = {
...EASCommandProjectIdContext,
};
async runAsync(): Promise<void> {
const {
flags: { event },
} = await this.parse(WebhookList);
const { projectId } = await this.getContextAsync(WebhookList, {
nonInteractive: true,
});
const projectDir = await findProjectRootAsync();
const exp = getExpoConfig(projectDir);
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
const projectDisplayName = await getDisplayNameForProjectIdAsync(projectId);
const spinner = ora(`Fetching the list of webhook on project ${projectFullName}`).start();
const spinner = ora(`Fetching the list of webhook on project ${projectDisplayName}`).start();
try {
const webhooks = await WebhookQuery.byAppIdAsync(projectId, event && { event });
if (webhooks.length === 0) {
spinner.fail(`There are no webhooks on project ${projectFullName}`);
spinner.fail(`There are no webhooks on project ${projectDisplayName}`);
} else {
spinner.succeed(`Found ${webhooks.length} webhooks on project ${projectFullName}`);
spinner.succeed(`Found ${webhooks.length} webhooks on project ${projectDisplayName}`);
const list = webhooks
.map(webhook => formatWebhook(webhook))
.join(`\n\n${chalk.dim('———')}\n\n`);
@@ -48,7 +47,7 @@ export default class WebhookList extends EasCommand {
}
} catch (err) {
spinner.fail(
`Something went wrong and we couldn't fetch the webhook list for the project ${projectFullName}`
`Something went wrong and we couldn't fetch the webhook list for the project ${projectDisplayName}`
);
throw err;
}
@@ -1,6 +1,7 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { WebhookType } from '../../graphql/generated';
import { WebhookMutation } from '../../graphql/mutations/WebhookMutation';
import { WebhookQuery } from '../../graphql/queries/WebhookQuery';
@@ -27,6 +28,7 @@ export default class WebhookUpdate extends EasCommand {
description:
"Secret used to create a hash signature of the request payload, provided in the 'Expo-Signature' header.",
}),
...EASNonInteractiveFlag,
};
async runAsync(): Promise<void> {
@@ -36,7 +38,7 @@ export default class WebhookUpdate extends EasCommand {
const webhook = await WebhookQuery.byIdAsync(webhookId);
const webhookInputParams = await prepareInputParamsAsync(
pick(flags, ['event', 'url', 'secret']),
pick(flags, ['event', 'url', 'secret', 'non-interactive']),
webhook
);
@@ -7,6 +7,7 @@ import {
AppFragment,
CommonAndroidAppCredentialsFragment,
GoogleServiceAccountKeyFragment,
Role,
} from '../../graphql/generated';
import * as AndroidGraphqlClient from '../android/api/GraphqlClient';
import { Keystore } from '../android/credentials';
@@ -55,6 +56,18 @@ export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
export const testLegacyAndroidFcmFragment: AndroidFcmFragment = {
@@ -1,18 +1,40 @@
import { Actor } from '../../user/User';
import { AppFragment, Role } from '../../graphql/generated';
export const jester: Actor = {
__typename: 'User',
export const jester = {
__typename: 'User' as const,
id: 'jester-id',
username: 'jester',
accounts: [{ id: 'jester-account-id', name: 'jester' }],
primaryAccount: {
id: 'jester-account-id',
name: 'jester',
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
},
accounts: [
{
id: 'jester-account-id',
name: 'jester',
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
},
{
id: 'other-account-id',
name: 'other',
users: [{ role: Role.ViewOnly, actor: { id: 'jester-id' } }],
},
],
isExpoAdmin: false,
};
export const jester2: Actor = {
export const jester2 = {
__typename: 'User',
id: 'jester2-id',
username: 'jester2',
accounts: [{ id: 'jester2-account-id', name: 'jester2' }],
accounts: [
{
id: 'jester2-account-id',
name: 'jester2',
users: [{ role: Role.Admin, actor: { id: 'jester2-id' } }],
},
],
isExpoAdmin: false,
};
@@ -47,3 +69,10 @@ export const testAppJsonWithDifferentOwner = {
...testAppJson,
owner: jester2.username,
};
export const testAppQueryByIdResponse: AppFragment = {
id: testProjectId,
slug: testSlug,
fullName: testExperienceName,
ownerAccount: jester.accounts[0],
};
@@ -12,6 +12,7 @@ import {
CommonIosAppCredentialsFragment,
IosAppBuildCredentialsFragment,
IosDistributionType,
Role,
} from '../../graphql/generated';
import * as IosGraphqlClient from '../ios/api/GraphqlClient';
import {
@@ -55,6 +56,18 @@ export const testAppFragment: AppFragment = {
id: 'test-app-id',
fullName: '@testuser/testapp',
slug: 'testapp',
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
export const testAscApiKeyFragment: AppStoreConnectApiKeyFragment = {
@@ -5,9 +5,11 @@ import { AndroidAppBuildCredentialsFragment } from '../../../graphql/generated';
import { ora } from '../../../ora';
import { getApplicationIdAsync } from '../../../project/android/applicationId';
import { GradleBuildContext } from '../../../project/android/gradle';
import { getProjectAccountName, getProjectConfigDescription } from '../../../project/projectUtils';
import {
getOwnerAccountForProjectIdAsync,
getProjectConfigDescription,
} from '../../../project/projectUtils';
import { promptAsync } from '../../../prompts';
import { findAccountByName } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';
@@ -88,13 +90,9 @@ export async function getAppLookupParamsFromContextAsync(
ctx: CredentialsContext,
gradleContext?: GradleBuildContext
): Promise<AppLookupParams> {
ctx.ensureProjectContext();
const projectName = ctx.exp.slug;
const accountName = getProjectAccountName(ctx.exp, ctx.user);
const account = findAccountByName(ctx.user.accounts, accountName);
if (!account) {
throw new Error(`You do not have access to account: ${accountName}`);
}
const projectId = ctx.projectId;
const account = await getOwnerAccountForProjectIdAsync(projectId);
const androidApplicationIdentifier = await getApplicationIdAsync(
ctx.projectDir,
@@ -1,11 +1,10 @@
import { AndroidFcmFragment, AndroidFcmVersion } from '../../../graphql/generated';
import { AccountFragment, AndroidFcmFragment, AndroidFcmVersion } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
export class CreateFcm {
constructor(private account: Account) {}
constructor(private account: AccountFragment) {}
public async runAsync(ctx: CredentialsContext): Promise<AndroidFcmFragment> {
if (ctx.nonInteractive) {
@@ -1,10 +1,9 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import { AccountFragment, GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { GoogleServiceAccountKey } from '../credentials';
import {
@@ -13,7 +12,7 @@ import {
} from '../utils/googleServiceAccountKey';
export class CreateGoogleServiceAccountKey {
constructor(private account: Account) {}
constructor(private account: AccountFragment) {}
public async runAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKeyFragment> {
if (ctx.nonInteractive) {
@@ -1,7 +1,5 @@
import { AndroidKeystoreFragment } from '../../../graphql/generated';
import { AccountFragment, AndroidKeystoreFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { getProjectIdAsync } from '../../../project/projectUtils';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { KeystoreWithType, keystoreSchema } from '../credentials';
@@ -9,14 +7,14 @@ import { generateRandomKeystoreAsync } from '../utils/keystore';
import { getKeystoreWithType, validateKeystore } from '../utils/keystoreNew';
export class CreateKeystore {
constructor(private account: Account) {}
constructor(private account: AccountFragment) {}
public async runAsync(ctx: CredentialsContext): Promise<AndroidKeystoreFragment> {
if (ctx.nonInteractive) {
throw new Error(`New keystore cannot be created in non-interactive mode.`);
}
const projectId = await getProjectIdAsync(ctx.exp);
const projectId = ctx.projectId;
const keystore = await this.provideOrGenerateAsync(projectId);
const keystoreFragment = await ctx.android.createKeystoreAsync(this.account, keystore);
Log.succeed('Created keystore');
@@ -1,12 +1,11 @@
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import { AccountFragment, GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { selectGoogleServiceAccountKeyAsync } from '../utils/googleServiceAccountKey';
export class SelectAndRemoveGoogleServiceAccountKey {
constructor(private account: Account) {}
constructor(private account: AccountFragment) {}
async runAsync(ctx: CredentialsContext): Promise<void> {
if (ctx.nonInteractive) {
@@ -1,11 +1,10 @@
import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import { AccountFragment, GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { selectGoogleServiceAccountKeyAsync } from '../utils/googleServiceAccountKey';
export class UseExistingGoogleServiceAccountKey {
constructor(private account: Account) {}
constructor(private account: AccountFragment) {}
public async runAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKeyFragment | null> {
if (ctx.nonInteractive) {
@@ -1,9 +1,16 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testLegacyAndroidFcmFragment } from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { AssignFcm } from '../AssignFcm';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
jest.mock('../../../../graphql/queries/AppQuery');
describe(AssignFcm, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('assigns an fcm api key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,9 +1,16 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testGoogleServiceAccountKeyFragment } from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { AssignGoogleServiceAccountKey } from '../AssignGoogleServiceAccountKey';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
jest.mock('../../../../graphql/queries/AppQuery');
describe(AssignGoogleServiceAccountKey, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('assigns a Google Service Account key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,3 +1,4 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { confirmAsync, promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
@@ -7,6 +8,7 @@ import {
testLegacyAndroidBuildCredentialsFragment,
testLegacyAndroidFcmFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
canCopyLegacyCredentialsAsync,
@@ -17,8 +19,12 @@ import {
jest.mock('../../../../ora');
jest.mock('../../../../prompts');
jest.mocked(confirmAsync).mockImplementation(async () => true);
jest.mock('../../../../graphql/queries/AppQuery');
describe('BuildCredentialsUtils', () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
describe(canCopyLegacyCredentialsAsync, () => {
it('returns true if the user has legacy credentials and no modern ones', async () => {
const ctx = createCtxMock({
@@ -1,12 +1,19 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../../prompts';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateFcm } from '../CreateFcm';
jest.mock('../../../../prompts');
jest.mocked(promptAsync).mockImplementation(async () => ({ fcmApiKey: 'blah' }));
jest.mock('../../../../graphql/queries/AppQuery');
describe(CreateFcm, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('creates an fcm api key in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
@@ -1,6 +1,8 @@
import { vol } from 'memfs';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../../prompts';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateGoogleServiceAccountKey } from '../CreateGoogleServiceAccountKey';
@@ -10,12 +12,17 @@ jest.mock('fs');
jest.mocked(promptAsync).mockImplementation(async () => ({
filePath: '/google-service-account-key.json',
}));
jest.mock('../../../../graphql/queries/AppQuery');
beforeEach(() => {
vol.reset();
});
describe(CreateGoogleServiceAccountKey, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('creates a Google Service Account Key in Interactive Mode', async () => {
vol.fromJSON({
'/google-service-account-key.json': JSON.stringify({
@@ -1,4 +1,8 @@
import { jester as mockJester } from '../../../../credentials/__tests__/fixtures-constants';
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
@@ -6,15 +10,16 @@ import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../../project/ensureProjectExists');
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../utils/promptForCredentials');
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
describe('CreateKeystore', () => {
beforeEach(() => {
jest.mocked(generateRandomKeystoreAsync).mockReset();
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('creates a keystore in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
@@ -1,13 +1,19 @@
import fs from 'fs-extra';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testAndroidBuildCredentialsFragment } from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { DownloadKeystore } from '../DownloadKeystore';
jest.mock('fs-extra');
jest.mock('../../../../graphql/queries/AppQuery');
describe(DownloadKeystore, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('downloads a keystore', async () => {
const fsWriteFileSpy = jest.spyOn(fs, 'writeFile');
const ctx = createCtxMock({
@@ -1,16 +1,22 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { confirmAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidAppCredentialsFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { RemoveFcm } from '../RemoveFcm';
jest.mock('../../../../prompts');
jest.mocked(confirmAsync).mockImplementation(async () => true);
jest.mock('../../../../graphql/queries/AppQuery');
describe(RemoveFcm, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('removes an FCM API Key', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,5 +1,7 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { confirmAsync } from '../../../../prompts';
import { testAndroidBuildCredentialsFragment } from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { RemoveKeystore } from '../RemoveKeystore';
@@ -7,8 +9,13 @@ import { RemoveKeystore } from '../RemoveKeystore';
jest.mock('fs-extra');
jest.mock('../../../../prompts');
jest.mocked(confirmAsync).mockImplementation(async () => true);
jest.mock('../../../../graphql/queries/AppQuery');
describe(RemoveKeystore, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('removes a keystore', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,9 +1,13 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import {
getNewAndroidApiMock,
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import { jester as mockJester } from '../../../__tests__/fixtures-constants';
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
@@ -11,14 +15,15 @@ import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpBuildCredentials } from '../SetUpBuildCredentials';
jest.mock('../../../../ora');
jest.mock('../../../../project/ensureProjectExists');
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
describe('SetUpBuildCredentials', () => {
beforeEach(() => {
jest.mocked(generateRandomKeystoreAsync).mockReset();
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('skips setup when there are prior credentials', async () => {
@@ -1,12 +1,14 @@
import { vol } from 'memfs';
import { AndroidKeystoreType } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { confirmAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
SelectAndroidBuildCredentials,
@@ -21,12 +23,16 @@ jest.mock('fs');
jest.mock('../../../../prompts');
jest.mocked(confirmAsync).mockImplementation(async () => true);
jest.mock('../../../manager/SelectAndroidBuildCredentials');
jest.mock('../../../../graphql/queries/AppQuery');
beforeEach(() => {
vol.reset();
});
describe(SetUpBuildCredentialsFromCredentialsJson, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('sets up a new build configuration from credentials.json upon user request', async () => {
jest.mocked(SelectAndroidBuildCredentials).mockImplementation((() => {
return {
@@ -1,11 +1,13 @@
import { vol } from 'memfs';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testAndroidAppCredentialsFragment,
testGoogleServiceAccountKeyFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
@@ -16,12 +18,16 @@ jest.mock('fs');
jest.mocked(promptAsync).mockImplementation(async () => ({
filePath: '/google-service-account-key.json',
}));
jest.mock('../../../../graphql/queries/AppQuery');
beforeEach(() => {
vol.reset();
});
describe(SetUpGoogleServiceAccountKey, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('skips setup when there is a Google Service Account Key already assigned to the project', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,8 +1,10 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../../prompts';
import {
getNewAndroidApiMock,
testGoogleServiceAccountKeyFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { UseExistingGoogleServiceAccountKey } from '../UseExistingGoogleServiceAccountKey';
@@ -11,8 +13,12 @@ jest.mock('../../../../prompts');
jest
.mocked(promptAsync)
.mockImplementation(async () => ({ chosenKey: testGoogleServiceAccountKeyFragment }));
jest.mock('../../../../graphql/queries/AppQuery');
describe(UseExistingGoogleServiceAccountKey, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('uses an existing Google Service Account Key in Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -1,4 +1,5 @@
import {
AccountFragment,
AndroidAppBuildCredentialsFragment,
AndroidFcmFragment,
AndroidFcmVersion,
@@ -8,7 +9,6 @@ import {
GoogleServiceAccountKeyFragment,
} from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { Account } from '../../../user/Account';
import { GoogleServiceAccountKey, KeystoreWithType } from '../credentials';
import { AndroidAppBuildCredentialsMutation } from './graphql/mutations/AndroidAppBuildCredentialsMutation';
import { AndroidAppCredentialsMutation } from './graphql/mutations/AndroidAppCredentialsMutation';
@@ -19,7 +19,7 @@ import { AndroidAppCredentialsQuery } from './graphql/queries/AndroidAppCredenti
import { GoogleServiceAccountKeyQuery } from './graphql/queries/GoogleServiceAccountKeyQuery';
export interface AppLookupParams {
account: Account;
account: AccountFragment;
projectName: string;
androidApplicationIdentifier: string; // 'android.package' field in app.json
}
@@ -208,7 +208,7 @@ export async function createOrUpdateDefaultIosAppBuildCredentialsAsync(): Promis
}
export async function createKeystoreAsync(
account: Account,
account: AccountFragment,
keystore: KeystoreWithType
): Promise<AndroidKeystoreFragment> {
return await AndroidKeystoreMutation.createAndroidKeystoreAsync(
@@ -228,7 +228,7 @@ export async function deleteKeystoreAsync(keystore: AndroidKeystoreFragment): Pr
}
export async function createFcmAsync(
account: Account,
account: AccountFragment,
fcmApiKey: string,
version: AndroidFcmVersion
): Promise<AndroidFcmFragment> {
@@ -243,7 +243,7 @@ export async function deleteFcmAsync(fcm: AndroidFcmFragment): Promise<void> {
}
export async function createGoogleServiceAccountKeyAsync(
account: Account,
account: AccountFragment,
jsonKey: GoogleServiceAccountKey
): Promise<GoogleServiceAccountKeyFragment> {
return await GoogleServiceAccountKeyMutation.createGoogleServiceAccountKeyAsync(
@@ -261,7 +261,7 @@ export async function deleteGoogleServiceAccountKeyAsync(
}
export async function getGoogleServiceAccountKeysForAccountAsync(
account: Account
account: AccountFragment
): Promise<GoogleServiceAccountKeyFragment[]> {
return await GoogleServiceAccountKeyQuery.getAllForAccountAsync(account.name);
}
@@ -1,22 +1,28 @@
import mockdate from 'mockdate';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import Log from '../../../../log';
import {
testAndroidAppCredentialsFragment,
testGoogleServiceAccountKeyFragment,
testLegacyAndroidFcmFragment,
} from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { getAppLookupParamsFromContextAsync } from '../../actions/BuildCredentialsUtils';
import { displayAndroidAppCredentials } from '../printCredentials';
jest.mock('../../../../log');
jest.mock('chalk', () => ({ bold: jest.fn(log => log), cyan: { bold: jest.fn(log => log) } }));
jest.mock('../../../../graphql/queries/AppQuery');
mockdate.set(testLegacyAndroidFcmFragment.updatedAt);
mockdate.set(testGoogleServiceAccountKeyFragment.updatedAt);
describe('print credentials', () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('prints the AndroidAppCredentials fragment', async () => {
const ctx = createCtxMock();
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more