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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))

### 🧹 Chores

## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
Expand Down
15 changes: 13 additions & 2 deletions packages/eas-cli/src/commandUtils/__tests__/convex-test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import dateFormat from 'dateformat';

import {
confirmRecentConvexInviteAsync,
formatConvexProject,
Expand All @@ -22,6 +24,7 @@ describe('Convex command utilities', () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
hasBeenClaimed: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
Expand Down Expand Up @@ -58,8 +61,12 @@ describe('Convex command utilities', () => {
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
expect(formatConvexTeamConnection(mockConnection)).toContain('Claimed');
expect(formatConvexTeamConnection(mockConnection)).toContain('Yes');
expect(formatConvexTeamConnection(mockConnection)).toContain('user@example.com');
expect(formatConvexTeamConnection(mockConnection)).toContain('2024-01-02T00:00:00.000Z');
expect(formatConvexTeamConnection(mockConnection)).toContain(
dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss')
);
expect(formatConvexProject(mockProject)).toContain('project-123');
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
Expand Down Expand Up @@ -93,12 +100,13 @@ describe('Convex command utilities', () => {

it('prompts before resending a recent invite in interactive mode', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const recentInviteTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString();

await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
invitedAt: recentInviteTimestamp,
},
{ nonInteractive: false }
)
Expand All @@ -107,6 +115,9 @@ describe('Convex command utilities', () => {
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining(dateFormat(recentInviteTimestamp, 'mmm dd HH:MM:ss')),
});
});

it('warns and proceeds for recent invites in non-interactive mode', async () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/eas-cli/src/commandUtils/convex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import dateFormat from 'dateformat';

import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
import Log, { link } from '../log';
Expand All @@ -7,6 +8,10 @@ import { confirmAsync } from '../prompts';
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;

export function formatConvexInviteTimestamp(timestamp: string): string {
return dateFormat(timestamp, 'mmm dd HH:MM:ss');
}

export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
}
Expand All @@ -25,13 +30,14 @@ export function formatConvexTeamConnection(connection: ConvexTeamConnectionData)
const lines = [
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`,
];

if (connection.invitedEmail) {
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
lines.push(`${chalk.bold('Invited at')}: ${connection.invitedAt}`);
lines.push(`${chalk.bold('Invited at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
}

return lines.join('\n');
Expand Down Expand Up @@ -69,7 +75,7 @@ export async function confirmRecentConvexInviteAsync(
return true;
}

const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${connection.invitedAt}.`;
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${formatConvexInviteTimestamp(connection.invitedAt)}.`;
if (nonInteractive) {
Log.warn(
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe(IntegrationsConvexConnect, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
Expand Down Expand Up @@ -153,6 +154,9 @@ describe(IntegrationsConvexConnect, () => {
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
Expand Down Expand Up @@ -237,6 +241,32 @@ describe(IntegrationsConvexConnect, () => {
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
});

it('skips sending an invite when the Convex team has already been claimed', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
hasBeenClaimed: true,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);

await createCommand([]).runAsync();

expect(confirmAsync).not.toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Convex team has already been claimed. Skipping Convex team invitation.'
);
expect(Log.log).not.toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
});

it('writes the deploy key and Convex URL to .env.local', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe(IntegrationsConvexDashboard, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe(IntegrationsConvexProjectDelete, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe(IntegrationsConvexProject, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe(IntegrationsConvexTeamDelete, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import dateFormat from 'dateformat';

import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
Expand Down Expand Up @@ -47,6 +49,7 @@ describe(IntegrationsConvexTeamInvite, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
Expand Down Expand Up @@ -98,8 +101,12 @@ describe(IntegrationsConvexTeamInvite, () => {
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('connection-1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Previous invite'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('No'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('previous@example.com'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('2024-01-02T00:00:00.000Z'));
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining(dateFormat(mockConnection.invitedAt, 'mmm dd HH:MM:ss'))
);
});

it('prompts before resending a recent invite', async () => {
Expand Down Expand Up @@ -133,6 +140,24 @@ describe(IntegrationsConvexTeamInvite, () => {
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});

it('skips sending an invite when the Convex team has already been claimed', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
hasBeenClaimed: true,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);

await createCommand([]).runAsync();

expect(confirmAsync).not.toHaveBeenCalled();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
'Convex team has already been claimed. Skipping Convex team invitation.'
);
});

it('resends recent invites in non-interactive mode with a warning', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe(IntegrationsConvexTeam, () => {
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
hasBeenClaimed: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
Expand Down Expand Up @@ -65,6 +66,8 @@ describe(IntegrationsConvexTeam, () => {
expect.stringContaining('Convex teams linked to @testuser')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Claimed'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Yes'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});

Expand Down
21 changes: 16 additions & 5 deletions packages/eas-cli/src/commands/integrations/convex/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const CONVEX_REGIONS = [

const DEFAULT_REGION = 'aws-us-east-1';

type TeamInviteResult = 'sent' | 'skipped' | 'failed';

export default class IntegrationsConvexConnect extends EasCommand {
static override description = 'connect Convex to your Expo project';

Expand Down Expand Up @@ -131,7 +133,9 @@ export default class IntegrationsConvexConnect extends EasCommand {
}

// 6. Send team invite (non-fatal)
await this.sendTeamInviteAsync(graphqlClient, connection, actor, { nonInteractive });
const teamInviteResult = await this.sendTeamInviteAsync(graphqlClient, connection, actor, {
nonInteractive,
});

// 7. Write deploy key and URL to .env.local
await this.writeEnvLocalAsync(
Expand All @@ -148,7 +152,7 @@ export default class IntegrationsConvexConnect extends EasCommand {
Log.log('Next steps:');
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
Log.newLine();
if (this.getActorEmail(actor)) {
if (teamInviteResult === 'sent') {
Log.log(
`Check your email for an invitation to join your Convex team. Accept it for full dashboard access.`
);
Expand Down Expand Up @@ -233,34 +237,41 @@ export default class IntegrationsConvexConnect extends EasCommand {
connection: ConvexTeamConnectionData,
actor: Actor,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
): Promise<TeamInviteResult> {
if (connection.hasBeenClaimed) {
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
return 'skipped';
}

const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return;
return 'skipped';
}

if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
return 'skipped';
}

try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
Log.withTick(`Sent Convex team invitation to ${chalk.bold(email)}`);
return 'sent';
} catch (error) {
Log.warn(
`Failed to send Convex team invitation to ${email}. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} to retry.`
);
Log.warn(error instanceof Error ? error.message : String(error));
return 'failed';
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
confirmRecentConvexInviteAsync,
formatConvexInviteTimestamp,
formatConvexTeam,
getConvexTeamDashboardUrl,
logNoConvexTeams,
Expand Down Expand Up @@ -77,6 +78,11 @@ export default class IntegrationsConvexTeamInvite extends EasCommand {
this.logPreviousInvite(connection);
Log.newLine();

if (connection.hasBeenClaimed) {
Log.warn('Convex team has already been claimed. Skipping Convex team invitation.');
return;
}

if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
Expand Down Expand Up @@ -106,6 +112,7 @@ export default class IntegrationsConvexTeamInvite extends EasCommand {
Log.log(
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`
);
Log.log(`${chalk.bold('Claimed')}: ${connection.hasBeenClaimed ? 'Yes' : 'No'}`);
}

private logPreviousInvite(connection: ConvexTeamConnectionData): void {
Expand All @@ -119,7 +126,7 @@ export default class IntegrationsConvexTeamInvite extends EasCommand {
Log.log(`${chalk.bold('Email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
Log.log(`${chalk.bold('Sent at')}: ${connection.invitedAt}`);
Log.log(`${chalk.bold('Sent at')}: ${formatConvexInviteTimestamp(connection.invitedAt)}`);
}
}

Expand Down
Loading
Loading