diff --git a/packages/eas-cli/src/__tests__/commands/credentials-test.ts b/packages/eas-cli/src/__tests__/commands/credentials-test.ts new file mode 100644 index 0000000000..1b706c8cd4 --- /dev/null +++ b/packages/eas-cli/src/__tests__/commands/credentials-test.ts @@ -0,0 +1,141 @@ +import Credentials from '../../commands/credentials'; +import * as AndroidGraphqlClient from '../../credentials/android/api/GraphqlClient'; +import { getAppLookupParamsFromContextAsync } from '../../credentials/android/actions/BuildCredentialsUtils'; +import { testJksAndroidKeystoreFragment } from '../../credentials/__tests__/fixtures-android'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; +import { getMockEasJson, getMockExpoConfig, mockCommandContext, mockTestCommand } from './utils'; + +jest.mock('fs'); +jest.mock('../../credentials/android/api/GraphqlClient'); +jest.mock('../../credentials/android/actions/BuildCredentialsUtils'); +jest.mock('../../utils/json'); + +describe(Credentials, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('--json --non-interactive --platform android', () => { + const expWithAndroidPackage = { + ...getMockExpoConfig(), + android: { package: 'com.eas.test' }, + }; + + test('outputs keystore info as JSON when credentials exist', async () => { + const ctx = mockCommandContext(Credentials, { + easJson: getMockEasJson(), + exp: expWithAndroidPackage, + }); + jest.mocked(getAppLookupParamsFromContextAsync).mockResolvedValue({ + account: { id: 'account-id', name: 'testuser' } as any, + projectName: 'testapp', + androidApplicationIdentifier: 'com.eas.test', + }); + jest.mocked(AndroidGraphqlClient.getDefaultAndroidAppBuildCredentialsAsync).mockResolvedValue({ + id: 'build-creds-id', + name: 'production', + isDefault: true, + isLegacy: false, + androidKeystore: testJksAndroidKeystoreFragment, + } as any); + + const cmd = mockTestCommand(Credentials, [ + '--json', + '--non-interactive', + '--platform', + 'android', + ], ctx); + await cmd.run(); + + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + keystore: { + id: testJksAndroidKeystoreFragment.id, + type: testJksAndroidKeystoreFragment.type, + keyAlias: testJksAndroidKeystoreFragment.keyAlias, + md5CertificateFingerprint: testJksAndroidKeystoreFragment.md5CertificateFingerprint, + sha1CertificateFingerprint: testJksAndroidKeystoreFragment.sha1CertificateFingerprint, + sha256CertificateFingerprint: testJksAndroidKeystoreFragment.sha256CertificateFingerprint, + createdAt: testJksAndroidKeystoreFragment.createdAt, + updatedAt: testJksAndroidKeystoreFragment.updatedAt, + }, + }); + }); + + test('outputs keystore null when no credentials exist', async () => { + const ctx = mockCommandContext(Credentials, { + easJson: getMockEasJson(), + exp: expWithAndroidPackage, + }); + jest.mocked(getAppLookupParamsFromContextAsync).mockResolvedValue({ + account: { id: 'account-id', name: 'testuser' } as any, + projectName: 'testapp', + androidApplicationIdentifier: 'com.eas.test', + }); + jest.mocked(AndroidGraphqlClient.getDefaultAndroidAppBuildCredentialsAsync).mockResolvedValue( + null + ); + + const cmd = mockTestCommand(Credentials, [ + '--json', + '--non-interactive', + '--platform', + 'android', + ], ctx); + await cmd.run(); + + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ keystore: null }); + }); + + test('outputs keystore null when build credentials exist but have no keystore', async () => { + const ctx = mockCommandContext(Credentials, { + easJson: getMockEasJson(), + exp: expWithAndroidPackage, + }); + jest.mocked(getAppLookupParamsFromContextAsync).mockResolvedValue({ + account: { id: 'account-id', name: 'testuser' } as any, + projectName: 'testapp', + androidApplicationIdentifier: 'com.eas.test', + }); + jest.mocked(AndroidGraphqlClient.getDefaultAndroidAppBuildCredentialsAsync).mockResolvedValue({ + id: 'build-creds-id', + name: 'production', + isDefault: true, + isLegacy: false, + androidKeystore: null, + } as any); + + const cmd = mockTestCommand(Credentials, [ + '--json', + '--non-interactive', + '--platform', + 'android', + ], ctx); + await cmd.run(); + + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ keystore: null }); + }); + + test('throws when no project directory', async () => { + const ctx = mockCommandContext(Credentials, { + easJson: getMockEasJson(), + optionalPrivateProjectConfig: null, + }); + + const cmd = mockTestCommand(Credentials, [ + '--json', + '--non-interactive', + '--platform', + 'android', + ], ctx); + + await expect(cmd.run()).rejects.toThrow( + 'Run this command from a project directory with app.json and eas.json to output Android keystore info as JSON.' + ); + expect(printJsonOnlyOutput).not.toHaveBeenCalled(); + }); + }); + +}); diff --git a/packages/eas-cli/src/__tests__/commands/utils.ts b/packages/eas-cli/src/__tests__/commands/utils.ts index b1d3a15706..ac745f8792 100644 --- a/packages/eas-cli/src/__tests__/commands/utils.ts +++ b/packages/eas-cli/src/__tests__/commands/utils.ts @@ -78,12 +78,16 @@ export function mockCommandContext< projectId?: string; getDynamicPrivateProjectConfigAsync?: DynamicConfigContextFn; getDynamicPublicProjectConfigAsync?: DynamicConfigContextFn; + optionalPrivateProjectConfig?: { exp: ExpoConfig; projectId: string; projectDir: string } | null; } ): ContextOutput { const projectDir = path.join('/test', uuidv4()); vol.reset(); vol.fromJSON({ 'eas.json': JSON.stringify(overrides.easJson ?? getMockEasJson()) }, projectDir); const contextDefinition = commandClass.contextDefinition; + const exp = overrides.exp ?? getMockExpoConfig(); + const projectId = overrides.projectId ?? mockProjectId; + const projectConfig = { exp, projectId, projectDir }; const result: any = {}; for (const [contextKey] of Object.entries(contextDefinition)) { @@ -91,7 +95,7 @@ export function mockCommandContext< result.loggedIn = {}; } if (contextKey === 'projectId') { - result.projectId = overrides.projectId ?? mockProjectId; + result.projectId = projectId; } if (contextKey === 'projectDir') { result.projectDir = projectDir; @@ -103,26 +107,30 @@ export function mockCommandContext< result.sessionManager = {}; } if (contextKey === 'projectConfig') { - result.projectConfig = { - exp: overrides.exp ?? getMockExpoConfig(), - projectId: overrides.projectId ?? mockProjectId, - projectDir, - }; + result.projectConfig = projectConfig; + } + if (contextKey === 'optionalPrivateProjectConfig') { + result.optionalPrivateProjectConfig = + overrides.optionalPrivateProjectConfig !== undefined + ? overrides.optionalPrivateProjectConfig + : projectConfig; } if (contextKey === 'getDynamicPrivateProjectConfigAsync') { - result.getDynamicPrivateProjectConfigAsync = () => ({ - exp: overrides.exp ?? getMockExpoConfig(), - projectId: overrides.projectId ?? mockProjectId, - projectDir, - }); + result.getDynamicPrivateProjectConfigAsync = + overrides.getDynamicPrivateProjectConfigAsync ?? + (() => Promise.resolve(projectConfig)); } if (contextKey === 'getDynamicPublicProjectConfigAsync') { - result.getDynamicPublicProjectConfigAsync = () => ({ - exp: overrides.exp ?? getMockExpoConfig(), - projectId: overrides.projectId ?? mockProjectId, - projectDir, - }); + result.getDynamicPublicProjectConfigAsync = + overrides.getDynamicPublicProjectConfigAsync ?? + (() => Promise.resolve(projectConfig)); + } + if (contextKey === 'analytics') { + result.analytics = {}; + } + if (contextKey === 'vcsClient') { + result.vcsClient = {}; } } return result; diff --git a/packages/eas-cli/src/commands/credentials/index.ts b/packages/eas-cli/src/commands/credentials/index.ts index 5866fdd1f3..caf3b909f9 100644 --- a/packages/eas-cli/src/commands/credentials/index.ts +++ b/packages/eas-cli/src/commands/credentials/index.ts @@ -1,13 +1,31 @@ +import { Platform } from '@expo/eas-build-job'; +import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json'; import { Flags } from '@oclif/core'; import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import * as AndroidGraphqlClient from '../../credentials/android/api/GraphqlClient'; +import { CredentialsContext } from '../../credentials/context'; +import { getAppLookupParamsFromContextAsync } from '../../credentials/android/actions/BuildCredentialsUtils'; import { SelectPlatform } from '../../credentials/manager/SelectPlatform'; +import { resolveGradleBuildContextAsync } from '../../project/android/gradle'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; export default class Credentials extends EasCommand { static override description = 'manage credentials'; + static override examples = [ + '$ eas credentials --profile development --platform android --json --non-interactive # Output Android keystore info from development env as JSON', + ]; + static override flags = { platform: Flags.option({ char: 'p', options: ['android', 'ios'] as const })(), + profile: Flags.string({ + char: 'e', + description: 'Name of the profile to manage', + helpValue: 'PROFILE_NAME', + }), + ...EasNonInteractiveAndJsonFlags, }; static override contextDefinition = { @@ -27,10 +45,81 @@ export default class Credentials extends EasCommand { analytics, vcsClient, } = await this.getContextAsync(Credentials, { - nonInteractive: false, + nonInteractive: flags['non-interactive'] ?? false, withServerSideEnvironment: null, }); + if (flags.json) { + enableJsonOutput(); + } + + if ( + flags.json && + flags['non-interactive'] && + flags.platform === 'android' + ) { + if (!privateProjectConfig) { + throw new Error( + 'Run this command from a project directory with app.json and eas.json to output Android keystore info as JSON.' + ); + } + const projectDir = privateProjectConfig.projectDir; + const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir); + const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor); + if (profileNames.length === 0) { + throw new Error( + 'No build profiles found in eas.json. Add at least one build profile to use this command.' + ); + } + const profileName = flags.profile ?? profileNames[0]; + const buildProfile = await EasJsonUtils.getBuildProfileAsync( + easJsonAccessor, + Platform.ANDROID, + profileName + ); + const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ + env: buildProfile.env, + }); + const ctx = new CredentialsContext({ + projectDir, + projectInfo: { exp, projectId }, + user: actor, + graphqlClient, + analytics, + env: buildProfile.env, + nonInteractive: true, + vcsClient, + }); + const gradleContext = await resolveGradleBuildContextAsync( + projectDir, + buildProfile, + vcsClient + ); + const appLookupParams = await getAppLookupParamsFromContextAsync(ctx, gradleContext); + const defaultBuildCredentials = + await AndroidGraphqlClient.getDefaultAndroidAppBuildCredentialsAsync( + graphqlClient, + appLookupParams + ); + const keystore = defaultBuildCredentials?.androidKeystore ?? null; + const output = keystore + ? { + keystore: { + id: keystore.id, + type: keystore.type, + keyAlias: keystore.keyAlias, + md5CertificateFingerprint: keystore.md5CertificateFingerprint ?? null, + sha1CertificateFingerprint: keystore.sha1CertificateFingerprint ?? null, + sha256CertificateFingerprint: keystore.sha256CertificateFingerprint ?? null, + createdAt: keystore.createdAt, + updatedAt: keystore.updatedAt, + }, + } + : { keystore: null }; + printJsonOnlyOutput(output); + return; + } + await new SelectPlatform( actor, graphqlClient, @@ -38,7 +127,10 @@ export default class Credentials extends EasCommand { analytics, privateProjectConfig ?? null, getDynamicPrivateProjectConfigAsync, - flags.platform + { + flagPlatform: flags.platform, + flagProfileName: flags.profile, + } ).runAsync(); } } diff --git a/packages/eas-cli/src/credentials/manager/ManageAndroid.ts b/packages/eas-cli/src/credentials/manager/ManageAndroid.ts index 538b9f2589..eb0cddca62 100644 --- a/packages/eas-cli/src/credentials/manager/ManageAndroid.ts +++ b/packages/eas-cli/src/credentials/manager/ManageAndroid.ts @@ -50,13 +50,14 @@ import { AndroidPackageNotDefinedError } from '../errors'; export class ManageAndroid { constructor( protected callingAction: Action, - protected projectDir: string + protected projectDir: string, + private readonly profileName?: string ) {} async runAsync(currentActions: ActionInfo[] = highLevelActions): Promise { const hasProjectContext = !!this.callingAction.projectInfo; const buildProfile = hasProjectContext - ? await new SelectBuildProfileFromEasJson(this.projectDir, Platform.ANDROID).runAsync() + ? await new SelectBuildProfileFromEasJson(this.projectDir, Platform.ANDROID, this.profileName).runAsync() : null; let projectInfo: CredentialsContextProjectInfo | null = null; if (hasProjectContext) { diff --git a/packages/eas-cli/src/credentials/manager/SelectBuildProfileFromEasJson.ts b/packages/eas-cli/src/credentials/manager/SelectBuildProfileFromEasJson.ts index 24d6822dd7..cb7279398d 100644 --- a/packages/eas-cli/src/credentials/manager/SelectBuildProfileFromEasJson.ts +++ b/packages/eas-cli/src/credentials/manager/SelectBuildProfileFromEasJson.ts @@ -9,13 +9,14 @@ export class SelectBuildProfileFromEasJson { constructor( projectDir: string, - private readonly platform: T + private readonly platform: T, + private readonly profileName?: string ) { this.easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir); } async runAsync(): Promise> { - const profileName = await this.getProfileNameFromEasConfigAsync(); + const profileName = this.profileName ? this.profileName : await this.getProfileNameFromEasConfigAsync(); const easConfig = await EasJsonUtils.getBuildProfileAsync( this.easJsonAccessor, this.platform, diff --git a/packages/eas-cli/src/credentials/manager/SelectPlatform.ts b/packages/eas-cli/src/credentials/manager/SelectPlatform.ts index 91387841d8..fe4cede80e 100644 --- a/packages/eas-cli/src/credentials/manager/SelectPlatform.ts +++ b/packages/eas-cli/src/credentials/manager/SelectPlatform.ts @@ -16,16 +16,19 @@ export class SelectPlatform { public readonly analytics: Analytics, public readonly projectInfo: CredentialsContextProjectInfo | null, public readonly getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn, - private readonly flagPlatform?: string + private readonly options: { + flagPlatform?: string; + flagProfileName?: string; + } ) {} async runAsync(): Promise { - const platform = await selectPlatformWithExitOptionAsync(this.flagPlatform); + const platform = await selectPlatformWithExitOptionAsync(this.options.flagPlatform); if (platform === 'ios') { await new ManageIos(this, process.cwd()).runAsync(); return; } - await new ManageAndroid(this, process.cwd()).runAsync(); + await new ManageAndroid(this, process.cwd(), this.options.flagProfileName).runAsync(); } }