Skip to content

Commit 28ef7cd

Browse files
Merge d8feb6b into 4fcc1e2
2 parents 4fcc1e2 + d8feb6b commit 28ef7cd

15 files changed

Lines changed: 542 additions & 154 deletions

File tree

‎cli/commands/auth/login.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { input } from '@inquirer/prompts';
2+
import { __, sprintf } from '@wordpress/i18n';
3+
import { DEFAULT_TOKEN_LIFETIME_MS } from 'common/constants';
4+
import { getAuthenticationUrl } from 'common/lib/oauth';
5+
import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions';
6+
import { getUserInfo } from 'cli/lib/api';
7+
import {
8+
getAuthToken,
9+
lockAppdata,
10+
readAppdata,
11+
saveAppdata,
12+
unlockAppdata,
13+
} from 'cli/lib/appdata';
14+
import { openBrowser } from 'cli/lib/browser';
15+
import { getAppLocale } from 'cli/lib/i18n';
16+
import { Logger, LoggerError } from 'cli/logger';
17+
import { StudioArgv } from 'cli/types';
18+
19+
const CLI_REDIRECT_URI = `https://developer.wordpress.com/copy-oauth-token`;
20+
21+
export async function runCommand(): Promise< void > {
22+
const logger = new Logger< LoggerAction >();
23+
24+
try {
25+
await getAuthToken();
26+
logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) );
27+
return;
28+
} catch ( error ) {
29+
// Assume the token is invalid and proceed with authentication
30+
}
31+
32+
logger.reportStart( LoggerAction.LOGIN, __( 'Opening browser for authentication…' ) );
33+
34+
const appLocale = await getAppLocale();
35+
const authUrl = getAuthenticationUrl( appLocale, CLI_REDIRECT_URI );
36+
37+
try {
38+
await openBrowser( authUrl );
39+
logger.reportSuccess( __( 'Browser opened successfully' ) );
40+
} catch ( error ) {
41+
// If the browser fails to open, allow users to manually open the URL
42+
const loggerError = new LoggerError(
43+
sprintf( __( 'Failed to open browser. Please open the URL manually: %s' ), authUrl ),
44+
error
45+
);
46+
logger.reportError( loggerError );
47+
}
48+
49+
console.log(
50+
__( 'Please complete authentication in your browser and paste the generated token here.' )
51+
);
52+
console.log( '' );
53+
54+
let accessToken: Awaited< ReturnType< typeof input > >;
55+
let user: Awaited< ReturnType< typeof getUserInfo > >;
56+
57+
try {
58+
accessToken = await input( { message: __( 'Authentication token:' ) } );
59+
user = await getUserInfo( accessToken );
60+
logger.reportSuccess( __( 'Authentication completed successfully!' ) );
61+
} catch ( error ) {
62+
logger.reportError( new LoggerError( __( 'Authentication failed. Please try again.' ) ) );
63+
return;
64+
}
65+
66+
try {
67+
await lockAppdata();
68+
const userData = await readAppdata();
69+
70+
userData.authToken = {
71+
accessToken,
72+
id: user.ID,
73+
email: user.email,
74+
displayName: user.display_name,
75+
expiresIn: DEFAULT_TOKEN_LIFETIME_MS / 1000,
76+
expirationTime: Date.now() + DEFAULT_TOKEN_LIFETIME_MS,
77+
};
78+
79+
await saveAppdata( userData );
80+
} catch ( error ) {
81+
if ( error instanceof LoggerError ) {
82+
logger.reportError( error );
83+
} else {
84+
logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) );
85+
}
86+
} finally {
87+
await unlockAppdata();
88+
}
89+
}
90+
91+
export const registerCommand = ( yargs: StudioArgv ) => {
92+
return yargs.command( {
93+
command: 'login',
94+
describe: __( 'Log in to WordPress.com' ),
95+
handler: async () => {
96+
await runCommand();
97+
},
98+
} );
99+
};
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { input } from '@inquirer/prompts';
2+
import { getAuthenticationUrl } from 'common/lib/oauth';
3+
import { getUserInfo } from 'cli/lib/api';
4+
import {
5+
getAuthToken,
6+
lockAppdata,
7+
readAppdata,
8+
saveAppdata,
9+
unlockAppdata,
10+
} from 'cli/lib/appdata';
11+
import { openBrowser } from 'cli/lib/browser';
12+
import { getAppLocale } from 'cli/lib/i18n';
13+
import { Logger, LoggerError } from 'cli/logger';
14+
15+
jest.mock( '@inquirer/prompts' );
16+
jest.mock( 'common/lib/oauth' );
17+
jest.mock( 'cli/lib/api' );
18+
jest.mock( 'cli/lib/appdata' );
19+
jest.mock( 'cli/lib/browser' );
20+
jest.mock( 'cli/lib/i18n' );
21+
jest.mock( 'cli/logger' );
22+
23+
describe( 'Auth Login Command', () => {
24+
const mockAccessToken = 'mock-access-token-12345';
25+
const mockAuthUrl = 'https://public-api.wordpress.com/oauth2/authorize?client_id=123';
26+
const mockUserData = {
27+
ID: 12345,
28+
email: 'test@example.com',
29+
display_name: 'Test User',
30+
};
31+
const mockAppdata = {
32+
authToken: {
33+
accessToken: 'existing-token',
34+
id: 999,
35+
email: 'existing@example.com',
36+
displayName: 'Existing User',
37+
expiresIn: 1209600,
38+
expirationTime: Date.now() + 1209600000,
39+
},
40+
};
41+
42+
let mockLogger: {
43+
reportStart: jest.Mock;
44+
reportSuccess: jest.Mock;
45+
reportError: jest.Mock;
46+
reportKeyValuePair: jest.Mock;
47+
};
48+
49+
beforeEach( () => {
50+
jest.clearAllMocks();
51+
52+
mockLogger = {
53+
reportStart: jest.fn(),
54+
reportSuccess: jest.fn(),
55+
reportError: jest.fn(),
56+
reportKeyValuePair: jest.fn(),
57+
};
58+
59+
( Logger as jest.Mock ).mockReturnValue( mockLogger );
60+
( getAuthenticationUrl as jest.Mock ).mockReturnValue( mockAuthUrl );
61+
( getAppLocale as jest.Mock ).mockResolvedValue( 'en' );
62+
( getUserInfo as jest.Mock ).mockResolvedValue( mockUserData );
63+
( openBrowser as jest.Mock ).mockResolvedValue( undefined );
64+
( input as jest.Mock ).mockResolvedValue( mockAccessToken );
65+
( readAppdata as jest.Mock ).mockResolvedValue( mockAppdata );
66+
( getAuthToken as jest.Mock ).mockRejectedValue( new Error( 'Mock error' ) );
67+
( lockAppdata as jest.Mock ).mockResolvedValue( undefined );
68+
( unlockAppdata as jest.Mock ).mockResolvedValue( undefined );
69+
( saveAppdata as jest.Mock ).mockResolvedValue( undefined );
70+
} );
71+
72+
afterEach( () => {
73+
jest.restoreAllMocks();
74+
} );
75+
76+
it( 'should skip login if already authenticated', async () => {
77+
( getAuthToken as jest.Mock ).mockResolvedValue( mockAppdata.authToken );
78+
79+
const { runCommand } = await import( '../login' );
80+
await runCommand();
81+
82+
expect( openBrowser ).not.toHaveBeenCalled();
83+
expect( input ).not.toHaveBeenCalled();
84+
} );
85+
86+
it( 'should complete the login process successfully', async () => {
87+
const { runCommand } = await import( '../login' );
88+
await runCommand();
89+
90+
expect( getAuthenticationUrl ).toHaveBeenCalledWith(
91+
'en',
92+
'https://developer.wordpress.com/copy-oauth-token'
93+
);
94+
expect( openBrowser ).toHaveBeenCalledWith( mockAuthUrl );
95+
expect( input ).toHaveBeenCalledWith( {
96+
message: 'Authentication token:',
97+
} );
98+
expect( getUserInfo ).toHaveBeenCalledWith( mockAccessToken );
99+
expect( lockAppdata ).toHaveBeenCalled();
100+
expect( saveAppdata ).toHaveBeenCalledWith( {
101+
authToken: {
102+
accessToken: mockAccessToken,
103+
id: mockUserData.ID,
104+
email: mockUserData.email,
105+
displayName: mockUserData.display_name,
106+
expiresIn: expect.any( Number ),
107+
expirationTime: expect.any( Number ),
108+
},
109+
} );
110+
expect( unlockAppdata ).toHaveBeenCalled();
111+
} );
112+
113+
it( 'should proceed with login if existing token is invalid', async () => {
114+
const { runCommand } = await import( '../login' );
115+
await runCommand();
116+
117+
expect( openBrowser ).toHaveBeenCalled();
118+
expect( input ).toHaveBeenCalled();
119+
} );
120+
121+
it( 'should handle browser open failure', async () => {
122+
const browserError = new LoggerError( 'Failed to open browser' );
123+
( openBrowser as jest.Mock ).mockRejectedValue( browserError );
124+
125+
const { runCommand } = await import( '../login' );
126+
await runCommand();
127+
128+
expect( input ).toHaveBeenCalled();
129+
} );
130+
131+
it( 'should handle API error when fetching user info', async () => {
132+
const apiError = new LoggerError( 'Failed to fetch user info' );
133+
( getUserInfo as jest.Mock ).mockRejectedValue( apiError );
134+
135+
const { runCommand } = await import( '../login' );
136+
await runCommand();
137+
138+
expect( mockLogger.reportError ).toHaveBeenCalled();
139+
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
140+
expect( getUserInfo ).toHaveBeenCalled();
141+
} );
142+
143+
it( 'should unlock appdata even if save fails', async () => {
144+
const saveError = new Error( 'Failed to save' );
145+
( saveAppdata as jest.Mock ).mockRejectedValue( saveError );
146+
147+
const { runCommand } = await import( '../login' );
148+
await runCommand();
149+
150+
expect( mockLogger.reportError ).toHaveBeenCalled();
151+
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
152+
expect( lockAppdata ).toHaveBeenCalled();
153+
expect( unlockAppdata ).toHaveBeenCalled();
154+
} );
155+
156+
it( 'should handle lock appdata failure', async () => {
157+
const lockError = new Error( 'Failed to lock' );
158+
( lockAppdata as jest.Mock ).mockRejectedValue( lockError );
159+
160+
const { runCommand } = await import( '../login' );
161+
await runCommand();
162+
163+
expect( mockLogger.reportError ).toHaveBeenCalled();
164+
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
165+
} );
166+
167+
it( 'should use provided locale', async () => {
168+
( getAppLocale as jest.Mock ).mockResolvedValue( 'fr' );
169+
170+
const { runCommand } = await import( '../login' );
171+
await runCommand();
172+
173+
expect( getAuthenticationUrl ).toHaveBeenCalledWith(
174+
'fr',
175+
'https://developer.wordpress.com/copy-oauth-token'
176+
);
177+
} );
178+
} );

