The Push command
Sync local changes — files and/or database — up to a remote WordPress site. The inverse direction of pull, built on the same streaming, cursor, and HMAC machinery, but governed by a much stricter safety model because the target is live.
push command does not exist yet. This is a design sketch showing how it could be built on top of Reprint's existing Pull architecture. Items marked NEW are unbuilt; items marked reuse already ship today.
01Why Push is not "Pull in reverse"
Pull works because the remote is a source of truth you only read, and the local target is disposable — a half-finished import just gets re-run. Push inverts every one of those properties, which is why it's fundamentally a safety and authorization problem rather than a transport problem.
| Pull (today) | Push (proposed) | |
|---|---|---|
| Data flows | remote → local | local → remote |
| Producer (has the data) | remote plugin | local CLI |
| Consumer (applies it) | local CLI (full engine) | remote plugin (thin, budgeted) |
| Target is… | disposable, offline | live, irreplaceable prod/staging |
| Failure cost | just re-run | could break a live site |
| Conflicts | none (local overwritten wholesale) | remote may have changed (orders, comments) |
02Role inversion
The local CLI must now produce a dump + file tree, and the remote plugin must apply them. The producer code already exists; the remote apply engine is what's new.
flowchart TB
subgraph PULL["PULL — today"]
direction LR
RP["remote plugin
PRODUCES (read-only)"]:::rem -->|"streams down"| LC["local CLI
CONSUMES + APPLIES"]:::loc
end
subgraph PUSH["PUSH — proposed"]
direction LR
LP["local CLI
PRODUCES"]:::loc -->|"uploads up"| RC["remote plugin
CONSUMES + APPLIES (NEW)"]:::rem
end
classDef rem fill:#f3effe,stroke:#c0adfb,color:#46329b;
classDef loc fill:#eafaf8,stroke:#7fd8cf,color:#0a6b62;
03What today's code gives us for free
Three facts (verified in the source) make Push much cheaper than it looks — the transport and auth for sending bytes up to the remote are already solved.
Remote is read-only today
All five endpoints (file_index, file_fetch, sql_chunk, db_index, preflight) only read. No write path to fight — we add new endpoints cleanly.
Uploads already work
file_fetch already accepts a client-uploaded $_FILES["file_list"] via multipart POST. Sending bytes up is a solved transport.
Uploads are already signed
The HMAC server's get_received_content() hashes concatenated $_FILES contents in deterministic order. Authenticated uploads are already correct.
So what's missing is write endpoints, a remote apply engine, and a safety model — not transport.
04Proposed architecture
flowchart LR
subgraph LOCAL["LOCAL — reprint.phar push"]
direction TB
PO["Push orchestrator
(mirror of Pull)"]:::core
PROD["local producers
MySQLDumpProducer · FileTreeProducer"]:::reuse
DIFF["diff merge-join →
UPLOAD + DELETE lists"]:::reuse
REW["URL rewrite
localhost → prod"]:::reuse
PO --> PROD --> DIFF --> REW
end
subgraph REMOTE["REMOTE plugin — NEW write endpoints (capability-gated)"]
direction TB
GATE["push capability gate
default OFF"]:::new
FW["file_write
atomic staging"]:::new
FD["file_delete"]:::new
SA["sql_apply engine
(shared, factored from importer)"]:::new
SNAP["snapshot begin / commit / abort
+ pre-push backup"]:::new
GATE --> FW
GATE --> FD
GATE --> SA
GATE --> SNAP
end
REW == "multipart upload · HMAC-signed
(plumbing already exists)" ==> GATE
classDef core fill:#0c1322,stroke:#0c1322,color:#fff;
classDef reuse fill:#eafaf8,stroke:#7fd8cf,color:#0a6b62;
classDef new fill:#fdeef0,stroke:#f0b6bd,color:#a23440;
Reusable vs. new vs. inverted
reuse Almost as-is
HMAC auth incl. multipart uploads · multipart/mixed transport · X-Cursor conventions · ResourceBudget · AIMD tuner · the diff merge-join · the remote file_index endpoint · the URL-rewrite pipeline (direction-agnostic) · the query-stream splitter · external-merge-sort · state files + exit-code-2 resumption.
NEW Must be built
Remote write endpoints · a remote-side SQL apply engine (factored from the importer into a shared package) · atomic file writer + backup/staging · the authorization/capability gate · conflict/fingerprint tracking · the push orchestrator + files-push/db-push sub-commands.
↔ Inverted
The producer now runs locally; consumer logic now (partly) runs inside the remote plugin, under the same budget constraints the importer faces today.
05New remote endpoints
Added to Site_Export_HTTP_Server::default_handlers(), all gated behind the push capability, all resumable via cursor + budget exactly like the read endpoints.
| Endpoint | Role | Notes |
|---|---|---|
| file_write NEW | Receive uploaded file chunks → write to disk | Atomic: write to *.reprint-tmp then rename. Resumable by {path, offset} cursor for large files. Reuses the existing $_FILES + HMAC content-hash plumbing. |
| file_delete NEW | Remove files deleted locally | Only within allowlisted roots; logged to the audit log. |
| sql_apply NEW | Execute pushed SQL against the remote DB | Statement-offset cursor, idempotent (DROP TABLE IF EXISTS + INSERT), budget-bounded — the importer's db-apply shape, now running server-side. |
| snapshot_* NEW | begin / commit / abort — two-phase staging + rollback | Captures a pre-push backup; commit swaps staging → live; abort restores. |
preflight is extended to report push capability (enabled? writable roots? free disk? DB privileges?) so the client fails fast.
06The safety model — the crux
Push writes to a live site, so this is where most of the engineering goes and where the design must be conservative.
flowchart TD
START["push request (capability ON)"]:::core --> DRY{--dry-run?}:::q
DRY -->|yes| PLAN["print plan, write nothing"]:::safe
DRY -->|no| CONF{remote drifted
vs sync marker?}:::q
CONF -->|"yes and not --force"| ABORT1["abort: non-fast-forward"]:::stop
CONF -->|no / forced| BK["snapshot_begin:
backup DB + overwritten files"]:::safe
BK --> STAGE["write to staging
(*.reprint-tmp)"]:::n
STAGE --> OK{all chunks applied
within budget?}:::q
OK -->|yes| COMMIT["commit:
atomic rename / table swap"]:::safe
OK -->|"error / interrupt"| ROLL["abort:
restore from backup"]:::stop
classDef core fill:#0c1322,stroke:#0c1322,color:#fff;
classDef q fill:#fff4e6,stroke:#f0c182,color:#8a5410;
classDef safe fill:#eafaf8,stroke:#7fd8cf,color:#0a6b62;
classDef n fill:#f1f4fa,stroke:#cdd6e6,color:#37445c;
classDef stop fill:#fdeef0,stroke:#f0b6bd,color:#a23440;
- Explicit, separate authorization. The read secret must not silently grant write. Push requires a distinct, default-off capability (e.g. a
reprint_push_enabledoption in the plugin admin UI, ideally with its own scope/secret). Never enabled implicitly. - Backup before apply. Before any write, snapshot the remote DB and back up files about to be overwritten — using the existing export producers (the remote can dump itself). Store under a restore handle.
- Atomicity + rollback. Files: write to staging, atomic-rename on commit so the site is never half-broken (critical for core/plugin files that would fatal mid-write). DB: apply within a transaction or to shadow tables, swap on commit.
snapshot_abortrestores from backup. --dry-runfirst. Compute and print the full plan (files to add/update/delete, tables/rows affected, URL rewrites) without writing anything. High value, zero risk, reuses the entire diff path.- Conflict detection. Refuse to clobber a remote that changed unexpectedly unless
--force(see §07). - Path guardrails. Hard allowlist of writable roots (default
wp-content/only); refusewp-config.php, refuse core unless--allow-core, refuse traversal (reusepath_is_within_root). - Preserve remote-only data. Default to not deleting remote files outside the push scope (user uploads) and not replacing high-churn tables — the inverse of pull's
preserve-local.
07Database push — the hardest part
A full dump-and-replace (what pull does) is usually wrong for push: it would wipe remote-only rows (orders, comments, sessions). Offer escalating modes.
flowchart LR N["--db=none
(default)
files only"]:::a --> T["--db=tables:…
named tables only"]:::b T --> R["--db=replace
full DROP+CREATE+INSERT"]:::c R --> F["(future)
row-level diff"]:::d classDef a fill:#eafaf8,stroke:#7fd8cf,color:#0a6b62; classDef b fill:#eef4ff,stroke:#9bb8f0,color:#1c3a6e; classDef c fill:#fff4e6,stroke:#f0c182,color:#8a5410; classDef d fill:#f1f4fa,stroke:#cdd6e6,color:#7a8499;
replace onward.--db=none(default) — push files only.--db=tables:wp_options,wp_postmeta,…— push only named tables (e.g. theme/options/content, never users/orders). The pragmatic 80% case.--db=replace— full DROP+CREATE+INSERT. Simple, destructive; fine for staging or when local is authoritative. Backup mandatory.- (future) row-level structured diff — out of scope for v1.
--rewrite-url http://localhost:8881 https://prod.example.com and the existing pipeline rewrites serialized PHP / JSON / block markup correctly on the way out — it's direction-agnostic.
max(id) / row-count / checksum). Before a push, re-index the remote and compare; if it drifted, behave like git's non-fast-forward — --on-conflict=abort|force|backup.
08File push flow
A mirror of files-pull, with the diff's output inverted.
flowchart LR IDX["① index
local + remote
(reuse file_index)"]:::st --> DF["② diff
invert →
upload + delete lists"]:::st DF --> UP["③ upload
→ file_write
(resumable)"]:::st UP --> DEL["④ delete
→ file_delete"]:::st DEL --> VF["⑤ verify
re-fingerprint +
record marker"]:::st classDef st fill:#eafaf8,stroke:#7fd8cf,color:#0a6b62;
09CLI surface
php reprint.phar push <remote-url> --state-dir=… --fs-root=… --secret=… \
[--dry-run] [--db=none|replace|tables:…] \
[--rewrite-url http://localhost:8881 https://prod.example.com] \
[--delete] [--on-conflict=abort|force|backup] [--allow-core]
Plus low-level files-push and db-push, same 0 / 1 / 2 exit-code contract as pull, wrapped by a Push orchestrator (mirror of Pull::run_until_complete).
10Phasing roadmap
Each milestone is independently shippable; risk increases with each step, so safety infrastructure lands before destructive capability.
flowchart LR M1["M1 · push --dry-run
zero writes"]:::m --> M2["M2 · file push
file_write/delete
+ staging"]:::m M2 --> M3["M3 · db push (replace)
sql_apply + backup
+ rewrite"]:::m M3 --> M4["M4 · safety hardening
conflicts, rollback,
allowlists"]:::m M4 --> M5["M5 · push orchestrator
+ UI integration"]:::m classDef m fill:#2f6df6,stroke:#2455c7,color:#fff;
M1 — push --dry-run
Index + diff + plan output, zero writes. Reuses ~everything; proves the inverted-role model at no risk. Highest value-to-effort.
M2 — file push
file_write + file_delete + atomic staging + file backup + capability gate. No DB.
M3 — db push (replace)
Factor the shared apply engine, add sql_apply, pre-push DB backup, reverse URL rewrite.
M4 — safety hardening
Conflict fingerprints, selective tables, snapshot_abort rollback, allowlists.
M5 — orchestrator + UI
The push orchestrator and Studio / Playground-UI integration.
Recommended first step
M1 writes nothing and reuses the entire existing diff / transport / auth stack — validate the model before building a single write endpoint.
11Open questions to settle first
- Authorization: is a shared secret enough to grant write to production, or do we want a separate push secret plus an explicit per-push confirmation token?
- Where does the local producer run — bundle the exporter producers into
reprint.phar(it currently bundles the importer + SQLite), or shell out to a local export? - Live-site consistency: maintenance mode during the DB swap? How big a write window is acceptable?
- Bootability invariant: guaranteeing the remote stays bootable if a push is interrupted mid-file-swap (core/plugin files).
- Scope of "push": files + DB only — explicitly exclude runtime/host config (you don't push your local dev
runtime.phpto prod).