Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
999b2a8
CLI: Implement `auth login` command
fredrikekelund Nov 5, 2025
8650b02
Fix lint and tests
fredrikekelund Nov 6, 2025
fc661e0
Add tests, tweak command
fredrikekelund Nov 6, 2025
c8b228c
Consider expiration time
fredrikekelund Nov 6, 2025
9fcc45f
Move logic around
fredrikekelund Nov 6, 2025
36e6059
Unwrap
fredrikekelund Nov 6, 2025
a228ae9
Fix type error
fredrikekelund Nov 6, 2025
b838c0c
CLI: Implement `auth logout` command
fredrikekelund Nov 6, 2025
c1799fe
Address review comments
fredrikekelund Nov 6, 2025
23aa26c
Reject unsupported platforms
fredrikekelund Nov 6, 2025
ed4f216
Merge branch 'f26d/cli-auth-login-command' into f26d/cli-auth-logout-…
fredrikekelund Nov 6, 2025
49966e3
Merge branch 'trunk' into f26d/cli-auth-login-command
fredrikekelund Nov 7, 2025
e13fd34
Address review feedback
fredrikekelund Nov 7, 2025
c72a70d
Fix test
fredrikekelund Nov 7, 2025
edb407f
Merge branch 'f26d/cli-auth-login-command' into f26d/cli-auth-logout-…
fredrikekelund Nov 7, 2025
6ba6a05
Fix
fredrikekelund Nov 7, 2025
2908170
Merge branch 'trunk' into f26d/cli-auth-login-command
fredrikekelund Nov 17, 2025
fc816bf
docs: Update CLI documentation for auth login command
github-actions[bot] Nov 17, 2025
5748109
Restore package-lock.json
fredrikekelund Nov 17, 2025
7b1c743
Install inquirer dependency again
fredrikekelund Nov 17, 2025
d8feb6b
Tweak
fredrikekelund Nov 17, 2025
20c7566
Merge branch 'f26d/cli-auth-login-command' into f26d/cli-auth-logout-…
fredrikekelund Nov 17, 2025
58c6aa7
Merge branch 'trunk' into f26d/cli-auth-logout-command
fredrikekelund Nov 17, 2025
3247311
Fix merge
fredrikekelund Nov 17, 2025
ffd836f
docs: Update CLI documentation
github-actions[bot] Nov 17, 2025
d660ff0
Tweaks
fredrikekelund Nov 17, 2025
c11f125
Lock appfile before calling API
fredrikekelund Nov 17, 2025
b6d4c68
Fix test
fredrikekelund Nov 17, 2025
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
55 changes: 55 additions & 0 deletions cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { __ } from '@wordpress/i18n';
Copy link

Choose a reason for hiding this comment

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

Documentation Missing: CLAUDE.md needs update

The CLAUDE.md file documents the auth login command (lines 52-82) but doesn't include documentation for the new auth logout command.

Suggested addition after line 82 in CLAUDE.md:

#### `studio auth logout`
Log out from WordPress.com and revoke the access token.

