Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dd81bef
Use Reprint metadata and Jetpack file listings for pulls
fredrikekelund Aug 6, 2026
1b4a8ba
Bundle Reprint metadata test build
fredrikekelund Aug 10, 2026
b422a7e
Pass the SQLite target to Reprint apply-runtime
fredrikekelund Aug 6, 2026
78f595e
Update pull reprint tests for metadata command
fredrikekelund Aug 10, 2026
db6ee33
Use wp-content tokens for Reprint selections
fredrikekelund Aug 12, 2026
87b8fbf
Simplify Reprint file selection
fredrikekelund Aug 12, 2026
746ff2f
Remove uploads issue draft
fredrikekelund Aug 12, 2026
96e5abb
Use Reprint's adopt conflict mode when flattening
fredrikekelund Aug 12, 2026
376b380
Bundle a Reprint build with the three pull PRs
fredrikekelund Aug 12, 2026
a8d279d
Drop Studio's local wp-content preservation
fredrikekelund Aug 12, 2026
0b8e9d8
Remove the selection path resolver left without a caller
fredrikekelund Aug 12, 2026
40a11fc
Rebuild the bundled Reprint with merge-wp-content
fredrikekelund Aug 13, 2026
f24aab9
Merge local wp-content through Reprint's own command
fredrikekelund Aug 13, 2026
9af2e2e
Rebuild the bundled Reprint with two merge-wp-content fixes
fredrikekelund Aug 13, 2026
94e65d7
Name the wp-content directory itself when merging
fredrikekelund Aug 13, 2026
56df1f4
Find symlinks behind a pulled site's wp-content
fredrikekelund Aug 13, 2026
7cba081
Merge remote-tracking branch 'origin/trunk' into f26d/pull-reprint-ad…
fredrikekelund Aug 13, 2026
9dbcc2a
Install latest Reprint release
fredrikekelund Aug 20, 2026
c80daac
Allow files in pull-reprint selections
fredrikekelund Aug 18, 2026
69e281e
Merge branch 'trunk' into fix/pull-reprint-file-picker
fredrikekelund Aug 20, 2026
1f24d7c
Pass `--mode=mirror` to Reprint
fredrikekelund Aug 20, 2026
46528ab
Update Reprint client to 0.10.3
fredrikekelund Aug 25, 2026
af8352d
Update Reprint client to 0.10.4
fredrikekelund Aug 25, 2026
ff72ada
Update pull-reprint mirror mode test
fredrikekelund Aug 26, 2026
ed56eaf
Route the agentic UI's pull through pull-reprint behind a beta feature
fredrikekelund Aug 26, 2026
c95ba01
Honour the pull dialog's selection when pulling with Reprint
fredrikekelund Aug 26, 2026
d28cb31
Warn when a pulled site looks too big to push back
fredrikekelund Aug 26, 2026
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
189 changes: 110 additions & 79 deletions apps/cli/commands/pull-reprint.ts

Large diffs are not rendered by default.

139 changes: 121 additions & 18 deletions apps/cli/commands/tests/pull-reprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { pickSyncSite } from 'cli/lib/sync-site-picker';
import {
runFullPull,
ensureScopedPullWpConfig,
findMatchingWpComSite,
getReprintApiUrlForSite,
normalizeSiteUrl,
resolveSourceSite,
Expand Down Expand Up @@ -138,15 +137,6 @@ describe( 'CLI: studio pull-reprint helpers', () => {
).toBe( 'https://example.com/?reprint-api-jetpack' );
} );

it( 'matches WordPress.com sites by normalized URL or host', () => {
expect(
findMatchingWpComSite(
[ { id: 1, name: 'Example', url: 'https://example.wordpress.com/' } ],
'https://example.wordpress.com'
)
).toEqual( { id: 1, name: 'Example', url: 'https://example.wordpress.com/' } );
} );

