Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/eas-cli/src/__tests__/commands/credentials-test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});

});
40 changes: 24 additions & 16 deletions packages/eas-cli/src/__tests__/commands/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,24 @@ export function mockCommandContext<
projectId?: string;
getDynamicPrivateProjectConfigAsync?: DynamicConfigContextFn;
getDynamicPublicProjectConfigAsync?: DynamicConfigContextFn;
optionalPrivateProjectConfig?: { exp: ExpoConfig; projectId: string; projectDir: string } | null;
}
): ContextOutput<C> {
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)) {
if (contextKey === 'loggedIn') {
result.loggedIn = {};
}
if (contextKey === 'projectId') {
result.projectId = overrides.projectId ?? mockProjectId;
result.projectId = projectId;
}
if (contextKey === 'projectDir') {
result.projectDir = projectDir;
Expand All @@ -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;
Expand Down
96 changes: 94 additions & 2 deletions packages/eas-cli/src/commands/credentials/index.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -27,18 +45,92 @@ 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'
Comment on lines +57 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor --json implication of non-interactive mode

The credentials command now uses EasNonInteractiveAndJsonFlags, whose shared flag contract says --json implies --non-interactive, but this branch requires both flags to be explicitly true. In a normal TTY session, eas credentials --platform android --json will skip this JSON path and drop into the interactive manager flow instead of emitting JSON-only output, which breaks script automation unless users add an extra undocumented requirement (--non-interactive).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm that's a good point! In this case should I just make the tool non-interactive as soon as the --json flag is passed? I don't know what other expectations are there around interactivity that it could break.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex can you suggest a good way forward?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) {
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,
vcsClient,
analytics,
privateProjectConfig ?? null,
getDynamicPrivateProjectConfigAsync,
flags.platform
{
flagPlatform: flags.platform,
flagProfileName: flags.profile,
}
).runAsync();
}
}
5 changes: 3 additions & 2 deletions packages/eas-cli/src/credentials/manager/ManageAndroid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ export class SelectBuildProfileFromEasJson<T extends Platform> {

constructor(
projectDir: string,
private readonly platform: T
private readonly platform: T,
private readonly profileName?: string
) {
this.easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
}

async runAsync(): Promise<BuildProfile<T>> {
const profileName = await this.getProfileNameFromEasConfigAsync();
const profileName = this.profileName ? this.profileName : await this.getProfileNameFromEasConfigAsync();
const easConfig = await EasJsonUtils.getBuildProfileAsync<T>(
this.easJsonAccessor,
this.platform,
Expand Down
9 changes: 6 additions & 3 deletions packages/eas-cli/src/credentials/manager/SelectPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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();
}
}
Loading