Skip to content
Draft
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
62 changes: 62 additions & 0 deletions cli/commands/ai/ask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { __, sprintf } from '@wordpress/i18n';
import { askAI, getBasicContext, AIAssistantError } from 'common/lib/ai-assistant';
import { SiteDetails } from 'common/types/sites';
import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata';
import { LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

export async function runCommand( question: string, sitePath: string ): Promise< void > {
try {
// Get authentication token
const authToken = await getAuthToken();

// Try to get site information, fall back to basic context
const siteDetails: SiteDetails = await getSiteByFolder( sitePath );
const context = getBasicContext( siteDetails );

const response = await askAI( question, context, authToken.accessToken );
console.log( '\n' + response.content + '\n' );

const quotaMessage = response.quota.userCanSendMessage
? sprintf(
__( 'Remaining prompts: %d/%d' ),
response.quota.remaining_quota,
response.quota.max_quota
)
: response.quota.daysUntilReset <= 0
? __( "You've reached your usage limit for this month. Your limit will reset today." )
: sprintf(
__( "You've reached your usage limit for this month. Your limit will reset in %d days." ),
response.quota.daysUntilReset
);

console.log( `ℹ ${ quotaMessage }\n` );
} catch ( error ) {
if ( error instanceof LoggerError ) {
throw error;
}

if ( error instanceof AIAssistantError ) {
throw new LoggerError( __( 'AI Assistant Error: %s', error.message ), error.cause );
}

throw new LoggerError( __( 'Failed to get AI response' ), error );
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'ask <question>',
describe: __( 'Ask a question to the AI assistant' ),
builder: ( yargs ) => {
return yargs.positional( 'question', {
describe: __( 'The question to ask the AI assistant' ),
type: 'string',
demandOption: true,
} );
},
handler: async ( argv ) => {
await runCommand( argv.question as string, argv.path );
},
} );
};
164 changes: 164 additions & 0 deletions cli/commands/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { __ } from '@wordpress/i18n';
import { PROTOCOL_PREFIX } from 'common/constants';
import WPCOM from 'wpcom';
import { z } from 'zod';
import { validateAccessToken } from 'cli/lib/api';
import { saveAppdata, readAppdata, lockAppdata, unlockAppdata } from 'cli/lib/appdata';
import { AuthToken } from 'cli/lib/token-waiter';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

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

export function parseAuthUrl( url: string ): {
accessToken: string;
expiresIn?: number;
state?: string;
} {
const urlObject = new URL( url );

if ( urlObject.protocol !== `${ PROTOCOL_PREFIX }:` || urlObject.hostname !== 'auth' ) {
throw new Error( 'Invalid URL format. Expected wpcom-local-dev://auth#...' );
}

const hash = urlObject.hash;
if ( ! hash || ! hash.startsWith( '#' ) ) {
throw new Error( 'No authentication data found in URL' );
}

const params = new URLSearchParams( hash.substring( 1 ) );
const error = params.get( 'error' );
const errorDescription = params.get( 'error_description' );

if ( error ) {
const errorMessage = errorDescription || error;
throw new Error( `Authentication failed: ${ errorMessage }` );
}

const accessToken = params.get( 'access_token' );
const expiresIn = params.get( 'expires_in' );
const state = params.get( 'state' );

if ( ! accessToken ) {
throw new Error( 'No access token found in URL' );
}

return {
accessToken,
expiresIn: expiresIn ? parseInt( expiresIn ) : undefined,
state: state || undefined,
};
}

export async function validateAndGetUserInfo( accessToken: string ): Promise< AuthToken > {
await validateAccessToken( accessToken );

const wpcom = new WPCOM( accessToken );
const rawResponse = await wpcom.req.get( '/me?fields=ID,email,display_name' );
const response = meResponseSchema.parse( rawResponse );

const expiresIn = 3600; // Default to 1 hour
const expirationTime = new Date().getTime() + expiresIn * 1000;

return {
accessToken,
expiresIn,
expirationTime,
id: response.ID,
email: response.email,
displayName: response.display_name,
};
}

export async function saveAuthToken( authToken: AuthToken, logger: Logger< string > ) {
logger.reportStart( 'APPDATA_SAVE', __( 'Saving authentication…' ) );

try {
await lockAppdata();
let userData;

try {
userData = await readAppdata();
} catch {
userData = {
version: 1,
newSites: [],
sites: [],
snapshots: [],
};
}

userData.authToken = {
accessToken: authToken.accessToken,
id: authToken.id,
expiresIn: authToken.expiresIn,
expirationTime: authToken.expirationTime,
email: authToken.email,
displayName: authToken.displayName,
};

await saveAppdata( userData );
logger.reportSuccess( __( 'Authentication saved successfully' ) );
} finally {
await unlockAppdata();
}
}

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

