Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
- [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp))
- [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `ref` input to the `eas/checkout` step to check out a different git ref (branch, tag, or commit SHA) than the one that triggered the job. ([#4035](https://github.com/expo/eas-cli/pull/4035) by [@sswrk](https://github.com/sswrk))
Expand Down
164 changes: 164 additions & 0 deletions packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../../graphql/types/SupabaseConnection';
import Log from '../../log';
import {
formatSupabaseOrganization,
formatSupabaseProject,
formatSupabaseProjectLabel,
getSupabaseProjectDashboardUrl,
logNoSupabaseProject,
parseSupabaseProjectRef,
} from '../supabase';

jest.mock('../../log', () => ({
__esModule: true,
default: {
warn: jest.fn(),
log: jest.fn(),
},
link: jest.fn((url: string) => url),
}));

describe('commandUtils/supabase', () => {
const project: SupabaseProjectData = {
id: 'project-1',
supabaseProjectRef: 'abcdefghijklmnop',
supabaseProjectName: 'Demo App',
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
supabaseRegion: 'us-east-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};

it('getSupabaseProjectDashboardUrl encodes the project ref', () => {
expect(getSupabaseProjectDashboardUrl({ supabaseProjectRef: 'abc/def' })).toBe(
'https://supabase.com/dashboard/project/abc%2Fdef'
);
});

it('formatSupabaseOrganization prefers a distinct name', () => {
const connection: Pick<
SupabaseConnectionData,
'supabaseOrganizationSlug' | 'supabaseOrganizationName'
> = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: 'Org Name',
};
expect(formatSupabaseOrganization(connection)).toBe('Org Name (org-slug)');
});

it('formatSupabaseOrganization falls back to live org name', () => {
const connection = {
supabaseOrganizationSlug: 'org-slug',
supabaseOrganizationName: null as unknown as string,
};
const organizations: SupabaseOrganizationData[] = [
{ id: '1', slug: 'org-slug', name: 'Live Name' },
];
expect(formatSupabaseOrganization(connection, organizations)).toBe('Live Name (org-slug)');
});

it('formatSupabaseOrganization returns slug when name matches or is missing', () => {
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'same',
supabaseOrganizationName: 'same',
})
).toBe('same');
expect(
formatSupabaseOrganization({
supabaseOrganizationSlug: 'only-slug',
supabaseOrganizationName: '',
})
).toBe('only-slug');
});

it('formatSupabaseProjectLabel prefers a distinct name', () => {
expect(formatSupabaseProjectLabel(project)).toBe('Demo App (abcdefghijklmnop)');
});

it('formatSupabaseProjectLabel returns ref when name matches or is missing', () => {
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: 'ref',
})
).toBe('ref');
expect(
formatSupabaseProjectLabel({
supabaseProjectRef: 'ref',
supabaseProjectName: '',
})
).toBe('ref');
});

it('formatSupabaseProject includes key fields', () => {
const formatted = formatSupabaseProject(project);
expect(formatted).toContain('Demo App');
expect(formatted).toContain('abcdefghijklmnop');
expect(formatted).toContain('https://abcdefghijklmnop.supabase.co');
expect(formatted).toContain('us-east-1');
expect(formatted).toContain('https://supabase.com/dashboard/project/abcdefghijklmnop');
});

it('logNoSupabaseProject warns with the project name', () => {
logNoSupabaseProject('my-app');
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('my-app'));
});
});

