mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26210d7895 |
No files matched your search
@@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `eas link` and `eas unlink` commands for managing EAS project linking. `eas link` can be used instead of `eas init`. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
|
||||
|
||||
+2
-2
@@ -169,7 +169,7 @@ describe(getProjectIdAsync, () => {
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas link". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
@@ -228,7 +228,7 @@ describe(getProjectIdAsync, () => {
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas link". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
appForProjectId.ownerAccount.name
|
||||
}) does not match the logged in user (${actorUsername}) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas link". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
@@ -150,7 +150,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
appForProjectId.ownerAccount.name
|
||||
}) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas link". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
@@ -191,7 +191,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
throw new Error(
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
'Must configure EAS project by running "eas link" before using a robot user to manage the project.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
import { vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
import ProjectDirContextField from '../../../commandUtils/context/ProjectDirContextField';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../../../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester, jester2 } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppMutation } from '../../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { isExpoInstalled } from '../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import ProjectLink from '../link';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../project/expoConfig', () => ({
|
||||
...jest.requireActual('../../../project/expoConfig'),
|
||||
createOrModifyExpoConfigAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/actions');
|
||||
jest.mock('../../../graphql/mutations/AppMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: () => ({ succeed: () => {}, fail: () => {} }),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
process.argv = [];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
|
||||
function mockTestProject(options: {
|
||||
configuredProjectId?: string;
|
||||
configuredOwner?: string;
|
||||
}): void {
|
||||
const projectRoot = '/test-project';
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const appJSON: AppJSONConfig = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: options.configuredOwner,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: options.configuredProjectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
|
||||
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
|
||||
},
|
||||
'/'
|
||||
);
|
||||
|
||||
const mockManifest = { exp: appJSON.expo };
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
|
||||
jest.spyOn(ProjectDirContextField.prototype, 'getValueAsync').mockResolvedValue('/test-project');
|
||||
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
actor: jester,
|
||||
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
|
||||
});
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(true);
|
||||
}
|
||||
|
||||
const commandOptions = { root: '/test-project' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe(ProjectLink.name, () => {
|
||||
describe('when id flag is provided', () => {
|
||||
describe('when it is already configured', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
|
||||
});
|
||||
|
||||
describe('interactive', () => {
|
||||
it('is no-op if already configured for id', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
name: 'testing-123',
|
||||
slug: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
await new ProjectLink(['--id', '1234'], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts to overwrite when different', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
name: 'testing-123',
|
||||
slug: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
await new ProjectLink(['--id', '12345'], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts when prompt to overwrite is declined', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
await expect(
|
||||
new ProjectLink(['--id', '12345'], commandOptions).run()
|
||||
).rejects.toThrowError('Aborting');
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('force', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not prompt to overwrite when different', async () => {
|
||||
await new ProjectLink(['--id', '12345', '--force'], {
|
||||
root: '/test-project',
|
||||
} as any).run();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '12345');
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive', () => {
|
||||
it('aborts when different', async () => {
|
||||
await expect(
|
||||
new ProjectLink(['--id', '12345', '--non-interactive'], {
|
||||
root: '/test-project',
|
||||
} as any).run()
|
||||
).rejects.toThrowError(
|
||||
`Project is already linked to a different ID: ${chalk.bold(
|
||||
'1234'
|
||||
)}. Use --force flag to overwrite.`
|
||||
);
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks owner and slug consistency', () => {
|
||||
it('is a no-op if already consistent', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectLink(['--id', '1234'], commandOptions).run();
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts to configure if not consistent', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-124',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-124',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
});
|
||||
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectLink(['--id', '1234'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledTimes(2);
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
owner: 'jester2',
|
||||
});
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
slug: 'testing-124',
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides if force flag is present and it is not consistent', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-124',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-124',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectLink(['--id', '1234', '--force'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledTimes(2);
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
owner: 'jester2',
|
||||
});
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
slug: 'testing-124',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if non-interactive is present and it is not consistent', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-124',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-124',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await expect(
|
||||
new ProjectLink(['--id', '1234', '--non-interactive'], commandOptions).run()
|
||||
).rejects.toThrow(
|
||||
'Project config error: Project owner (jester2) does not match the value configured in the "owner" field (jester). Use --force flag to overwrite.'
|
||||
);
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is not yet configured', () => {
|
||||
it('configures', async () => {
|
||||
mockTestProject({ configuredOwner: jester.accounts[0].name });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectLink(['--id', '1234'], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no arguments are provided', () => {
|
||||
describe('when it is already configured', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not configure', async () => {
|
||||
await new ProjectLink([], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is not yet configured', () => {
|
||||
describe('when owner is specified', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredOwner: jester.accounts[0].name });
|
||||
});
|
||||
|
||||
describe('when project exists on server', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
|
||||
});
|
||||
|
||||
it('prompts for confirmation to link', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectLink([], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not save when confirmation to link is denied', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
await expect(new ProjectLink([], commandOptions).run()).rejects.toThrow(
|
||||
'Project ID configuration canceled. Re-run the command to select a different account/project.'
|
||||
);
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project does not exist on server', () => {
|
||||
it('asks to create it', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '0129',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectLink([], commandOptions).run();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not create it if declined', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
await expect(new ProjectLink([], commandOptions).run()).rejects.toThrowError(
|
||||
'Project ID configuration canceled for @jester/testing-123.'
|
||||
);
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
expect(confirmAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when owner is not specified', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({});
|
||||
});
|
||||
|
||||
it('prompts for an account', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValue({ account: { name: 'other' } });
|
||||
await expect(new ProjectLink([], commandOptions).run()).rejects.toThrowError(
|
||||
`You don't have permission to create a new project on the other account and no matching project already exists on the account.`
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the user does not have permission to create projects on account', async () => {
|
||||
mockTestProject({ configuredOwner: jester.accounts[1].name });
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
|
||||
await expect(new ProjectLink([], commandOptions).run()).rejects.toThrowError(
|
||||
`You don't have permission to create a new project on the other account and no matching project already exists on the account.`
|
||||
);
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
import { vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import MaybeLoggedInContextField from '../../../commandUtils/context/MaybeLoggedInContextField';
|
||||
import ProjectDirContextField from '../../../commandUtils/context/ProjectDirContextField';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import {
|
||||
createOrModifyExpoConfigAsync,
|
||||
isUsingStaticExpoConfig,
|
||||
} from '../../../project/expoConfig';
|
||||
import { isExpoInstalled } from '../../../project/projectUtils';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import ProjectUnlink from '../unlink';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../project/expoConfig', () => ({
|
||||
...jest.requireActual('../../../project/expoConfig'),
|
||||
createOrModifyExpoConfigAsync: jest.fn(),
|
||||
isUsingStaticExpoConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/actions');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
process.argv = [];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
|
||||
function mockTestProject(options: {
|
||||
configuredProjectId?: string;
|
||||
configuredOwner?: string;
|
||||
updatesUrl?: string;
|
||||
}): void {
|
||||
const projectRoot = '/test-project';
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const appJSON: AppJSONConfig = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: options.configuredOwner,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: options.configuredProjectId,
|
||||
},
|
||||
},
|
||||
updates: options.updatesUrl ? { url: options.updatesUrl } : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
|
||||
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
|
||||
},
|
||||
'/'
|
||||
);
|
||||
|
||||
const mockManifest = { exp: appJSON.expo };
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
|
||||
jest.spyOn(ProjectDirContextField.prototype, 'getValueAsync').mockResolvedValue('/test-project');
|
||||
jest.spyOn(MaybeLoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
actor: jester,
|
||||
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
|
||||
});
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(true);
|
||||
jest.mocked(isUsingStaticExpoConfig).mockReturnValue(true);
|
||||
}
|
||||
|
||||
const commandOptions = { root: '/test-project' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe(ProjectUnlink.name, () => {
|
||||
describe('when project is not linked', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({});
|
||||
});
|
||||
|
||||
it('logs message and exits', async () => {
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project is linked', () => {
|
||||
const projectId = '1234';
|
||||
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredProjectId: projectId, configuredOwner: jester.accounts[0].name });
|
||||
});
|
||||
|
||||
describe('interactive mode', () => {
|
||||
it('prompts for confirmation and unlinks when confirmed', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: projectId,
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
extra: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts when confirmation is declined', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: projectId,
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with --force flag', () => {
|
||||
it('skips confirmation prompt', async () => {
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: projectId,
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink(['--force'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('throws error without --force flag', async () => {
|
||||
await expect(
|
||||
new ProjectUnlink(['--non-interactive'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
`This project is linked to ${chalk.bold(
|
||||
projectId
|
||||
)}. Use --force flag to unlink in non-interactive mode.`
|
||||
);
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works with --force flag', async () => {
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink(['--non-interactive', '--force'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with EAS updates URL', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({
|
||||
configuredProjectId: projectId,
|
||||
configuredOwner: jester.accounts[0].name,
|
||||
updatesUrl: 'https://u.expo.dev/1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes updates.url when it matches EAS pattern', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
extra: {},
|
||||
updates: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with staging EAS updates URL', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({
|
||||
configuredProjectId: projectId,
|
||||
configuredOwner: jester.accounts[0].name,
|
||||
updatesUrl: 'https://staging-u.expo.dev/1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes staging updates.url', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
extra: {},
|
||||
updates: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with non-EAS updates URL', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({
|
||||
configuredProjectId: projectId,
|
||||
configuredOwner: jester.accounts[0].name,
|
||||
updatesUrl: 'https://custom-updates.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not remove custom updates.url', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
extra: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with dynamic config', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({
|
||||
configuredProjectId: projectId,
|
||||
configuredOwner: jester.accounts[0].name,
|
||||
});
|
||||
jest.mocked(isUsingStaticExpoConfig).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('throws error with helpful message', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
await expect(new ProjectUnlink([], commandOptions).run()).rejects.toThrowError(
|
||||
'Cannot automatically modify dynamic app configuration.'
|
||||
);
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when logged out', () => {
|
||||
beforeEach(() => {
|
||||
mockTestProject({ configuredProjectId: '1234' });
|
||||
jest.spyOn(MaybeLoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
actor: null,
|
||||
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
|
||||
graphqlClient: instance(mock<ExpoGraphqlClient>()),
|
||||
authenticationInfo: { accessToken: null, sessionSecret: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows project ID in confirmation since it cannot fetch project name', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
|
||||
await new ProjectUnlink([], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('1234'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,450 +1,16 @@
|
||||
import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getProjectDashboardUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import ProjectLink from './link';
|
||||
import Log from '../../log';
|
||||
|
||||
type InitializeMethodOptions = {
|
||||
force: boolean;
|
||||
nonInteractive: boolean;
|
||||
};
|
||||
|
||||
export default class ProjectInit extends EasCommand {
|
||||
export default class ProjectInit extends ProjectLink {
|
||||
static override description = 'create or link an EAS project';
|
||||
static override aliases = ['init'];
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'ID of the EAS project to link',
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
private static async saveProjectIdAndLogSuccessAsync(
|
||||
projectDir: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
await saveProjectIdToAppConfigAsync(projectDir, projectId);
|
||||
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
|
||||
}
|
||||
|
||||
private static async modifyExpoConfigAsync(
|
||||
projectDir: string,
|
||||
modifications: Partial<ExpoConfig>
|
||||
): Promise<void> {
|
||||
let result;
|
||||
try {
|
||||
result = await createOrModifyExpoConfigAsync(projectDir, modifications);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError && error.code === 'MODULE_NOT_FOUND') {
|
||||
Log.warn(
|
||||
'Cannot determine which native SDK version your project uses because the module `expo` is not installed.'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
switch (result.type) {
|
||||
case 'success':
|
||||
break;
|
||||
case 'warn': {
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`Warning: Your project uses dynamic app configuration, and cannot be automatically modified.`
|
||||
);
|
||||
Log.warn(
|
||||
chalk.dim(
|
||||
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
|
||||
)
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`To complete the setup process, add the following in your ${chalk.bold(
|
||||
getProjectConfigDescription(projectDir)
|
||||
)}:`
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(chalk.bold(JSON.stringify(modifications, null, 2)));
|
||||
Log.warn();
|
||||
throw new Error(result.message);
|
||||
}
|
||||
case 'fail':
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
throw new Error('Unexpected result type from modifyConfigAsync');
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureOwnerSlugConsistencyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const correctOwner = appForProjectId.ownerAccount.name;
|
||||
const correctSlug = appForProjectId.slug;
|
||||
|
||||
if (exp.owner && exp.owner !== correctOwner) {
|
||||
if (force) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
} else {
|
||||
const message = `Project owner (${correctOwner}) does not match the value configured in the "owner" field (${exp.owner}).`;
|
||||
if (nonInteractive) {
|
||||
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${message}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
}
|
||||
} else if (!exp.owner) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
}
|
||||
|
||||
if (exp.slug && exp.slug !== correctSlug) {
|
||||
if (force) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
} else {
|
||||
const message = `Project slug (${correctSlug}) does not match the value configured in the "slug" field (${exp.slug}).`;
|
||||
if (nonInteractive) {
|
||||
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${message}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
}
|
||||
} else if (!exp.slug) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
}
|
||||
}
|
||||
|
||||
private static async setExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (projectId === existingProjectId) {
|
||||
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingProjectId) {
|
||||
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectId !== existingProjectId) {
|
||||
if (force) {
|
||||
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Use --force flag to overwrite.`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initializeWithExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
await this.setExplicitIDAsync(projectId, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
private static async initializeWithoutExplicitIDAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<string> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (existingProjectId) {
|
||||
Log.succeed(
|
||||
`Project already linked (ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
return existingProjectId;
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
|
||||
allAccounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
|
||||
// if no owner field, ask the user which account they want to use to create/link the project
|
||||
let accountName = exp.owner;
|
||||
if (!accountName) {
|
||||
if (allAccounts.length === 1) {
|
||||
accountName = allAccounts[0].name;
|
||||
} else if (nonInteractive) {
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`There are multiple accounts that you have access to: ${allAccounts
|
||||
.map(a => a.name)
|
||||
.join(
|
||||
', '
|
||||
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
|
||||
allAccounts[0].name
|
||||
}.`
|
||||
);
|
||||
}
|
||||
accountName = allAccounts[0].name;
|
||||
Log.log(`Using default account ${accountName} for non-interactive and force mode`);
|
||||
} else {
|
||||
const choices = ProjectInit.getAccountChoices(
|
||||
actor,
|
||||
accountNamesWhereUserHasSufficientPermissionsToCreateApp
|
||||
);
|
||||
|
||||
accountName = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices,
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountName) {
|
||||
throw new Error('No account selected for project. Canceling.');
|
||||
}
|
||||
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
if (existingProjectIdOnServer) {
|
||||
if (!force) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
|
||||
);
|
||||
}
|
||||
|
||||
const affirmedLink = await confirmAsync({
|
||||
message: `Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Link this project?`,
|
||||
});
|
||||
if (!affirmedLink) {
|
||||
throw new Error(
|
||||
`Project ID configuration canceled. Re-run the command to select a different account/project.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
|
||||
return existingProjectIdOnServer;
|
||||
}
|
||||
|
||||
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
|
||||
throw new Error(
|
||||
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
|
||||
);
|
||||
}
|
||||
const affirmedCreate = await confirmAsync({
|
||||
message: `Would you like to create a project for ${projectFullName}?`,
|
||||
});
|
||||
if (!affirmedCreate) {
|
||||
throw new Error(`Project ID configuration canceled for ${projectFullName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
|
||||
const projectLink = link(projectDashboardUrl, { text: projectFullName });
|
||||
|
||||
const account = nullthrows(allAccounts.find(a => a.name === accountName));
|
||||
|
||||
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
|
||||
let createdProjectId: string;
|
||||
try {
|
||||
createdProjectId = await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
projectName,
|
||||
});
|
||||
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
|
||||
} catch (err) {
|
||||
spinner.fail();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await ProjectInit.saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
|
||||
return createdProjectId;
|
||||
}
|
||||
|
||||
private static getAccountChoices(
|
||||
actor: Actor,
|
||||
namesWithSufficientPermissions: Set<string>
|
||||
): Choice[] {
|
||||
const allAccounts = actor.accounts;
|
||||
|
||||
const sortedAccounts =
|
||||
actor.__typename === 'Robot'
|
||||
? allAccounts
|
||||
: [...allAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
if (actor.__typename !== 'Robot') {
|
||||
const personalAccount = allAccounts?.find(
|
||||
account => account?.ownerUserActor?.id === actor.id
|
||||
);
|
||||
|
||||
const personalAccountChoice = personalAccount
|
||||
? {
|
||||
title: personalAccount.name,
|
||||
value: personalAccount,
|
||||
description: !namesWithSufficientPermissions.has(personalAccount.name)
|
||||
? '(Personal) (Viewer Role)'
|
||||
: '(Personal)',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userAccounts = allAccounts
|
||||
?.filter(account => account.ownerUserActor && account.name !== actor.username)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Team) (Viewer Role)'
|
||||
: '(Team)',
|
||||
}));
|
||||
|
||||
const organizationAccounts = allAccounts
|
||||
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Organization) (Viewer Role)'
|
||||
: '(Organization)',
|
||||
}));
|
||||
|
||||
let choices: Choice[] = [];
|
||||
if (personalAccountChoice) {
|
||||
choices = [personalAccountChoice];
|
||||
}
|
||||
|
||||
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
}
|
||||
|
||||
return sortedAccounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ProjectInit);
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(ProjectInit, { nonInteractive });
|
||||
|
||||
let idForConsistency: string;
|
||||
if (idArgument) {
|
||||
await ProjectInit.initializeWithExplicitIDAsync(idArgument, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
idForConsistency = idArgument;
|
||||
} else {
|
||||
idForConsistency = await ProjectInit.initializeWithoutExplicitIDAsync(
|
||||
graphqlClient,
|
||||
actor,
|
||||
projectDir,
|
||||
{
|
||||
force,
|
||||
nonInteractive,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await ProjectInit.ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
override async runAsync(): Promise<void> {
|
||||
Log.log(chalk.dim('Tip: You can also use `eas link` for this.'));
|
||||
Log.newLine();
|
||||
await super.runAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getProjectDashboardUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { validSlugName, validateFullNameAndSlug } from '../../commandUtils/projectNameValidation';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, confirmAsync, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
|
||||
type InitializeMethodOptions = {
|
||||
force: boolean;
|
||||
nonInteractive: boolean;
|
||||
};
|
||||
|
||||
export default class ProjectLink extends EasCommand {
|
||||
static override description = 'link a local project to an EAS project';
|
||||
static override aliases = ['link'];
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'ID of the EAS project to link',
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
protected static async saveProjectIdAndLogSuccessAsync(
|
||||
projectDir: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
await saveProjectIdToAppConfigAsync(projectDir, projectId);
|
||||
Log.withTick(`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`);
|
||||
}
|
||||
|
||||
protected static async modifyExpoConfigAsync(
|
||||
projectDir: string,
|
||||
modifications: Partial<ExpoConfig>
|
||||
): Promise<void> {
|
||||
let result;
|
||||
try {
|
||||
result = await createOrModifyExpoConfigAsync(projectDir, modifications);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError && error.code === 'MODULE_NOT_FOUND') {
|
||||
Log.warn(
|
||||
'Cannot determine which native SDK version your project uses because the module `expo` is not installed.'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
switch (result.type) {
|
||||
case 'success':
|
||||
break;
|
||||
case 'warn': {
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`Warning: Your project uses dynamic app configuration, and cannot be automatically modified.`
|
||||
);
|
||||
Log.warn(
|
||||
chalk.dim(
|
||||
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
|
||||
)
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`To complete the setup process, add the following in your ${chalk.bold(
|
||||
getProjectConfigDescription(projectDir)
|
||||
)}:`
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(chalk.bold(JSON.stringify(modifications, null, 2)));
|
||||
Log.warn();
|
||||
throw new Error(result.message);
|
||||
}
|
||||
case 'fail':
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
throw new Error('Unexpected result type from modifyConfigAsync');
|
||||
}
|
||||
}
|
||||
|
||||
protected static async ensureOwnerSlugConsistencyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const correctOwner = appForProjectId.ownerAccount.name;
|
||||
const correctSlug = appForProjectId.slug;
|
||||
|
||||
if (exp.owner && exp.owner !== correctOwner) {
|
||||
if (force) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
} else {
|
||||
const message = `Project owner (${correctOwner}) does not match the value configured in the "owner" field (${exp.owner}).`;
|
||||
if (nonInteractive) {
|
||||
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${message}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
}
|
||||
} else if (!exp.owner) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { owner: correctOwner });
|
||||
}
|
||||
|
||||
if (exp.slug && exp.slug !== correctSlug) {
|
||||
if (force) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
} else {
|
||||
const message = `Project slug (${correctSlug}) does not match the value configured in the "slug" field (${exp.slug}).`;
|
||||
if (nonInteractive) {
|
||||
throw new Error(`Project config error: ${message} Use --force flag to overwrite.`);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `${message}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
}
|
||||
} else if (!exp.slug) {
|
||||
await this.modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
}
|
||||
}
|
||||
|
||||
protected static async setExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (projectId === existingProjectId) {
|
||||
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingProjectId) {
|
||||
await ProjectLink.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectId !== existingProjectId) {
|
||||
if (force) {
|
||||
await ProjectLink.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Use --force flag to overwrite.`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await ProjectLink.saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
protected static async initializeWithExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
await this.setExplicitIDAsync(projectId, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
protected static async initializeWithoutExplicitIDAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<string> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (existingProjectId) {
|
||||
Log.succeed(
|
||||
`Project already linked (ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
return existingProjectId;
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
|
||||
allAccounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
|
||||
// if no owner field, ask the user which account they want to use to create/link the project
|
||||
let accountName = exp.owner;
|
||||
if (!accountName) {
|
||||
if (allAccounts.length === 1) {
|
||||
accountName = allAccounts[0].name;
|
||||
} else if (nonInteractive) {
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`There are multiple accounts that you have access to: ${allAccounts
|
||||
.map(a => a.name)
|
||||
.join(
|
||||
', '
|
||||
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
|
||||
allAccounts[0].name
|
||||
}.`
|
||||
);
|
||||
}
|
||||
accountName = allAccounts[0].name;
|
||||
Log.log(`Using default account ${accountName} for non-interactive and force mode`);
|
||||
} else {
|
||||
const choices = ProjectLink.getAccountChoices(
|
||||
actor,
|
||||
accountNamesWhereUserHasSufficientPermissionsToCreateApp
|
||||
);
|
||||
|
||||
accountName = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices,
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountName) {
|
||||
throw new Error('No account selected for project. Canceling.');
|
||||
}
|
||||
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
if (existingProjectIdOnServer) {
|
||||
if (!force) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
|
||||
);
|
||||
}
|
||||
|
||||
const affirmedLink = await confirmAsync({
|
||||
message: `Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Link this project?`,
|
||||
});
|
||||
if (!affirmedLink) {
|
||||
throw new Error(
|
||||
`Project ID configuration canceled. Re-run the command to select a different account/project.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await ProjectLink.saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
|
||||
return existingProjectIdOnServer;
|
||||
}
|
||||
|
||||
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
|
||||
throw new Error(
|
||||
`You don't have permission to create a new project on the ${accountName} account and no matching project already exists on the account.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
|
||||
);
|
||||
}
|
||||
const affirmedCreate = await confirmAsync({
|
||||
message: `Would you like to create a project for ${projectFullName}?`,
|
||||
});
|
||||
if (!affirmedCreate) {
|
||||
throw new Error(`Project ID configuration canceled for ${projectFullName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
|
||||
const projectLink = link(projectDashboardUrl, { text: projectFullName });
|
||||
|
||||
const account = nullthrows(allAccounts.find(a => a.name === accountName));
|
||||
|
||||
const spinner = ora(`Creating ${chalk.bold(projectFullName)}`).start();
|
||||
let createdProjectId: string;
|
||||
try {
|
||||
createdProjectId = await AppMutation.createAppAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
projectName,
|
||||
});
|
||||
spinner.succeed(`Created ${chalk.bold(projectLink)}`);
|
||||
} catch (err) {
|
||||
spinner.fail();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await ProjectLink.saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
|
||||
return createdProjectId;
|
||||
}
|
||||
|
||||
protected static getAccountChoices(
|
||||
actor: Actor,
|
||||
namesWithSufficientPermissions: Set<string>
|
||||
): Choice[] {
|
||||
const allAccounts = actor.accounts;
|
||||
|
||||
const sortedAccounts =
|
||||
actor.__typename === 'Robot'
|
||||
? allAccounts
|
||||
: [...allAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
if (actor.__typename !== 'Robot') {
|
||||
const personalAccount = allAccounts?.find(
|
||||
account => account?.ownerUserActor?.id === actor.id
|
||||
);
|
||||
|
||||
const personalAccountChoice = personalAccount
|
||||
? {
|
||||
title: personalAccount.name,
|
||||
value: personalAccount,
|
||||
description: !namesWithSufficientPermissions.has(personalAccount.name)
|
||||
? '(Personal) (Viewer Role)'
|
||||
: '(Personal)',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userAccounts = allAccounts
|
||||
?.filter(account => account.ownerUserActor && account.name !== actor.username)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Team) (Viewer Role)'
|
||||
: '(Team)',
|
||||
}));
|
||||
|
||||
const organizationAccounts = allAccounts
|
||||
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Organization) (Viewer Role)'
|
||||
: '(Organization)',
|
||||
}));
|
||||
|
||||
let choices: Choice[] = [];
|
||||
if (personalAccountChoice) {
|
||||
choices = [personalAccountChoice];
|
||||
}
|
||||
|
||||
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
}
|
||||
|
||||
return sortedAccounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ProjectLink);
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(ProjectLink, { nonInteractive });
|
||||
|
||||
let idForConsistency: string;
|
||||
if (idArgument) {
|
||||
await ProjectLink.initializeWithExplicitIDAsync(idArgument, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
idForConsistency = idArgument;
|
||||
} else {
|
||||
idForConsistency = await ProjectLink.initializeWithoutExplicitIDAsync(
|
||||
graphqlClient,
|
||||
actor,
|
||||
projectDir,
|
||||
{
|
||||
force,
|
||||
nonInteractive,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await ProjectLink.ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getProjectConfigDescription } from '@expo/config';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
createOrModifyExpoConfigAsync,
|
||||
getPrivateExpoConfigAsync,
|
||||
isUsingStaticExpoConfig,
|
||||
} from '../../project/expoConfig';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
|
||||
export default class ProjectUnlink extends EasCommand {
|
||||
static override description = 'unlink a local project from an EAS project';
|
||||
static override aliases = ['unlink'];
|
||||
|
||||
static override flags = {
|
||||
force: Flags.boolean({
|
||||
description: 'Skip confirmation prompt',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.MaybeLoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { force, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ProjectUnlink);
|
||||
const {
|
||||
maybeLoggedIn: { actor, graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(ProjectUnlink, { nonInteractive });
|
||||
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const projectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
Log.log('This project is not linked to an EAS project.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to fetch project name for better UX if logged in
|
||||
let projectName: string | null = null;
|
||||
if (actor) {
|
||||
try {
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
projectName = `@${app.ownerAccount.name}/${app.slug}`;
|
||||
} catch {
|
||||
// Ignore errors - user might not have access to the project
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = projectName ?? projectId;
|
||||
|
||||
// Require confirmation unless --force is passed
|
||||
if (!force) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`This project is linked to ${chalk.bold(
|
||||
displayName
|
||||
)}. Use --force flag to unlink in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = await confirmAsync({
|
||||
message: `Unlink this project from ${chalk.bold(displayName)}?`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if using dynamic config
|
||||
if (!isUsingStaticExpoConfig(projectDir)) {
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`Warning: Your project uses dynamic app configuration, and cannot be automatically modified.`
|
||||
);
|
||||
Log.warn(
|
||||
chalk.dim(
|
||||
'https://docs.expo.dev/workflow/configuration/#dynamic-configuration-with-appconfigjs'
|
||||
)
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(
|
||||
`To unlink from EAS, remove the following from your ${chalk.bold(
|
||||
getProjectConfigDescription(projectDir)
|
||||
)}:`
|
||||
);
|
||||
Log.warn();
|
||||
Log.warn(chalk.bold(' extra.eas.projectId'));
|
||||
if (exp.updates?.url && this.isEasUpdateUrl(exp.updates.url)) {
|
||||
Log.warn(chalk.bold(' updates.url'));
|
||||
}
|
||||
Log.warn();
|
||||
throw new Error('Cannot automatically modify dynamic app configuration.');
|
||||
}
|
||||
|
||||
// Remove extra.eas.projectId by setting it to undefined
|
||||
const newExtra = { ...exp.extra };
|
||||
if (newExtra.eas) {
|
||||
const { projectId: _, ...restEas } = newExtra.eas;
|
||||
if (Object.keys(restEas).length === 0) {
|
||||
delete newExtra.eas;
|
||||
} else {
|
||||
newExtra.eas = restEas;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove updates.url if it matches EAS Update URL pattern
|
||||
let updatesUrlRemoved = false;
|
||||
const modifications: Record<string, unknown> = { extra: newExtra };
|
||||
|
||||
if (exp.updates?.url && this.isEasUpdateUrl(exp.updates.url)) {
|
||||
const { url: _, ...restUpdates } = exp.updates;
|
||||
if (Object.keys(restUpdates).length === 0) {
|
||||
modifications.updates = undefined;
|
||||
} else {
|
||||
modifications.updates = restUpdates;
|
||||
}
|
||||
updatesUrlRemoved = true;
|
||||
}
|
||||
|
||||
const result = await createOrModifyExpoConfigAsync(projectDir, modifications);
|
||||
|
||||
if (result.type !== 'success') {
|
||||
throw new Error(result.message ?? 'Failed to modify app configuration.');
|
||||
}
|
||||
|
||||
Log.withTick(`Removed ${chalk.bold('extra.eas.projectId')} from app config`);
|
||||
if (updatesUrlRemoved) {
|
||||
Log.withTick(`Removed ${chalk.bold('updates.url')} from app config`);
|
||||
}
|
||||
Log.succeed('Project unlinked successfully.');
|
||||
}
|
||||
|
||||
private isEasUpdateUrl(url: string): boolean {
|
||||
// Match EAS Update URLs: https://u.expo.dev/*, https://staging-u.expo.dev/*
|
||||
return /^https:\/\/(staging-)?u\.expo\.dev\//.test(url);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
|
||||
jester
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
`Must configure EAS project by running 'eas link' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -32,7 +32,7 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
`Must configure EAS project by running 'eas link' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
|
||||
});
|
||||
if (!affirmedLink) {
|
||||
throw new Error(
|
||||
`EAS project ID configuration canceled for ${projectFullName}. Run 'eas init' to configure.`
|
||||
`EAS project ID configuration canceled for ${projectFullName}. Run 'eas link' to configure.`
|
||||
);
|
||||
}
|
||||
return projectIdOnServer;
|
||||
@@ -77,7 +77,7 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
|
||||
});
|
||||
if (!affirmedCreate) {
|
||||
throw new Error(
|
||||
`EAS project ID configuration canceled for ${projectFullName}. Run 'eas init' to configure.`
|
||||
`EAS project ID configuration canceled for ${projectFullName}. Run 'eas link' to configure.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user