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
156 changes: 154 additions & 2 deletions apps/cli/commands/pull-reprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ import {
type ReprintProcessResult,
runReprintCommandUntilComplete,
} from 'cli/lib/pull/migration-client';
import {
fetchReprintPullTree,
mapCliOnlyToReprint,
selectPullItems,
} from 'cli/lib/pull/reprint-selector';
import {
getContentDirFromState,
getReprintStatePath,
hasSkippedFiles,
readReprintState,
resetEssentialFilesState,
} from 'cli/lib/pull/reprint-state';
import {
ensureImportedSiteSqliteReady,
Expand Down Expand Up @@ -107,6 +113,23 @@ export const registerCommand = ( yargs: StudioArgv ) => {
describe: __( 'Skip the confirmation prompt and create the site without asking' ),
default: false,
} )
.option( 'only', {
type: 'string',
array: true,
describe: __(
'Restrict the pull to specific wp-content folders (e.g. plugins/akismet, themes, uploads); repeatable. Best on a site that already exists locally.'
),
} )
.option( 'skip-database', {
type: 'boolean',
describe: __( 'Do not pull the database (keeps the local one on an existing site)' ),
default: false,
} )
.option( 'skip-uploads', {
type: 'boolean',
describe: __( 'Do not pull the media library (uploads)' ),
default: false,
} )
.option( 'verbose', {
type: 'boolean',
describe: __( 'Show detailed error information and executed commands' ),
Expand All @@ -123,7 +146,12 @@ export const registerCommand = ( yargs: StudioArgv ) => {
argv.name as string | undefined,
verbose,
argv.abort as boolean,
argv.yes as boolean
argv.yes as boolean,
{
only: argv.only as string[] | undefined,
skipDatabase: argv[ 'skip-database' ] as boolean,
skipUploads: argv[ 'skip-uploads' ] as boolean,
}
);
} catch ( error ) {
if ( error instanceof PullError ) {
Expand Down Expand Up @@ -212,6 +240,19 @@ interface PullSessionMetadata {
remoteSiteUrl?: string;
tablePrefix?: string;
secret?: string;
/**
* Selective-sync choices (from the interactive selector or `--only`/`--skip-*`
* flags). Set together once and reused on every resume; cleared on a delta
* re-pull so the user is asked again. `selectionMade` gates the prompt.
*/
/** True once the selection step has run (so resumes don't re-prompt). */
selectionMade?: boolean;
/** True when the database should be skipped → pass `--no-db`. */
skipDatabase?: boolean;
/** True when the media library should be skipped → skip the deferred-uploads pass. */
skipUploads?: boolean;
/** reprint `--only` source values for an incremental folder-restricted pull. */
fileOnlyPaths?: string[];
}

/**
Expand Down Expand Up @@ -263,13 +304,23 @@ class PullError extends LoggerError {
* database is fully re-downloaded and re-applied (the dump is
* idempotent, so edits, inserts, and deletes all propagate).
*/
interface CliSelectionOptions {
/** Raw `--only` values (wp-content-relative paths or reprint tokens). */
only?: string[];
/** `--skip-database` flag. */
skipDatabase?: boolean;
/** `--skip-uploads` flag. */
skipUploads?: boolean;
}

export async function runCommand(
userProvidedUrl?: string,
userProvidedSecret?: string,
userProvidedName?: string,
verbose = false,
abort = false,
yes = false
yes = false,
cliSelection: CliSelectionOptions = {}
): Promise< void > {
if ( abort ) {
if ( ! userProvidedUrl ) {
Expand Down Expand Up @@ -309,6 +360,11 @@ export async function runCommand(
if ( isRepull ) {
studioMetadata.stage = 'initialized';
studioMetadata.hasCompletedOnce = true;
// Clear the prior selection so a delta re-pull prompts again.
studioMetadata.selectionMade = undefined;
studioMetadata.skipDatabase = undefined;
studioMetadata.skipUploads = undefined;
studioMetadata.fileOnlyPaths = undefined;
savePullMetadata( studioMetadata );

// Re-verify connectivity (and give the secret-rotation retry path
Expand Down Expand Up @@ -468,6 +524,21 @@ export async function runCommand(
// local one the Studio server will serve.
await ensurePort( studioMetadata );

// Selective sync: apply `--only`/`--skip-*` flags, or prompt interactively
// with the full wp-content folder tree + database toggle. Skipped once
// chosen or after the download has happened (resumes reuse the choice).
const proceed = await applySelection( {
metadata: studioMetadata,
yes,
cli: cliSelection,
apiUrl,
secret,
verbose,
} );
if ( ! proceed ) {
return;
}

// A single `reprint pull` runs the whole pipeline in one PHP-WASM
// fork: files-pull → db-pull → db-apply → flat-docroot →
// apply-runtime. reprint owns the stage ordering internally and, on
Expand Down Expand Up @@ -595,6 +666,8 @@ export async function runCommand(
}

if ( ! hasPullCompletedStage( studioMetadata, 'completed' ) ) {
// Fetch the deferred media/uploads. The selector's media choice is not
// applied yet (see runFullPull) — wired into pull-files in the follow-up.
if ( hasSkippedFiles( studioMetadata.stateDirectory ) ) {
await downloadSkippedFiles(
getSiteRuntime( site ),
Expand Down Expand Up @@ -630,6 +703,82 @@ export async function runCommand(
}
}

/**
* Resolve the selective-sync choice and record it on the metadata. Returns
* `true` to continue the pull or `false` to abort (interactive cancel).
*
* Order of precedence:
* 1. Already chosen / past the download → reuse the persisted choice.
* 2. `--only`/`--skip-*` flags → apply non-interactively (works with `--yes`).
* 3. Non-interactive with no flags → pull everything.
* 4. Interactive → the full wp-content folder tree + database toggle.
*
* The selector always shows the whole tree: `pull-reprint` operates on a site
* that already exists locally, so a `--only` selection is always safe (core and
* the flattened layout are already on disk).
*/
async function applySelection( params: {
metadata: PullSessionMetadata;
yes: boolean;
cli: CliSelectionOptions;
apiUrl: string;
secret: string;
verbose: boolean;
} ): Promise< boolean > {
const { metadata, yes, cli, apiUrl, secret, verbose } = params;

if ( hasPullCompletedStage( metadata, 'pulled' ) || metadata.selectionMade ) {
return true;
}

const cliOnly = cli.only?.filter( ( value ) => value.trim().length > 0 ) ?? [];
const cliDriven = cliOnly.length > 0 || cli.skipDatabase || cli.skipUploads;

if ( cliDriven ) {
if ( cliOnly.length > 0 ) {
const contentDir = getContentDirFromState( metadata.stateDirectory ) ?? '';
metadata.fileOnlyPaths = mapCliOnlyToReprint( cliOnly, contentDir );
}
metadata.skipDatabase = !! cli.skipDatabase;
metadata.skipUploads = !! cli.skipUploads;
metadata.selectionMade = true;
savePullMetadata( metadata );
return true;
}

if ( ! process.stdin.isTTY || yes ) {
return true; // non-interactive, no flags → full pull
}

// Interactive: the full wp-content folder tree + database toggle.
const { tree, contentDir } = await fetchReprintPullTree( {
stateDirectory: metadata.stateDirectory,
rawDirectory: metadata.rawDirectory,
apiUrl,
secret,
runtime: SITE_RUNTIME_NATIVE_PHP,
verbose,
} );
if ( tree.length === 0 || ! contentDir ) {
metadata.selectionMade = true;
savePullMetadata( metadata );
return true;
}
const selection = await selectPullItems( tree, contentDir );
if ( ! selection ) {
console.log( __( 'Cancelled.' ) );
return false;
}
metadata.fileOnlyPaths = selection.fileOnlyPaths;
metadata.skipDatabase = selection.skipDatabase;
metadata.selectionMade = true;
savePullMetadata( metadata );
// `files-index` left its own remote index + state behind; clear it (keep
// preflight) so the pull rebuilds the index cleanly.
resetEssentialFilesState( metadata.stateDirectory );
return true;
}

/**
* Runs `reprint preflight` against the remote site and caches the
* response envelope at `stateDirectory/preflight.json`.
Expand Down Expand Up @@ -899,6 +1048,9 @@ export async function runFullPull(
'--no-adaptive',
`--state-dir=${ metadata.stateDirectory }`,
`--fs-root=${ metadata.rawDirectory }`,
// NOTE: the interactive selector / `--only` / `--skip-*` choices are
// captured in metadata but NOT applied yet — this is a full pull. The
// selection is wired into `pull-files`/`pull-db` in the follow-up PR.
],
( progress ) => logger.reportProgress( progress ),
{
Expand Down
48 changes: 48 additions & 0 deletions apps/cli/commands/tests/pull-reprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,54 @@ describe( 'CLI: studio pull-reprint single pull phase', () => {
vi.restoreAllMocks();
} );

it( 'does not apply the selection yet — no --no-db/--only even when set in metadata (inert menu)', async () => {
const technicalSiteDirectory = fs.mkdtempSync(
path.join( os.tmpdir(), 'studio-import-pull-inert-' )
);
const stateDirectory = path.join( technicalSiteDirectory, 'state' );
const rawDirectory = path.join( technicalSiteDirectory, 'raw' );
fs.mkdirSync( stateDirectory, { recursive: true } );
fs.mkdirSync( rawDirectory, { recursive: true } );
fs.writeFileSync(
path.join( stateDirectory, '.import-state.json' ),
JSON.stringify( { preflight: { data: {} } } )
);

const reprint = vi
.spyOn( migrationClient, 'runReprintCommandUntilComplete' )
.mockResolvedValue( { stdout: '{"ok":true}', stderr: '', exitCode: 0 } );

await runFullPull(
SITE_RUNTIME_PLAYGROUND,
{
version: 1,
normalizedUrl: 'https://example.com/',
siteName: 'example',
sitePath: path.join( technicalSiteDirectory, 'site' ),
technicalSiteDirectory,
rawDirectory,
stateDirectory,
runtimeDirectory: path.join( technicalSiteDirectory, 'runtime' ),
runtimeBlueprintPath: path.join( technicalSiteDirectory, 'runtime', 'blueprint.json' ),
stage: 'initialized',
localUrl: 'http://localhost:8881',
// Selection captured in metadata, but the pull must ignore it for now.
skipDatabase: true,
skipUploads: true,
fileOnlyPaths: [ ':wp-plugins:', '/srv/htdocs/wp-content/plugins/akismet' ],
} as never,
'https://example.com/?reprint-api',
'hmac-secret',
false
);

const passedArgs = reprint.mock.calls[ 0 ][ 2 ] as string[];
expect( passedArgs ).not.toContain( '--no-db' );
expect( passedArgs.some( ( a ) => a.startsWith( '--only' ) ) ).toBe( false );

fs.rmSync( technicalSiteDirectory, { recursive: true, force: true } );
} );

it( 'runs one reprint pull with sqlite under the content dir, mounts the site + runtime, and advances the stage', async () => {
const technicalSiteDirectory = fs.mkdtempSync(
path.join( os.tmpdir(), 'studio-import-pull-' )
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/lib/pull/migration-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export async function runReprintCommandUntilComplete(
const tmpDir = path.join( path.dirname( stateDir ), 'tmp' );
fs.mkdirSync( tmpDir, { recursive: true } );

// Log the exact reprint command (at `<pull-dir>/reprint-commands.log`, next
// to the state dir) so it can be copied and re-run for debugging.
// Best-effort — never block a pull on logging.
try {
fs.appendFileSync(
path.join( path.dirname( stateDir ), 'reprint-commands.log' ),
`php reprint.phar ${ args.join( ' ' ) }\n`
);
} catch {
// ignore logging failures
}

// The native runtime spawns the bundled `php` binary, so make sure it's
// downloaded before the first invocation. reprint.phar is PHP-version
// agnostic, so any supported native version works.
Expand Down
Loading