Skip to content
19 changes: 9 additions & 10 deletions apps/cli/commands/pull-reprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
} from 'cli/lib/pull/runtime-start-options';
import { buildAutoLoginUrl } from 'cli/lib/site-utils';
import { fetchSyncableSites } from 'cli/lib/sync-api';
import { pickSyncSite } from 'cli/lib/sync-site-picker';
import { getSyncSupportError, pickSyncSite } from 'cli/lib/sync-site-picker';
import {
startWordPressServer,
stopWordPressServer,
Expand Down Expand Up @@ -787,15 +787,7 @@ export async function resolveSourceSite( url?: string ): Promise< PullSource | n
);
}
if ( matched.syncSupport !== 'syncable' ) {
throw new LoggerError(
sprintf(
// translators: %s: the site URL.
__(
'%s cannot be pulled. Pulling requires a WordPress.com or Pressable site with hosting features enabled.'
),
matched.url
)
);
throw getSyncSupportError( matched );
}
resolvedUrl = matched.url;
wpComSite = matched;
Expand All @@ -804,6 +796,13 @@ export async function resolveSourceSite( url?: string ): Promise< PullSource | n
// features enabled (`syncable`) — are pull candidates.
const pullableSites = sites.filter( ( site ) => site.syncSupport === 'syncable' );
if ( pullableSites.length === 0 ) {
// When the account has exactly one site and it can't be pulled
// (e.g. a lone Business-plan site awaiting Atomic transfer), report
// the specific condition and next step rather than a generic
// "nothing to pull" message.
if ( sites.length === 1 ) {
throw getSyncSupportError( sites[ 0 ] );
}
throw new LoggerError(
__(
'No pullable WordPress.com or Pressable sites found. Pulling requires a site with hosting features enabled.'
Expand Down
39 changes: 37 additions & 2 deletions apps/cli/commands/tests/pull-reprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( pickSyncSite ).not.toHaveBeenCalled();
} );

it( 'rejects a non-syncable site passed via --url with a clear message', async () => {
it( 'rejects a needs-transfer site passed via --url with the hosting-features message', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
Expand All @@ -564,11 +564,46 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
] );

await expect( resolveSourceSite( 'https://only-simple.example.com' ) ).rejects.toThrow(
/cannot be pulled/
/hosting features to be enabled.*hosting-features\/44/
);
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'rejects a needs-upgrade site passed via --url with the plan-upgrade message', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
id: 55,
name: 'Free',
url: 'https://free.example.com',
syncSupport: 'needs-upgrade',
} ),
] );

await expect( resolveSourceSite( 'https://free.example.com' ) ).rejects.toThrow(
/plan with hosting features.*plans\/55/
);
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'reports the specific reason when the only site is not pullable (no --url)', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
id: 66,
name: 'Simple',
url: 'https://lone-simple.example.com',
syncSupport: 'needs-transfer',
} ),
] );

await expect( resolveSourceSite() ).rejects.toThrow(
/hosting features to be enabled.*hosting-features\/66/
);
expect( pickSyncSite ).not.toHaveBeenCalled();
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'rejects a URL that is not connected to the WordPress.com account', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( sites );
Expand Down
97 changes: 82 additions & 15 deletions apps/cli/lib/sync-site-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,86 @@ import { normalizeHostname } from 'cli/lib/utils';
import { LoggerError } from 'cli/logger';
import type { SyncSite } from '@studio/common/types/sync';