describe(parseSupabaseProjectRef, () => {
it('accepts a bare reference ID', () => {
expect(parseSupabaseProjectRef('jfurmbuioogljwsqwnpd')).toBe('jfurmbuioogljwsqwnpd');
expect(parseSupabaseProjectRef(' jfurmbuioogljwsqwnpd ')).toBe('jfurmbuioogljwsqwnpd');
});

it('accepts a dashboard URL', () => {
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj')
).toBe('kwdfdxdzurxigtbtwddj');
expect(
parseSupabaseProjectRef(
'https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj/settings/general'
)
).toBe('kwdfdxdzurxigtbtwddj');
});

it('accepts a project API URL', () => {
expect(parseSupabaseProjectRef('https://kwdfdxdzurxigtbtwddj.supabase.co')).toBe(
'kwdfdxdzurxigtbtwddj'
);
});

it('lowercases the reference ID, which the server matches exactly', () => {
expect(parseSupabaseProjectRef('JFURMBUIOOGLJWSQWNPD')).toBe('jfurmbuioogljwsqwnpd');
expect(
parseSupabaseProjectRef('https://supabase.com/dashboard/project/KWDFDXDZURXIGTBTWDDJ')
).toBe('kwdfdxdzurxigtbtwddj');
});

it('rejects a project name with guidance', () => {
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow(
/Project Settings/
);
});

it('rejects an unrelated URL and an empty value', () => {
expect(() => parseSupabaseProjectRef('https://example.com/foo')).toThrow(
/not a Supabase project reference ID/
);
expect(() => parseSupabaseProjectRef(' ')).toThrow(/No Supabase project given/);
});

it('treats a malformed URL as a non-ref and rejects it', () => {
expect(() => parseSupabaseProjectRef('https://[')).toThrow(
/not a Supabase project reference ID/
);
});
});
96 changes: 96 additions & 0 deletions packages/eas-cli/src/commandUtils/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import chalk from 'chalk';

import {
SupabaseConnectionData,
SupabaseOrganizationData,
SupabaseProjectData,
} from '../graphql/types/SupabaseConnection';
import Log, { link } from '../log';

export function getSupabaseProjectDashboardUrl(
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
): string {
return `https://supabase.com/dashboard/project/${encodeURIComponent(project.supabaseProjectRef)}`;
}

function extractProjectRefFromUrl(value: string): string | null {
if (!/^https?:\/\//i.test(value)) {
return null;
}
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
const projectHost = url.hostname.match(/^([a-z0-9]+)\.supabase\./i);
if (projectHost) {
return projectHost[1];
}
const dashboardPath = url.pathname.match(/\/dashboard\/project\/([^/]+)/);
if (dashboardPath) {
return decodeURIComponent(dashboardPath[1]);
}
return null;
}

/**
* Accepts whatever a user is likely to have on hand: the bare reference ID, the dashboard URL, or
* the project API URL. Supabase labels the ref "Reference ID" under Project Settings → General.
*/
export function parseSupabaseProjectRef(input: string): string {
const value = input.trim();
if (!value) {
throw new Error(
'No Supabase project given. Pass the project reference ID or its dashboard URL, for example --link jfurmbuioogljwsqwnpd.'
);
}
const ref = extractProjectRefFromUrl(value) ?? value;
if (!/^[a-z0-9]+$/i.test(ref)) {
throw new Error(
`"${input}" is not a Supabase project reference ID. Supabase shows it as "Reference ID" under Project Settings → General; you can also paste the dashboard URL (https://supabase.com/dashboard/project/<ref>). Note that a project name is not a reference ID.`
);
}
// Refs are lowercase, and the server matches them exactly.
return ref.toLowerCase();
}

/** Prefer human-readable org name; Supabase slugs are often opaque ids like `ycjmzsygzfkryaazoitp`. */
export function formatSupabaseOrganization(
connection: Pick<SupabaseConnectionData, 'supabaseOrganizationSlug' | 'supabaseOrganizationName'>,
organizations: readonly SupabaseOrganizationData[] = []
): string {
const slug = connection.supabaseOrganizationSlug;
const storedName = connection.supabaseOrganizationName;
const liveName = organizations.find(organization => organization.slug === slug)?.name;
const name = storedName || liveName;
if (name && name !== slug) {
return `${name} (${slug})`;
}
return slug;
}

/** Prefer human-readable project name over the opaque project ref. */
export function formatSupabaseProjectLabel(
project: Pick<SupabaseProjectData, 'supabaseProjectRef' | 'supabaseProjectName'>
): string {
const { supabaseProjectRef: ref, supabaseProjectName: name } = project;
if (name && name !== ref) {
return `${name} (${ref})`;
}
return ref;
}

export function formatSupabaseProject(project: SupabaseProjectData): string {
return [
`${chalk.bold('Name')}: ${project.supabaseProjectName}`,
`${chalk.bold('Ref')}: ${project.supabaseProjectRef}`,
`${chalk.bold('URL')}: ${project.supabaseProjectUrl}`,
`${chalk.bold('Region')}: ${project.supabaseRegion}`,
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}

export function logNoSupabaseProject(projectName: string): void {
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
Loading
Loading