Compare commits

...
Author SHA1 Message Date
Brent Vatne 26210d7895 [eas-cli] Add eas link and eas unlink commands
Add `eas link` as the primary command for linking projects to EAS (same
functionality as current `eas init`). Add `eas unlink` to remove EAS
project configuration from app.json.

- `eas link` is now the recommended command for linking projects
- `eas init` still works but is hidden from help, with a tip suggesting
  `eas link` instead
- `eas unlink` removes `extra.eas.projectId` and `updates.url` (if it
  matches the EAS Update URL pattern) from app config
- Updated error messages to reference `eas link` instead of `eas init`
2026-01-25 14:13:22 -08:00
10 changed files with 1345 additions and 451 deletions

No files matched your search

+1
View File
@@ -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))
@@ -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'),
});
});
});
});
+8 -442
View File
@@ -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);
}
}
@@ -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.`
);
});
});
@@ -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.`
);
}