Proposal · design sketch · not yet implemented

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.

Inverts the producer ↔ consumer roles Reuses transport & auth New remote write endpoints Backup · staging · rollback dry-run first
Read this first The 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 flowsremote → locallocal → remote
Producer (has the data)remote pluginlocal CLI
Consumer (applies it)local CLI (full engine)remote plugin (thin, budgeted)
Target is…disposable, offlinelive, irreplaceable prod/staging
Failure costjust re-runcould break a live site
Conflictsnone (local overwritten wholesale)remote may have changed (orders, comments)
The leverage point The streaming / cursor / budget / HMAC machinery ports over almost unchanged. The hard parts are (a) giving the remote write capability without turning it into a foot-gun, and (b) not destroying remote-only data.

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;
Same wire, reversed direction — but the consumer is now a thin, budgeted WordPress plugin instead of a full CLI.

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;
Green = reused as-is. Red = new and must be built. The local side is mostly assembly of existing parts; the remote side is where the engineering lives.

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.

EndpointRoleNotes
file_write NEWReceive uploaded file chunks → write to diskAtomic: write to *.reprint-tmp then rename. Resumable by {path, offset} cursor for large files. Reuses the existing $_FILES + HMAC content-hash plumbing.
file_delete NEWRemove files deleted locallyOnly within allowlisted roots; logged to the audit log.
sql_apply NEWExecute pushed SQL against the remote DBStatement-offset cursor, idempotent (DROP TABLE IF EXISTS + INSERT), budget-bounded — the importer's db-apply shape, now running server-side.
snapshot_* NEWbegin / commit / abort — two-phase staging + rollbackCaptures 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;
Nothing is written until a backup exists and conflicts are cleared; an interrupt at any point restores the last good state.
  1. Explicit, separate authorization. The read secret must not silently grant write. Push requires a distinct, default-off capability (e.g. a reprint_push_enabled option in the plugin admin UI, ideally with its own scope/secret). Never enabled implicitly.
  2. 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.
  3. 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_abort restores from backup.
  4. --dry-run first. 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.
  5. Conflict detection. Refuse to clobber a remote that changed unexpectedly unless --force (see §07).
  6. Path guardrails. Hard allowlist of writable roots (default wp-content/ only); refuse wp-config.php, refuse core unless --allow-core, refuse traversal (reuse path_is_within_root).
  7. 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;
Destructiveness increases left → right. Backup is mandatory from 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.
Reverse URL rewriting comes for free Pass --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.
Conflict fingerprint At the end of every pull/push, record a marker (remote index snapshot + a DB fingerprint, e.g. per-table 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;
Where pull builds a download list of remote-newer files, push builds an upload list of local-newer files plus a guarded delete list.

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.php to prod).