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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { input } from '@inquirer/prompts';
import { __, sprintf } from '@wordpress/i18n';
import { DEFAULT_TOKEN_LIFETIME_MS } from 'common/constants';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import { getUserInfo } from 'cli/lib/api';
import {
getAuthToken,
lockAppdata,
readAppdata,
saveAppdata,
unlockAppdata,
} from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { getAppLocale } from 'cli/lib/i18n';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

const CLI_REDIRECT_URI = `https://developer.wordpress.com/copy-oauth-token`;

export async function runCommand(): Promise< void > {
const logger = new Logger< LoggerAction >();

try {
await getAuthToken();
logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) );
return;
} catch ( error ) {
// Assume the token is invalid and proceed with authentication
}

logger.reportStart( LoggerAction.LOGIN, __( 'Opening browser for authentication…' ) );

const appLocale = await getAppLocale();
const authUrl = getAuthenticationUrl( appLocale, CLI_REDIRECT_URI );

try {
await openBrowser( authUrl );
logger.reportSuccess( __( 'Browser opened successfully' ) );
} catch ( error ) {
// If the browser fails to open, allow users to manually open the URL
const loggerError = new LoggerError(
sprintf( __( 'Failed to open browser. Please open the URL manually: %s' ), authUrl ),
error
);
logger.reportError( loggerError );
}

console.log(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Code quality: Consider using the logger for consistency instead of console.log directly. This ensures uniform output formatting and enables better control over logging behavior.

__( 'Please complete authentication in your browser and paste the generated token here.' )
);
console.log( '' );

let accessToken: Awaited< ReturnType< typeof input > >;
let user: Awaited< ReturnType< typeof getUserInfo > >;

try {
accessToken = await input( { message: __( 'Authentication token:' ) } );
user = await getUserInfo( accessToken );
logger.reportSuccess( __( 'Authentication completed successfully!' ) );
} catch ( error ) {
logger.reportError( new LoggerError( __( 'Authentication failed. Please try again.' ) ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 UX Enhancement: As noted in the PR reviews, consider:

  1. Adding retry logic (e.g., 3 attempts like SSH)
  2. Providing more specific error messages from the API
  3. Preserving the original error context:
catch (error) {
  const message = error instanceof LoggerError 
    ? error.message 
    : __('Authentication failed. Please try again.');
  logger.reportError(new LoggerError(message, error));
  return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a retry logic would improve the UX to account for a mistake paste or enter

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another idea would be to have a "click here if you aren't automatically redirected" to allow users to manually open the browser tab.

Neither of these are blockers in my opinion, just considerations.

return;
}

try {
await lockAppdata();
const userData = await readAppdata();

userData.authToken = {
accessToken,
id: user.ID,
email: user.email,
displayName: user.display_name,
expiresIn: DEFAULT_TOKEN_LIFETIME_MS / 1000,
expirationTime: Date.now() + DEFAULT_TOKEN_LIFETIME_MS,
};

await saveAppdata( userData );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) );
}
} finally {
await unlockAppdata();
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'login',
describe: __( 'Log in to WordPress.com' ),
handler: async () => {
await runCommand();
},
} );
};
178 changes: 178 additions & 0 deletions cli/commands/auth/tests/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { input } from '@inquirer/prompts';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { getUserInfo } from 'cli/lib/api';
import {
getAuthToken,
lockAppdata,
readAppdata,
saveAppdata,
unlockAppdata,
} from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { getAppLocale } from 'cli/lib/i18n';
import { Logger, LoggerError } from 'cli/logger';

jest.mock( '@inquirer/prompts' );
jest.mock( 'common/lib/oauth' );
jest.mock( 'cli/lib/api' );
jest.mock( 'cli/lib/appdata' );
jest.mock( 'cli/lib/browser' );
jest.mock( 'cli/lib/i18n' );
jest.mock( 'cli/logger' );

