diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b0001220..4df8a044fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod)) - [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela)) +- [eas-cli] Say why Apple Developer access is required after all when the optional Apple sign-in was declined, instead of prompting for an Apple ID with no explanation. ([#4444](https://github.com/expo/eas-cli/pull/4444) by [@LizunovSergey](https://github.com/LizunovSergey)) ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/credentials/__tests__/context-test.ts b/packages/eas-cli/src/credentials/__tests__/context-test.ts new file mode 100644 index 0000000000..e4891cc0f3 --- /dev/null +++ b/packages/eas-cli/src/credentials/__tests__/context-test.ts @@ -0,0 +1,86 @@ +import { CredentialsContext } from '../context'; +import { authenticateAsync } from '../ios/appstore/authenticate'; +import Log from '../../log'; +import { confirmAsync } from '../../prompts'; + +jest.mock('../../prompts'); +jest.mock('../../log'); +jest.mock('../ios/appstore/authenticate'); + +function createContext(): CredentialsContext { + return new CredentialsContext({ + projectInfo: null, + nonInteractive: false, + projectDir: '.', + user: { __typename: 'User', username: 'quin', accounts: [] } as any, + graphqlClient: {} as any, + analytics: {} as any, + vcsClient: {} as any, + }); +} + +describe(CredentialsContext.prototype.bestEffortAppStoreAuthenticateAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(authenticateAsync).mockResolvedValue({ team: { id: 'TEAM' } } as any); + }); + + it('authenticates without further explanation when the offer is accepted', async () => { + jest.mocked(confirmAsync).mockResolvedValueOnce(true); + const ctx = createContext(); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + + expect(authenticateAsync).toHaveBeenCalledTimes(1); + expect(Log.warn).not.toHaveBeenCalled(); + }); + + it('says why a later step needs the login that was declined', async () => { + // Repros expo/eas-cli#4422: declining prints "we will ask you again about it", and the next + // step that needs Apple access then goes straight to the Apple ID prompt with no reason + // given, which reads as the decline having been ignored. + jest.mocked(confirmAsync).mockResolvedValueOnce(false); + const ctx = createContext(); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + expect(authenticateAsync).not.toHaveBeenCalled(); + expect(Log.warn).not.toHaveBeenCalled(); + + // A later step that cannot run without Apple access, e.g. listing distribution certificates. + await ctx.appStore.ensureAuthenticatedAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('the login you skipped is needed after all') + ); + expect(authenticateAsync).toHaveBeenCalledTimes(1); + }); + + it('explains once, not before every later call', async () => { + jest.mocked(confirmAsync).mockResolvedValueOnce(false); + const ctx = createContext(); + await ctx.bestEffortAppStoreAuthenticateAsync(); + + await ctx.appStore.ensureAuthenticatedAsync(); + ctx.appStore.authCtx = undefined; // a later step re-authenticates, e.g. after a mode switch + await ctx.appStore.ensureAuthenticatedAsync(); + + expect(Log.warn).toHaveBeenCalledTimes(1); + }); + + it('does not prompt or explain in non-interactive mode', async () => { + const ctx = new CredentialsContext({ + projectInfo: null, + nonInteractive: true, + projectDir: '.', + user: { __typename: 'User', username: 'quin', accounts: [] } as any, + graphqlClient: {} as any, + analytics: {} as any, + vcsClient: {} as any, + }); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + + expect(confirmAsync).not.toHaveBeenCalled(); + expect(Log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/credentials/context.ts b/packages/eas-cli/src/credentials/context.ts index 561bd319b9..76bd55949e 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -141,6 +141,16 @@ export class CredentialsContext { 'No problem! ๐Ÿ‘Œ If any of the next steps will require Apple account access we will ask you again about it.' ) ); + // That promise is only half kept: a step that cannot run without Apple access does not ask + // again, it goes straight to the Apple ID prompt, which reads as the decline having been + // ignored (expo/eas-cli#4422). Name the reason when that happens. + this.appStore.onBeforeForcedAuthentication = () => { + this.appStore.onBeforeForcedAuthentication = undefined; + Log.newLine(); + Log.warn( + 'This step cannot be completed without Apple Developer access, so the login you skipped is needed after all.' + ); + }; } this.shouldAskAuthenticateAppStore = false; } diff --git a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts index 2314198555..0c59fd0cad 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts @@ -52,6 +52,12 @@ import Log from '../../../log'; export default class AppStoreApi { public authCtx?: AuthCtx; public defaultAuthenticationMode: AuthenticationMode; + /** + * Invoked once, immediately before authentication is forced on a caller that did not ask for + * it. Set by {@link CredentialsContext.bestEffortAppStoreAuthenticateAsync} when the optional + * offer was declined, so the login that follows can say why it is required. + */ + public onBeforeForcedAuthentication?: () => void; constructor() { this.defaultAuthenticationMode = hasAscEnvVars() @@ -75,6 +81,7 @@ export default class AppStoreApi { public async ensureAuthenticatedAsync(options?: AuthenticateOptions): Promise { if (!this.authCtx) { + this.onBeforeForcedAuthentication?.(); const mode = options?.mode ?? this.defaultAuthenticationMode; this.authCtx = await authenticateAsync({ mode, ...options }); }