Compare commits

...
6 Commits
Author SHA1 Message Date
Colin MacKenzie 750c99184f removed some unused code 2025-09-23 15:01:46 -05:00
Colin MacKenzie 2bd628be1a update unit test 2025-09-23 14:39:47 -05:00
Colin MacKenzie 3a707229aa clean up getAccountChoices 2025-09-23 13:46:48 -05:00
Colin MacKenzie 5964b4ebe1 create expo project 2025-09-23 13:42:42 -05:00
Colin MacKenzie c11fa806c1 playing around with adding build 2025-09-23 10:37:44 -05:00
Colin MacKenzie e9f1b64285 incorporate pr feedback 2025-09-23 10:37:44 -05:00
2 changed files with 469 additions and 24 deletions

No files matched your search

@@ -1,3 +1,4 @@
import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -8,22 +9,53 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
import { jester, robot } from '../../../credentials/__tests__/fixtures-constants';
import { Role } from '../../../graphql/generated';
import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
import {
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../../onboarding/installDependencies';
import { runCommandAsync } from '../../../onboarding/runCommand';
import { promptAsync } from '../../../prompts';
import {
createOrModifyExpoConfigAsync,
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { confirmAsync, promptAsync } from '../../../prompts';
import { Actor } from '../../../user/User';
import New from '../new';
// Test helper types
interface MockExpoConfig extends Partial<ExpoConfig> {
name: string;
slug: string;
owner?: string;
extra?: {
eas?: {
projectId?: string;
[key: string]: any;
};
[key: string]: any;
};
}
interface MockConfigResult {
type: 'success' | 'warn' | 'fail';
message?: string;
config: ExpoConfig | null;
}
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../onboarding/git');
jest.mock('../../../onboarding/installDependencies');
jest.mock('../../../onboarding/runCommand');
jest.mock('../../../project/expoConfig');
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../prompts');
jest.mock('../../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
@@ -82,8 +114,14 @@ describe(New.name, () => {
const commandOptions = { root: process.cwd() } as any;
describe('successful project creation', () => {
// Create a single-account version of jester for basic tests
const singleAccountJester = {
...jester,
accounts: [jester.accounts[0]],
};
beforeEach(() => {
mockLoggedInContext(jester);
mockLoggedInContext(singleAccountJester);
mockFileSystem(targetProjectDir);
// Mock git operations
@@ -99,6 +137,19 @@ describe(New.name, () => {
// Mock fs operations
(fs.remove as jest.Mock).mockResolvedValue(undefined);
// Mock project configuration
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
} satisfies MockExpoConfig);
// Mock project initialization
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('test-project-id');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: null } satisfies MockConfigResult);
});
it('creates a new project with SSH clone method', async () => {
@@ -141,6 +192,25 @@ describe(New.name, () => {
command: 'git',
args: ['commit', '-m', 'Initial commit'],
});
expect(AppMutation.createAppAsync).toHaveBeenCalledTimes(1);
const createAppCall = jest.mocked(AppMutation.createAppAsync).mock.calls[0];
expect(createAppCall[1]).toEqual({
accountId: singleAccountJester.accounts[0].id,
projectName: 'jester-app',
});
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
targetProjectDir,
expect.objectContaining({
extra: expect.objectContaining({
eas: expect.objectContaining({
projectId: 'test-project-id',
}),
}),
}),
{ skipSDKVersionRequirement: true }
);
});
it('creates a new project with HTTPS clone method when SSH fails', async () => {
@@ -158,7 +228,6 @@ describe(New.name, () => {
});
it('logs appropriate messages during project creation', async () => {
mockLoggedInContext(jester);
mockFileSystem(targetProjectDir);
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
@@ -269,6 +338,229 @@ describe(New.name, () => {
});
});
describe('project initialization variants', () => {
beforeEach(() => {
mockLoggedInContext(jester);
mockFileSystem(targetProjectDir);
// Mock git operations
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
jest.mocked(runGitCloneAsync).mockResolvedValue({
targetProjectDir,
});
jest.mocked(installDependenciesAsync).mockResolvedValue();
jest.mocked(runCommandAsync).mockResolvedValue();
// Mock package manager selection (default to npm)
jest.mocked(promptForPackageManagerAsync).mockResolvedValue('npm');
// Mock fs operations
(fs.remove as jest.Mock).mockResolvedValue(undefined);
// Default project configuration (can be overridden in individual tests)
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
} satisfies MockExpoConfig);
// Default project initialization mocks (can be overridden in individual tests)
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('test-project-id');
jest
.mocked(createOrModifyExpoConfigAsync)
.mockResolvedValue({ type: 'success', config: null } satisfies MockConfigResult);
jest.mocked(promptAsync).mockResolvedValue({
account: { name: jester.accounts[0].name },
});
});
it('links existing project on server when user confirms', async () => {
const existingProjectId = 'existing-server-project-id';
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValue(existingProjectId);
jest.mocked(confirmAsync).mockResolvedValue(true);
const command = new New([targetProjectDir], commandOptions);
await command.run();
expect(confirmAsync).toHaveBeenCalledWith({
message: `Existing project found: @${jester.accounts[0].name}/jester-app (ID: ${existingProjectId}). Link this project?`,
});
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
targetProjectDir,
expect.objectContaining({
extra: expect.objectContaining({
eas: expect.objectContaining({
projectId: existingProjectId,
}),
}),
}),
{ skipSDKVersionRequirement: true }
);
// Should not create a new project since existing one was linked
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
it('throws error when user declines linking existing project', async () => {
const existingProjectId = 'existing-server-project-id';
jest
.mocked(findProjectIdByAccountNameAndSlugNullableAsync)
.mockResolvedValue(existingProjectId);
jest.mocked(confirmAsync).mockResolvedValue(false);
const command = new New([targetProjectDir], commandOptions);
await expect(command.run()).rejects.toThrow(
'Project ID configuration canceled. Re-run the command to select a different account/project.'
);
expect(confirmAsync).toHaveBeenCalled();
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
it('prompts for account selection when multiple accounts', async () => {
const multipleAccountsActor = {
...jester,
accounts: [
{ ...jester.accounts[0], name: 'account1' },
{ ...jester.accounts[0], name: 'account2' },
],
};
mockLoggedInContext(multipleAccountsActor);
jest.mocked(promptAsync).mockResolvedValueOnce({
account: { name: 'account2' },
});
const command = new New([targetProjectDir], commandOptions);
await command.run();
expect(promptAsync).toHaveBeenCalledWith({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices: expect.any(Array),
});
expect(AppMutation.createAppAsync).toHaveBeenCalledTimes(1);
const createAppCall = jest.mocked(AppMutation.createAppAsync).mock.calls[0];
expect(createAppCall[1]).toEqual({
accountId: jester.accounts[0].id, // Both accounts have same ID since we spread jester.accounts[0]
projectName: 'jester-app',
});
});
it('uses single account automatically when only one account available', async () => {
const singleAccountUser = {
...jester,
accounts: [jester.accounts[0]],
};
mockLoggedInContext(singleAccountUser);
const command = new New([targetProjectDir], commandOptions);
await command.run();
// Should complete the full flow
expect(runGitCloneAsync).toHaveBeenCalled();
expect(installDependenciesAsync).toHaveBeenCalled();
// Should not prompt for account selection when only one account
expect(promptAsync).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
})
);
expect(AppMutation.createAppAsync).toHaveBeenCalledTimes(1);
const createAppCall = jest.mocked(AppMutation.createAppAsync).mock.calls[0];
expect(createAppCall[1]).toEqual({
accountId: jester.accounts[0].id,
projectName: 'jester-app',
});
});
it('throws error when user has insufficient permissions', async () => {
const insufficientPermissionsActor = {
...jester,
accounts: [
{
...jester.accounts[0],
users: [{ role: Role.ViewOnly, actor: { id: jester.id } }],
},
],
};
mockLoggedInContext(insufficientPermissionsActor);
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
} satisfies MockExpoConfig);
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
const command = new New([targetProjectDir], commandOptions);
await expect(command.run()).rejects.toThrow(
`You don't have permission to create a new project on the ${insufficientPermissionsActor.accounts[0].name} account and no matching project already exists on the account.`
);
expect(AppMutation.createAppAsync).not.toHaveBeenCalled();
});
it('preserves existing extra config when saving project ID', async () => {
const existingExtra = { someField: 'someValue', eas: { otherField: 'otherValue' } };
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({
name: 'test-app',
slug: 'test-app',
extra: existingExtra,
} satisfies MockExpoConfig);
const command = new New([targetProjectDir], commandOptions);
await command.run();
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith(
targetProjectDir,
{
extra: {
...existingExtra,
eas: { ...existingExtra.eas, projectId: 'test-project-id' },
},
},
{ skipSDKVersionRequirement: true }
);
});
it('handles config modification failure gracefully', async () => {
const errorMessage = 'Failed to modify config';
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
type: 'fail',
message: errorMessage,
config: null,
} satisfies MockConfigResult);
const command = new New([targetProjectDir], commandOptions);
await expect(command.run()).rejects.toThrow(errorMessage);
// Should still complete git and dependency installation
expect(runGitCloneAsync).toHaveBeenCalled();
expect(installDependenciesAsync).toHaveBeenCalled();
});
});
describe('error handling', () => {
it('throws error for robot users', async () => {
mockLoggedInContext(robot);
+174 -21
View File
@@ -1,16 +1,25 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import { getProjectDashboardUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../../graphql/generated';
import { AppMutation } from '../../graphql/mutations/AppMutation';
import Log, { link } from '../../log';
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
import {
installDependenciesAsync,
promptForPackageManagerAsync,
} from '../../onboarding/installDependencies';
import { runCommandAsync } from '../../onboarding/runCommand';
import { promptAsync } from '../../prompts';
import { ora } from '../../ora';
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { Choice, confirmAsync, promptAsync } from '../../prompts';
import { Actor, getActorUsername } from '../../user/User';
export default class New extends EasCommand {
static override aliases = ['new'];
@@ -33,12 +42,9 @@ export default class New extends EasCommand {
} = await this.parse(New);
const {
loggedIn: { actor },
loggedIn: { actor, graphqlClient },
} = await this.getContextAsync(New, { nonInteractive: false });
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
if (actor.__typename === 'Robot') {
throw new Error(
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
@@ -50,6 +56,19 @@ export default class New extends EasCommand {
);
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
Log.newLine();
const targetProjectDirInput =
await this.getTargetProjectDirectoryAsync(targetProjectDirFromArgs);
const finalTargetProjectDirectory = await this.cloneTemplateAsync(targetProjectDirInput);
await this.installProjectDependenciesAsync(finalTargetProjectDirectory);
await this.initializeGitRepositoryAsync(finalTargetProjectDirectory);
await this.initializeProjectAsync(graphqlClient, actor, finalTargetProjectDirectory);
Log.log('🎉 We finished creating your new project.');
Log.newLine();
}
private async getTargetProjectDirectoryAsync(targetProjectDirFromArgs: string): Promise<string> {
Log.log(
`🚚 Let's start by cloning the default Expo template project from GitHub and installing dependencies.`
);
@@ -66,6 +85,13 @@ export default class New extends EasCommand {
).targetProjectDir;
}
return targetProjectDirInput;
}
private async cloneTemplateAsync(targetProjectDirInput: string): Promise<string> {
const githubUsername = 'expo';
const githubRepositoryName = 'expo-template-default';
Log.log(`📂 Cloning the project to ${targetProjectDirInput}`);
Log.newLine();
@@ -85,6 +111,20 @@ export default class New extends EasCommand {
cloneMethod,
});
return finalTargetProjectDirectory;
}
private async installProjectDependenciesAsync(
finalTargetProjectDirectory: string
): Promise<void> {
const packageManager = await promptForPackageManagerAsync();
await installDependenciesAsync({
projectDir: finalTargetProjectDirectory,
packageManager,
});
}
private async initializeGitRepositoryAsync(finalTargetProjectDirectory: string): Promise<void> {
await fs.remove(path.join(finalTargetProjectDirectory, '.git'));
await runCommandAsync({
cwd: finalTargetProjectDirectory,
@@ -92,19 +132,6 @@ export default class New extends EasCommand {
args: ['init'],
});
Log.log();
// TODO - need to create an app
// await configureProjectFromBareDefaultExpoTemplateAsync({
// app,
// vcsClient,
// targetDir: finalTargetProjectDirectory,
// });
const packageManager = await promptForPackageManagerAsync();
await installDependenciesAsync({
projectDir: finalTargetProjectDirectory,
packageManager,
});
await runCommandAsync({
cwd: finalTargetProjectDirectory,
command: 'git',
@@ -116,8 +143,134 @@ export default class New extends EasCommand {
command: 'git',
args: ['commit', '-m', 'Initial commit'],
});
}
Log.log('🎉 We finished creating your new project.');
Log.newLine();
private async initializeProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
projectDir: string
): Promise<string> {
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)
);
let accountName = allAccounts[0].name;
if (allAccounts.length > 1) {
const choices = this.getAccountChoices(
actor,
accountNamesWhereUserHasSufficientPermissionsToCreateApp
);
accountName = (
await promptAsync({
type: 'select',
name: 'account',
message: 'Which account should own this project?',
choices,
})
).account.name;
}
const projectName = getActorUsername(actor) + '-app';
const projectFullName = `@${accountName}/${projectName}`;
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
accountName,
projectName
);
if (existingProjectIdOnServer) {
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 this.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.`
);
}
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 this.saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
return createdProjectId;
}
private async saveProjectIdAndLogSuccessAsync(
projectDir: string,
projectId: string
): Promise<void> {
const exp = await getPrivateExpoConfigAsync(projectDir, { skipPlugins: true });
const result = await createOrModifyExpoConfigAsync(
projectDir,
{
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
},
{ skipSDKVersionRequirement: true }
);
switch (result.type) {
case 'success':
Log.withTick(
`Project successfully linked (ID: ${chalk.bold(projectId)}) (modified app.json)`
);
break;
case 'fail':
throw new Error(result.message);
default:
throw new Error('Unexpected result type from modifyConfigAsync');
}
}
private getAccountChoices(actor: Actor, namesWithSufficientPermissions: Set<string>): Choice[] {
const sortedAccounts = actor.accounts.sort((a, _b) =>
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
);
return sortedAccounts.map(account => {
const isPersonalAccount = actor.__typename === 'User' && account.name === actor.username;
const accountDisplayName = isPersonalAccount
? `${account.name} (personal account)`
: account.name;
const disabled = !namesWithSufficientPermissions.has(account.name);
return {
title: accountDisplayName,
value: { name: account.name },
...(disabled && {
disabled: true,
description:
'You do not have the required permissions to create projects on this account.',
}),
};
});
}
}