diff --git a/CHANGELOG.md b/CHANGELOG.md index 74de427db6..83a542ec5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon)) - [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon)) +- [eas-cli] Add `eas connections:asc` commands to manage App Store Connect connections for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index 6bafc90a56..6e6ef05207 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -90,6 +90,9 @@ eas --help COMMAND * [`eas channel:rollout [CHANNEL]`](#eas-channelrollout-channel) * [`eas channel:view [NAME]`](#eas-channelview-name) * [`eas config`](#eas-config) +* [`eas connections:asc:connect`](#eas-connectionsascconnect) +* [`eas connections:asc:disconnect`](#eas-connectionsascdisconnect) +* [`eas connections:asc:status`](#eas-connectionsascstatus) * [`eas credentials`](#eas-credentials) * [`eas credentials:configure-build`](#eas-credentialsconfigure-build) * [`eas deploy [options]`](#eas-deploy-options) @@ -970,6 +973,65 @@ DESCRIPTION _See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_ +## `eas connections:asc:connect` + +connect a project to an App Store Connect app + +``` +USAGE + $ eas connections:asc:connect [--api-key-id ] [--asc-app-id ] [--bundle-id ] [--json] + [--non-interactive] + +FLAGS + --api-key-id= Apple App Store Connect API Key ID + --asc-app-id= App Store Connect app identifier + --bundle-id= Filter discovered apps by bundle identifier + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + +DESCRIPTION + connect a project to an App Store Connect app +``` + +_See code: [packages/eas-cli/src/commands/connections/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/connections/asc/connect.ts)_ + +## `eas connections:asc:disconnect` + +disconnect the current project from its App Store Connect app + +``` +USAGE + $ eas connections:asc:disconnect [--yes] [--json] [--non-interactive] + +FLAGS + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + --yes Skip confirmation prompt + +DESCRIPTION + disconnect the current project from its App Store Connect app +``` + +_See code: [packages/eas-cli/src/commands/connections/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/connections/asc/disconnect.ts)_ + +## `eas connections:asc:status` + +show the App Store Connect app link status for the current project + +``` +USAGE + $ eas connections:asc:status [--json] [--non-interactive] + +FLAGS + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + +DESCRIPTION + show the App Store Connect app link status for the current project +``` + +_See code: [packages/eas-cli/src/commands/connections/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/connections/asc/status.ts)_ + ## `eas credentials` manage credentials diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index ec5baec603..8c55fbfaa2 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -6788,22 +6788,6 @@ "name": "createFcmV1Credential", "description": "Create a GoogleServiceAccountKeyEntity to store credential and\nconnect it with an edge from AndroidAppCredential", "args": [ - { - "name": "accountId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "androidAppCredentialsId", "description": null, @@ -8904,6 +8888,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "appStoreConnectApp", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appStoreUrl", "description": "ios.appStoreUrl field from most recent classic update manifest", @@ -15967,6 +15963,43 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "remoteAppStoreConnectApps", + "description": null, + "args": [ + { + "name": "bundleIdentifier", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "roles", "description": null, @@ -16321,6 +16354,305 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "description": null, + "fields": [ + { + "name": "app", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "App", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appStoreConnectApiKey", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectApiKey", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remoteAppStoreConnectApp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEventTypes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AppStoreConnectAppInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "appId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appStoreConnectApiKeyId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppStoreConnectAppMutation", + "description": null, + "fields": [ + { + "name": "createAppStoreConnectApp", + "description": "Create an App Store Connect app for an Expo app.", + "args": [ + { + "name": "appStoreConnectAppInput", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AppStoreConnectAppInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deleteAppStoreConnectApp", + "description": "Delete an App Store Connect app by ID.", + "args": [ + { + "name": "appStoreConnectAppId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeleteAppStoreConnectAppResult", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "AppStoreConnectUserRole", @@ -30359,6 +30691,33 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeleteAppStoreConnectAppResult", + "description": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DeleteAppleDeviceResult", @@ -36225,7 +36584,7 @@ "deprecationReason": null }, { - "name": "AppStoreConnectAppWebhookEntity", + "name": "AppStoreConnectAppEntity", "description": null, "isDeprecated": false, "deprecationReason": null @@ -41072,6 +41431,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "lastDeletionAttemptTime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "metadata", "description": null, @@ -41375,6 +41746,39 @@ "ofType": null } }, + "isDeprecated": true, + "deprecationReason": "Use scheduleGitHubRepositoryDeletion instead" + }, + { + "name": "scheduleGitHubRepositoryDeletion", + "description": "Delete a GitHub repository by ID in the background", + "args": [ + { + "name": "githubRepositoryId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BackgroundJobReceipt", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null } @@ -49873,6 +50277,73 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "description": null, + "fields": [ + { + "name": "appStoreIconUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bundleIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "RequestMethod", @@ -51160,6 +51631,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "appStoreConnectApp", + "description": "Mutations for App Store Connect apps.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectAppMutation", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appVersion", "description": "Mutations that modify an AppVersion", @@ -54455,6 +54942,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -55476,6 +55979,65 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "SizeBreakdownCategory", + "description": null, + "fields": [ + { + "name": "assetCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "category", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalBytes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Snack", @@ -61425,6 +61987,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "sizeBreakdownByCategory", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SizeBreakdownCategory", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "totalUniqueUsers", "description": null, @@ -63062,6 +63648,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -63889,6 +64491,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -75758,6 +76376,30 @@ "inputFields": null, "interfaces": null, "enumValues": [ + { + "name": "APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "EAS_SUBMIT", "description": null, diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 8e8b17d4db..d064966a8d 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -195,6 +195,9 @@ "channel": { "description": "manage update channels" }, + "connections": { + "description": "manage service connections" + }, "device": { "description": "manage Apple devices for Internal Distribution" }, diff --git a/packages/eas-cli/src/commands/connections/asc/__tests__/connect.test.ts b/packages/eas-cli/src/commands/connections/asc/__tests__/connect.test.ts new file mode 100644 index 0000000000..3878c22148 --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/__tests__/connect.test.ts @@ -0,0 +1,371 @@ +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasCommandError } from '../../../../commandUtils/errors'; +import { selectOrCreateAscApiKeyIdAsync } from '../../../../connections/asc/ascApiKey'; +import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery'; +import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation'; +import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery'; +import ConnectionsAscConnect from '../connect'; + +jest.mock('../../../../graphql/queries/AscAppLinkQuery'); +jest.mock('../../../../graphql/mutations/AscAppLinkMutation'); +jest.mock('../../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery'); +jest.mock('../../../../connections/asc/ascApiKey'); +jest.mock('../../../../log'); +jest.mock('../../../../ora'); + +const testProjectId = 'test-project-id'; +const mockMetadataConnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: { + id: 'asc-app-link-id', + ascAppIdentifier: '1234567890', + remoteAppStoreConnectApp: { + ascAppIdentifier: '1234567890', + bundleIdentifier: 'com.test.app', + name: 'Test App', + appStoreIconUrl: null, + }, + }, +}; + +const mockMetadataDisconnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: null, +}; + +const mockRemoteApps = [ + { + ascAppIdentifier: '9876543210', + bundleIdentifier: 'com.test.newapp', + name: 'New App', + appStoreIconUrl: null, + }, +]; + +describe(ConnectionsAscConnect, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const actor = { id: 'actor-id' } as any; + const analytics = {} as any; + const vcsClient = {} as any; + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fails when already connected', async () => { + jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'key-id', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow(EasCommandError); + }); + + it('connects successfully in non-interactive mode', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected) + .mockResolvedValueOnce(mockMetadataConnected); + jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps); + jest + .mocked(AscAppLinkMutation.createAppStoreConnectAppAsync) + .mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' }); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'key-id', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await command.runAsync(); + + expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + ascAppIdentifier: '9876543210', + appStoreConnectApiKeyId: 'key-id', + }); + }); + + it('accepts Apple key identifier in non-interactive mode', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected) + .mockResolvedValueOnce(mockMetadataConnected); + jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps); + jest + .mocked(AscAppLinkMutation.createAppStoreConnectAppAsync) + .mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' }); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'eas-key-uuid', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await command.runAsync(); + + expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + ascAppIdentifier: '9876543210', + appStoreConnectApiKeyId: 'eas-key-uuid', + }); + }); + + it('requires --api-key-id in non-interactive mode', async () => { + const command = new ConnectionsAscConnect( + ['--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow('--api-key-id is required'); + }); + + it('requires --asc-app-id in non-interactive mode', async () => { + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow('--asc-app-id is required'); + }); + + it('fails when asc-app-id is not found among discovered apps', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'key-id', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', 'nonexistent', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow('was not found among accessible apps'); + }); + + it('fails when passing EAS key id in non-interactive mode', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'eas-key-uuid', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'eas-key-uuid', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow( + 'No App Store Connect API key found with Apple key identifier' + ); + }); + + it('fails when multiple keys match Apple key identifier', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'eas-key-uuid-1', + keyIdentifier: 'FAKEKEY000', + }, + { + id: 'eas-key-uuid-2', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow( + 'Multiple App Store Connect API keys match Apple key identifier' + ); + }); + + it('fails when no accessible apps are discovered', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce([]); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'key-id', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow( + 'No accessible apps found on App Store Connect' + ); + }); + + it('fails when app discovery throws', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest + .mocked(AscAppLinkQuery.discoverAccessibleAppsAsync) + .mockRejectedValueOnce(new Error('discovery failed')); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([ + { + id: 'key-id', + keyIdentifier: 'FAKEKEY000', + }, + ] as any); + + const command = new ConnectionsAscConnect( + ['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await expect(command.runAsync()).rejects.toThrow('discovery failed'); + }); + + it('creates or selects key when no api-key-id is provided', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected) + .mockResolvedValueOnce(mockMetadataConnected); + jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([]); + jest.mocked(selectOrCreateAscApiKeyIdAsync).mockResolvedValueOnce('generated-key-id'); + jest.mocked(AscAppLinkQuery.discoverAccessibleAppsAsync).mockResolvedValueOnce(mockRemoteApps); + jest + .mocked(AscAppLinkMutation.createAppStoreConnectAppAsync) + .mockResolvedValueOnce({ id: 'new-link-id', ascAppIdentifier: '9876543210' }); + + const command = new ConnectionsAscConnect(['--asc-app-id', '9876543210'], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + projectDir: '/test/project', + analytics, + vcsClient, + loggedIn: { graphqlClient, actor }, + }); + + await command.runAsync(); + + expect(selectOrCreateAscApiKeyIdAsync).toHaveBeenCalled(); + expect(AscAppLinkMutation.createAppStoreConnectAppAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + ascAppIdentifier: '9876543210', + appStoreConnectApiKeyId: 'generated-key-id', + }); + }); +}); diff --git a/packages/eas-cli/src/commands/connections/asc/__tests__/disconnect.test.ts b/packages/eas-cli/src/commands/connections/asc/__tests__/disconnect.test.ts new file mode 100644 index 0000000000..7c9a2258fd --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/__tests__/disconnect.test.ts @@ -0,0 +1,133 @@ +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation'; +import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery'; +import Log from '../../../../log'; +import { toggleConfirmAsync } from '../../../../prompts'; +import ConnectionsAscDisconnect from '../disconnect'; + +jest.mock('../../../../graphql/queries/AscAppLinkQuery'); +jest.mock('../../../../graphql/mutations/AscAppLinkMutation'); +jest.mock('../../../../log'); +jest.mock('../../../../ora'); +jest.mock('../../../../prompts'); + +const testProjectId = 'test-project-id'; +const mockMetadataConnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: { + id: 'asc-app-link-id', + ascAppIdentifier: '1234567890', + remoteAppStoreConnectApp: { + ascAppIdentifier: '1234567890', + bundleIdentifier: 'com.test.app', + name: 'Test App', + appStoreIconUrl: null, + }, + }, +}; + +const mockMetadataDisconnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: null, +}; + +describe(ConnectionsAscDisconnect, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disconnects successfully with --yes', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataConnected) + .mockResolvedValueOnce(mockMetadataDisconnected); + jest.mocked(AscAppLinkMutation.deleteAppStoreConnectAppAsync).mockResolvedValueOnce(undefined); + + const command = new ConnectionsAscDisconnect(['--yes'], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + + expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).toHaveBeenCalledWith( + graphqlClient, + 'asc-app-link-id' + ); + expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenNthCalledWith( + 2, + graphqlClient, + testProjectId, + { + useCache: false, + } + ); + }); + + it('no-op when already disconnected', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + + const command = new ConnectionsAscDisconnect(['--yes'], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + + expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled(); + expect(jest.mocked(Log.log)).toHaveBeenCalled(); + }); + + it('prints json output when already disconnected in json mode', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + + const command = new ConnectionsAscDisconnect(['--yes', '--json'], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + + expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled(); + expect(jest.mocked(Log.log)).toHaveBeenCalledWith( + expect.stringContaining('"action": "disconnect"') + ); + }); + + it('cancels disconnection when confirmation is rejected', async () => { + const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected); + jest.mocked(toggleConfirmAsync).mockResolvedValueOnce(false); + + const command = new ConnectionsAscDisconnect([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await expect(command.runAsync()).rejects.toThrow('process.exit called'); + expect(AscAppLinkMutation.deleteAppStoreConnectAppAsync).not.toHaveBeenCalled(); + processExitSpy.mockRestore(); + }); +}); diff --git a/packages/eas-cli/src/commands/connections/asc/__tests__/status.test.ts b/packages/eas-cli/src/commands/connections/asc/__tests__/status.test.ts new file mode 100644 index 0000000000..6940144604 --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/__tests__/status.test.ts @@ -0,0 +1,106 @@ +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery'; +import Log from '../../../../log'; +import ConnectionsAscStatus from '../status'; + +jest.mock('../../../../graphql/queries/AscAppLinkQuery'); +jest.mock('../../../../log'); +jest.mock('../../../../ora'); + +const testProjectId = 'test-project-id'; +const mockMetadataConnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: { + id: 'asc-app-link-id', + ascAppIdentifier: '1234567890', + remoteAppStoreConnectApp: { + ascAppIdentifier: '1234567890', + bundleIdentifier: 'com.test.app', + name: 'Test App', + appStoreIconUrl: null, + }, + }, +}; + +const mockMetadataDisconnected = { + id: testProjectId, + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: null, +}; + +describe(ConnectionsAscStatus, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays connected status', async () => { + jest.mocked(AscAppLinkQuery.getAppMetadataAsync).mockResolvedValueOnce(mockMetadataConnected); + + const command = new ConnectionsAscStatus([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + expect(AscAppLinkQuery.getAppMetadataAsync).toHaveBeenCalledWith(graphqlClient, testProjectId); + expect(jest.mocked(Log.log)).toHaveBeenCalled(); + }); + + it('displays disconnected status', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + + const command = new ConnectionsAscStatus([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + expect(jest.mocked(Log.log)).toHaveBeenCalled(); + }); + + it('prints json output in json mode', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockResolvedValueOnce(mockMetadataDisconnected); + + const command = new ConnectionsAscStatus(['--json'], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await command.runAsync(); + expect(jest.mocked(Log.log)).toHaveBeenCalledWith( + expect.stringContaining('"action": "status"') + ); + }); + + it('throws when fetching status fails', async () => { + jest + .mocked(AscAppLinkQuery.getAppMetadataAsync) + .mockRejectedValueOnce(new Error('status failed')); + + const command = new ConnectionsAscStatus([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId: testProjectId, + loggedIn: { graphqlClient }, + }); + + await expect(command.runAsync()).rejects.toThrow('status failed'); + }); +}); diff --git a/packages/eas-cli/src/commands/connections/asc/connect.ts b/packages/eas-cli/src/commands/connections/asc/connect.ts new file mode 100644 index 0000000000..0e3577c7a1 --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/connect.ts @@ -0,0 +1,197 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { EasCommandError } from '../../../commandUtils/errors'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { CredentialsContext } from '../../../credentials/context'; +import { buildJsonOutput, formatAscAppLinkStatus } from '../../../connections/asc/utils'; +import { selectOrCreateAscApiKeyIdAsync } from '../../../connections/asc/ascApiKey'; +import { AppStoreConnectApiKeyQuery } from '../../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery'; +import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation'; +import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { selectAsync } from '../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +type DiscoveredAscApps = Awaited>; + +export default class ConnectionsAscConnect extends EasCommand { + static override description = 'connect a project to an App Store Connect app'; + + static override flags = { + 'api-key-id': Flags.string({ + description: 'Apple App Store Connect API Key ID', + }), + 'asc-app-id': Flags.string({ + description: 'App Store Connect app identifier', + }), + 'bundle-id': Flags.string({ + description: 'Filter discovered apps by bundle identifier', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.ProjectDir, + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.Analytics, + ...this.ContextOptions.Vcs, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ConnectionsAscConnect); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + + if (nonInteractive) { + if (!flags['api-key-id']) { + throw new EasCommandError('--api-key-id is required in non-interactive mode.'); + } + if (!flags['asc-app-id']) { + throw new EasCommandError('--asc-app-id is required in non-interactive mode.'); + } + } + + const { + projectId, + projectDir, + loggedIn: { actor, graphqlClient }, + analytics, + vcsClient, + } = await this.getContextAsync(ConnectionsAscConnect, { + nonInteractive, + }); + + // Step 1: Check current status + const statusSpinner = ora('Checking current App Store Connect app link status').start(); + const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId); + statusSpinner.succeed('Checked current status'); + + if (metadata.appStoreConnectApp) { + throw new EasCommandError( + `Project ${chalk.bold(metadata.fullName)} is already connected to App Store Connect app ${chalk.bold(metadata.appStoreConnectApp.ascAppIdentifier)}. Disconnect first with ${chalk.bold('eas connections asc disconnect')}.` + ); + } + + // Step 2: Get ASC API key + const keysSpinner = ora('Fetching App Store Connect API keys').start(); + const keys = await AppStoreConnectApiKeyQuery.getAllForAccountAsync( + graphqlClient, + metadata.ownerAccount.name + ); + keysSpinner.succeed(`Found ${keys.length} API key(s)`); + + let apiKeyId = flags['api-key-id']; + if (!apiKeyId) { + const credentialsContext = new CredentialsContext({ + projectInfo: null, + nonInteractive, + projectDir, + user: actor, + graphqlClient, + analytics, + vcsClient, + }); + + apiKeyId = await selectOrCreateAscApiKeyIdAsync({ + credentialsContext, + existingKeys: keys, + ownerAccount: metadata.ownerAccount, + }); + } else { + const keysByAppleId = keys.filter(key => key.keyIdentifier === apiKeyId); + if (keysByAppleId.length > 1) { + throw new EasCommandError( + `Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".` + ); + } else if (keysByAppleId.length === 1) { + apiKeyId = keysByAppleId[0].id; + } else { + throw new EasCommandError( + `No App Store Connect API key found with Apple key identifier "${apiKeyId}".` + ); + } + } + if (!apiKeyId) { + throw new EasCommandError('No App Store Connect API key selected.'); + } + + // Step 3: Discover remote apps + const discoverSpinner = ora('Discovering App Store Connect apps').start(); + let remoteApps: DiscoveredAscApps; + try { + remoteApps = await AscAppLinkQuery.discoverAccessibleAppsAsync( + graphqlClient, + apiKeyId, + flags['bundle-id'] + ); + discoverSpinner.succeed(`Found ${remoteApps.length} app(s) on App Store Connect`); + } catch (err) { + discoverSpinner.fail('Failed to discover apps'); + throw err; + } + + if (remoteApps.length === 0) { + throw new EasCommandError( + 'No accessible apps found on App Store Connect for the selected API key.' + + (flags['bundle-id'] + ? ` Try removing the --bundle-id filter or verify the bundle ID "${flags['bundle-id']}".` + : '') + ); + } + + // Step 4: Select remote app + let selectedApp: DiscoveredAscApps[number]; + if (flags['asc-app-id']) { + const match = remoteApps.find(app => app.ascAppIdentifier === flags['asc-app-id']); + if (!match) { + throw new EasCommandError( + `App with identifier "${flags['asc-app-id']}" was not found among accessible apps. Run ${chalk.bold('eas connections asc connect')} interactively to discover available apps.` + ); + } + selectedApp = match; + } else { + selectedApp = await selectAsync( + 'Select an App Store Connect app:', + remoteApps.map(app => ({ + title: `${app.name} (${app.bundleIdentifier}) [${app.ascAppIdentifier}]`, + value: app, + })) + ); + } + + // Step 5: Create link + const createSpinner = ora('Connecting project to App Store Connect app').start(); + try { + await AscAppLinkMutation.createAppStoreConnectAppAsync(graphqlClient, { + appId: metadata.id, + ascAppIdentifier: selectedApp.ascAppIdentifier, + appStoreConnectApiKeyId: apiKeyId, + }); + createSpinner.succeed('Connected project to App Store Connect app'); + } catch (err) { + createSpinner.fail('Failed to connect project'); + throw err; + } + + // Step 6: Refetch and display + const refetchSpinner = ora('Verifying connection').start(); + const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId); + refetchSpinner.succeed('Verified connection'); + + if (json) { + printJsonOnlyOutput(buildJsonOutput('connect', updatedMetadata)); + } else { + Log.addNewLineIfNone(); + Log.log(formatAscAppLinkStatus(updatedMetadata)); + } + } +} diff --git a/packages/eas-cli/src/commands/connections/asc/disconnect.ts b/packages/eas-cli/src/commands/connections/asc/disconnect.ts new file mode 100644 index 0000000000..6a18c2718e --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/disconnect.ts @@ -0,0 +1,109 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { buildJsonOutput, formatAscAppLinkStatus } from '../../../connections/asc/utils'; +import { AscAppLinkMutation } from '../../../graphql/mutations/AscAppLinkMutation'; +import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { toggleConfirmAsync } from '../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +export default class ConnectionsAscDisconnect extends EasCommand { + static override description = 'disconnect the current project from its App Store Connect app'; + + static override flags = { + yes: Flags.boolean({ + description: 'Skip confirmation prompt', + default: false, + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ConnectionsAscDisconnect); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ConnectionsAscDisconnect, { + nonInteractive: nonInteractive || flags.yes, + }); + + // Step 1: Check current status + const statusSpinner = ora('Checking current App Store Connect app link status').start(); + const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId); + statusSpinner.succeed('Checked current status'); + + if (!metadata.appStoreConnectApp) { + if (json) { + printJsonOnlyOutput(buildJsonOutput('disconnect', metadata)); + } else { + Log.addNewLineIfNone(); + Log.log( + `Project ${chalk.bold(metadata.fullName)} is not connected to any App Store Connect app.` + ); + } + return; + } + + // Step 2: Confirm + if (!flags.yes && !nonInteractive) { + Log.addNewLineIfNone(); + Log.log(formatAscAppLinkStatus(metadata)); + Log.newLine(); + Log.warn( + 'You are about to disconnect this project from its App Store Connect app.\nThis action is reversible by reconnecting.' + ); + Log.newLine(); + const confirmed = await toggleConfirmAsync({ + message: 'Are you sure you wish to proceed?', + }); + if (!confirmed) { + Log.error('Canceled disconnection'); + process.exit(1); + } + } + + // Step 3: Delete + const deleteSpinner = ora('Disconnecting App Store Connect app').start(); + try { + await AscAppLinkMutation.deleteAppStoreConnectAppAsync( + graphqlClient, + metadata.appStoreConnectApp.id + ); + deleteSpinner.succeed('Disconnected App Store Connect app'); + } catch (err) { + deleteSpinner.fail('Failed to disconnect App Store Connect app'); + throw err; + } + + // Step 4: Refetch and display + const refetchSpinner = ora('Verifying disconnection').start(); + const updatedMetadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId, { + useCache: false, + }); + refetchSpinner.succeed('Verified disconnection'); + + if (json) { + printJsonOnlyOutput(buildJsonOutput('disconnect', updatedMetadata)); + } else { + Log.addNewLineIfNone(); + Log.log(formatAscAppLinkStatus(updatedMetadata)); + } + } +} diff --git a/packages/eas-cli/src/commands/connections/asc/status.ts b/packages/eas-cli/src/commands/connections/asc/status.ts new file mode 100644 index 0000000000..0663ef4a11 --- /dev/null +++ b/packages/eas-cli/src/commands/connections/asc/status.ts @@ -0,0 +1,55 @@ +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { buildJsonOutput, formatAscAppLinkStatus } from '../../../connections/asc/utils'; +import { AscAppLinkQuery } from '../../../graphql/queries/AscAppLinkQuery'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +export default class ConnectionsAscStatus extends EasCommand { + static override description = + 'show the App Store Connect app link status for the current project'; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ConnectionsAscStatus); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ConnectionsAscStatus, { + nonInteractive, + }); + + const spinner = ora('Fetching App Store Connect app link status').start(); + try { + const metadata = await AscAppLinkQuery.getAppMetadataAsync(graphqlClient, projectId); + spinner.succeed('Fetched App Store Connect app link status'); + + if (json) { + printJsonOnlyOutput(buildJsonOutput('status', metadata)); + } else { + Log.addNewLineIfNone(); + Log.log(formatAscAppLinkStatus(metadata)); + } + } catch (err) { + spinner.fail('Failed to fetch App Store Connect app link status'); + throw err; + } + } +} diff --git a/packages/eas-cli/src/connections/asc/__tests__/utils.test.ts b/packages/eas-cli/src/connections/asc/__tests__/utils.test.ts new file mode 100644 index 0000000000..8e14da4c15 --- /dev/null +++ b/packages/eas-cli/src/connections/asc/__tests__/utils.test.ts @@ -0,0 +1,60 @@ +import { buildJsonOutput, formatAscAppLinkStatus } from '../utils'; + +const mockMetadataConnected = { + id: 'app-id', + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: { + id: 'asc-app-link-id', + ascAppIdentifier: '1234567890', + remoteAppStoreConnectApp: { + ascAppIdentifier: '1234567890', + bundleIdentifier: 'com.test.app', + name: 'Test App', + appStoreIconUrl: null, + }, + }, +}; + +const mockMetadataDisconnected = { + id: 'app-id', + fullName: '@testuser/testapp', + ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] }, + appStoreConnectApp: null, +}; + +describe('buildJsonOutput', () => { + it('returns connected output', () => { + const output = buildJsonOutput('status', mockMetadataConnected); + expect(output.ok).toBe(true); + expect(output.action).toBe('status'); + expect(output.project).toBe('@testuser/testapp'); + expect(output.connected).toBe(true); + expect(output.appStoreConnectApp).not.toBeNull(); + expect(output.appStoreConnectApp!.ascAppIdentifier).toBe('1234567890'); + expect(output.appStoreConnectApp!.name).toBe('Test App'); + expect(output.appStoreConnectApp!.bundleIdentifier).toBe('com.test.app'); + }); + + it('returns disconnected output', () => { + const output = buildJsonOutput('status', mockMetadataDisconnected); + expect(output.ok).toBe(true); + expect(output.connected).toBe(false); + expect(output.appStoreConnectApp).toBeNull(); + }); +}); + +describe('formatAscAppLinkStatus', () => { + it('formats connected status', () => { + const status = formatAscAppLinkStatus(mockMetadataConnected); + expect(status).toContain('Connected'); + expect(status).toContain('1234567890'); + expect(status).toContain('Test App'); + expect(status).toContain('com.test.app'); + }); + + it('formats disconnected status', () => { + const status = formatAscAppLinkStatus(mockMetadataDisconnected); + expect(status).toContain('Not connected'); + }); +}); diff --git a/packages/eas-cli/src/connections/asc/ascApiKey.ts b/packages/eas-cli/src/connections/asc/ascApiKey.ts new file mode 100644 index 0000000000..f8498cfacd --- /dev/null +++ b/packages/eas-cli/src/connections/asc/ascApiKey.ts @@ -0,0 +1,50 @@ +import { selectAsync } from '../../prompts'; +import { CredentialsContext } from '../../credentials/context'; +import { + AppStoreApiKeyPurpose, + formatAscApiKey, + provideOrGenerateAscApiKeyAsync, + sortAscApiKeysByUpdatedAtDesc, +} from '../../credentials/ios/actions/AscApiKeyUtils'; +import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../graphql/generated'; + +export async function selectOrCreateAscApiKeyIdAsync({ + credentialsContext, + existingKeys, + ownerAccount, +}: { + credentialsContext: CredentialsContext; + existingKeys: AppStoreConnectApiKeyFragment[]; + ownerAccount: AccountFragment; +}): Promise { + const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys); + const createKeyOption = { + title: '[Create or upload a new API key]', + value: '__create_new_key__', + }; + + const selectedValue = + sortedKeys.length === 0 + ? createKeyOption.value + : await selectAsync('Select an App Store Connect API key:', [ + ...sortedKeys.map(key => ({ + title: formatAscApiKey(key), + value: key.id, + })), + createKeyOption, + ]); + + if (selectedValue !== createKeyOption.value) { + return selectedValue; + } + + const newKey = await credentialsContext.ios.createAscApiKeyAsync( + credentialsContext.graphqlClient, + ownerAccount, + await provideOrGenerateAscApiKeyAsync( + credentialsContext, + AppStoreApiKeyPurpose.ASC_APP_CONNECTION + ) + ); + return newKey.id; +} diff --git a/packages/eas-cli/src/connections/asc/utils.ts b/packages/eas-cli/src/connections/asc/utils.ts new file mode 100644 index 0000000000..44268126f4 --- /dev/null +++ b/packages/eas-cli/src/connections/asc/utils.ts @@ -0,0 +1,68 @@ +import chalk from 'chalk'; + +import { AscAppLinkQuery } from '../../graphql/queries/AscAppLinkQuery'; + +type AscAppLinkMetadata = Awaited>; + +export interface AscAppLinkJsonOutput { + ok: boolean; + action: string; + project: string; + connected: boolean; + appStoreConnectApp: { + id: string; + ascAppIdentifier: string; + name: string | null; + bundleIdentifier: string | null; + appleUrl: string; + } | null; +} + +export function buildJsonOutput( + action: string, + metadata: AscAppLinkMetadata, + ok: boolean = true +): AscAppLinkJsonOutput { + const link = metadata.appStoreConnectApp; + return { + ok, + action, + project: metadata.fullName, + connected: link !== null, + appStoreConnectApp: link + ? { + id: link.id, + ascAppIdentifier: link.ascAppIdentifier, + name: link.remoteAppStoreConnectApp?.name ?? null, + bundleIdentifier: link.remoteAppStoreConnectApp?.bundleIdentifier ?? null, + appleUrl: getAppleAppUrl(link.ascAppIdentifier), + } + : null, + }; +} + +export function formatAscAppLinkStatus(metadata: AscAppLinkMetadata): string { + const link = metadata.appStoreConnectApp; + if (!link) { + return `Project ${chalk.bold(metadata.fullName)}: ${chalk.yellow('Not connected')} to App Store Connect.`; + } + + const lines: string[] = [ + `Project ${chalk.bold(metadata.fullName)}: ${chalk.green('Connected')} to App Store Connect.`, + ` ASC App ID: ${chalk.bold(link.ascAppIdentifier)}`, + ]; + + if (link.remoteAppStoreConnectApp) { + const remote = link.remoteAppStoreConnectApp; + lines.push(` Name: ${remote.name}`); + lines.push(` Bundle ID: ${remote.bundleIdentifier}`); + } + + lines.push(` Apple URL: ${getAppleAppUrl(link.ascAppIdentifier)}`); + + return lines.join('\n'); +} + +function getAppleAppUrl(ascAppIdentifier: string): string { + return `https://appstoreconnect.apple.com/apps/${encodeURIComponent(ascAppIdentifier)}/distribution`; +} diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index 5b6915115d..6e82e4eb38 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -24,6 +24,7 @@ import { isAscApiKeyValidAndTrackedAsync } from '../validators/validateAscApiKey export enum AppStoreApiKeyPurpose { SUBMISSION_SERVICE = 'EAS Submit', + ASC_APP_CONNECTION = 'EAS Connect', } export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise { diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 82fccef79d..e84a4c64f2 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -1116,7 +1116,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = { export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = { - accountId: Scalars['ID']['input']; androidAppCredentialsId: Scalars['String']['input']; credential: Scalars['String']['input']; }; @@ -1358,6 +1357,7 @@ export type App = Project & { activityTimelineProjectActivities: Array; /** Android app credentials for the project */ androidAppCredentials: Array; + appStoreConnectApp?: Maybe; /** * ios.appStoreUrl field from most recent classic update manifest * @deprecated Classic updates have been deprecated. @@ -2376,10 +2376,16 @@ export type AppStoreConnectApiKey = { keyIdentifier: Scalars['String']['output']; keyP8: Scalars['String']['output']; name?: Maybe; + remoteAppStoreConnectApps: Array; roles?: Maybe>; updatedAt: Scalars['DateTime']['output']; }; + +export type AppStoreConnectApiKeyRemoteAppStoreConnectAppsArgs = { + bundleIdentifier?: InputMaybe; +}; + export type AppStoreConnectApiKeyInput = { appleTeamId?: InputMaybe; issuerIdentifier: Scalars['String']['input']; @@ -2430,6 +2436,43 @@ export type AppStoreConnectApiKeyUpdateInput = { appleTeamId?: InputMaybe; }; +export type AppStoreConnectApp = { + __typename?: 'AppStoreConnectApp'; + app: App; + appStoreConnectApiKey: AppStoreConnectApiKey; + ascAppIdentifier: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + remoteAppStoreConnectApp: RemoteAppStoreConnectApp; + updatedAt: Scalars['DateTime']['output']; + webhookEventTypes: Array; + webhookIdentifier: Scalars['ID']['output']; +}; + +export type AppStoreConnectAppInput = { + appId: Scalars['ID']['input']; + appStoreConnectApiKeyId: Scalars['ID']['input']; + ascAppIdentifier: Scalars['String']['input']; +}; + +export type AppStoreConnectAppMutation = { + __typename?: 'AppStoreConnectAppMutation'; + /** Create an App Store Connect app for an Expo app. */ + createAppStoreConnectApp: AppStoreConnectApp; + /** Delete an App Store Connect app by ID. */ + deleteAppStoreConnectApp: DeleteAppStoreConnectAppResult; +}; + + +export type AppStoreConnectAppMutationCreateAppStoreConnectAppArgs = { + appStoreConnectAppInput: AppStoreConnectAppInput; +}; + + +export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = { + appStoreConnectAppId: Scalars['ID']['input']; +}; + export enum AppStoreConnectUserRole { AccessToReports = 'ACCESS_TO_REPORTS', AccountHolder = 'ACCOUNT_HOLDER', @@ -4312,6 +4355,11 @@ export type DeleteAndroidKeystoreResult = { id: Scalars['ID']['output']; }; +export type DeleteAppStoreConnectAppResult = { + __typename?: 'DeleteAppStoreConnectAppResult'; + id: Scalars['ID']['output']; +}; + export type DeleteAppleDeviceResult = { __typename?: 'DeleteAppleDeviceResult'; id: Scalars['ID']['output']; @@ -5223,7 +5271,7 @@ export enum EntityTypeName { AndroidKeystoreEntity = 'AndroidKeystoreEntity', AppEntity = 'AppEntity', AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity', - AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity', + AppStoreConnectAppEntity = 'AppStoreConnectAppEntity', AppleDeviceEntity = 'AppleDeviceEntity', AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity', AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity', @@ -5880,6 +5928,7 @@ export type GitHubRepository = { githubRepositoryIdentifier: Scalars['Int']['output']; githubRepositoryUrl?: Maybe; id: Scalars['ID']['output']; + lastDeletionAttemptTime?: Maybe; metadata: GitHubRepositoryMetadata; nodeIdentifier: Scalars['String']['output']; }; @@ -5903,8 +5952,13 @@ export type GitHubRepositoryMutation = { createAndConfigureRepository: BackgroundJobReceipt; /** Create a GitHub repository for an App */ createGitHubRepository: GitHubRepository; - /** Delete a GitHub repository by ID */ + /** + * Delete a GitHub repository by ID + * @deprecated Use scheduleGitHubRepositoryDeletion instead + */ deleteGitHubRepository: GitHubRepository; + /** Delete a GitHub repository by ID in the background */ + scheduleGitHubRepositoryDeletion: BackgroundJobReceipt; }; @@ -5927,6 +5981,11 @@ export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = { githubRepositoryId: Scalars['ID']['input']; }; + +export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = { + githubRepositoryId: Scalars['ID']['input']; +}; + export type GitHubRepositoryOwner = { __typename?: 'GitHubRepositoryOwner'; avatarUrl: Scalars['String']['output']; @@ -7091,6 +7150,14 @@ export type PublishUpdateGroupInput = { updateInfoGroup?: InputMaybe; }; +export type RemoteAppStoreConnectApp = { + __typename?: 'RemoteAppStoreConnectApp'; + appStoreIconUrl?: Maybe; + ascAppIdentifier: Scalars['String']['output']; + bundleIdentifier: Scalars['String']['output']; + name?: Maybe; +}; + export enum RequestMethod { Delete = 'DELETE', Get = 'GET', @@ -7270,6 +7337,8 @@ export type RootMutation = { app?: Maybe; /** Mutations that modify an App Store Connect Api Key */ appStoreConnectApiKey: AppStoreConnectApiKeyMutation; + /** Mutations for App Store Connect apps. */ + appStoreConnectApp: AppStoreConnectAppMutation; /** Mutations that modify an AppVersion */ appVersion: AppVersionMutation; /** Mutations that modify an Identifier for an iOS App */ @@ -7705,6 +7774,7 @@ export type SsoUser = Actor & UserActor & { /** @deprecated No longer supported */ industry?: Maybe; isExpoAdmin: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -7867,6 +7937,13 @@ export type SentryProjectMutationDeleteSentryProjectArgs = { sentryProjectId: Scalars['ID']['input']; }; +export type SizeBreakdownCategory = { + __typename?: 'SizeBreakdownCategory'; + assetCount: Scalars['Int']['output']; + category: Scalars['String']['output']; + totalBytes: Scalars['Float']['output']; +}; + export type Snack = Project & { __typename?: 'Snack'; /** Description of the Snack */ @@ -8674,6 +8751,7 @@ export type UpdateInsights = { cumulativeAverageMetrics: CumulativeAverageMetrics; cumulativeMetrics: CumulativeMetrics; id: Scalars['ID']['output']; + sizeBreakdownByCategory: Array; totalUniqueUsers: Scalars['Int']['output']; }; @@ -8903,6 +8981,7 @@ export type User = Actor & UserActor & { /** @deprecated No longer supported */ isLegacy: Scalars['Boolean']['output']; isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -9010,6 +9089,7 @@ export type UserActor = { /** @deprecated No longer supported */ industry?: Maybe; isExpoAdmin: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -10587,6 +10667,10 @@ export type WorkflowRunTimeRangeInput = { }; export enum WorkflowRunTriggerEventType { + AppStoreConnectAppVersionStateChanged = 'APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED', + AppStoreConnectBetaFeedbackSubmitted = 'APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED', + AppStoreConnectBuildUploadStateChanged = 'APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED', + AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED', EasSubmit = 'EAS_SUBMIT', ExpoLaunch = 'EXPO_LAUNCH', GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED', @@ -11240,6 +11324,20 @@ export type CreateAppVersionMutationVariables = Exact<{ export type CreateAppVersionMutation = { __typename?: 'RootMutation', appVersion: { __typename?: 'AppVersionMutation', createAppVersion: { __typename?: 'AppVersion', id: string } } }; +export type CreateAppStoreConnectAppMutationVariables = Exact<{ + appStoreConnectAppInput: AppStoreConnectAppInput; +}>; + + +export type CreateAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', createAppStoreConnectApp: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string } } }; + +export type DeleteAppStoreConnectAppMutationVariables = Exact<{ + appStoreConnectAppId: Scalars['ID']['input']; +}>; + + +export type DeleteAppStoreConnectAppMutation = { __typename?: 'RootMutation', appStoreConnectApp: { __typename?: 'AppStoreConnectAppMutation', deleteAppStoreConnectApp: { __typename?: 'DeleteAppStoreConnectAppResult', id: string } } }; + export type CreateAndroidBuildMutationVariables = Exact<{ appId: Scalars['ID']['input']; job: AndroidJobInput; @@ -11585,6 +11683,21 @@ export type LatestAppVersionQueryVariables = Exact<{ export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } }; +export type AscAppLinkAppMetadataQueryVariables = Exact<{ + appId: Scalars['String']['input']; +}>; + + +export type AscAppLinkAppMetadataQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, fullName: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, appStoreConnectApp?: { __typename?: 'AppStoreConnectApp', id: string, ascAppIdentifier: string, remoteAppStoreConnectApp: { __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null } } | null } } }; + +export type DiscoverAccessibleAppStoreConnectAppsQueryVariables = Exact<{ + appStoreConnectApiKeyId: Scalars['ID']['input']; + bundleIdentifier?: InputMaybe; +}>; + + +export type DiscoverAccessibleAppStoreConnectAppsQuery = { __typename?: 'RootQuery', appStoreConnectApiKey: { __typename?: 'AppStoreConnectApiKeyQuery', byId: { __typename?: 'AppStoreConnectApiKey', id: string, remoteAppStoreConnectApps: Array<{ __typename?: 'RemoteAppStoreConnectApp', ascAppIdentifier: string, bundleIdentifier: string, name?: string | null, appStoreIconUrl?: string | null }> } } }; + export type GetAssetSignedUrlsQueryVariables = Exact<{ updateId: Scalars['ID']['input']; storageKeys: Array | Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/mutations/AscAppLinkMutation.ts b/packages/eas-cli/src/graphql/mutations/AscAppLinkMutation.ts new file mode 100644 index 0000000000..bf4d4195c3 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/AscAppLinkMutation.ts @@ -0,0 +1,70 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; + +export const AscAppLinkMutation = { + async createAppStoreConnectAppAsync( + graphqlClient: ExpoGraphqlClient, + appStoreConnectAppInput: { + appId: string; + ascAppIdentifier: string; + appStoreConnectApiKeyId: string; + } + ): Promise<{ id: string; ascAppIdentifier: string }> { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation<{ + appStoreConnectApp: { + createAppStoreConnectApp: { + id: string; + ascAppIdentifier: string; + }; + }; + }>( + gql` + mutation CreateAppStoreConnectApp($appStoreConnectAppInput: AppStoreConnectAppInput!) { + appStoreConnectApp { + createAppStoreConnectApp(appStoreConnectAppInput: $appStoreConnectAppInput) { + id + ascAppIdentifier + } + } + } + `, + { appStoreConnectAppInput } + ) + .toPromise() + ); + + return data.appStoreConnectApp.createAppStoreConnectApp; + }, + + async deleteAppStoreConnectAppAsync( + graphqlClient: ExpoGraphqlClient, + appStoreConnectAppId: string + ): Promise { + await withErrorHandlingAsync( + graphqlClient + .mutation<{ + appStoreConnectApp: { + deleteAppStoreConnectApp: { + id: string; + }; + }; + }>( + gql` + mutation DeleteAppStoreConnectApp($appStoreConnectAppId: ID!) { + appStoreConnectApp { + deleteAppStoreConnectApp(appStoreConnectAppId: $appStoreConnectAppId) { + id + } + } + } + `, + { appStoreConnectAppId } + ) + .toPromise() + ); + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/AscAppLinkQuery.ts b/packages/eas-cli/src/graphql/queries/AscAppLinkQuery.ts new file mode 100644 index 0000000000..655dd33af9 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/AscAppLinkQuery.ts @@ -0,0 +1,102 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + AscAppLinkAppMetadataQuery, + AscAppLinkAppMetadataQueryVariables, + DiscoverAccessibleAppStoreConnectAppsQuery, + DiscoverAccessibleAppStoreConnectAppsQueryVariables, +} from '../generated'; +import { AccountFragmentNode } from '../types/Account'; + +export const AscAppLinkQuery = { + async getAppMetadataAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + options?: { + useCache?: boolean; + } + ): Promise { + const useCache = options?.useCache ?? true; + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AscAppLinkAppMetadata($appId: String!) { + app { + byId(appId: $appId) { + id + fullName + ownerAccount { + id + ...AccountFragment + } + appStoreConnectApp { + id + ascAppIdentifier + remoteAppStoreConnectApp { + ascAppIdentifier + bundleIdentifier + name + appStoreIconUrl + } + } + } + } + } + ${print(AccountFragmentNode)} + `, + { appId }, + { + requestPolicy: useCache ? 'cache-first' : 'network-only', + additionalTypenames: ['App', 'AppStoreConnectApp'], + } + ) + .toPromise() + ); + + return data.app.byId; + }, + + async discoverAccessibleAppsAsync( + graphqlClient: ExpoGraphqlClient, + appStoreConnectApiKeyId: string, + bundleIdentifier?: string + ): Promise< + DiscoverAccessibleAppStoreConnectAppsQuery['appStoreConnectApiKey']['byId']['remoteAppStoreConnectApps'] + > { + const data = await withErrorHandlingAsync( + graphqlClient + .query< + DiscoverAccessibleAppStoreConnectAppsQuery, + DiscoverAccessibleAppStoreConnectAppsQueryVariables + >( + gql` + query DiscoverAccessibleAppStoreConnectApps( + $appStoreConnectApiKeyId: ID! + $bundleIdentifier: String + ) { + appStoreConnectApiKey { + byId(id: $appStoreConnectApiKeyId) { + id + remoteAppStoreConnectApps(bundleIdentifier: $bundleIdentifier) { + ascAppIdentifier + bundleIdentifier + name + appStoreIconUrl + } + } + } + } + `, + { appStoreConnectApiKeyId, bundleIdentifier }, + { additionalTypenames: ['AppStoreConnectApp'] } + ) + .toPromise() + ); + + return data.appStoreConnectApiKey.byId?.remoteAppStoreConnectApps ?? []; + }, +};