function throwSyncSupportError( site: SyncSite ): never {
if ( site.syncSupport === 'needs-transfer' ) {
throw new LoggerError(
sprintf(
__(
'Site %1$s requires hosting features to be enabled. Please visit https://wordpress.com/hosting-features/%2$d to activate them, then try again.'
),
site.name,
site.id
)
);
/**
* Maps a site's `syncSupport` state to a clear, actionable error explaining
* why it can't be synced. Shared by every CLI sync command (push, pull,
* pull-reprint) so a Business-plan site awaiting Atomic transfer, a site that
* needs a plan upgrade, and so on each report the specific condition and next
* step instead of a generic failure or a raw internal state identifier.
*/
export function getSyncSupportError( site: SyncSite ): LoggerError {
switch ( site.syncSupport ) {
case 'needs-transfer':
return new LoggerError(
sprintf(
// translators: %1$s: site name. %2$d: WordPress.com site ID.
__(
'Site %1$s requires hosting features to be enabled. Please visit https://wordpress.com/hosting-features/%2$d to activate them, then try again.'
),
site.name,
site.id
)
);
case 'needs-upgrade':
return new LoggerError(
sprintf(
// translators: %1$s: site name. %2$d: WordPress.com site ID.
__(
'Site %1$s requires a plan with hosting features to sync. Please upgrade at https://wordpress.com/plans/%2$d, then try again.'
Comment thread
gcsecsey marked this conversation as resolved.
),
site.name,
site.id
)
);
case 'missing-permissions':
return new LoggerError(
sprintf(
// translators: %s: site name.
__(
'You do not have permission to sync site %s. Please ask a site administrator for access, then try again.'
),
site.name
)
);
case 'unsupported':
return new LoggerError(
sprintf(
// translators: %s: site name.
__(
'Site %s is hosted somewhere Studio cannot sync with. Only WordPress.com and Pressable sites are supported.'
),
site.name
)
);
case 'deleted':
return new LoggerError(
sprintf(
// translators: %s: site name.
__( 'Site %s has been deleted and can no longer be synced.' ),
site.name
)
);
case 'already-connected':
return new LoggerError(
sprintf(
// translators: %s: site name.
__( 'Site %s is already connected to another local site.' ),
site.name
)
);
default:
return new LoggerError(
sprintf(
// translators: %s: site name.
__( 'Site %s cannot be synced.' ),
site.name
)
);
}
throw new LoggerError(
sprintf( __( 'Site %1$s is not syncable (%2$s)' ), site.name, site.syncSupport )
);
}

function throwSyncSupportError( site: SyncSite ): never {
throw getSyncSupportError( site );
}

export function findSyncSiteByIdentifier( sites: SyncSite[], identifier: string ): SyncSite {
Expand Down Expand Up @@ -73,8 +138,10 @@ function getSyncSupportLabel( syncSupport: SyncSite[ 'syncSupport' ] ): string {
return __( 'Deleted' );
case 'missing-permissions':
return __( 'Missing permissions' );
case 'already-connected':
return __( 'Already connected' );
default:
return syncSupport;
return __( 'Not syncable' );
}
}

Expand Down
83 changes: 83 additions & 0 deletions packages/common/lib/sync/tests/transform-sites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { transformSitesResponse } from '../transform-sites';

// WordPress.com only returns options.software_version for Atomic/Jetpack
// sites. Simple sites — which includes Business plans that have not yet been
// transferred to Atomic, plus Free/Personal plans — omit it entirely.
function simpleBusinessSite() {
return {
ID: 1,
name: 'Simple Business',
URL: 'https://simplebusiness.wordpress.com',
is_wpcom_atomic: false,
jetpack: false,
is_deleted: false,
is_a8c: false,
hosting_provider_guess: 'automattic',
capabilities: { manage_options: true },
plan: {
features: { active: [ 'studio-sync' ] },
product_id: 1008,
product_name_short: 'Business',
product_slug: 'business-bundle',
},
options: { created_at: '2021-11-17T16:23:55+00:00', wpcom_staging_blog_ids: [] },
};
}

function freeSite() {
return {
...simpleBusinessSite(),
ID: 2,
name: 'Free',
URL: 'https://free.wordpress.com',
plan: {
features: { active: [] as string[] },
product_id: 1,
product_name_short: 'Free',
product_slug: 'free_plan',
is_free: true,
},
};
}

function atomicSite() {
return {
ID: 3,
name: 'Atomic',
URL: 'https://atomic.wordpress.com',
is_wpcom_atomic: true,
jetpack: true,
is_deleted: false,
is_a8c: false,
capabilities: { manage_options: true },
plan: {
features: { active: [ 'studio-sync' ] },
product_id: 1008,
product_name_short: 'Business',
product_slug: 'business-bundle',
},
options: {
created_at: '2021-11-17T16:23:55+00:00',
wpcom_staging_blog_ids: [],
software_version: '6.5',
},
};
}

describe( 'transformSitesResponse', () => {
it( 'retains Simple sites whose options omit software_version and classifies them', () => {
const result = transformSitesResponse( [ simpleBusinessSite(), freeSite() ] );

expect( result ).toHaveLength( 2 );
expect( result.find( ( s ) => s.id === 1 )?.syncSupport ).toBe( 'needs-transfer' );
expect( result.find( ( s ) => s.id === 2 )?.syncSupport ).toBe( 'needs-upgrade' );
} );

it( 'reports software_version as wpVersion when present, undefined when omitted', () => {
const result = transformSitesResponse( [ atomicSite(), simpleBusinessSite() ] );

expect( result.find( ( s ) => s.id === 3 )?.wpVersion ).toBe( '6.5' );
expect( result.find( ( s ) => s.id === 1 )?.wpVersion ).toBeUndefined();
} );
} );
6 changes: 5 additions & 1 deletion packages/common/types/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export const sitesEndpointSiteSchema = z.object( {
.object( {
created_at: z.string(),
wpcom_staging_blog_ids: z.array( z.number() ),
software_version: z.string(),
// WordPress.com only returns software_version for Atomic/Jetpack
// sites; Simple sites (e.g. Business plans not yet transferred to
// Atomic, or Free/Personal plans) omit it. Requiring it here would
// silently drop every Simple site from the synced-sites list.
software_version: z.string().optional(),
Comment on lines +27 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The list of sites returned should match the existing list returned by the pull command, so we can mimic that functionality. I think simple sites are not returned now as they are not supported, so I'd say that it's fine not to return them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point on keeping feature parity, but I think this change preserves it. The change is in the shared fetchSyncableSites, so pull, push, pull-reprint and the Desktop all stay consistent.

Earlier, simple sites weren’t deliberately filtered out, it was a schema bug. options.software_version was marked required, but WordPress.com only returns it for Atomic/Jetpack sites, so every Simple site failed validation and was dropped.

IMO keeping them in the list is a small UX enhancement. Rather than hiding a site the customer paid for, we surface it as non-syncable with a suggested next step (“enable hosting features” / “upgrade your plan”).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That sounds like a great improvement to me, if the next step is clear to the user. Thanks for improving this!

} )
.optional(),
capabilities: z
Expand Down