Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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))
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))

Expand Down
55 changes: 34 additions & 21 deletions packages/eas-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -433,6 +433,29 @@ DESCRIPTION

_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.5.1/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
Expand Down Expand Up @@ -1860,11 +1883,13 @@ open the PostHog dashboard for the linked PostHog project

```
USAGE
$ eas integrations:posthog:dashboard [--json] [--non-interactive]
$ eas integrations:posthog:dashboard [--show-link] [--json] [--non-interactive]

FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--show-link Print the signed-in dashboard URL in addition to opening it. The URL contains a single-use login
token.

DESCRIPTION
open the PostHog dashboard for the linked PostHog project
Expand Down Expand Up @@ -2049,7 +2074,8 @@ USAGE
<value>] [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
[METRIC] (tti|ttr|cold_launch|warm_launch|bundle_load|update_download) Metric to query (e.g. tti, cold_launch)
[METRIC] (nav_cold_ttr|nav_warm_ttr|nav_tti|tti|ttr|cold_launch|warm_launch|bundle_load|update_download) Metric to
query (e.g. tti, cold_launch, nav_tti)

FLAGS
--after=<value> Cursor for pagination. Use the endCursor from a previous query to fetch the next page.
Expand Down Expand Up @@ -2080,7 +2106,7 @@ display aggregated performance metric statistics grouped by app version
```
USAGE
$ eas observe:metrics-summary [--platform android|ios] [--metric
tti|ttr|cold_launch|warm_launch|bundle_load|update_download...] [--stat
nav_cold_ttr|nav_warm_ttr|nav_tti|tti|ttr|cold_launch|warm_launch|bundle_load|update_download...] [--stat
min|median|max|average|p80|p90|p99|eventCount...] [--start <value> | --days <value>] [--end <value> | ]
[--project-id <value>] [--json] [--non-interactive]

Expand All @@ -2089,7 +2115,8 @@ FLAGS
--end=<value> End of time range (ISO date)
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--metric=<option>... Metric name to display (can be specified multiple times).
<options: tti|ttr|cold_launch|warm_launch|bundle_load|update_download>
<options:
nav_cold_ttr|nav_warm_ttr|nav_tti|tti|ttr|cold_launch|warm_launch|bundle_load|update_download>
--non-interactive Run the command in non-interactive mode.
--platform=<option> Filter by platform
<options: android|ios>
Expand All @@ -2110,7 +2137,7 @@ display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route

```
USAGE
$ eas observe:routes [--platform android|ios] [--metric cold_ttr|warm_ttr|nav_tti...] [--stat
$ eas observe:routes [--platform android|ios] [--metric nav_cold_ttr|nav_warm_ttr|nav_tti...] [--stat
median|med|p90|count|event_count|eventCount...] [--after <value>] [--limit <value>] [--start <value> | --days
<value>] [--end <value> | ] [--app-version <value>] [--update-id <value>] [--build-number <value>] [--route-name
<value>...] [--project-id <value>] [--json] [--non-interactive]
Expand All @@ -2124,7 +2151,7 @@ FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--limit=<value> The number of items to fetch each query. Defaults to 50 and is capped at 200.
--metric=<option>... Navigation metric to display (can be specified multiple times). Defaults to all three.
<options: cold_ttr|warm_ttr|nav_tti>
<options: nav_cold_ttr|nav_warm_ttr|nav_tti>
--non-interactive Run the command in non-interactive mode.
--platform=<option> Filter by platform
<options: android|ios>
Expand Down Expand Up @@ -2188,20 +2215,6 @@ DESCRIPTION

_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.5.1/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/v20.5.1/packages/eas-cli/src/commands/open.ts)_

## `eas project:info`

information about the current project
Expand Down
35 changes: 35 additions & 0 deletions packages/eas-cli/src/build/utils/__tests__/url.test.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
16 changes: 12 additions & 4 deletions packages/eas-cli/src/build/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
142 changes: 142 additions & 0 deletions packages/eas-cli/src/commands/__tests__/browse.test.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
Loading
Loading