‎cli/index.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n';
44
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
55
import { StatsGroup, StatsMetric } from 'common/types/stats';
66
import yargs from 'yargs';
7+
import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login';
78
import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create';
89
import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete';
910
import { registerCommand as registerListCommand } from 'cli/commands/preview/list';
@@ -18,12 +19,12 @@ import { StudioArgv } from 'cli/types';
1819
suppressPunycodeWarning();
1920

2021
async function main() {
21-
const locale = await loadTranslations();
22+
const yargsLocale = await loadTranslations();
2223

2324
const studioArgv: StudioArgv = yargs( process.argv.slice( 2 ) )
2425
.scriptName( 'studio' )
2526
.usage( __( 'WordPress Studio CLI' ) )
26-
.locale( locale )
27+
.locale( yargsLocale )
2728
.version( version )
2829
.option( 'avoid-telemetry', {
2930
type: 'boolean',
@@ -45,6 +46,10 @@ async function main() {
4546
);
4647
}
4748
} )
49+
.command( 'auth', __( 'Manage authentication' ), ( authYargs ) => {
50+
registerAuthLoginCommand( authYargs );
51+
authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) );
52+
} )
4853
.command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => {
4954
registerCreateCommand( previewYargs );
5055
registerListCommand( previewYargs );

‎cli/lib/api.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,23 @@ export async function validateAccessToken( token: string ): Promise< void > {
144144
throw new LoggerError( __( 'Invalid authentication token' ), error );
145145
}
146146
}
147+
148+
const userResponseSchema = z.object( {
149+
ID: z.number(),
150+
email: z.string().email(),
151+
display_name: z.string(),
152+
} );
153+
154+
export async function getUserInfo(
155+
token: string
156+
): Promise< z.infer< typeof userResponseSchema > > {
157+
const wpcom = wpcomFactory( token, wpcomXhrRequest );
158+
try {
159+
const rawResponse = await wpcom.req.get( '/me', {
160+
fields: 'ID,login,email,display_name',
161+
} );
162+
return userResponseSchema.parse( rawResponse );
163+
} catch ( error ) {
164+
throw new LoggerError( __( 'Failed to fetch user info' ), error );
165+
}
166+
}

‎cli/lib/appdata.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ const userDataSchema = z
4646
authToken: z
4747
.object( {
4848
accessToken: z.string().min( 1, __( 'Access token cannot be empty' ) ),
49+
expiresIn: z.number(), // Seconds
50+
expirationTime: z.number(), // Milliseconds since the Unix epoch
4951
id: z.number().optional(),
52+
email: z.string(),
53+
displayName: z.string().default( '' ),
5054
} )
5155
.passthrough()
5256
.optional(),
@@ -145,7 +149,7 @@ export async function getAuthToken(): Promise< ValidatedAuthToken > {
145149
try {
146150
const { authToken } = await readAppdata();
147151

148-
if ( ! authToken?.accessToken || ! authToken?.id ) {
152+
if ( ! authToken?.accessToken || ! authToken?.id || Date.now() >= authToken?.expirationTime ) {
149153
throw new Error( 'Authentication required' );
150154
}
151155

0 commit comments

Comments
 (0)