it( 'synthesizes a wp-config when a scoped pull left only an empty symlink target', () => {
const technicalSiteDirectory = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-wpconfig-' ) );
const stateDirectory = path.join( technicalSiteDirectory, 'state' );
Expand Down Expand Up @@ -337,6 +327,7 @@ describe( 'CLI: studio pull-reprint single pull phase', () => {
'pull-files',
'https://example.com/?reprint-api',
'--secret=hmac-secret',
'--mode=mirror',
'--no-adaptive',
`--state-dir=${ stateDirectory }`,
`--fs-root=${ rawDirectory }`,
Expand Down Expand Up @@ -935,7 +926,7 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
setTTY( false );
vi.mocked( fetchSyncableSites ).mockResolvedValue( sites );

await expect( resolveSourceSite() ).rejects.toThrow( /Re-run with `--url/ );
await expect( resolveSourceSite() ).rejects.toThrow( /Re-run with `--remote-site/ );
expect( pickSyncSite ).not.toHaveBeenCalled();
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );
Expand Down Expand Up @@ -1000,7 +991,7 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( source ).toMatchObject( { url: 'https://one.wordpress.com', wpComSite: sites[ 0 ] } );
} );

it( 'rotates a secret for a syncable site matched by --url', async () => {
it( 'rotates a secret for a syncable site matched by --remote-site URL', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( sites );

Expand All @@ -1015,7 +1006,21 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( pickSyncSite ).not.toHaveBeenCalled();
} );

it( 'rejects a needs-transfer site passed via --url with the hosting-features message', async () => {
it( 'resolves a site passed to --remote-site as a numeric WordPress.com ID', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( sites );

const source = await resolveSourceSite( '22' );

expect( source ).toMatchObject( {
url: 'https://two.wordpress.com',
wpComSite: sites[ 1 ],
} );
expect( rotateReprintSecret ).toHaveBeenCalledWith( 22, token.accessToken, 'v1' );
expect( pickSyncSite ).not.toHaveBeenCalled();
} );

it( 'rejects a needs-transfer site passed via --remote-site with the hosting-features message', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
Expand All @@ -1032,7 +1037,7 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'rejects a needs-upgrade site passed via --url with the plan-upgrade message', async () => {
it( 'rejects a needs-upgrade site passed via --remote-site with the plan-upgrade message', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
Expand All @@ -1049,7 +1054,7 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'reports the specific reason when the only site is not pullable (no --url)', async () => {
it( 'reports the specific reason when the only site is not pullable (no --remote-site)', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( [
syncSite( {
Expand All @@ -1067,12 +1072,12 @@ describe( 'CLI: studio pull-reprint source resolution', () => {
expect( rotateReprintSecret ).not.toHaveBeenCalled();
} );

it( 'rejects a URL that is not connected to the WordPress.com account', async () => {
it( 'rejects an identifier that is not connected to the WordPress.com account', async () => {
setTTY( true );
vi.mocked( fetchSyncableSites ).mockResolvedValue( sites );

await expect( resolveSourceSite( 'https://third-party.example' ) ).rejects.toThrow(
/not a WordPress\.com or Pressable site connected to your account/
/No site found matching "https:\/\/third-party\.example"/
);
expect( pickSyncSite ).not.toHaveBeenCalled();
expect( rotateReprintSecret ).not.toHaveBeenCalled();
Expand Down Expand Up @@ -1389,7 +1394,6 @@ describe( 'CLI: studio pull-reprint first-pull selective sync', () => {
const pullsRoot = path.join( fakeHome, '.studio', 'pulls' );
const technicalSiteDirectory = path.join( pullsRoot, 'fresh-id' );
const stateDirectory = path.join( technicalSiteDirectory, 'state' );
const rawDirectory = path.join( technicalSiteDirectory, 'raw' );
const sitePath = path.join( fakeHome, 'Studio', 'My-Fresh-Site' );

seedCliConfigSite( fakeHome, [
Expand Down Expand Up @@ -1500,6 +1504,105 @@ describe( 'CLI: studio pull-reprint first-pull selective sync', () => {
] );
expect( sidecar.skipDatabase ).toBe( true );
} );

// The site directory is only rewritten from `preserveUnselectedLocalContent`
// onwards; every download before that lands in the scratch. `pulling` marks
// "half-written", so it must not be set while the site is still intact.
describe( 'the site is only marked as being written once it actually is', () => {
async function runPullFailingAt( failingStep: string ) {
const { runCommand } = await loadRunCommandWithFakeHome();
mockWpComPullSource();

const stateDirectory = path.join( fakeHome, '.studio', 'pulls', 'fresh-id', 'state' );
const sitePath = path.join( fakeHome, 'Studio', 'My-Fresh-Site' );

seedCliConfigSite( fakeHome, [
makeSiteRecord( {
id: 'fresh-id',
name: 'My Fresh Site',
path: sitePath,
status: 'ready',
} ),
] );

fs.mkdirSync( path.join( sitePath, 'wp-content', 'database' ), { recursive: true } );
fs.writeFileSync( path.join( sitePath, 'wp-content', 'database', '.ht.sqlite' ), 'local-db' );

fs.mkdirSync( stateDirectory, { recursive: true } );
fs.writeFileSync(
path.join( stateDirectory, '.import-state.json' ),
JSON.stringify( {
preflight: {
data: {
database: { wp: { paths_urls: { content_dir: '/srv/htdocs/wp-content' } } },
wp_detect: { roots: [ { path: '/wordpress/core/7.0' } ] },
},
},
} )
);

const migrationClientMod = await import( 'cli/lib/pull/migration-client' );
vi.spyOn( migrationClientMod, 'runReprintCommandUntilComplete' ).mockImplementation(
async ( _stateDir, _rawDir, args ) => {
if ( args[ 0 ] === failingStep ) {
throw new Error( `stop at ${ failingStep }` );
}
if ( args[ 0 ] === 'preflight' ) {
return {
stdout: JSON.stringify( {
data: {
ok: true,
database: { wp: { siteurl: 'https://example.com', table_prefix: 'wp_' } },
php: { version: '8.3' },
},
} ),
stderr: '',
exitCode: 0,
};
}
if ( args[ 0 ] === 'import-metadata' ) {
return {
stdout: JSON.stringify( {
...emptyReprintMetadata,
sourceSite: {
...emptyReprintMetadata.sourceSite,
contentDirectory: '/srv/htdocs/wp-content',
wordpressRoots: [ '/wordpress/core/7.0', '/wordpress/core' ],
},
} ),
stderr: '',
exitCode: 0,
};
}
return { stdout: '{"ok":true}', stderr: '', exitCode: 0 };
}
);
vi.spyOn( console, 'log' ).mockImplementation( () => undefined );
vi.spyOn( console, 'error' ).mockImplementation( () => undefined );

await expect( runCommand( sitePath, 'https://example.com', false ) ).rejects.toThrow(
new RegExp( `stop at ${ failingStep }` )
);

return readSeededCliConfig( fakeHome ).sites.find( ( s ) => s.id === 'fresh-id' )!;
}

it( 'leaves a site untouched by a failed download as `ready`, not `pull-failed`', async () => {
// A dropped connection during pull-files is the likeliest failure,
// and the site is still the intact `studio create` install.
expect( ( await runPullFailingAt( 'pull-files' ) ).status ).toBe( 'ready' );
} );

it( 'still records the origin so the failed run can be resumed without --remote-site', async () => {
expect( ( await runPullFailingAt( 'pull-files' ) ).reprintOrigin ).toMatchObject( {
remoteUrl: 'https://example.com/',
} );
} );

it( 'marks `pull-failed` once the flatten has started rewriting the site', async () => {
expect( ( await runPullFailingAt( 'flat-docroot' ) ).status ).toBe( 'pull-failed' );
} );
} );
} );

describe( 'CLI: studio pull-reprint admin credentials re-apply', () => {
Expand Down
4 changes: 1 addition & 3 deletions apps/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,7 @@ async function main() {

registerPushCommand( studioArgv );
registerPullCommand( studioArgv );
if ( process.env.STUDIO_ENABLE_PULL_REPRINT ) {
registerPullReprintCommand( studioArgv );
}
registerPullReprintCommand( studioArgv );

registerImportCommand( studioArgv );
registerExportCommand( studioArgv );
Expand Down
37 changes: 37 additions & 0 deletions apps/cli/lib/pull/migration-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { snapshotFraction } from './migration-client';

describe( 'snapshotFraction', () => {
it( 'derives the fraction from file counters', () => {
expect( snapshotFraction( { downloadedFiles: 42, totalFiles: 84 } ) ).toBe( 0.5 );
} );

it( 'falls back to bytes when file totals are absent', () => {
expect( snapshotFraction( { downloadedBytes: 25, totalBytes: 100 } ) ).toBe( 0.25 );
} );

it( 'falls back to statements while the database is being applied', () => {
expect( snapshotFraction( { statementsExecuted: 3, statementsTotal: 4 } ) ).toBe( 0.75 );
} );

it( 'prefers files over bytes when reprint reports both', () => {
expect(
snapshotFraction( {
downloadedFiles: 1,
totalFiles: 10,
downloadedBytes: 90,
totalBytes: 100,
} )
).toBe( 0.1 );
} );

it( 'returns undefined when nothing is countable, so the bar holds its band start', () => {
expect( snapshotFraction( {} ) ).toBeUndefined();
expect( snapshotFraction( { phase: 'streaming' } ) ).toBeUndefined();
expect( snapshotFraction( { downloadedFiles: 5 } ) ).toBeUndefined();
} );

it( 'ignores a zero total rather than dividing by it', () => {
expect( snapshotFraction( { downloadedFiles: 0, totalFiles: 0 } ) ).toBeUndefined();
} );
} );
26 changes: 22 additions & 4 deletions apps/cli/lib/pull/migration-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function runReprintCommandUntilComplete(
stateDir: string,
fsRoot: string,
args: string[],
onProgress?: ( output: string ) => void,
onProgress?: ( output: string, fraction?: number ) => void,
options: {
mounts?: Array< { hostPath: string; vfsPath: string } >;
progressLabel?: string;
Expand Down Expand Up @@ -362,7 +362,7 @@ interface ProgressReporter {
function createProgressReporter(
label: string,
startTime: number,
onProgress?: ( output: string ) => void
onProgress?: ( output: string, fraction?: number ) => void
): ProgressReporter {
let lineBuffer = '';
let snapshot: ProgressSnapshot = {};
Expand All @@ -379,7 +379,7 @@ function createProgressReporter(
snapshot = updateSnapshot( parsed, snapshot );
const msg = formatSnapshot( snapshot, label, elapsedSeconds() );
if ( msg ) {
onProgress( msg );
onProgress( msg, snapshotFraction( snapshot ) );
}
}
};
Expand All @@ -391,7 +391,7 @@ function createProgressReporter(
setInterval( () => {
const msg = formatSnapshot( snapshot, label, elapsedSeconds() );
if ( msg ) {
onProgress( msg );
onProgress( msg, snapshotFraction( snapshot ) );
}
}, 250 );

Expand Down Expand Up @@ -512,6 +512,24 @@ function updateSnapshot(
return next;
}

/**
* How far through the current reprint sub-command we are, as 0–1, or
* undefined when nothing in the snapshot is countable. Files are preferred:
* a step that moves files reports totals reliably, whereas byte totals are
* absent on some phases and statement counts only appear while the database
* is being applied. Callers map this onto an overall percentage.
*/
export function snapshotFraction( snapshot: ProgressSnapshot ): number | undefined {
const ratio = ( done?: number, total?: number ) =>
done !== undefined && total !== undefined && total > 0 ? done / total : undefined;

return (
ratio( snapshot.downloadedFiles, snapshot.totalFiles ) ??
ratio( snapshot.downloadedBytes, snapshot.totalBytes ) ??
ratio( snapshot.statementsExecuted, snapshot.statementsTotal )
);
}

/**
* Formats the current progress snapshot into a single-line status string
* like "Downloading files · 42/1337 files · 12.3 MB · 3m 12s".
Expand Down
57 changes: 57 additions & 0 deletions apps/cli/lib/pull/pull-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { overallPercent, PullStep, withPercent } from './pull-progress';

describe( 'overallPercent', () => {
it( 'parks a step with no measurable progress at the start of its band', () => {
expect( overallPercent( PullStep.FILES ) ).toBe( 5 );
expect( overallPercent( PullStep.DATABASE ) ).toBe( 60 );
} );

it( 'interpolates a step fraction within that band', () => {
// Files span 5–60, so halfway through the files step is 33 overall.
expect( overallPercent( PullStep.FILES, 0.5 ) ).toBe( 33 );
expect( overallPercent( PullStep.FILES, 1 ) ).toBe( 60 );
} );

it( 'reaches exactly 100 only at the end of the final step', () => {
expect( overallPercent( PullStep.START, 1 ) ).toBe( 100 );
expect( overallPercent( PullStep.RUNTIME, 1 ) ).toBeLessThan( 100 );
} );

it( 'clamps a fraction that overshoots or goes negative so the bar cannot leave its band', () => {
// Reprint restarts partial transfers, so counters can regress or exceed
// their total mid-stream; neither may move the bar out of the band.
expect( overallPercent( PullStep.FILES, 5 ) ).toBe( 60 );
expect( overallPercent( PullStep.FILES, -2 ) ).toBe( 5 );
} );

it( 'never moves backwards across steps run in order', () => {
const order = [
PullStep.SETUP,
PullStep.PREFLIGHT,
PullStep.FILES,
PullStep.DATABASE,
PullStep.MERGE,
PullStep.FLATTEN,
PullStep.RUNTIME,
PullStep.LINK,
PullStep.START,
];
const starts = order.map( ( step ) => overallPercent( step ) );
expect( starts ).toEqual( [ ...starts ].sort( ( a, b ) => a - b ) );

// A step's completion never exceeds the next step's start, so skipping a
// step (e.g. --skip-database) jumps forward rather than stalling.
order.slice( 0, -1 ).forEach( ( step, index ) => {
expect( overallPercent( step, 1 ) ).toBeLessThanOrEqual( starts[ index + 1 ] );
} );
} );
} );

describe( 'withPercent', () => {
it( 'appends the token pullSite parses out of the message', () => {
expect( withPercent( 'Pulling files · 42/1337 files', 30 ) ).toBe(
'Pulling files · 42/1337 files (30%)'
);
} );
} );
Loading