describe( 'Auth Login Command', () => {
const mockAccessToken = 'mock-access-token-12345';
const mockAuthUrl = 'https://public-api.wordpress.com/oauth2/authorize?client_id=123';
const mockUserData = {
ID: 12345,
email: 'test@example.com',
display_name: 'Test User',
};
const mockAppdata = {
authToken: {
accessToken: 'existing-token',
id: 999,
email: 'existing@example.com',
displayName: 'Existing User',
expiresIn: 1209600,
expirationTime: Date.now() + 1209600000,
},
};

let mockLogger: {
reportStart: jest.Mock;
reportSuccess: jest.Mock;
reportError: jest.Mock;
reportKeyValuePair: jest.Mock;
};

beforeEach( () => {
jest.clearAllMocks();

mockLogger = {
reportStart: jest.fn(),
reportSuccess: jest.fn(),
reportError: jest.fn(),
reportKeyValuePair: jest.fn(),
};

( Logger as jest.Mock ).mockReturnValue( mockLogger );
( getAuthenticationUrl as jest.Mock ).mockReturnValue( mockAuthUrl );
( getAppLocale as jest.Mock ).mockResolvedValue( 'en' );
( getUserInfo as jest.Mock ).mockResolvedValue( mockUserData );
( openBrowser as jest.Mock ).mockResolvedValue( undefined );
( input as jest.Mock ).mockResolvedValue( mockAccessToken );
( readAppdata as jest.Mock ).mockResolvedValue( mockAppdata );
( getAuthToken as jest.Mock ).mockRejectedValue( new Error( 'Mock error' ) );
( lockAppdata as jest.Mock ).mockResolvedValue( undefined );
( unlockAppdata as jest.Mock ).mockResolvedValue( undefined );
( saveAppdata as jest.Mock ).mockResolvedValue( undefined );
} );

afterEach( () => {
jest.restoreAllMocks();
} );

it( 'should skip login if already authenticated', async () => {
( getAuthToken as jest.Mock ).mockResolvedValue( mockAppdata.authToken );

const { runCommand } = await import( '../login' );
await runCommand();

expect( openBrowser ).not.toHaveBeenCalled();
expect( input ).not.toHaveBeenCalled();
} );

it( 'should complete the login process successfully', async () => {
const { runCommand } = await import( '../login' );
await runCommand();

expect( getAuthenticationUrl ).toHaveBeenCalledWith(
'en',
'https://developer.wordpress.com/copy-oauth-token'
);
expect( openBrowser ).toHaveBeenCalledWith( mockAuthUrl );
expect( input ).toHaveBeenCalledWith( {
message: 'Authentication token:',
} );
expect( getUserInfo ).toHaveBeenCalledWith( mockAccessToken );
expect( lockAppdata ).toHaveBeenCalled();
expect( saveAppdata ).toHaveBeenCalledWith( {
authToken: {
accessToken: mockAccessToken,
id: mockUserData.ID,
email: mockUserData.email,
displayName: mockUserData.display_name,
expiresIn: expect.any( Number ),
expirationTime: expect.any( Number ),
},
} );
expect( unlockAppdata ).toHaveBeenCalled();
} );

it( 'should proceed with login if existing token is invalid', async () => {
const { runCommand } = await import( '../login' );
await runCommand();

expect( openBrowser ).toHaveBeenCalled();
expect( input ).toHaveBeenCalled();
} );

it( 'should handle browser open failure', async () => {
const browserError = new LoggerError( 'Failed to open browser' );
( openBrowser as jest.Mock ).mockRejectedValue( browserError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( input ).toHaveBeenCalled();
} );

it( 'should handle API error when fetching user info', async () => {
const apiError = new LoggerError( 'Failed to fetch user info' );
( getUserInfo as jest.Mock ).mockRejectedValue( apiError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( getUserInfo ).toHaveBeenCalled();
} );

it( 'should unlock appdata even if save fails', async () => {
const saveError = new Error( 'Failed to save' );
( saveAppdata as jest.Mock ).mockRejectedValue( saveError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( lockAppdata ).toHaveBeenCalled();
expect( unlockAppdata ).toHaveBeenCalled();
} );

it( 'should handle lock appdata failure', async () => {
const lockError = new Error( 'Failed to lock' );
( lockAppdata as jest.Mock ).mockRejectedValue( lockError );

const { runCommand } = await import( '../login' );
await runCommand();

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
} );

it( 'should use provided locale', async () => {
( getAppLocale as jest.Mock ).mockResolvedValue( 'fr' );

const { runCommand } = await import( '../login' );
await runCommand();

expect( getAuthenticationUrl ).toHaveBeenCalledWith(
'fr',
'https://developer.wordpress.com/copy-oauth-token'
);
} );
} );
9 changes: 7 additions & 2 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n';
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
import { StatsGroup, StatsMetric } from 'common/types/stats';
import yargs from 'yargs';
import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login';
import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create';
import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete';
import { registerCommand as registerListCommand } from 'cli/commands/preview/list';
Expand All @@ -18,12 +19,12 @@ import { StudioArgv } from 'cli/types';
suppressPunycodeWarning();

async function main() {
const locale = await loadTranslations();
const yargsLocale = await loadTranslations();

const studioArgv: StudioArgv = yargs( process.argv.slice( 2 ) )
.scriptName( 'studio' )
.usage( __( 'WordPress Studio CLI' ) )
.locale( locale )
.locale( yargsLocale )
.version( version )
.option( 'avoid-telemetry', {
type: 'boolean',
Expand All @@ -45,6 +46,10 @@ async function main() {
);
}
} )
.command( 'auth', __( 'Manage authentication' ), ( authYargs ) => {
registerAuthLoginCommand( authYargs );
authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) );
} )
.command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => {
registerCreateCommand( previewYargs );
registerListCommand( previewYargs );
Expand Down
20 changes: 20 additions & 0 deletions cli/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,23 @@ export async function validateAccessToken( token: string ): Promise< void > {
throw new LoggerError( __( 'Invalid authentication token' ), error );
}
}

const userResponseSchema = z.object( {
ID: z.number(),
email: z.string().email(),
display_name: z.string(),
} );

export async function getUserInfo(
token: string
): Promise< z.infer< typeof userResponseSchema > > {
const wpcom = wpcomFactory( token, wpcomXhrRequest );
try {
const rawResponse = await wpcom.req.get( '/me', {
fields: 'ID,login,email,display_name',
} );
return userResponseSchema.parse( rawResponse );
} catch ( error ) {
throw new LoggerError( __( 'Failed to fetch user info' ), error );
}
}
6 changes: 5 additions & 1 deletion cli/lib/appdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ const userDataSchema = z
authToken: z
.object( {
accessToken: z.string().min( 1, __( 'Access token cannot be empty' ) ),
expiresIn: z.number(), // Seconds
expirationTime: z.number(), // Milliseconds since the Unix epoch
id: z.number().optional(),
email: z.string(),
displayName: z.string().default( '' ),
} )
.passthrough()
.optional(),
Expand Down Expand Up @@ -145,7 +149,7 @@ export async function getAuthToken(): Promise< ValidatedAuthToken > {
try {
const { authToken } = await readAppdata();

if ( ! authToken?.accessToken || ! authToken?.id ) {
if ( ! authToken?.accessToken || ! authToken?.id || Date.now() >= authToken?.expirationTime ) {
throw new Error( 'Authentication required' );
}

Expand Down
Loading
Loading