diff --git a/CHANGELOG.md b/CHANGELOG.md index f45468fc40..e65de4470d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index 371e1d2979..99c02d9a67 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -67,6 +67,7 @@ eas --help COMMAND * [`eas branch:list`](#eas-branchlist) * [`eas branch:rename`](#eas-branchrename) * [`eas branch:view [NAME]`](#eas-branchview-name) +* [`eas browse [PAGE]`](#eas-browse-page) * [`eas build`](#eas-build) * [`eas build:cancel [BUILD_ID]`](#eas-buildcancel-build_id) * [`eas build:configure`](#eas-buildconfigure) @@ -142,7 +143,6 @@ eas --help COMMAND * [`eas observe:routes`](#eas-observeroutes) * [`eas observe:session SESSIONID`](#eas-observesession-sessionid) * [`eas observe:versions`](#eas-observeversions) -* [`eas open`](#eas-open) * [`eas project:info`](#eas-projectinfo) * [`eas project:init`](#eas-projectinit) * [`eas project:new [PATH]`](#eas-projectnew-path) @@ -433,6 +433,29 @@ DESCRIPTION _See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/branch/view.ts)_ +## `eas browse [PAGE]` + +Transition from the terminal to the web browser to view and interact with your project on https://expo.dev + +``` +USAGE + $ eas browse [PAGE] [-n] [--json] [--non-interactive] + +ARGUMENTS + [PAGE] (build|builds|submit|submissions|update|updates|workflow|workflows|cicd|hosting|deployments|credentials|env|in + sights|observe|settings) Project subpage to open. Defaults to the project dashboard. + +FLAGS + -n, --no-browser Print the URL instead of opening it in a web browser + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + +DESCRIPTION + Transition from the terminal to the web browser to view and interact with your project on https://expo.dev +``` + +_See code: [packages/eas-cli/src/commands/browse.ts](https://github.com/expo/eas-cli/blob/v20.5.1/packages/eas-cli/src/commands/browse.ts)_ + ## `eas build` start a build @@ -2192,20 +2215,6 @@ DESCRIPTION _See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/observe/versions.ts)_ -## `eas open` - -open the project page in a web browser - -``` -USAGE - $ eas open - -DESCRIPTION - open the project page in a web browser -``` - -_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v21.0.0/packages/eas-cli/src/commands/open.ts)_ - ## `eas project:info` information about the current project diff --git a/packages/eas-cli/src/build/utils/__tests__/url.test.ts b/packages/eas-cli/src/build/utils/__tests__/url.test.ts new file mode 100644 index 0000000000..fdc4488d4e --- /dev/null +++ b/packages/eas-cli/src/build/utils/__tests__/url.test.ts @@ -0,0 +1,35 @@ +import { getProjectDashboardUrl, getProjectPageUrl } from '../url'; + +describe(getProjectPageUrl, () => { + it('builds the project dashboard URL when no page is provided', () => { + expect(getProjectPageUrl('testuser', 'testapp')).toBe( + 'https://expo.dev/accounts/testuser/projects/testapp' + ); + }); + + it('appends the page path when provided', () => { + expect(getProjectPageUrl('testuser', 'testapp', 'builds')).toBe( + 'https://expo.dev/accounts/testuser/projects/testapp/builds' + ); + }); + + it('preserves nested page paths without encoding the separator', () => { + expect(getProjectPageUrl('testuser', 'testapp', 'hosting/deployments')).toBe( + 'https://expo.dev/accounts/testuser/projects/testapp/hosting/deployments' + ); + }); + + it('encodes account and project names', () => { + expect(getProjectPageUrl('my org', 'my app', 'updates')).toBe( + 'https://expo.dev/accounts/my%20org/projects/my%20app/updates' + ); + }); +}); + +describe(getProjectDashboardUrl, () => { + it('matches the page URL with no page', () => { + expect(getProjectDashboardUrl('testuser', 'testapp')).toBe( + getProjectPageUrl('testuser', 'testapp') + ); + }); +}); diff --git a/packages/eas-cli/src/build/utils/url.ts b/packages/eas-cli/src/build/utils/url.ts index 41a151f14a..81efce6236 100644 --- a/packages/eas-cli/src/build/utils/url.ts +++ b/packages/eas-cli/src/build/utils/url.ts @@ -4,10 +4,18 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api'; import { AppPlatform, BuildFragment } from '../../graphql/generated'; export function getProjectDashboardUrl(accountName: string, projectName: string): string { - return new URL( - `/accounts/${accountName}/projects/${projectName}`, - getExpoWebsiteBaseUrl() - ).toString(); + return getProjectPageUrl(accountName, projectName); +} + +export function getProjectPageUrl( + accountName: string, + projectName: string, + page?: string | null +): string { + const projectPath = `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent( + projectName + )}`; + return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString(); } export function getBuildLogsUrl(build: BuildFragment, hash?: string): string { diff --git a/packages/eas-cli/src/commands/__tests__/browse.test.ts b/packages/eas-cli/src/commands/__tests__/browse.test.ts new file mode 100644 index 0000000000..c33e4e69f8 --- /dev/null +++ b/packages/eas-cli/src/commands/__tests__/browse.test.ts @@ -0,0 +1,142 @@ +import openBrowserAsync from 'better-opn'; + +import { getMockOclifConfig } from '../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { testProjectId } from '../../credentials/__tests__/fixtures-constants'; +import { AccountFragment } from '../../graphql/generated'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; +import Browse from '../browse'; + +jest.mock('better-opn'); +jest.mock('../../log'); +jest.mock('../../project/projectUtils'); +jest.mock('../../utils/json'); +jest.mock('../../ora', () => ({ + ora: jest.fn(() => ({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + })), +})); + +describe(Browse, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + + function createCommand(argv: string[]): Browse { + const command = new Browse(argv, mockConfig); + jest.spyOn(command as any, 'getContextAsync').mockReturnValue({ + privateProjectConfig: { + projectId: testProjectId, + exp: { slug: 'testapp' }, + }, + loggedIn: { graphqlClient }, + } as never); + return command; + } + + beforeEach(() => { + jest.resetAllMocks(); + jest + .mocked(getOwnerAccountForProjectIdAsync) + .mockResolvedValue({ name: 'testuser' } as AccountFragment); + jest.mocked(openBrowserAsync).mockResolvedValue({} as never); + jest.mocked(ora).mockReturnValue({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + } as any); + }); + + it('opens the project dashboard when no page is provided', async () => { + await createCommand([]).runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith( + 'https://expo.dev/accounts/testuser/projects/testapp' + ); + }); + + it('opens a subpage by its EAS command name', async () => { + await createCommand(['build']).runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith( + 'https://expo.dev/accounts/testuser/projects/testapp/builds' + ); + }); + + it('maps the website wording to the same subpage', async () => { + await createCommand(['submissions']).runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith( + 'https://expo.dev/accounts/testuser/projects/testapp/submissions' + ); + }); + + it('opens the observe page', async () => { + await createCommand(['observe']).runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith( + 'https://expo.dev/accounts/testuser/projects/testapp/observe' + ); + }); + + it('prints the URL without opening a browser when --no-browser is passed', async () => { + await createCommand(['hosting', '--no-browser']).runAsync(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(Log.log).toHaveBeenCalledWith( + 'https://expo.dev/accounts/testuser/projects/testapp/hosting' + ); + }); + + it('rejects an unknown page', async () => { + await expect(createCommand(['nope']).runAsync()).rejects.toThrow(); + expect(openBrowserAsync).not.toHaveBeenCalled(); + }); + + it('forwards non-interactive mode to the context so it can run without prompts', async () => { + const command = createCommand(['--non-interactive']); + + await command.runAsync(); + + expect((command as any).getContextAsync).toHaveBeenCalledWith( + Browse, + expect.objectContaining({ nonInteractive: true }) + ); + }); + + it('prints the URL as JSON without opening a browser when --json is passed', async () => { + const command = createCommand(['build', '--json']); + + await command.runAsync(); + + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + url: 'https://expo.dev/accounts/testuser/projects/testapp/builds', + }); + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect((command as any).getContextAsync).toHaveBeenCalledWith( + Browse, + expect.objectContaining({ nonInteractive: true }) + ); + }); + + it('fails the spinner when the browser cannot be opened', async () => { + jest.mocked(openBrowserAsync).mockResolvedValue(false); + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + }; + jest.mocked(ora).mockReturnValue(spinner as any); + + await createCommand([]).runAsync(); + + expect(spinner.fail).toHaveBeenCalledWith( + expect.stringContaining('Unable to open a web browser') + ); + }); +}); diff --git a/packages/eas-cli/src/commands/browse.ts b/packages/eas-cli/src/commands/browse.ts new file mode 100644 index 0000000000..16e8a5f186 --- /dev/null +++ b/packages/eas-cli/src/commands/browse.ts @@ -0,0 +1,104 @@ +import { Args, Flags } from '@oclif/core'; +import openBrowserAsync from 'better-opn'; + +import { getProjectPageUrl } from '../build/utils/url'; +import EasCommand from '../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../commandUtils/flags'; +import Log from '../log'; +import { ora } from '../ora'; +import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils'; +import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json'; + +const PROJECT_PAGES: Record = { + build: 'builds', + builds: 'builds', + submit: 'submissions', + submissions: 'submissions', + update: 'updates', + updates: 'updates', + workflow: 'workflows', + workflows: 'workflows', + cicd: 'workflows', + hosting: 'hosting', + deployments: 'hosting/deployments', + credentials: 'credentials', + env: 'environment-variables', + insights: 'insights', + observe: 'observe', + settings: 'settings', +}; + +export default class Browse extends EasCommand { + static override description = + 'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev'; + + static override args = { + page: Args.string({ + description: 'Project subpage to open. Defaults to the project dashboard.', + required: false, + options: Object.keys(PROJECT_PAGES), + }), + }; + + static override flags = { + 'no-browser': Flags.boolean({ + char: 'n', + description: 'Print the URL instead of opening it in a web browser', + default: false, + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectConfig, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { args, flags } = await this.parse(Browse); + const page = args.page ? PROJECT_PAGES[args.page] : null; + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (jsonFlag) { + enableJsonOutput(); + } + + const { + privateProjectConfig: { projectId, exp }, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(Browse, { + nonInteractive, + withServerSideEnvironment: null, + }); + + const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); + const url = getProjectPageUrl(account.name, exp.slug, page); + + if (jsonFlag) { + printJsonOnlyOutput({ url }); + return; + } + + if (flags['no-browser']) { + Log.log(url); + return; + } + + const failedMessage = `Unable to open a web browser. Project page is available at: ${url}`; + const spinner = ora(`Opening ${url}`).start(); + try { + const opened = await openBrowserAsync(url); + + if (opened) { + spinner.succeed(`Opened ${url}`); + } else { + spinner.fail(failedMessage); + } + } catch (error) { + spinner.fail(failedMessage); + throw error; + } + } +} diff --git a/packages/eas-cli/src/commands/open.ts b/packages/eas-cli/src/commands/open.ts index 1fdef3704b..6ba2b1bcd0 100644 --- a/packages/eas-cli/src/commands/open.ts +++ b/packages/eas-cli/src/commands/open.ts @@ -1,46 +1,12 @@ -import openBrowserAsync from 'better-opn'; - -import { getProjectDashboardUrl } from '../build/utils/url'; import EasCommand from '../commandUtils/EasCommand'; -import { ora } from '../ora'; -import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils'; +import Browse from './browse'; export default class Open extends EasCommand { - static override description = 'open the project page in a web browser'; + static override description = 'open the project page in a web browser (alias of `eas browse`)'; - static override contextDefinition = { - ...this.ContextOptions.ProjectConfig, - ...this.ContextOptions.LoggedIn, - }; + static override hidden = true; async runAsync(): Promise { - // this command is interactive by nature (only really run by humans in a terminal) - const { - privateProjectConfig: { projectId, exp }, - loggedIn: { graphqlClient }, - } = await this.getContextAsync(Open, { - nonInteractive: false, - withServerSideEnvironment: null, - }); - - const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); - - const projectName = exp.slug; - - const projectDashboardUrl = getProjectDashboardUrl(account.name, projectName); - const failedMessage = `Unable to open a web browser. Project page is available at: ${projectDashboardUrl}`; - const spinner = ora(`Opening ${projectDashboardUrl}`).start(); - try { - const opened = await openBrowserAsync(projectDashboardUrl); - - if (opened) { - spinner.succeed(`Opened ${projectDashboardUrl}`); - } else { - spinner.fail(failedMessage); - } - } catch (error) { - spinner.fail(failedMessage); - throw error; - } + await Browse.run([]); } }