diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index e5fd404cab..c083f6a50f 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -9,6 +9,7 @@ import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { search } from '@inquirer/prompts'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import * as fsUtils from '@studio/common/lib/fs-utils'; @@ -123,13 +124,6 @@ export const registerCommand = ( yargs: StudioArgv ) => { */ const PULLS_ROOT = path.join( os.homedir(), '.studio', 'pulls' ); -/** - * Display a hint with this many WordPress.com sites when the user calls just - * `studio pull-reprint` without the `--url`. Some accounts have hundreds of sites. - * Let's only display the first few. - */ -const DEFAULT_WPCOM_SITE_LIST_LIMIT = 15; - const pullStageOrder = [ 'initialized', 'essential-files-complete', @@ -1041,20 +1035,70 @@ export function findMatchingWpComSite( } ); } -export function formatWpComSitesList( - sites: WpComSiteInfo[], - limit = DEFAULT_WPCOM_SITE_LIST_LIMIT -): string { - const visibleSites = sites.slice( 0, limit ); - const lines = visibleSites.map( - ( site, index ) => `${ index + 1 }. ${ site.name } - ${ site.url }` - ); +/** + * Interactive searchable picker for WordPress.com sites. Shared as a + * module-level export so tests can spy on / mock it without driving + * `@inquirer/prompts` directly. + */ +export async function pickWpComSite( + sites: WpComSiteInfo[] +): Promise< WpComSiteInfo | undefined > { + const choices = sites.map( ( site ) => ( { + name: `${ site.name } ${ chalk.dim( site.url ) }`, + value: site.id, + } ) ); + + const abortController = new AbortController(); + const handleEscKey = ( chunk: Buffer | string ) => { + const bytes = Buffer.isBuffer( chunk ) ? chunk : Buffer.from( chunk ); + if ( bytes.length === 1 && bytes[ 0 ] === 0x1b ) { + abortController.abort(); + } + }; - if ( sites.length > visibleSites.length ) { - lines.push( `... and ${ sites.length - visibleSites.length } more.` ); + if ( process.stdin.isTTY ) { + process.stdin.on( 'data', handleEscKey ); } - return lines.join( '\n' ); + try { + const selectedId = await search( + { + message: __( 'Select a WordPress.com site to pull:' ), + source: ( term ) => { + if ( ! term ) { + return choices; + } + const lowerTerm = term.toLowerCase(); + return choices.filter( ( choice ) => { + const site = sites.find( ( s ) => s.id === choice.value ); + if ( ! site ) { + return false; + } + return ( + site.name.toLowerCase().includes( lowerTerm ) || + site.url.toLowerCase().includes( lowerTerm ) + ); + } ); + }, + pageSize: 12, + }, + { signal: abortController.signal } + ); + + return sites.find( ( site ) => site.id === selectedId ); + } catch ( error ) { + if ( + error instanceof Error && + ( error.name === 'AbortPromptError' || error.name === 'ExitPromptError' ) + ) { + return undefined; + } + throw error; + } finally { + if ( process.stdin.isTTY ) { + process.stdin.off( 'data', handleEscKey ); + } + } } /** @@ -1066,9 +1110,10 @@ export function formatWpComSitesList( * 2. `--url` alone — try a previously cached secret from an earlier * run for this URL; fall back to rotating a fresh WP.com secret. * 3. No `--url` — if the user has exactly one connected WP.com - * site, pick it; otherwise list and abort. + * site, pick it automatically; otherwise show an interactive + * picker. */ -async function resolveSourceSite( +export async function resolveSourceSite( url?: string, providedSecret?: string, providedName?: string, @@ -1139,18 +1184,24 @@ async function resolveSourceSite( } if ( sites.length > 1 ) { - console.log( __( 'Connected WordPress.com sites:' ) ); - console.log( formatWpComSitesList( sites ) ); - console.log( '' ); - throw new LoggerError( - __( 'Multiple WordPress.com sites are available. Re-run with `--url `.' ) - ); + const picked = await pickWpComSite( sites ); + if ( ! picked ) { + throw new LoggerError( __( 'No WordPress.com site selected.' ) ); + } + wpComSite = picked; + resolvedUrl = picked.url; + } else { + // If the user only has one connected WordPress.com site, pull it by default. + wpComSite = sites[ 0 ]; + resolvedUrl = wpComSite.url; + console.log( `${ __( 'Using your only connected WordPress.com site:' ) } ${ resolvedUrl }` ); } - // If the user only has one connected WordPress.com site, pull it by default. - wpComSite = sites[ 0 ]; - resolvedUrl = wpComSite.url; - console.log( `${ __( 'Using your only connected WordPress.com site:' ) } ${ resolvedUrl }` ); + console.log( + __( + 'Note: pull-reprint currently only works with WP Cloud-hosted WordPress.com sites (Personal plan and above).' + ) + ); console.log( '' ); } diff --git a/apps/cli/commands/tests/pull-reprint.test.ts b/apps/cli/commands/tests/pull-reprint.test.ts index cbd3c76b9c..4e73d78f2b 100644 --- a/apps/cli/commands/tests/pull-reprint.test.ts +++ b/apps/cli/commands/tests/pull-reprint.test.ts @@ -1,20 +1,39 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { search } from '@inquirer/prompts'; +import { readAuthToken } from '@studio/common/lib/shared-config'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getWpComSites, rotateReprintSecret } from 'cli/lib/api'; import * as migrationClient from 'cli/lib/pull/migration-client'; import { shouldRestartFilesSyncIndex } from 'cli/lib/pull/reprint-state'; import { applyDownloadedDatabase, downloadSkippedFiles, findMatchingWpComSite, - formatWpComSitesList, getReprintApiUrlForSite, + pickWpComSite, getPrivateDirNameForImportSession, inferSiteNameFromUrl, normalizeSiteUrl, + resolveSourceSite, } from '../pull-reprint'; +vi.mock( '@inquirer/prompts', () => ( { + search: vi.fn(), +} ) ); + +vi.mock( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), +} ) ); + +vi.mock( 'cli/lib/api', async () => ( { + ...( await vi.importActual( 'cli/lib/api' ) ), + getWpComSites: vi.fn(), + rotateReprintSecret: vi.fn(), +} ) ); + describe( 'CLI: studio pull-reprint helpers', () => { it( 'normalizes URLs by stripping hashes and trailing slashes', () => { expect( normalizeSiteUrl( 'https://example.com/foo//#section' ) ).toBe( @@ -70,16 +89,25 @@ describe( 'CLI: studio pull-reprint helpers', () => { } ); } ); - it( 'formats the truncated WordPress.com site list with a remaining-count suffix', () => { - expect( - formatWpComSitesList( - [ - { id: 1, name: 'One', url: 'https://one.wordpress.com' }, - { id: 2, name: 'Two', url: 'https://two.wordpress.com' }, - ], - 1 - ) - ).toContain( '... and 1 more.' ); + it( 'returns the WordPress.com site selected through the interactive picker', async () => { + const sites = [ + { id: 1, name: 'One', url: 'https://one.wordpress.com' }, + { id: 2, name: 'Two', url: 'https://two.wordpress.com' }, + ]; + vi.mocked( search ).mockResolvedValueOnce( 2 ); + + const picked = await pickWpComSite( sites ); + + expect( picked ).toEqual( sites[ 1 ] ); + expect( search ).toHaveBeenCalledOnce(); + } ); + + it( 'returns undefined when the user cancels the picker', async () => { + const sites = [ { id: 1, name: 'One', url: 'https://one.wordpress.com' } ]; + const abortError = Object.assign( new Error( 'aborted' ), { name: 'AbortPromptError' } ); + vi.mocked( search ).mockRejectedValueOnce( abortError ); + + await expect( pickWpComSite( sites ) ).resolves.toBeUndefined(); } ); it( 'restarts files-sync indexing only when the saved state has no resumable cursor', () => { @@ -177,6 +205,90 @@ describe( 'CLI: studio pull-reprint helpers', () => { } ); } ); +describe( 'CLI: studio pull-reprint resolveSourceSite', () => { + const mockToken = { + accessToken: 'mock-token', + id: 1, + expiresIn: 3600, + expirationTime: Date.now() + 3_600_000, + email: 'test@example.com', + displayName: 'Test User', + }; + + beforeEach( () => { + vi.mocked( readAuthToken ).mockResolvedValue( mockToken ); + vi.mocked( rotateReprintSecret ).mockResolvedValue( 'rotated-secret' ); + } ); + + afterEach( () => { + vi.mocked( search ).mockReset(); + vi.mocked( getWpComSites ).mockReset(); + vi.mocked( rotateReprintSecret ).mockReset(); + vi.mocked( readAuthToken ).mockReset(); + } ); + + it( 'invokes the interactive picker when no URL is provided and the user has multiple WP.com sites', async () => { + const sites = [ + { id: 1, name: 'One', url: 'https://one.wordpress.com' }, + { id: 2, name: 'Two', url: 'https://two.wordpress.com' }, + ]; + vi.mocked( getWpComSites ).mockResolvedValueOnce( sites ); + vi.mocked( search ).mockResolvedValueOnce( 2 ); + + const result = await resolveSourceSite(); + + expect( search ).toHaveBeenCalledOnce(); + expect( getWpComSites ).toHaveBeenCalledWith( mockToken.accessToken ); + expect( result?.url ).toBe( 'https://two.wordpress.com' ); + expect( result?.wpComSite ).toEqual( sites[ 1 ] ); + expect( rotateReprintSecret ).toHaveBeenCalledWith( 2, mockToken.accessToken ); + } ); + + it( 'auto-picks the single connected WP.com site without prompting', async () => { + const sites = [ { id: 1, name: 'Solo', url: 'https://solo.wordpress.com' } ]; + vi.mocked( getWpComSites ).mockResolvedValueOnce( sites ); + + const result = await resolveSourceSite(); + + expect( search ).not.toHaveBeenCalled(); + expect( result?.url ).toBe( 'https://solo.wordpress.com' ); + } ); + + it( 'does not invoke the picker when a URL is provided alongside a secret', async () => { + const result = await resolveSourceSite( 'https://example.com', 'trusted-secret' ); + + expect( search ).not.toHaveBeenCalled(); + expect( getWpComSites ).not.toHaveBeenCalled(); + expect( result ).toEqual( { url: 'https://example.com', secret: 'trusted-secret' } ); + } ); + + it( 'does not invoke the picker when the user passes a URL that matches a WP.com site', async () => { + const sites = [ + { id: 1, name: 'One', url: 'https://one.wordpress.com' }, + { id: 2, name: 'Two', url: 'https://two.wordpress.com' }, + ]; + vi.mocked( getWpComSites ).mockResolvedValueOnce( sites ); + + const result = await resolveSourceSite( 'https://two.wordpress.com' ); + + expect( search ).not.toHaveBeenCalled(); + expect( result?.url ).toBe( 'https://two.wordpress.com' ); + expect( result?.wpComSite ).toEqual( sites[ 1 ] ); + } ); + + it( 'throws a helpful error when the user cancels the picker', async () => { + vi.mocked( getWpComSites ).mockResolvedValueOnce( [ + { id: 1, name: 'One', url: 'https://one.wordpress.com' }, + { id: 2, name: 'Two', url: 'https://two.wordpress.com' }, + ] ); + vi.mocked( search ).mockRejectedValueOnce( + Object.assign( new Error( 'aborted' ), { name: 'AbortPromptError' } ) + ); + + await expect( resolveSourceSite() ).rejects.toThrow( /No WordPress.com site selected/ ); + } ); +} ); + describe( 'CLI: studio pull-reprint db-apply phase', () => { afterEach( () => { vi.restoreAllMocks();