**Usage:**
```bash
node dist/cli/main.js auth logout

Description:
This command logs you out from WordPress.com by:

  1. Revoking the access token on the WordPress.com server
  2. Removing the token from your local app data
  3. Syncing the logout state with the Studio desktop app

Options:

  • None required

Example:

npm run cli:build
node dist/cli/main.js auth logout
# Output: ✓ Successfully logged out

Notes:

  • If already logged out, the command will notify you without error
  • Logout is shared between the CLI and the Studio desktop app
  • The token is revoked on WordPress.com, invalidating all sessions using that token

This maintains consistency with the existing documentation style and helps users understand the command.

import { AuthCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import { revokeAuthToken } from 'cli/lib/api';
import {
readAppdata,
saveAppdata,
lockAppdata,
unlockAppdata,
getAuthToken,
} from 'cli/lib/appdata';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

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

logger.reportStart( LoggerAction.LOGOUT, __( 'Logging out…' ) );
let token: Awaited< ReturnType< typeof getAuthToken > >;

try {
token = await getAuthToken();
} catch ( error ) {
logger.reportSuccess( __( 'Already logged out' ) );
return;
}
Comment on lines +17 to +25
Copy link

Choose a reason for hiding this comment

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

Code Quality: Inconsistent with login command pattern

The login command (lines 24-30 in login.ts) silently catches errors from getAuthToken() and proceeds with authentication. The logout command does the same but reports "Already logged out" as success.

However, there's an inconsistency in the user experience:

  • Login: If already authenticated, shows "Already authenticated with WordPress.com" (line 26)
  • Logout: If not authenticated, shows "Already logged out" (line 23)

But getAuthToken() can fail for multiple reasons:

  1. No token exists (truly logged out) ✅
  2. Token expired (should we revoke expired tokens?)
  3. Token invalid/corrupted (edge case)
  4. Network error during validation (line 156 in appdata.ts calls validateAccessToken)
  5. Appdata file missing/corrupted

Suggestion: Consider being more specific about the reason:

try {
	token = await getAuthToken();
} catch ( error ) {
	// Check if it's specifically "no token" vs other errors
	if ( error instanceof LoggerError && error.message.includes( 'Authentication required' ) ) {
		logger.reportSuccess( __( 'Already logged out' ) );
		return;
	}
	// For other errors (corrupted file, network issues), report them
	logger.reportError( error instanceof LoggerError ? error : new LoggerError( __( 'Failed to check authentication status' ), error ) );
	return;
}


try {
await revokeAuthToken( token.accessToken );
Copy link
Contributor

Choose a reason for hiding this comment

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

In Studio, failing to revoke the auth token doesn't fail the logout operation.
Have you considered keeping the same behavior in the CLI?

Copy link
Contributor

Choose a reason for hiding this comment

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

If we consider the revoke of the token as non-critical we should do the api call after removing the token from appdata, preventing the unlikely scenario of having a revoked token store in appdata


await lockAppdata();
Copy link

Choose a reason for hiding this comment

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

Race Condition: Lock acquired AFTER API call

The lockAppdata() is called after revokeAuthToken(), which creates a race condition. Consider this scenario:

  1. User A runs studio auth logout (Process A starts)
  2. Process A calls revokeAuthToken() - succeeds
  3. User A runs studio auth login from another terminal (Process B starts)
  4. Process B locks appdata, writes new token
  5. Process A locks appdata (waiting for Process B)
  6. Process A deletes the NEW token that was just created

Fix: Move the lock acquisition before the revoke call:

try {
	await lockAppdata();
	await revokeAuthToken( token.accessToken );
	const userData = await readAppdata();
	delete userData.authToken;
	await saveAppdata( userData );
	logger.reportSuccess( __( 'Successfully logged out' ) );
} catch ( error ) {
	// error handling
} finally {
	await unlockAppdata();
}

This ensures the entire logout operation is atomic.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's unlikely, but I think it's a good approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree the case that Claude describes is very unlikely, but I still realize that lockAppdata should always be the first thing we call in a try..catch block to prevent cases where we unlock appdata without actually having acquired the lock.

const userData = await readAppdata();
delete userData.authToken;
await saveAppdata( userData );
Copy link

Choose a reason for hiding this comment

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

Security Issue: Token remains valid after local deletion

The current implementation has a critical security flaw: if the revokeAuthToken() call succeeds but the local deletion fails (lines 30-33), the user will see an error message, but their token has already been revoked on the server. This means:

  1. The token is revoked remotely (server-side)
  2. The local deletion fails (e.g., file permission issue)
  3. User sees "Failed to log out" error
  4. The invalid token remains in local appdata

Suggested fix:

try {
	await revokeAuthToken( token.accessToken );
} catch ( error ) {
	logger.reportError( new LoggerError( __( 'Failed to revoke token on server' ), error ) );
	return; // Don't proceed with local deletion if server revocation failed
}

// Now attempt local cleanup
try {
	await lockAppdata();
	const userData = await readAppdata();
	delete userData.authToken;
	await saveAppdata( userData );
	logger.reportSuccess( __( 'Successfully logged out' ) );
} catch ( error ) {
	// Token is revoked on server but local cleanup failed
	logger.reportError( new LoggerError( __( 'Token revoked but failed to update local config. Please try again.' ), error ) );
} finally {
	await unlockAppdata();
}

This ensures the error messages accurately reflect the state and prevents confusion.


logger.reportSuccess( __( 'Successfully logged out' ) );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Failed to log out' ), error ) );
}
} finally {
await unlockAppdata();
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'logout',
describe: __( 'Log out and clear WordPress.com authentication' ),
handler: async () => {
await runCommand();
},
} );
};
113 changes: 113 additions & 0 deletions cli/commands/auth/tests/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { revokeAuthToken } from 'cli/lib/api';
import {
getAuthToken,
lockAppdata,
readAppdata,
saveAppdata,
unlockAppdata,
} from 'cli/lib/appdata';
import { Logger, LoggerError } from 'cli/logger';

jest.mock( 'cli/lib/appdata' );
jest.mock( 'cli/logger' );
jest.mock( 'cli/lib/api' );

describe( 'Auth Logout Command', () => {
function getMockAppdata() {
return {
authToken: {
accessToken: 'existing-token',
id: 999,
email: '[email protected]',
displayName: 'Existing User',
expiresIn: 1209600,
expirationTime: Date.now() + 1209600000,
},
};
}

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

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

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

( Logger as jest.Mock ).mockReturnValue( mockLogger );
( getAuthToken as jest.Mock ).mockResolvedValue( getMockAppdata().authToken );
( revokeAuthToken as jest.Mock ).mockResolvedValue( undefined );
( lockAppdata as jest.Mock ).mockResolvedValue( undefined );
( unlockAppdata as jest.Mock ).mockResolvedValue( undefined );
( readAppdata as jest.Mock ).mockResolvedValue( getMockAppdata() );
( saveAppdata as jest.Mock ).mockResolvedValue( undefined );
} );

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

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

expect( getAuthToken ).toHaveBeenCalled();
expect( revokeAuthToken ).toHaveBeenCalled();
expect( lockAppdata ).toHaveBeenCalled();
expect( readAppdata ).toHaveBeenCalled();
expect( saveAppdata ).toHaveBeenCalledWith(
expect.not.objectContaining( { authToken: expect.anything() } )
);
expect( unlockAppdata ).toHaveBeenCalled();
expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Successfully logged out' );
} );

it( 'should report an error if revoking the token fails', async () => {
( revokeAuthToken as jest.Mock ).mockRejectedValue( new Error( 'Failed to revoke token' ) );
Comment on lines +72 to +73
Copy link

Choose a reason for hiding this comment

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

Test Gap: Testing against implementation bug

This test verifies the current buggy behavior where token revocation failure prevents local cleanup. However, there's a better approach:

Scenario 1: Server revocation fails

  • Token remains valid on server
  • Local token should remain (current behavior is correct)

Scenario 2: Server revocation succeeds, local cleanup fails

  • Token is invalid on server
  • Local token should be cleaned up in a retry

The current implementation doesn't handle Scenario 2 well. Consider adding a test:

it( 'should handle partial failure gracefully (server revoke success, local cleanup fails)', async () => {
	( revokeAuthToken as jest.Mock ).mockResolvedValue( undefined );
	( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Disk full' ) );

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

	expect( revokeAuthToken ).toHaveBeenCalled();
	expect( mockLogger.reportError ).toHaveBeenCalled();
	// The error message should indicate the token is revoked on server
	// but local cleanup failed
} );


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

expect( getAuthToken ).toHaveBeenCalled();
expect( lockAppdata ).not.toHaveBeenCalled();
Copy link

Choose a reason for hiding this comment

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

Test Issue: Incorrect assertion - lock should NOT be called

This test expects lockAppdata to not be called when token revocation fails, but the current implementation (line 30 in logout.ts) calls lockAppdata() AFTER revokeAuthToken().

If revokeAuthToken() throws an error, the code jumps to the catch block at line 36, and lockAppdata() is never reached. So this assertion is actually testing the bug mentioned in the other comment about the race condition.

When the race condition is fixed (by moving lockAppdata() before revokeAuthToken()), this test will fail and needs to be updated to:

expect( lockAppdata ).toHaveBeenCalled();
expect( unlockAppdata ).toHaveBeenCalled();

Because the lock should be acquired before attempting revocation to ensure atomicity.

expect( readAppdata ).not.toHaveBeenCalled();
expect( saveAppdata ).not.toHaveBeenCalledWith( {} );
expect( unlockAppdata ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
} );

it( 'should report already logged out if no auth token exists', async () => {
( getAuthToken as jest.Mock ).mockRejectedValue( new Error( 'No auth token' ) );

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

expect( getAuthToken ).toHaveBeenCalled();
expect( revokeAuthToken ).not.toHaveBeenCalled();
expect( lockAppdata ).not.toHaveBeenCalled();
expect( readAppdata ).not.toHaveBeenCalled();
expect( saveAppdata ).not.toHaveBeenCalled();
expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Already logged out' );
} );

it( 'should unlock appdata even if save fails', async () => {
Copy link

Choose a reason for hiding this comment

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

Missing Test Coverage: Lock failure scenario

There's no test for when lockAppdata() itself fails. This is an important edge case since file locking can fail due to:

  • Another process holding the lock
  • File system permissions
  • Stale lock files

Suggested additional test:

it( 'should report an error if locking appdata fails', async () => {
	( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock acquisition failed' ) );

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

	expect( getAuthToken ).toHaveBeenCalled();
	expect( lockAppdata ).toHaveBeenCalled();
	expect( revokeAuthToken ).not.toHaveBeenCalled(); // Should not proceed
	expect( unlockAppdata ).toHaveBeenCalled(); // Should still unlock
	expect( mockLogger.reportError ).toHaveBeenCalled();
} );

( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Failed to save' ) );

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

expect( revokeAuthToken ).toHaveBeenCalled();
expect( lockAppdata ).toHaveBeenCalled();
expect( unlockAppdata ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalled();
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
} );
} );
2 changes: 2 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 registerAuthLogoutCommand } from 'cli/commands/auth/logout';
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 Down Expand Up @@ -48,6 +49,7 @@ async function main() {
} )
.command( 'auth', __( 'Manage authentication' ), ( authYargs ) => {
registerAuthLoginCommand( authYargs );
registerAuthLogoutCommand( authYargs );
authYargs.demandCommand( 1, __( 'You must provide a valid auth command' ) );
} )
.command( 'preview', __( 'Manage preview sites' ), ( previewYargs ) => {
Expand Down
13 changes: 13 additions & 0 deletions cli/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,16 @@ export async function getUserInfo(
throw new LoggerError( __( 'Failed to fetch user info' ), error );
}
}

export async function revokeAuthToken( token: string ): Promise< void > {
const wpcom = wpcomFactory( token, wpcomXhrRequest );
try {
await wpcom.req.del( {
apiNamespace: 'wpcom/v2',
path: '/studio-app/token',
method: 'DELETE',
Copy link

Choose a reason for hiding this comment

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

Minor: Redundant method parameter

The wpcom.req.del() method already implies DELETE, so the method: 'DELETE' parameter is redundant. Looking at other uses of wpcom.req.del() in this file (line 125), the method parameter is not included.

Suggested fix:

await wpcom.req.del( {
	apiNamespace: 'wpcom/v2',
	path: '/studio-app/token',
} );

This keeps the code consistent with the rest of the file.

} );
} catch ( error ) {
throw new LoggerError( __( 'Failed to revoke token' ), error );
}
}
Comment on lines +168 to +179
Copy link

Choose a reason for hiding this comment

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

API Issue: Error handling masks important information

The current error handling wraps all errors in a generic "Failed to revoke token" message, which makes debugging difficult. Consider these scenarios:

  1. Network timeout - User needs to retry
  2. Token already revoked (404) - This is actually success
  3. Invalid token format (400) - Token is corrupted
  4. Server error (500) - WordPress.com API issue

Suggested improvement:

export async function revokeAuthToken( token: string ): Promise< void > {
	const wpcom = wpcomFactory( token, wpcomXhrRequest );
	try {
		await wpcom.req.del( {
			apiNamespace: 'wpcom/v2',
			path: '/studio-app/token',
			method: 'DELETE',
		} );
	} catch ( error ) {
		// If token is already revoked, treat as success
		if ( error instanceof Error && 'statusCode' in error && error.statusCode === 404 ) {
			return;
		}
		
		// Preserve the original error for better debugging
		if ( error instanceof Error ) {
			throw new LoggerError( 
				__( 'Failed to revoke token on WordPress.com' ), 
				error 
			);
		}
		
		throw new LoggerError( __( 'Failed to revoke token' ), error );
	}
}

This provides better error messages and handles the idempotent case (token already revoked).

1 change: 1 addition & 0 deletions common/logger-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

export enum AuthCommandLoggerAction {
LOGIN = 'login',
LOGOUT = 'logout',
Copy link

Choose a reason for hiding this comment

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

Documentation: Consider adding JSDoc

Since this enum is shared between CLI and main app (as noted in the comment), it would be helpful to document what each action represents:

/**
 * Logger actions for authentication commands.
 * These actions are used for telemetry and progress reporting.
 */
export enum AuthCommandLoggerAction {
	/** User login to WordPress.com */
	LOGIN = 'login',
	/** User logout from WordPress.com */
	LOGOUT = 'logout',
}

This is especially helpful given the note about avoiding Webpack issues - future developers will understand why this file exists separately.

}

export enum PreviewCommandLoggerAction {
Expand Down
29 changes: 29 additions & 0 deletions docs/ai-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,35 @@ node dist/cli/main.js auth login
- Authentication is shared between the CLI and the Studio desktop app
- If the browser fails to open, the URL will be displayed for manual opening

#### `studio auth logout`
Log out from WordPress.com and revoke the access token.

**Usage:**
```bash
node dist/cli/main.js auth logout
```

**Description:**
This command logs you out from WordPress.com by:
1. Revoking the access token on the WordPress.com server
2. Removing the token from your local app data
3. Syncing the logout state with the Studio desktop app

**Options:**
- None required

**Example:**
```bash
npm run cli:build
node dist/cli/main.js auth logout
# Output: ✓ Successfully logged out
```

**Notes:**
- If already logged out, the command will notify you without error
- Logout is shared between the CLI and the Studio desktop app
- The token is revoked on WordPress.com, invalidating all sessions using that token

### Preview Site Commands

See the existing preview site commands (create, list, delete, update) in `cli/commands/preview/`.
Expand Down
Loading