try {
if ( ! url ) {
throw new LoggerError( __( 'No callback URL provided' ) );
}

logger.reportStart( 'CALLBACK_PROCESS', __( 'Processing OAuth callback…' ) );

const parsedAuth = parseAuthUrl( url );
const { accessToken } = parsedAuth;
logger.reportSuccess( __( 'OAuth callback parsed successfully' ) );

logger.reportStart( 'TOKEN_VALIDATE', __( 'Validating access token…' ) );
const authToken = await validateAndGetUserInfo( accessToken );
logger.reportSuccess( __( 'Access token validated' ) );

await saveAuthToken( authToken, logger );

// Show success
logger.reportSuccess( __( 'Authentication completed successfully!' ) );
logger.reportKeyValuePair( 'status', __( 'Authenticated' ) );
logger.reportKeyValuePair( 'user_id', authToken.id.toString() );
logger.reportKeyValuePair( 'email', authToken.email );
logger.reportKeyValuePair( 'display_name', authToken.displayName );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'OAuth callback processing failed' ), error ) );
}

// Exit with error code to indicate failure
process.exit( 1 );
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'callback <url>',
describe: __( 'Handle OAuth callback (internal use only)' ),
builder: ( yargs ) => {
return yargs.positional( 'url', {
type: 'string',
description: __( 'OAuth callback URL' ),
demandOption: true,
} );
},
handler: async ( argv ) => {
await runCommand( argv.url );
},
} );
};
76 changes: 76 additions & 0 deletions cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { __ } from '@wordpress/i18n';
import { SupportedLocale } from 'common/lib/locale';
import { getAuthenticationUrl } from 'common/lib/oauth';
import { validateAccessToken } from 'cli/lib/api';
import { readAppdata } from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { registerProtocolHandler, unregisterProtocolHandler } from 'cli/lib/protocol-handler';
import { waitForAuthenticationToken, getAuthStartTimestamp } from 'cli/lib/token-waiter';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

export async function runCommand( locale: SupportedLocale = 'en' ): Promise< void > {
const logger = new Logger();

try {
const existingData = await readAppdata();
if ( existingData.authToken?.accessToken ) {
await validateAccessToken( existingData.authToken.accessToken );
logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) );
return;
}

logger.reportStart( 'AUTH_INIT', __( 'Starting authentication flow…' ) );

// Get timestamp before starting auth to detect new tokens
const authStartTime = getAuthStartTimestamp();

// Register CLI as temporary protocol handler
logger.reportStart( 'PROTOCOL_REGISTER', __( 'Registering CLI as protocol handler…' ) );
await registerProtocolHandler();
logger.reportSuccess( __( 'Protocol handler registered' ) );

logger.reportStart( 'BROWSER_OPEN', __( 'Opening browser for authentication…' ) );
const authUrl = getAuthenticationUrl( locale );
await openBrowser( authUrl );
logger.reportSuccess( __( 'Browser opened successfully' ) );

console.log( __( 'Please complete authentication in your browser.' ) );
console.log( '' );

const authToken = await waitForAuthenticationToken( authStartTime, 120000, logger );
await unregisterProtocolHandler();
logger.reportSuccess( __( 'Authentication completed successfully!' ) );
logger.reportKeyValuePair( 'status', __( 'Authenticated' ) );
logger.reportKeyValuePair( 'user_id', authToken.id.toString() );
logger.reportKeyValuePair( 'email', authToken.email );
logger.reportKeyValuePair( 'display_name', authToken.displayName );
} catch ( error ) {
// Clean up protocol registration on error
await unregisterProtocolHandler();

if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) );
}
throw error;
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'login',
describe: __( 'Log in to WordPress.com' ),
builder: ( yargs ) => {
return yargs.option( 'locale', {
type: 'string',
default: 'en',
description: __( 'Locale for the authentication flow' ),
} );
},
handler: async ( argv ) => {
await runCommand( argv.locale as SupportedLocale );
},
} );
};
46 changes: 46 additions & 0 deletions cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { __ } from '@wordpress/i18n';
import { readAppdata, saveAppdata, lockAppdata, unlockAppdata } 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();

logger.reportStart( 'LOGOUT', __( 'Logging out…' ) );

try {
await lockAppdata();

const userData = await readAppdata();

if ( ! userData.authToken ) {
logger.reportError( new LoggerError( __( 'Already logged out' ) ) );
return;
}

delete userData.authToken;
await saveAppdata( userData );

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

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'logout',
describe: __( 'Log out and clear authentication' ),
handler: async () => {
await runCommand();
},
} );
};
Loading