Clone any WordPress site over HTTP. One command pulls the files, database, and server config, then starts a local copy you can open in your browser.
php reprint.phar install-serverThis prints the download URL and step-by-step instructions for installing the WordPress plugin on the site you want to clone. The plugin exposes the HTTP API that reprint connects to.
MySQL 5.5 sources are supported. Source row filters and resume queries use UNHEX()
on these servers and keep FROM_BASE64() on newer MySQL. The downloaded SQL still
uses base64 for destination imports and URL rewriting; this does not add MySQL 5.5
destination support. Update the source site's Reprint Server plugin to get this
compatibility change.
php reprint.phar pull https://example.com \
--secret=YOUR_SECRET \
--state-dir=./state --fs-root=./filesThat's it. Reprint will:
- Preflight the remote site (check connectivity, detect WordPress version and hosting environment)
- Download all files (themes, plugins, uploads, core) into
--fs-root - Download the database as a SQL dump
- Generate server config for PHP's built-in server
- Start the local server and print the URL
The output looks like:
Pulling example.com
[1/7] Preflight
✓ Preflight — WordPress 6.9.4, PHP 8.4.19
[2/7] Pulling files
[5091 files] ...wp-content/uploads/2024/photo.jpg
✓ Pulling files
[3/7] Pulling database
Downloading SQL: 4.2 MB (67.3%)
✓ Pulling database
[4/7] Importing database
db-apply: 1234 / 5678 statements (45.2%)
✓ Importing database
[5/7] Generating runtime
✓ Generating runtime
✓ Pull complete
Starting the server at http://localhost:8881
Press Ctrl-C to stop.
Database import — add target database options and reprint will also import the SQL with URL rewriting:
# MySQL
php reprint.phar pull https://example.com --secret=TOKEN \
--state-dir=./state --fs-root=./files \
--target-user=root --target-db=wp_local \
--new-site-url=http://localhost:8881
# SQLite (no MySQL needed)
php reprint.phar pull https://example.com --secret=TOKEN \
--state-dir=./state --fs-root=./files \
--target-engine=sqlite \
--new-site-url=http://localhost:8881Runtime — defaults to php-builtin (starts a server at the end). Override with --runtime=nginx-fpm or --runtime=playground-cli for other environments.
Resume — if interrupted, re-run the same command. It picks up where it left off. Running pull again after completion performs a delta sync. By default, the delta catches up with remote changes; mirror mode is available when the selected local paths must match the remote site.
All options — run php reprint.phar pull --help for the full list.
A source site at D:\Sites\example.test can be pulled with the same command
on Linux. Update both the source plugin and the client to include Windows path
support. Drive-letter paths accept backslashes, forward slashes, mixed
separators, and repeated separators. Network shares use \\server\share\site.
The default layout under --fs-root keeps different drives and shares separate:
| Source path | Path under --fs-root |
|---|---|
D:\Sites\example.test\index.php |
D:/Sites/example.test/index.php |
c:/Sites\example.test/index.php |
C:/Sites/example.test/index.php |
\\server\share\site\index.php |
UNC/SERVER/SHARE/site/index.php |
Add --flatten-to=/var/www/site to place WordPress directly in that directory.
Unix filename bytes, including literal backslashes, are unchanged. A Unix
symlink target named D:\photos is a relative name, not a Windows drive path.
Preflight reports path_format: "unix" or path_format: "windows". The client
saves that field and passes it to path conversion and validation. It never
selects the format from a path prefix. Thus //server/share/photos is a Unix
path in Unix mode and a network share in Windows mode. Servers without the
field retain the earlier Unix-only contract; an invalid field is rejected.
A relative link target also needs the source link's directory. With
symlink following enabled, a copied gallery -> D:\photos link is rewritten
when --remap moves that target. Selecting a link also retains intermediate
links needed to reach its downloaded content.
File selections use the same rules on Unix and Windows: supply a full absolute
path or a WordPress token such as :wp-content:/uploads. Tokens expand from
preflight data on the client; preparing selections makes no extra request.
Relative selections such as .\site, \site and D:site are rejected, as are
. and .. components. Namespace prefixes such as \\?\ and \\.\ are also
rejected as selection inputs. Use the ordinary drive-letter or UNC spelling
for the same files. These restrictions apply to selections, not stored link
targets, which are interpreted relative to the source link.
All source reads use PHP's file functions. Reprint does not use FFI or run an
external program to read Windows files. PHP's open_basedir restriction still
applies. Long UNC paths use a PHP-supported namespace spelling at the file I/O
call; that spelling does not enter the index or reach the Linux client. The
Windows CI jobs run with the FFI extension absent.
PHP cannot safely read every name that NTFS permits. For example, with two
separate files named report and report., PHP can return report's metadata
when asked for report.. Reprint rejects a file or directory component ending
in a dot or space before calling PHP, including names found while walking a
parent directory. Rename these entries on the source before migration. The
pull fails again on resume; it does not skip the unreadable entry.
Volume GUID and GLOBALROOT selections require a drive-letter or UNC spelling.
Some Windows links can be followed by PHP but cannot be read by readlink();
these stop the pull rather than becoming empty links. Recreate such links with
a backslash target. If PHP cannot resolve the link, use a full drive-letter
target instead.
There are limits that a migration cannot hide:
- A Linux filesystem may reject a name that Windows accepts. The ext4 target in
CI allows 255 bytes per filename or directory component. For example, 126
copies of
éfollowed by.txtoccupy 256 UTF-8 bytes. Shorten such names on the source; Reprint reports the filesystem error rather than renaming them. - Windows normally resolves
HELLO.TXTtohello.txt; Linux does not. Reprint preserves the actual filename case. Correct wrong-case references in site code or URLs. Use the source spelling for--excludeand--remap; those rules compare path text and do not correct filename case or resolve alternate names. This limitation is tracked in #816. - Physical devices and named pipes are not migration files and are rejected. A UNC path must name both a server and a share.
- NTFS alternate data streams are not migrated. Only each file's main contents are copied; no stream sidecar files are created.
These distinctions follow the Windows path rules and filesystem name limits. The CI workflow runs the same full WordPress migration from a drive and a network share, with native Windows PHP/MySQL and a Linux client under WSL2. It checks hashes, empty directories, database table row counts, URL rewriting, and both raw and flattened runtimes. Separate path pulls cover punctuation, Unicode, long names, drive and share spellings, case-sensitive siblings, and explicit failures for relative selections, namespace inputs, unreadable names, and filesystem limits.
The server and client are published as separate Composer packages:
wp-php-toolkit/reprint-server— Streaming export engine (SQL dumps, file trees, cursor-based resumption) that backs the Reprint Server plugin.wp-php-toolkit/reprint-client— Streaming site importer with CLI and PHAR support.
Install whichever you need:
composer require wp-php-toolkit/reprint-server
composer require wp-php-toolkit/reprint-clientThese packages were previously published as wp-php-toolkit/reprint-exporter
and wp-php-toolkit/reprint-importer. Each renamed package replaces its
predecessor at self.version, so the old and new names cannot be installed
side by side; consumers migrate by changing the requirement name.
The client package depends on
wp-php-toolkit/data-liberation
and wp-php-toolkit/html,
which Composer pulls in automatically. The server package has no package
dependencies.
packages/reprint-server— Source for thewp-php-toolkit/reprint-serverComposer package (the HTTP endpoint on the WordPress host).packages/reprint-client— Source for thewp-php-toolkit/reprint-clientComposer package (the CLI).packages/reprint-client/bin/reprint-clientis the entry point for repo checkouts; Composer installs it asvendor/bin/reprint-client.reprint-server-wp— WordPress plugin distribution that bundlesreprint-server. The release ZIP keeps the legacyreprint-exporter-wp.zipname so upgrades land in the existing installed plugin directory.tests— PHPUnit suite (tests/), Docker-based e2e scenarios (tests/e2e/), and PHPStan support files (tests/phpstan/).docs— design choices, focused design documents, and project logos.bin— build tooling (PHAR build, plugin version stamping).lib— thesqlite-database-integrationgit submodule used by the MySQL query parser.
On the migration source side:
- The release WordPress exporter plugin supports pull endpoints on PHP 5.6.20+; push endpoints require PHP 7.2+
- The typed
wp-php-toolkit/reprint-serverComposer package requires PHP 7.2+ - ext-json — JSON encoding/decoding
- ext-hash — hash_hmac, hash_equals
- ext-zlib (optional) — PHP 7.0+ uses it for gzip streaming; otherwise the exporter streams the multipart response without compression
- ext-pdo + ext-pdo_mysql — recommended for MySQL exports; the plugin falls back to WordPress's wpdb layer when they are unavailable
Hosts exposing the push endpoints must set display_errors=Off at PHP
startup, through php.ini, .user.ini, or the PHP-FPM pool configuration.
log_errors=On is recommended so startup failures remain available to
operators. PHP and the web server can reject an oversized request before the
exporter runs, so those responses are not guaranteed to use the push JSON
protocol. The sender accepts a bare HTTP 413 and learns a smaller request
ceiling from it.
On the migration target side:
- PHP 7.4+
- ext-json — JSON encoding/decoding
- ext-hash — hash_hmac, hash_equals
- ext-zlib — deflate_init/deflate_add for gzip streaming
- ext-mysqli — for MySQL targets
- ext-pdo + ext-pdo_sqlite — for SQLite targets via sqlite-database-integration
This repo uses WordPress Coding Standards for PHP. The ruleset lives in
phpcs.xml.dist; run composer lint:php for the WPCS audit and
composer lint:php:fix for the available PHPCS auto-fixes.
The ruleset has temporary exclusions for existing standards debt so cleanup can happen in focused passes instead of one giant formatting change. New or touched code should follow WPCS unless the ruleset explicitly excludes that sniff.
Run composer lint:php:compat to check the typed exporter source on PHP 7.2+
and the importer on PHP 7.4+. CI builds the exporter plugin ZIP and checks every
PHP file in that generated artifact with PHP 5.6.
To cut a release, run the Tag Release workflow from the GitHub Actions tab
(.github/workflows/tag-release.yml) and pick a bump type (patch, minor,
major, or custom). It creates and pushes the vMAJOR.MINOR.PATCH tag, then
bumps trunk to the next -dev version. Pushing the tag automatically triggers
the build, which publishes the GitHub Release (reprint.phar +
reprint-exporter-wp.zip) and the Composer packages to Packagist.
The pull command is designed for developers cloning a site to their local machine. If you're building a hosting platform that migrates sites programmatically, you'll want the low-level commands instead — they give you full control over each step, exit codes for scripting, and structured JSON output for progress tracking.
Download the latest release artifacts from GitHub Releases:
reprint.phar— a self-contained PHP archive that runs on the migration target (the hosting account you are migrating to). No cloning orcomposer installneeded.reprint-exporter-wp.zip— install this on the migration source (the remote WordPress site you want to migrate).
Both must share the same secret string. The plugin has a UI screen where the user can paste the secret, and then
the importer must be fed the same secret string (more details below). Alternatively, the plugin
can be pre-packaged with a ./reprint-exporter-wp/secret.php file where a pre-determined secret is shipped:
<?php
return 'MY_SECRET_STRING';The migration process has a few steps:
- Preflight
- Download the files
- Download the database dump
- Download the files delta
All commands below use the same base invocation. We'll use $URL and $DIR as shorthand:
URL="https://example.com/?reprint-api"
STATE_DIR="./local-directory-where-the-migration-state-will-be-tracked"
FS_ROOT="./local-directory-where-the-remote-site-files-will-be-recreated"
SECRET="your-shared-secret"First, we'll make sure the server is reachable and the environment is in a good shape:
php reprint.phar preflight "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"The preflight contacts the export server and collects environment details: PHP/MySQL versions, memory limits, filesystem access, database connectivity, WordPress version, plugins, themes, and directory layout. The result is stored in $STATE_DIR/remotes/<md5-of-trimmed-remote-reprint-api-url>/pull/state.json under the preflight key.
Reprint rejects HTTP remote Reprint API URLs before selecting remote state or making requests unless --insecure explicitly allows an unencrypted connection. This applies to every remote command, including local-only commands and local hosts. The supplied URL's scheme is never changed.
For a site with an expired, self-signed, or wrong-hostname HTTPS certificate, --insecure also skips certificate and hostname verification. HTTPS stays encrypted, but Reprint cannot verify the server's identity; an attacker on the connection can read or modify transferred content. This does not repair other TLS handshake failures. REPRINT_INSECURE_TLS=1 is an alternative to the flag; unset it or set it to 0 to keep the default checks. The choice is not saved in migration state, so supply it again when resuming.
To resume state created over HTTP by an older client, keep using the HTTP URL with --insecure.
The command's JSON result keeps the response data and HTTP details, and includes
status, error, error_code, and message. On failure, status is error,
error contains the failure detail, and message is the same detail prefixed
with Error: . HTTP failures use the same codes as downloads, such as
SERVER_ERROR, AUTH_FAILED, and REDIRECT. Connection failures use
CURL_ERROR; malformed JSON uses INVALID_JSON; a response without a preflight
object uses INVALID_PREFLIGHT_RESPONSE. Failed preflight checks use
PREFLIGHT_FAILED. On success, status is complete, and both error fields
are null. Failures exit with code 1.
progress.json receives the same error and error_code as the command's
JSON result, rather than a generic "Preflight failed" message.
Some hosts, including Hostinger, provide preview domains so you can browse a site before its real domain points at the host. They replace the real domain in outgoing pages so links and assets stay on the preview while the stored WordPress home URL stays unchanged. Hostinger also applies this rewriting to JSON responses.
The server sends a base64 copy of the WordPress home domain to detect this.
For a home URL of https://example.com:8443/blog, the domain is example.com,
without the scheme, port, or path. If the decoded copy differs from the domain
in the plain home URL, preflight fails and reports both domains. Low-level pull
commands also reject that mismatch in saved preflight data.
Older servers that omit the copy, or report null because no domain was available,
remain compatible. The comparison checks the home domain, not every URL in the response.
All other commands check that a preflight has been completed and refuse to start without one.
To run very basic diagnostics that confirms the remote server replied and it has a sound-looking filesystem and a database connection, run:
php reprint.phar preflight-assert "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"preflight-assert reads the saved report without making another preflight
request. Its JSONL result includes the same error fields alongside the existing
checks list. Request failures retain their saved detail and code. Assertion
failures report the failed check details with PREFLIGHT_FAILED. If no report
has been saved, it reports PREFLIGHT_REQUIRED and asks you to run preflight.
The terminal summary and progress.json also include the failure detail.
The preflight stage inside pull, pull-files, and pull-db uses the same
failure details and codes. Download errors use the same four fields; their
message may add the failed stage, while error and progress.json retain
the failure detail. cURL failures use CURL_ERROR for both preflight and downloads.
For hosting platform-specific checks, such as database version compatibility or php version compatibility, you might need your own custom logic. See the State and progress files section for more details.
This first builds a full index of the remote directory tree, then streams every file. It can be interrupted and resumed at any time — just re-run the same command:
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"The command returns one of four exit codes:
- 0: sync completed
- 1: failure
- 2: partial completion, needs re-running
- 3: temporary transfer failure, retry the same command later
Run again immediately after exit 2. Reprint retries temporary streaming
failures itself. It stops with exit 3 only after three consecutive failed
requests do not advance the durable cursor. A successful request or a failed
request that advances the cursor resets that count.
After exit 3, a caller may wait and run the same command later with the same
state directory and filesystem root. Reprint keeps its saved progress and gives
the later run a new internal retry allowance. Scheduling that later run is
optional; without it, a person can run the command again. Exit 1 requires
checking the error instead of scheduling an automatic retry.
JSON error records on stdout and stderr for exit 3 include
consecutive_failures_without_progress, which is 3 when the internal limit
is reached. The records retain error and error_code, and include the
reported exception class, http_code when received, and a nonzero
curl_errno when available. For example, repeated HTTP 520 responses without
transfer progress report:
{
"status": "error",
"error": "The remote request failed 3 consecutive times without cursor progress during file_index. Try the command again later. Last failure: The remote server crashed (HTTP 520).\n\nThis is a problem on the remote server. Check its PHP error log for details.",
"error_code": "SERVER_ERROR",
"message": "Error: The remote request failed 3 consecutive times without cursor progress during file_index. Try the command again later. Last failure: The remote server crashed (HTTP 520).\n\nThis is a problem on the remote server. Check its PHP error log for details.",
"exception": "Reprint\\Importer\\RetryLaterException",
"http_code": 520,
"consecutive_failures_without_progress": 3
}This applies to temporary streaming failures in file and database transfers,
including those reached through pull, pull-files, and pull-db:
- HTTP
400,408,413,418,421,425,429,500,502,503,504, and520–524without an explicit Reprint error. - Unmarked HTTP
401or403after a signed request. - cURL timeouts, connection resets during transfer, empty or cut-short responses, invalid compressed responses, and HTTP/2 or HTTP/3 stream errors.
- Multipart responses missing their boundary or completion marker.
Explicit Reprint errors (JSON containing a matching HTTP code) remain fatal.
Preflight failures, DNS lookup failures, refused connections, certificate errors,
and local errors keep their existing classification. Reprint does not choose a
wait time or schedule later runs.
File pull modes
files-pull, pull-files, and pull use --mode=catch-up by default.
Catch-up applies paths whose remote index entries changed since the previous
completed pull. A local edit remains when that remote path did not change, and
a path found only in the local tree remains in place.
Use mirror mode when paths inside today's pull selection must match today's remote index:
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--mode=mirrorFor example, suppose wp-content/themes/example/style.css still contains
blue remotely but contains red locally, and debug.log exists only
locally. Catch-up leaves both paths alone when the remote index did not change.
Mirror downloads the remote style.css and removes debug.log when both paths
are inside the current pull selection.
The normal path options define that selection. This includes --include,
--exclude, --filter, and --remap. Mirror does not change paths outside
the selection. --on-fs-root-nonempty=preserve-local
also keeps pre-existing local paths which were not recorded by the first pull.
File pulls always omit paths matched by the built-in default skip rules. These
rules cover known generated backup archives, cache, log, upgrade, and temporary
paths, plus version-control metadata, IDE and package-manager
caches, operating-system metadata, and editor scratch files. --include,
--exclude, --filter, and --remap cannot override these omissions.
node_modules directories are included by default because plugins and themes
may bundle runtime PHP, JavaScript, and CSS dependencies there. Use --exclude
to omit a specific directory when you know the site does not need it.
Host platform plugins
New pulls keep host platform plugins, MU plugins, and host cache drop-ins. Reprint does not infer whether the destination needs them or whether the local copy will later be pushed back to the source host.
To request Reprint's host-plugin cleanup, pass --exclude-host-plugins before
the pull starts:
php reprint.phar pull-files "$REMOTE_REPRINT_API_URL" --secret="$SECRET_KEY" \
--state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --exclude-host-pluginsCleanup skips listed host platform paths, including WP Engine and WP Cloud MU plugins, even when they are leftovers from an earlier host. Portable plugins stay: this includes Redis Cache, WP Rocket, SiteGround's Speed and Security Optimizers, and Force Strong Passwords. Generic cache drop-ins are excluded only when current preflight paths identify WP Cloud or WP Engine.
The pull choice is saved for that remote in the state directory. Later pulls
use the same selection, and db-apply deactivates excluded regular plugins.
--include-host-plugins selects preservation again. Existing state keeps its
saved choice; only new state defaults to preservation. Changing the choice
during an unfinished pull requires --abort first. Neither flag overrides
explicit --exclude paths or the generated-file skip rules above.
Local runtime cleanup is separate. apply-runtime removes the listed local
copies by default, including plugins that were downloaded by a preserving pull.
Portable plugins stay. Pass apply-runtime --include-host-plugins to leave the
local copies in place for that invocation. apply-runtime --exclude-host-plugins
explicitly requests the default cleanup. Neither flag changes the saved pull
selection, and the two flags cannot be combined.
The high-level pull command prepares a local runtime and uses this default
cleanup regardless of its download selection. Integrations such as Studio can
pull files unchanged, then call apply-runtime before starting WordPress.
Targets such as wp.com can keep the plugins and run their own cleanup without
calling apply-runtime. Preserved host plugins may prevent WordPress from
booting without the source host's services.
Runtime cleanup records its document-root-relative paths before deleting any
local copy. files-diff and files-push exclude those paths, so a later theme
push does not delete the source host's plugins. The record survives command
resets, database imports, and later runtime opt-outs: skipping a later cleanup
does not restore files already removed. Finish an interrupted files-push, or
finish or abort an interrupted files-pull, before applying runtime cleanup.
This protects the recorded file paths; it does not make other local runtime or
database changes suitable for production.
To fetch plugins skipped by a completed pull, start another pull-files with
--include-host-plugins, or abort the completed files-pull and run it again
with that flag. This cannot restore plugin activation removed by an earlier
import. Runtime setup itself does not connect to or edit the database.
Mirror mode requires --state-dir to be outside --fs-root, because the state
files must not appear in the local tree being compared. The selected mode is
saved when files-pull starts. To switch modes, first run the same command with
--abort, then start it again with the other --mode value.
Non-empty local fs-root
By default, files-pull refuses to start if --fs-root is non-empty. If you need to use a non-empty local fs-root,
the --on-fs-root-nonempty flag controls this behavior. It takes the following values:
--on-fs-root-nonempty=error(default): throw an error and abort.--on-fs-root-nonempty=preserve-local: import into the non-empty directory while preserving all existing local content.
Filtering files
The --filter flag controls which files are downloaded. This is useful when the media library is large
and you want to bring the site online before downloading all the uploads:
# Step 1: download only essential files (code, config, themes, plugins)
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--filter=essential-filesThe pipeline rewrites this preset to exclude the :wp-uploads: path prefix,
then proceeds through the normal index, diff, and fetch stages. When the
essential files are done, the sync marks itself complete. At this point you
can apply the database and bring the site online.
# Step 2: download the uploads
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--filter=skipped-earlierThe three filter values:
--filter=none(default): download all files.--filter=essential-files: skip uploads, download only code/config/themes/plugins.--filter=skipped-earlier: include only the:wp-uploads:path prefix. The name is retained for compatibility; a prioressential-filesrun is not required.
The uploads directory is detected from preflight data (uploads.basedir), falling back to
wp-content/uploads/ if unavailable.
The presets use the same repeatable path-prefix options available directly on
files-pull and pull-files:
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--include=:wp-content: --exclude=:wp-uploads:--include=SOURCE supplies an included source prefix and --exclude=SOURCE
supplies an excluded source prefix. Both accept WordPress path tokens or
absolute source paths, and exclusions win when the prefixes overlap. Switching
filters after a completed run starts a new filtered delta against the shared
remote index; there is no separate skipped-file list or fetch stage.
Symlinks are followed by default. With --no-follow-symlinks, a selected
symlink is copied as a link without indexing its target. A selected path reached
through a symlinked parent is rejected; use the default or
--follow-symlinks so Reprint can preserve the requested link path and index
the physical target.
pull-files runs the file side of the high-level pull pipeline:
preflightfiles-pull
Use it when you want a git pull-style file update without running any
database stages:
php reprint.phar pull-files "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"It accepts the pull file filters (--filter=none and
--filter=essential-files) plus the same path selection options as
files-pull, including repeated --include and --exclude values:
php reprint.phar pull-files "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--include=:wp-content: --exclude=:wp-uploads:pull-db runs the database side of the high-level pull pipeline:
preflightdb-pulldb-apply
Use it when you want the local database to catch up with the remote site without touching files or runtime configuration:
php reprint.phar pull-db "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--target-engine=sqliteIf a different pull-like command is already in progress, finish or abort that command first. This avoids reusing partially written state for a different pipeline.
By default, this streams a SQL dump into $STATE_DIR/db.sql:
php reprint.phar db-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"You can also pipe the SQL directly to stdout or stream it into a MySQL server
without writing a file to disk. Use --sql-output to choose the mode:
# Pipe to stdout — useful for feeding into mysql CLI or another tool
php reprint.phar db-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--sql-output=stdout | mysql -u root my_database
# Stream directly into MySQL — no intermediate file, no pipe
php reprint.phar db-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--sql-output=mysql --mysql-database=my_database --mysql-host=127.0.0.1 --mysql-user=root --mysql-password=secretThe three modes:
| Mode | What happens | Output file |
|---|---|---|
file (default) |
Writes SQL to $STATE_DIR/db.sql |
db.sql |
stdout |
Streams SQL to stdout, progress/status goes to stderr | none |
mysql |
Connects through mysqli and executes complete statements one at a time | none |
When a source response stops mid-stream, the same importer asks for the
remaining SQL and keeps an unfinished statement in memory. Each decoded SQL
multipart body is at most 16 MiB. Each INSERT statement is also at most 16 MiB.
When a batch contains complete statements followed by an unfinished statement,
the source sends the complete prefix first so the importer can save its cursor.
That split can leave at most 16 MiB after the checkpoint, and finishing the
current bounded INSERT can add at most another 16 MiB. The next complete
checkpoint is therefore at most 32 MiB away. A db.sql group adds one
separator newline before its cursor marker.
Direct MySQL output stores the last committed group cursor in the target table
__reprint_db_pull_progress_<uuid>. The UUID makes an accidental name clash
unlikely and changes whenever the table schema changes. Reprint logs and excludes
that internal name from the source dump.
File output keeps the same SQL groups by adding a harmless comment after each
complete group. The comment records the exporter cursor. db-apply reads one
group at a time and sends it through the same database importer for MySQL and
SQLite. The target stores the exporter cursor and matching db.sql byte offset.
A replacement process reads that row and seeks directly to the next group.
For direct MySQL output and later db-apply, the MySQL importer executes the
statements in a complete group one at a time. It stores the group cursor only
after every statement in that group succeeds.
For transactional target tables, the group and its next cursor are committed
together. SQLite uses this transaction for every group. Table replacement and
oversized-value updates remain separate groups. If a MySQL process stops, its
replacement waits for the old target connection, reruns db-session-setup.sql,
and continues from the cursor stored in MySQL. A repeated INSERT skips rows
identified by a non-null unique key, so this also works for keyed MyISAM tables.
Reprint cannot continue a nontransactional table without such a key, or while
an oversized value is being appended in separate UPDATE statements; those cases
require aborting and starting the database import again. After db-apply
finishes, it removes its internal cursor table from the imported database.
For example, Online Backup for WordPress uses a MyISAM table whose
AUTO_INCREMENT column is second in a composite primary key. If the target
reports enforce_storage_engine=InnoDB, direct MySQL output and db-apply
add a non-unique index starting with that column so InnoDB can create the
table. Existing rows, IDs, and keys are kept. Future IDs use one table-wide
sequence instead of separate sequences for each group; plugins that rely on
per-group numbering may need changes. Reprint reports this change in terminal
output, JSONL warning records, and audit.log. It does not change the source
database or the downloaded SQL file, and leaves compatible schemas unchanged.
The mysql mode requires --mysql-database and accepts --mysql-host,
--mysql-port, --mysql-user, and --mysql-password (or the MYSQL_PASSWORD
environment variable). The host string also supports host:port and
host:/path/to/socket formats (same as WordPress DB_HOST), but
--mysql-port takes precedence when both are specified.
The command returns one of four exit codes:
- 0: sync completed
- 1: failure
- 2: partial completion, needs re-running
- 3: temporary transfer failure, retry the same command later
While the database was being dumped, some files may have changed.
First, we must abort the previous files-pull. Otherwise, it would just tell us it's completed and refuse to proceed:
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" --abortFrom here, we can run the files-pull command again. It will index
the remote filesystem once again, compute which files have changed
since the initial sync, and apply that delta in the local directory:
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET"The command returns one of four exit codes:
- 0: sync completed
- 1: failure
- 2: partial completion, needs re-running
- 3: temporary transfer failure, retry the same command later
If the site URL changes, pass --new-site-url or --rewrite-url FROM TO
to the first files-pull as well. The download rewrites mapped URLs in .css
files, including generated stylesheets under uploads. It preserves the files
instead of flushing builder caches. pull applies its URL mappings to both
CSS downloads and the database; pull-files also accepts these options.
CSS rewriting uses the shared DataLiberation processor to find url(), bare
@import strings, and image-set() strings. It matches HTTP(S) and
protocol-relative URL bases after decoding CSS escapes. Comments, displayed
text, relative URLs, other domains, URLs containing user information, and
other file types stay unchanged. Replacements use the existing CSS value setter:
it quotes unquoted URLs and writes the decoded value with CSS escaping.
For example, url(https://old.example/a.png) becomes
url("https://new.example/a.png").
The caller feeds append_bytes(), scans with next_url(), edits with
set_raw_url(), and marks the real file end with input_finished(). Completed
output is flushed after each URL. The shared parser cursor is an opaque string
with no source bytes. An HTTP part can end inside a URL, so Reprint saves its
unfinished bytes separately beside that cursor. Resume supplies those bytes
before appending the next HTTP part. A URL split across requests is rewritten once.
The processor keeps each unfinished CSS token and parses it again when more
bytes arrive. A large comment or embedded image therefore increases memory
use and saved state size; token size is not capped. Completed input is released.
Malformed string and URL tokens stay unchanged. More than 128 nested
image-set() functions are rejected. An error names the stylesheet; the
partial file is not marked complete.
File URL mappings remain bound to the saved remote index. Later downloads
reuse them when the options are omitted. A full pull also checks for changed
mappings when resume skips the completed file stage, before applying the
database. To use different mappings, start
with a new state directory and an empty filesystem root. A later db-apply
or db-rewrite-urls does not change files already downloaded.
If the site's domain is changing (e.g. migrating from https://old-site.com
to https://new-site.com), use db-apply with --rewrite-url to import
the SQL dump into a target database while rewriting all URLs in one pass.
MySQL target:
php reprint.phar db-apply "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--target-user=root --target-db=wp_new \
--rewrite-url https://old-site.com https://new-site.comSQLite target:
php reprint.phar db-apply "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--target-engine=sqlite --target-sqlite-path="$STATE_DIR/wordpress.sqlite" \
--target-db=wp_new \
--rewrite-url https://old-site.com https://new-site.comThis reads db.sql from the state directory and executes each statement against
the target database. For every data-bearing statement (INSERT, UPDATE), it
decodes the base64-encoded column values, detects the data format (serialized PHP,
JSON, block markup, plain text), and rewrites URLs through the appropriate parser
so that surrounding structure stays intact. Serialized PHP s:N: length prefixes
are recalculated, JSON is re-encoded, and block comment attributes are updated.
You can map multiple domains by repeating the flag:
php reprint.phar db-apply "$URL" --state-dir="$STATE_DIR" --fs-root="$FS_ROOT" --secret="$SECRET" \
--target-user=root --target-db=wp_new \
--rewrite-url https://old-site.com https://new-site.com \
--rewrite-url https://cdn.old-site.com https://cdn.new-site.comIf the domain isn't changing, you can skip db-apply and import db.sql
directly with a MySQL tool, or use db-apply --target-engine=sqlite to load it
into SQLite through the bundled sqlite-database-integration driver.
The downloaded files need server-specific configuration to actually work —
PHP constants, INI directives, and request handlers that the source host
relied on. apply-runtime reads the preflight data, detects the source
hosting provider, and generates the configuration files your target server needs.
For PHP's built-in development server:
php reprint.phar apply-runtime "$URL" --state-dir="$STATE_DIR" \
--flat-document-root="$FLAT_DIR" --output-dir="$RUNTIME_DIR" --runtime=php-builtin
bash "$RUNTIME_DIR/start.sh"For nginx + PHP-FPM:
php reprint.phar apply-runtime "$URL" --state-dir="$STATE_DIR" \
--flat-document-root="$FLAT_DIR" --output-dir="$RUNTIME_DIR" --runtime=nginx-fpm
# Include $RUNTIME_DIR/nginx.conf in your nginx configuration, then reloadThe command accepts either --fs-root (the raw download directory — the remote
document_root path is appended automatically) or --flat-document-root (a
directory created by flat-docroot, used as-is). These are mutually exclusive.
Host and port default to the URL rewrite target from db-apply (so the server
listens on the same address the database was rewritten to). Override with
--host and --port.
The database written into runtime.php is the one db-apply connected to. To
point the runtime at a database db-apply did not create — a local database you
are keeping, for instance, when you pull the files but not the database — name
it with the same --target-* options db-apply takes:
php reprint.phar apply-runtime "$URL" --state-dir="$STATE_DIR" \
--flat-document-root="$FLAT_DIR" --output-dir="$RUNTIME_DIR" --runtime=php-builtin \
--target-engine=sqlite --target-sqlite-path="$FLAT_DIR/wp-content/database/.ht.sqlite"The SQLite file itself may be missing — the sqlite-database-integration plugin
creates it on the first request — but its directory must exist. The options
apply to this run only; apply-runtime does not record them in state.
Every field you leave out falls back to what db-apply recorded, as long as the
engine matches. Name the whole connection when you point at a different MySQL
database — otherwise you inherit db-apply's host, port or password.
What gets generated:
The command produces a runtime.php file that sets PHP constants, server
variables, and route handlers the source site needs. Each target runtime
wraps it differently:
| Runtime | Output files | How runtime.php loads |
|---|---|---|
php-builtin |
runtime.php, start.sh |
Used as the router script for php -S |
nginx-fpm |
runtime.php, nginx.conf |
Loaded via auto_prepend_file in fastcgi_param PHP_VALUE |
The architecture separates source host detection from target runtime configuration. Host analyzers read preflight data and produce a declarative manifest (constants, INI directives, routes). Runtime appliers consume the manifest and write server-specific files. Adding a new source host or target server is independent — you implement one interface without touching the other.
Currently supported source hosts: WP Cloud (with on-the-fly thumbnail
generation for missing image sizes and auto-detection of extra directories from
auto_prepend_file/auto_append_file INI values), WP Engine, and a generic default.
apply-runtime removes known host-plugin copies by default, independently of
the saved download selection. Pass apply-runtime --include-host-plugins to
skip that local cleanup. See the host-plugin section above for push exclusions.
Currently supported target runtimes: nginx + PHP-FPM, PHP's built-in
development server, and WordPress Playground CLI.
For Playground CI, set PHP_BINARY=tests/e2e/ci/playground-php.sh. The wrapper
delegates to @wp-playground/cli php by default. When
WP_MYSQL_PARSER_EXTENSION_MANIFEST is set, it runs the local @php-wasm/node
runner instead so it can load the SQLite Integration plugin's Rust
wp_mysql_parser PHP.wasm extension. CI verifies that this path does more than
load the .so: tests/e2e/ci/verify-wp-mysql-parser.php asserts that
WP_MySQL_Lexer resolves to the native lexer and that the SQLite driver creates
a native-backed parser before benchmarking Playground db-pull and db-apply.
That path requires Node.js with JSPI support; CI uses Node 24.
Once the destination has its files, database, and working wp-config.php, run
all post-migration tasks with:
reprint post-process --fs-root=/path/to/wordpress --state-dir=/path/to/migration-stateRun only selected tasks with --tasks:
reprint post-process --fs-root=/path/to/wordpress --tasks=disable-failing-plugins
reprint post-process --fs-root=/path/to/wordpress --state-dir=/path/to/migration-state \
--tasks=disable-hosting-pluginsOmitting --tasks, or passing --tasks=all, runs both tasks. A comma-separated
list selects only those tasks. Hosting cleanup runs first and startup recovery
runs second, regardless of the list's order. Processing stops at the first
failure. A theme or must-use plugin fatal during startup therefore cannot
prevent the earlier hosting cleanup.
disable-hosting-plugins removes known source-host plugin, MU-plugin, and
drop-in files using the same rules as apply-runtime. It reads the saved
preflight from --state-dir; it does not contact the source, load WordPress,
change active_plugins, or generate runtime files. Like runtime cleanup, it
records removed paths for later pushes without changing the saved pull selection.
Generic cache drop-ins are removed only when the saved source-host data calls
for that. Finish an interrupted files-push, or finish or abort an interrupted
files-pull, before running this task.
Use the ready-to-run WordPress root containing wp-load.php as --fs-root,
not the raw download directory. Hosting cleanup uses the standard wp-content
layout under that root. If the state directory contains several remotes, pass
the original source URL to select one:
reprint post-process https://source.example/?reprint-api \
--fs-root=/path/to/wordpress --state-dir=/path/to/migration-statePost-processing does not uninstall Reprint. Use WordPress's plugin uninstall flow when Reprint is no longer needed so the plugin can clean up its settings.
disable-failing-plugins performs the startup recovery described below. It
does not need migration state when run alone. JSON output has status and a
results list with each attempted task's name, status, and removed paths or
disabled plugins. Earlier results remain in the report if a later task fails.
Exit code 0 means all selected tasks completed; exit code 1 means processing
stopped. These tasks do not check page rendering or the web server.
Once the destination has its files, database, and working wp-config.php, run:
reprint recover --fs-root=/path/to/wordpressreprint recover requires wp-load.php in a fresh PHP process. If a fatal
error's file belongs to one active regular plugin, it deactivates that plugin
and tries again. It keeps the plugin's files and data and skips its deactivation hooks.
Other failures stop the command. Multisite plugins are not deactivated.
The JSON result lists the disabled plugins and their errors. Exit code 0 means
wp-load.php loaded; exit code 1 means the check could not complete. The command
uses the same PHP binary as Reprint. It does not check page rendering or the
web server, and disabling a plugin may remove features from the site.
No remote URL, connection token, or state directory is required.
You've got a copy of the remote files in the --fs-root directory and
the database either already applied (via db-apply) or in --state-dir/db.sql.
From here, you need to figure out how to run that on your platform.
The db.sql file will contain the relevant DELETE TABLE IF EXISTS
statements to make sure it can always succeed. You might want to,
before the first run, clean up any tables that may have been already
created by your environment. We won't need them. Furthermore, they may
not get deleted during the database import if the site doesn't use
the same table prefix as your environment.
If you used --sql-output=mysql, the SQL was already executed — there's
no db.sql to import. For --sql-output=stdout, the SQL was piped to
whatever tool was reading stdout (typically mysql CLI).
Reprint uses $STATE_DIR exactly as supplied. Consumers that want the state
hidden can choose a directory named .reprint; Reprint does not append that
name itself. State-directory-wide command progress and the audit log live
directly in the state directory. The retained local index lives at
remotes/<md5-of-trimmed-remote-reprint-api-url>/local_index.jsonl.
files-pull advances it for each completed local mutation without scanning
unrelated paths; files-push replaces it only after the target confirms
commit. Pull and push operation state live in the sibling pull/
and push/ directories.
State-directory-wide and pull filenames do not begin with a dot or repeat the
scope supplied by their parent directory.
The directory name is md5(rtrim(<remote-reprint-api-url>, "?&")). Commands
which read local pull state receive the same remote Reprint API URL even when
they make no network request; the URL selects the remote state directory. The
filesystem root is not part of that directory name, so a different filesystem
root uses a different state directory.
<remote-state-directory>/pull/state.json and the state-directory-wide
progress.json are written atomically:
a .tmp file is written first and then renamed to its final name so readers
never see partially written state.
While there's many of these files, most of them are for internal use only. The two that might be particularly useful for integrators are:
progress.json– the current progress<remote-state-directory>/pull/state.json– the pull state store
When an external process (e.g. a web UI) needs to poll migration progress, it can read
$STATE_DIR/progress.json.
Pass --step=N and --steps=N to your import.php calls to embed the pipeline position in
the progress file. For example, a four-step pipeline would pass --step=1 --steps=4 for the
preflight, --step=2 --steps=4 for db-index, and so on.
The file uses one versioned shape for every command. Fields that do not apply
to the current phase are null instead of disappearing:
{
"schema_version": 1,
"step": 2,
"steps": 4,
"command": "files-pull",
"status": "in_progress",
"phase": "fetch",
"message": "Downloading files",
"progress": {
"items": {
"unit": "files",
"done": 41,
"total": 83
},
"bytes": {
"done": 7340032,
"total": 52428800
},
"current_file": {
"path_b64": "L3dwLWNvbnRlbnQvdXBsb2Fkcy9sYXJnZS56aXA=",
"bytes_done": 5242880,
"bytes_total": 20971520
},
"current_table": null
},
"error": null,
"error_code": null,
"reason": null,
"detail": null,
"ts": 1707600000.123
}| Field | Type | Description |
|---|---|---|
schema_version |
int |
Version of this documented progress-screen shape. Currently 1. |
step |
int | null |
Current pipeline step (1-indexed). null when --step is not passed. |
steps |
int | null |
Total pipeline steps. null when --steps is not passed. |
command |
string | null |
Current command name (preflight, files-pull, db-pull, etc.). |
status |
string |
Command status. Common values are in_progress, partial, complete, error, and aborted; files-push also uses interrupted, restart, and failed. |
phase |
string | null |
Durable phase within the command, such as index, diff, fetch, or sql. |
message |
string | null |
Short action text suitable for the main progress label. Read numeric progress from progress instead of parsing this text. |
progress |
object |
Stable progress-screen counters described below. |
error |
string | null |
Error message when status is error, otherwise null. |
error_code |
string | null |
Machine-readable error code when one is available. |
reason |
string | null |
Files-push terminal reason when one is available. |
detail |
string | null |
Files-push terminal detail when one is available. |
ts |
float |
Unix timestamp with microsecond precision (microtime(true)). |
progress.items is either null or { "unit", "done", "total" }.
The unit says what is counted: files, tables, local_paths, records, or
statements. total is null when Reprint cannot know the total before
finishing the work. progress.bytes is either null or { "done", "total" }
for the current byte-bounded phase. progress.current_file is either null or
{ "path_b64", "bytes_done", "bytes_total" }. The path is base64 because
filesystem names are arbitrary bytes. During a file fetch, the item and byte
totals cover the paths selected by the current pull. A delta pull therefore
reports only changed paths. The byte total adds regular-file content sizes;
directories and symlinks add zero bytes.
During db-pull, progress.items counts exported tables and
progress.current_table reports row progress for the active table. MySQL's
table row count is an estimate, so rows_total_is_estimate is always true.
The exporter loads estimates with the table list, then supplies the current
table's estimate with SQL progress. Reporting progress does not read the local
table index or search the exporter's table list:
{
"command": "db-pull",
"phase": "sql",
"message": "Downloading SQL dump",
"progress": {
"items": {
"unit": "tables",
"done": 2,
"total": 12
},
"bytes": {
"done": 500100,
"total": null
},
"current_file": null,
"current_table": {
"name": "wp_posts",
"rows_done": 500,
"rows_total": 12000,
"rows_total_is_estimate": true
}
}
}Reprint replaces progress.json atomically at most once per second while a
command is active. A terminal, partial, cleared, or error state is written
immediately. A polling UI can therefore read this file without parsing JSONL.
The JSONL records that report screen progress carry the same schema_version
and nested progress object, while their event-specific top-level fields stay
available for existing consumers.
Ordinary log events, such as a skipped path, do not replace the screen's action
label. File counts include every processed path, including failed or skipped
paths, directories and symlinks. Live updates and resume use the same rule.
If a batch must be downloaded again, its old batch counts are cleared first.
A new file reports its own path and size even after an error in the previous file.
Completed file bytes exclude failed files and use their downloaded size, not
the earlier index size. The existing fetch checkpoint saves completed bytes
before and within the current batch. This keeps byte counts stable after
resume without another file scan. Failed files or source size changes can make
completed bytes differ from the planned byte total.
Finish or abort active file downloads with the previous build before updating. Old checkpoints with saved fetch progress lack the completed-byte counts and cannot resume in this build. Cleared fetch checkpoints, including those left by completed pulls, load with zero completed bytes. Retained indexes are unchanged.
Every command run by ImportClient accepts --progress=auto|tty|jsonl|compact. The
default auto mode uses terminal progress when its output stream is a TTY and
JSONL otherwise. Use --progress=tty to force the terminal presentation when
output is captured, or --progress=jsonl to force structured progress in a
terminal:
php reprint.phar files-push "$URL" --state-dir="$STATE_DIR" \
--fs-root="$FS_ROOT" --secret="$SECRET" --progress=jsonlThe selected mode applies only to that invocation and is not retained in
command state. Explicit tty, jsonl, and compact modes cannot be combined with
--verbose.
The selector governs progress, lifecycle, and status output. It does not
reformat a command's data result, such as preflight or pull-metadata JSON,
files-stats JSON, or SQL written with --sql-output=stdout.
The file-download bar in pull and pull-files uses downloaded bytes divided
by the selected file byte total, including bytes written for the current file.
The file count beside it still counts completed paths. When the byte total is
unknown or zero, downloads show a spinner instead of a percentage.
Use --progress=compact to print command starts, stage changes, command results,
warnings, and errors as JSON lines. During a stage, it prints item and byte
counters at most once every 30 seconds, only when they changed. These updates
omit individual file paths and table names. They come from existing progress
events, so a blocked request or a stage without item/byte counters stays quiet.
It hides per-file records, preserve-local skips, repeated stage labels,
per-request results, receive-rate diagnostics, and debug chatter.
Warnings and errors, including rejected symlink targets, remain individual
records; compact mode does not group them.
php reprint.phar files-pull "$URL" --state-dir="$STATE_DIR" \
--fs-root="$FS_ROOT" --secret="$SECRET" --progress=compactCompact mode only changes the displayed output. It does not save the omitted
records or create progress.jsonl. progress.json still receives the latest
progress snapshot, and audit.log remains available for troubleshooting.
Use --progress=jsonl when the caller wants the full progress stream.
With --sql-output=stdout, SQL stays on stdout and compact progress goes to stderr.
JSONL and compact CLI output automatically append one versioned JSON result for
the invoked command. The default auto mode does this when its progress stream
is not a terminal. Terminal output does not append a report. Command data is
unchanged, but captured commands such as preflight now print their data followed
by the final report rather than a single JSON document.
See CLI reporting for the schema, preflight checks,
pipeline boundaries, and handling a missing report.
The files-push terminal presentation uses one stage-weighted progress bar. The
percentage comes first, followed by a major stage such as Indexing, Pushing,
or Committing. While pushing local paths, the line also shows target-confirmed
file bytes against the file byte total collected by the plan. Durable index byte
offsets, target-confirmed counts and byte offsets, and phase milestones advance
the bar. The percentage describes lifecycle progress, not elapsed time or an
estimated completion time.
The JSONL presentation emits push_progress records. After planning completes,
their legacy files_done and files_total fields remain available. The same
counts also appear in progress.items with the local_paths unit. During a
byte-bounded files-push phase, progress.bytes reports the durable byte
position and total. The final result and progress.json use the same nested
progress object. Target-confirmed counts and byte positions survive
exit-code-2 restarts.
This is the pull state store. Pull commands read it on startup and write it back periodically and on shutdown. It stores the current command, cursor position, AIMD tuning state, and per-phase bookmarks. Direct MySQL output also records each committed cursor in the target database.
Written atomically (temp file + rename) so a crash mid-write never corrupts it.
If the JSON is invalid on load, the importer renames it to
state.json.corrupt.<timestamp> in the same pull state directory and starts
fresh.
For the hosting platform: Read this file to determine whether a command is
still running, completed, or needs resuming. The command + status fields
tell you where the pipeline is. The stage field gives finer granularity
(e.g., "scanning", "sorting", "streaming" for file sync).
For pull lifecycle and source-site details, prefer pull-metadata over reading
<remote-state-directory>/pull/state.json directly. It exposes a small, stable
JSON contract for host integrations:
php reprint.phar pull-metadata "$URL" --state-dir="$STATE_DIR" | jq '.hasCompletedOnce'The sourceSite object contains the source WordPress home URL, site URL, table
prefix, WordPress database charset, and database server charset reported by
preflight. Each field is null when preflight did not report it.
During files-pull, a file on the source may be modified while the importer is
streaming it. When that happens, the server returns a different content hash than
expected and the importer records the file in
<remote-state-directory>/pull/volatile-files.json
instead of failing.
The file is a flat JSON object mapping paths to the number of times each file was detected as changed:
{
"/srv/htdocs/wp-content/debug.log": 4,
"/srv/htdocs/wp-content/cache/object-cache.tmp": 2
}At the end of files-pull, the importer prints a summary of volatile files so
the caller can decide what to do — re-run the sync, ignore them, or ask the user.
Files that are subsequently downloaded successfully are automatically removed
from the tracker. The file is deleted entirely once all entries are cleared.
Every significant event during import is recorded in audit.log as a
timestamped line. This includes file downloads, deletions, volatile file
detections, errors, and state transitions. The log is append-only — it's never
truncated or rotated, so it provides a complete history of the migration.
[2025-01-15 10:30:01] VOLATILE | path=/srv/htdocs/wp-content/debug.log | count=1
[2025-01-15 10:30:05] VOLATILE CLEARED | path=/srv/htdocs/wp-content/debug.log
[2025-01-15 10:31:12] FILE TRUNCATE | /tmp/reprint-state/remotes/0123456789abcdef0123456789abcdef/pull/index.wal | pull index WAL batch applied
Pass --verbose to also print audit log entries to the console as they happen.
This is useful for debugging but noisy for production use.
The importer accepts the following commands:
php reprint.phar <command> <URL> --state-dir=DIR --fs-root=DIR [options]
preflight— Runs the preflight check and prints the full result as JSON. Exits with code 0 if OK, code 1 if not.preflight-assert— Checks the saved preflight report and prints a human-readable pass/fail summary in terminal mode or one structured result in JSONL mode. Exits with code 0 if migration looks feasible, code 1 if not.pull-files— Runspreflightandfiles-pullas one resumable high-level command.pull-db— Runspreflight,db-pull, anddb-applyas one resumable high-level command.files-pull— Pull all files (initial) or a delta. Catch-up mode applies remote changes; mirror mode makes the selected local paths match the current remote index. Runs files-index if needed.files-index— Index all remote files (initial) or detect changes (delta). No file contents downloaded.db-pull— Pull the database as a SQL dump. Defaults to writingdb.sql; use--sql-output=stdoutor--sql-output=mysqlto stream elsewhere.db-apply— Appliesdb.sqlto a target MySQL or SQLite database. Both engines continue from the file group named by the cursor stored in the target. Accepts--rewrite-url FROM TO(repeatable) to rewrite domains during import.db-rewrite-urls— Rewrites URLs directly in an existing MySQL or SQLite database. You can stop it and continue later. See Rewrite URLs in a live database.db-index— Indexes database tables and their statistics (name, row count, size) todb-tables.jsonl.pull-metadata— Prints pull lifecycle and source-site metadata as JSON. The remote Reprint API URL selects the pull state; no network calls are made.flat-docroot— Reassemble pulled files into a standard WordPress directory layout using symlinks. Useful when the source site has a non-standard layout (e.g. WP Cloud with ABSPATH separate from wp-content).apply-runtime— Generates server configuration files (runtime.php,start.shornginx.conf) from the pull state selected by the remote Reprint API URL. No network calls are made. See Step 6.
All commands except preflight-assert support --abort to abort the current sync and exit. For files-pull, this clears sync progress but keeps the remote index and downloaded files — the next run performs a delta sync. For db-pull and db-index, it clears the output file so the next run starts from scratch. Interrupted commands automatically resume from the last saved cursor.
{ "command": "files-pull", // active command "status": "in_progress", // "in_progress" | "complete" | null "cursor": "...", // server-side cursor (opaque string) "stage": "streaming", // current phase within the command "preflight": { ... }, // cached preflight response "version": "...", // importer version "follow_symlinks": true, "max_allowed_packet": null, // client-side MySQL packet limit // Per-command state sections: "db_index": { "file": "db-tables.jsonl", "tables": 42, "rows_estimated": 150000, "bytes": 8192, "updated_at": "2025-01-15T10:30:00Z" }, "diff": { "index_diff_cursor": { "old_index_byte_offset": 512, "new_index_byte_offset": 1024, "preceding_new_index_entry_path_b64": "base64..." }, // Output bytes covered by the index-diff cursor. "fetch_list_byte_offset": 256, "pull_index_wal_byte_offset": 128 }, "index": { "cursor": "..." // file_index cursor }, "filter": "none", // "none" | "essential-files" | "skipped-earlier" "fetch": { "offset": 512, // byte offset into fetch list "next_offset": 1024, "batch_file": null, "cursor": "..." // file_fetch cursor }, // Crash recovery: if the importer dies mid-write, these let it // truncate the partially-written file back to its last good state. "current_file": "wp-content/uploads/photo.jpg", "current_file_bytes": 1048576, // expected size after last complete write "sql_bytes": 524288, // expected db.sql size "sql_output": "file", // "file" | "stdout" | "mysql" "tuning": { "config": { ... }, // AIMD parameters "state": { ... } // current AIMD sizes } }