Skip to content
Open
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
111 changes: 81 additions & 30 deletions apps/cli/commands/pull-reprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 );
}
}
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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 <site-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( '' );
}

Expand Down
136 changes: 124 additions & 12 deletions apps/cli/commands/tests/pull-reprint.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down
Loading