From fbc603ccffd551a88248a57e92615d398492c97f Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 10:56:30 -0400 Subject: [PATCH 01/67] Add plans 16-26 and expand roadmap tracks Adds detailed planning docs for plans 16 through 26, covering router coexistence, snapshots, remote import, chunked sync, clone/blueprints, CLI ServerKit support, multi-stack capabilities, reconciliation, site tools, release polish, and a PHP/Laravel stack. Updates ROADMAP.md to include these plans in build order, links previously unplanned items to concrete plans, and introduces a new multi-stack track with clearer cross-track dependencies. --- docs/plans/16_router-coexistence.md | 105 +++++++++++++++++++++ docs/plans/17_snapshots.md | 88 +++++++++++++++++ docs/plans/18_import-remote-site.md | 100 ++++++++++++++++++++ docs/plans/19_sync-v2-chunked.md | 91 ++++++++++++++++++ docs/plans/20_clone-and-blueprints.md | 88 +++++++++++++++++ docs/plans/21_cli-serverkit.md | 74 +++++++++++++++ docs/plans/22_multi-stack-core.md | 100 ++++++++++++++++++++ docs/plans/23_reconciliation.md | 87 +++++++++++++++++ docs/plans/24_site-tools.md | 88 +++++++++++++++++ docs/plans/25_release-polish-completion.md | 89 +++++++++++++++++ docs/plans/26_php-laravel-stack.md | 88 +++++++++++++++++ docs/plans/ROADMAP.md | 38 ++++++-- 12 files changed, 1028 insertions(+), 8 deletions(-) create mode 100644 docs/plans/16_router-coexistence.md create mode 100644 docs/plans/17_snapshots.md create mode 100644 docs/plans/18_import-remote-site.md create mode 100644 docs/plans/19_sync-v2-chunked.md create mode 100644 docs/plans/20_clone-and-blueprints.md create mode 100644 docs/plans/21_cli-serverkit.md create mode 100644 docs/plans/22_multi-stack-core.md create mode 100644 docs/plans/23_reconciliation.md create mode 100644 docs/plans/24_site-tools.md create mode 100644 docs/plans/25_release-polish-completion.md create mode 100644 docs/plans/26_php-laravel-stack.md diff --git a/docs/plans/16_router-coexistence.md b/docs/plans/16_router-coexistence.md new file mode 100644 index 0000000..8fde7ac --- /dev/null +++ b/docs/plans/16_router-coexistence.md @@ -0,0 +1,105 @@ +# 16 — Router coexistence: port-conflict pre-flight + configurable router ports + +Status: ⬜ planned + +Make local domains survive alongside other tools that also claim ports 80/443 +and the `.test`/`.local` hosts space (LocalWP's nginx router is the canonical +case: it binds 80/443 machine-wide and answers *every* unknown local host with +its own "Site Not Found" 404 page, so a LocalKit site at `http://test.test/` +silently hits Local's router instead of LocalKit's Caddy). + +## Motivation + +Today the router only finds out about a port conflict *after* the fact: +`router::port_conflict_hint` string-matches the failed `docker compose up` +stderr and guesses "LocalWP's router, IIS, Skype, or another web server". +Worse, if Local is already bound to 80/443, *its* router keeps answering while +LocalKit's Caddy is down — the user sees a foreign 404 page, not a LocalKit +error, and has no idea the two apps are fighting. There is no recovery path +short of quitting the other app. Hosts-file entries coexist fine (both tools +manage their own marked blocks), so the entire conflict is about who owns +80/443. + +## Design + +### Phase 1 — Port pre-flight probe (`src-tauri/src/router.rs`) + +- `probe_ports(http: u16, https: u16) -> Vec`: try + `std::net::TcpListener::bind(("0.0.0.0", port))` for each router port; a + failed bind means something else owns it. Pure std, no Docker round-trip. +- `identify_port_owner(port) -> Option`: best-effort process name for + the conflict message. Windows: PowerShell + `Get-NetTCPConnection -LocalPort

-State Listen` → `OwningProcess` → + `Get-Process -Id` (spawn via `docker::no_window`); macOS/Linux: + `lsof -nP -iTCP:

-sTCP:LISTEN`. Failure to identify is fine — the message + falls back to the generic hint list. +- `PortConflict { port, process: Option }` — serializable, surfaced in + `RouterStatus` as `conflicts: Vec`. +- `set_enabled` runs the probe *before* touching the hosts file; a conflict + short-circuits with a named error ("port 80 is held by `httpd.exe` + (LocalWP's router) — stop it or switch LocalKit to fallback ports") instead + of writing hosts entries that would point at a foreign router. +- `status()` probes whenever `enabled && !running` so reopening the app while + the conflict persists shows the same diagnosis instead of a bare "not + running". + +### Phase 2 — Configurable router ports (fallback mode) + +- New `app_settings` keys (KV, no migration): `router_http_port` (default 80), + `router_https_port` (default 443). Settings → Domains gets two validated + number fields; changing them regenerates compose + Caddyfile, restarts the + router, and calls `rewrite_site_urls` (the same path the enable toggle + already uses). +- `render_compose` binds `:80` / `:443` (container ports stay + 80/443 — only the host mapping moves). `render_caddyfile` is unchanged. +- `site_url(slug, ca_trusted)` becomes port-aware: default ports → clean + `https://slug.test`; fallback ports → `http://slug.test:8080`. All consumers + already funnel through `site_url` / `site_public_url` (frontend `siteUrl` + mirror, one-click login, `site.rs` install-time URL) — extend the mirror in + `src/lib/types.ts` and the settings store accessors. +- The hosts block is port-blind (`127.0.0.1 slug.test`), so no hosts changes; + the browser appends the port from the URL. +- HTTPS in fallback mode still works (`https://slug.test:8443`, `tls + internal`), but the UI should default fallback URLs to http to avoid a + second cert prompt on a non-standard port. + +### Phase 3 — Conflict UX + +- Settings → Domains: when `conflicts` is non-empty, show an amber callout + naming the process + port, with two actions: "Use fallback ports" + (one-click sets 8080/8443 and retries enable) and "Retry" (after the user + quit the other app). No silent failures. +- SiteDetail: if the site's public URL is a domain URL and the router is in + conflict, show a dismissible banner with the same callout (the toast alone + is too easy to miss — the user is staring at a foreign 404 page). +- `lk doctor` reports port 80/443 ownership and the active router mode + (default/fallback/disabled), so support questions have a copy-paste answer. + +### Phase 4 — Docs + +- README troubleshooting entry: "LocalWP / Local by Flywheel is installed" → + expected behavior, fallback ports, why both apps can't share 80. +- AGENTS.md router convention block: note the port settings keys and that + `site_url` is port-aware. + +## Risks + +- WP absolute URLs: in fallback mode `home`/`siteurl` contain `:8080`. + `sync.rs` search-replace on pull uses the *current* `site_public_url`, which + is port-aware after Phase 2, so local→remote and remote→local both + round-trip; verify with a port-bearing URL in the m4 smoke. +- Another tool may bind 8080 too — the fallback enable path runs the same + pre-flight probe and reports the new conflict by name instead of looping. +- Docker Desktop's own port forwarding on Windows can hold 80 briefly after a + failed `compose up`; the probe runs before any compose mutation, so it can't + race our own containers. + +## Verification + +- `cargo test --lib router`: new unit tests — `render_compose` with custom + ports, `site_url` port formatting, probe returning empty when ports free. +- Manual matrix: (1) LocalWP running → enable domains → named conflict, + one-click fallback → `http://test.test:8080` serves the LocalKit site; + (2) quit LocalWP → "Retry" → clean `http://test.test`; (3) nothing + conflicting → unchanged default behavior. +- `lk doctor` output in both modes. diff --git a/docs/plans/17_snapshots.md b/docs/plans/17_snapshots.md new file mode 100644 index 0000000..29a51a2 --- /dev/null +++ b/docs/plans/17_snapshots.md @@ -0,0 +1,88 @@ +# 17 — Local site snapshots & one-click restore + +Status: ⬜ planned + +Point-in-time copies of a site (DB dump + `wp-content` archive) with +one-click restore, taken automatically before every destructive operation +(push, pull, delete) and manually from the UI/CLI. This is the safety net +that makes plan 18 (import) and plan 19 (sync v2) safe to build on. + +## Motivation + +Every mutating operation in LocalKit is currently one-way: pull DB overwrites +the local database, push DB overwrites the *remote* database, delete is +forever. `sync_history` records that something happened but cannot undo it. +A bad search-replace or a pull against the wrong connection means data loss. +Snapshots turn all of these into reversible operations and give users a +cheap "checkpoint before I try something" habit. + +## Design + +### Phase 1 — Snapshot engine (`src-tauri/src/snapshot.rs`) + +- Layout: `/snapshots///` containing `db.sql.gz` + (`wp db export -` via `docker::compose_run`, gzipped with flate2 — same + stack as `sync.rs`), `wp-content.tar.gz` (in-memory tar of the + bind-mounted `wp-content/`, same code path as push code), and + `manifest.json`. +- Manifest: `{site_id, created_at, kind, note, db_bytes, code_bytes, + wp_version}` where `kind` is `manual | pre_push | pre_pull | pre_delete | + pre_restore`. Yes, snapshot before restore too — restoring is destructive. +- Commands: `list_snapshots(site_id)`, `create_snapshot(site_id, kind, + note?)`, `restore_snapshot(snapshot_id)`, `delete_snapshot(snapshot_id)`. + Restore = `wp db import` via `compose_run_stdin` + extract the tar into the + site's `wp-content` dir (plain fs write, bind-mounted) + `wp cache flush`. + Site must be running to import the DB; restore auto-starts a stopped site + and reports that it did. +- Long operations emit `site-event` stages (`snapshot` / `restore`) so the + pinned progress toast pattern keeps working. +- Retention: auto-kinds are pruned to the newest 5 per site per kind after + each create; `manual` snapshots are never auto-pruned. Deleting a site + keeps its snapshots directory unless the delete dialog's new "also delete + snapshots" checkbox is checked. + +### Phase 2 — Wiring into destructive flows + +- `sync.rs`: `push_db` and `pull_db` take a local pre-sync snapshot first + (kind `pre_push` / `pre_pull`, note = connection name + remote URL). + Failure to snapshot aborts the sync with a clear error — never mutate + without a net. +- `site.rs::delete_site`: kind `pre_delete` snapshot, blocking, before any + container teardown. Delete dialog copy: "A restorable snapshot will be + kept." +- Snapshot-before-delete is also the foundation for a future "restore deleted + site" flow (out of scope here, but the manifest keeps enough metadata). + +### Phase 3 — UI + CLI + +- SiteDetail → new "Snapshots" tab: table (created, kind badge, note, sizes, + DB/code presence), actions Restore / Delete / (manual) Create with an + optional note field. Restore confirms with a dialog that names the + snapshot time and mentions the pre-restore snapshot. +- `lk snapshot list|create|restore|delete ` following CLI conventions + (stdout data only, `--json`, restore prompts with default No, `--yes` on + non-TTY). +- Command palette: "Create snapshot" per-site command via the existing + `buildCommands()` per-site command block. + +## Risks + +- Disk usage: `wp-content` archives can be large (uploads). Mitigate with + the retention cap + sizes visible in the UI + the delete-site checkbox. + A future plan can add exclude-paths for `uploads/cache`-style dirs. +- Restore while the user has the terminal open mid-write is racy in theory; + in practice `wp db import` is atomic enough and the site stays up. Not + worth a maintenance-mode flag for v1. +- Snapshot of a site whose containers are stopped: DB export needs the db + container — auto-start just the db service (`docker compose up -d db`), + wait healthy, export. Reuse the create flow's wait loop. + +## Verification + +- New `cargo run --example snapshot_smoke` — create site → snapshot → break + the DB (`wp post delete 1 --force`) → restore → assert the post is back; + pre-delete snapshot survives site deletion. +- Unit tests: retention pruning (pure function over manifest list), manifest + serde round-trip. +- `npm run dev:mock`: mock snapshots in `src/mock/data.ts` + `core.ts` so the + tab renders without Docker. diff --git a/docs/plans/18_import-remote-site.md b/docs/plans/18_import-remote-site.md new file mode 100644 index 0000000..87a17ad --- /dev/null +++ b/docs/plans/18_import-remote-site.md @@ -0,0 +1,100 @@ +# 18 — Import a ServerKit site as a new local site + +Status: ⬜ planned + +One-click "clone to local" for any site on a connected ServerKit server: +provision a fresh local site, pull down the remote `wp-content` and database, +rewrite URLs, and land the user on a working local copy. Closes the last +open item in Track B (today pull only targets an *existing* local site). + +## Motivation + +The most common real workflow — "client's site is on the server, I need to +work on it locally" — currently requires: create a local site by hand, +delete its stock content, pull the DB, and somehow get the remote +`wp-content` (which LocalKit cannot fetch at all today: the extension has +push endpoints only). Each step is manual and the URL/plugin/theme mismatch +failure modes are unforgiving. This plan adds the missing download direction +for code and orchestrates the whole flow behind one button. + +## Design + +### Phase 1 — Server side: `GET /api/v1/localkit/pull/code` (ServerKit repo) + +- New endpoint in the `serverkit-localkit` extension mirroring `pull/db`: + `site_id` param → tar.gz of the remote site's `wp-content/` (streamed, + `after_this_request` temp cleanup, same admin RBAC decorators). +- Reuse the extension's existing `_resolve_wp_content_dir` knowledge of the + container layout; create the archive with `tar czf - -C wp-content` via `docker exec` so symlinks/perms survive, stream + the file back. +- Extend `GET /pair` with a `features` array (`["sites", "push", "pull-db", + "pull-code"]`) so LocalKit can gate the Import button on extension + capability instead of failing mid-flow. Older extension = feature absent = + button disabled with a tooltip. +- Also extend `GET /sites` payloads with `wp_version`, `php_version`, and + `site_url` if not already present — the import flow needs them to pick + local versions and run search-replace. + +### Phase 2 — Orchestration (`src-tauri/src/sync.rs`) + +- `pull_new_site(connection_id, remote_site_id, local_name?) -> + Result`: + 1. Read remote metadata (name, wp/php version, URL). + 2. `site::create_site_files` equivalent: new record, unique slug from the + remote name (or `local_name`), fresh ports, compose + `.env` with the + closest matching PHP version from `PHP_VERSIONS` (record the mismatch + in a warning event if not exact). + 3. Pre-pull images (existing `pulling` stage path). + 4. Download `pull/code` → extract into the site's `wp-content` bind mount + (safe-extract policy: reject absolute paths, `..`, symlinks escaping + the target — the client-side mirror of the server's tar policy). + 5. Start containers, wait healthy (existing stages). + 6. Download `pull/db` → `wp db import` via `compose_run_stdin` → + `wp search-replace --all-tables` (serialization- + safe; `local_url` from port-aware `site_public_url`) → `rewrite flush`. + 7. Skip `wp core install` entirely — the imported DB *is* the site. The + local `admin_user` record comes from the first administrator in the + imported users table (`site_wp_users` logic), falling back to the + remote admin email; the one-click login MU plugin is written by the + existing `ensure_login_plugin` on first login. + 8. Record a `SyncRecord` (`kind: "import"`) and emit per-stage + `site-event`s throughout, ending in `done`. +- A `pre_import` guard: refuse if a local site with the same slug exists and + was itself created by an import linked to the same remote site — offer + "pull into existing" instead. Store `remote_site_id` + `connection_id` on + the local site (new columns, **migration 5**) so future pulls default to + the right remote. + +### Phase 3 — UI + CLI + +- Connection detail (ServerKit page): each remote site row gets an "Import" + button next to the existing push/pull targets → dialog with local name + override, PHP/WP version readout (with mismatch warning), and the progress + toast doing the rest. +- Dashboard: imported sites show a subtle link icon with the connection + name (from the migration-5 columns). +- `lk import [--name ]` — same site/connection + resolution rules as the rest of the CLI, `--json` prints the created site. + +## Risks + +- Large sites: archive is streamed but still monolithic — the 100 MB + `MAX_CONTENT_LENGTH` on the server bounds downloads too. Plan 19 (chunked + sync) generalizes this; the Import button should warn when the remote + reports a huge `wp-content`. +- PHP/WP version drift: importing a PHP 8.3 site onto an 8.1 image usually + works but not always — the mismatch warning event + sync-history note is + enough for v1; don't attempt image-matrix matching. +- Multisite and custom `WP_CONTENT_DIR` remotes: detect and refuse with a + clear error rather than producing a half-broken copy. + +## Verification + +- Extend `examples/m4_smoke.rs` (and `mock_localkit_ext.cjs`) with an + `import` path: seed the mock server with a fake site → run import → + assert local site runs, URLs rewritten, sync_history row written. +- `cargo test --lib sync`: safe-extract unit tests (traversal, absolute + paths, symlink escapes) against fixture archives. +- Manual E2E against a real ServerKit box: import → one-click login works → + edit a theme file locally → push code back. diff --git a/docs/plans/19_sync-v2-chunked.md b/docs/plans/19_sync-v2-chunked.md new file mode 100644 index 0000000..b88f970 --- /dev/null +++ b/docs/plans/19_sync-v2-chunked.md @@ -0,0 +1,91 @@ +# 19 — Sync v2: chunked transfers, byte progress, resume, cancel + +Status: ⬜ planned + +Replace the monolithic in-memory push/pull with a chunked, resumable +transfer protocol between LocalKit and the `serverkit-localkit` extension, +with real byte-level progress and cancellable operations. + +## Motivation + +Sync v1 (plan 4) builds the whole `wp-content` tar.gz in memory, POSTs it in +one request, and hopes: bounded by the server's 100 MB body limit, no +progress beyond coarse stages, a dropped connection at 99% means starting +over, and the UI can offer no cancel button because the operation is one +giant `await`. Any site with a real `uploads/` directory hits these walls. +The extension's own docstring already flags "sync runs inline, no job queue" +as its known v1 limitation. + +## Design + +### Phase 1 — Chunked upload protocol (both sides) + +- Server (`serverkit-localkit` extension, ServerKit repo): + - `POST /push/{code,db}/init` → `{transfer_id}`; body describes the + transfer: `site_id`, `total_bytes`, `chunk_size`, `sha256` of the whole + archive, plus operation metadata (`local_url` for DB pushes). + - `PUT /push/{code,db}/chunk` — `{transfer_id, offset, sha256}` + raw + body; server writes the range into a temp file, records the chunk hash, + returns the set of offsets already confirmed (idempotent: re-sending a + confirmed chunk is a no-op 200). + - `POST /push/{code,db}/finish` — verifies whole-file sha256, then runs + the *existing* v1 processing path (safe-extract → `docker cp` / DB + import → search-replace) on the assembled temp file, streams the result. + - Stale transfers (no chunk for 30 min) are reaped by a lazy sweep on + `init`. +- Client (`src-tauri/src/sync.rs`): stream the archive through a + hasher+chunker (8 MiB chunks) instead of a `Vec` — for push code this + also means tarring straight to the socket pipeline instead of memory, + which fixes the RAM blowup on big sites as a side effect. +- Resume: before uploading, `init` returns any previously confirmed offsets + for the same `(site_id, sha256)`; the client skips those chunks. Retrying + a failed push therefore re-sends only what was lost. + +### Phase 2 — Progress, cancel, versioning + +- `site-event` payload extended with `{bytes_done, bytes_total}` during + transfer stages; `sites.ts handleEvent` renders "Pushing code — + 148 / 312 MB" in the pinned progress toast (keep stage messages for the + non-transfer phases). +- Cancel: new `cancel_sync(site_id)` command drops an `Arc` + checked between chunks; server-side, an unfinished transfer is simply + abandoned and reaped — no half-applied state is possible because + processing only happens in `finish` after hash verification. +- Capability negotiation: `GET /pair` `features` array (plan 18) gains + `"sync-v2"`. Older extension → LocalKit silently uses the v1 monolithic + path with the v1 progress granularity. One client, both servers. + +### Phase 3 — Pull direction + job handoff + +- Downloads: server sends `Content-Length` and supports `Range`; client + downloads in ranges to a temp file with the same resume/verify/cancel + mechanics, then imports. No protocol invention needed — HTTP already is + the chunked protocol here. +- Server-side long processing (the import/extract in `finish`) moves onto + the extension's job queue with a `GET /jobs/` poll, so a client + disconnect during *processing* (not transfer) can re-attach and learn the + outcome instead of guessing. `SyncRecord` is written from the poll result. +- Timeouts: reqwest per-chunk timeout only (no whole-request cap); the + whole operation is bounded by liveness, not duration. + +## Risks + +- Two code paths (v1/v2) in `sync.rs` — keep v1 as a single isolated + function and route through a `match features` at the top; do not sprinkle + conditionals through the flow. +- Hash-verified `finish` means the server holds a trusted-but-unprocessed + archive; the safe-extract policy stays mandatory exactly as in v1. +- Chunk size tradeoff: 8 MiB is a round number that keeps request counts + low on LAN-ish links without making progress bars jumpy; make it a const, + not a setting. + +## Verification + +- `examples/mock_localkit_ext.cjs` implements v2 (in-memory chunk store) → + `m4_smoke` runs the full v2 flow against it, including: kill the client + mid-upload, re-run, assert only missing chunks were re-sent (mock counts + requests) and the final hashes match. +- Synthetic >100 MB `wp-content` fixture (sparse files) proves the v1 limit + is gone and memory stays flat (`docker stats`-level eyeball is fine). +- Unit tests: chunker/hasher pipeline (offset math, hash continuity), + resume-set subtraction. diff --git a/docs/plans/20_clone-and-blueprints.md b/docs/plans/20_clone-and-blueprints.md new file mode 100644 index 0000000..8296d09 --- /dev/null +++ b/docs/plans/20_clone-and-blueprints.md @@ -0,0 +1,88 @@ +# 20 — Site clone + reusable blueprints + +Status: ⬜ planned + +Two related creation flows: **clone** an existing local site in one click, +and save any site as a named **blueprint** (content + config recipe) that +new sites can be created from. Builds directly on the plan-17 snapshot +engine. + +## Motivation + +Track A's open item ("site duplication / clone") covers the daily case: +"I need a throwaway copy of this site to test a plugin update." The wider +case is just as common: developers who spin up client sites keep +re-installing the same starter stack — same theme, same five plugins, same +settings. Today that muscle memory lives outside the app. Blueprints make a +configured site a first-class, reusable template, and both flows share 90% +of their machinery with snapshots, so the marginal cost is low. + +## Design + +### Phase 1 — Clone (`src-tauri/src/site.rs`) + +- `clone_site(id, new_name) -> Result`: + 1. Snapshot the source via the plan-17 engine (`kind: "clone_source"`, + pruned aggressively — it's an implementation detail, not a user + snapshot). + 2. Create the target record: `unique_slug(new_name)`, fresh ports, fresh + DB passwords + WP salts in `.env` / `wp-config` — never copy secrets. + 3. Restore the snapshot into the target's dirs/containers. + 4. `wp search-replace --all-tables` + (both URLs from `site_public_url`, port-aware per plan 16) + + `rewrite flush` + `cache flush`. + 5. Rewrite the login MU plugin (`ensure_login_plugin`) and default the + clone's `admin_user` from the source. +- Emits `site-event` stages (`snapshot → files → containers → import → + done`) so the progress toast works unchanged. +- UI: Clone button in SiteDetail header + dashboard card context area + (name dialog, then progress). CLI: `lk clone `. + +### Phase 2 — Blueprints + +- Storage: `/blueprints//` = `blueprint.json` + `db.sql.gz` + + `wp-content.tar.gz` (the snapshot artifacts, copied) — + `blueprint.json` adds `{name, description, wp_version, php_version, + plugins: [...], theme, created_at, source_site_name}`. Plugin/theme lists + are captured via `wp plugin list --format=json` at save time — display + metadata only, v1 does not re-resolve them. +- Commands: `save_blueprint(site_id, name, description?)`, + `list_blueprints()`, `delete_blueprint(id)`, + `create_site_from_blueprint(blueprint_id, name, ...)` — the create flow + with steps 3–5 of clone (fresh creds/salts, restore, search-replace from + the recorded source URL to the new site URL). +- NewSiteDialog gains a "From blueprint" section (list with plugin/theme + chips + a description line); Dashboard empty-state suggests saving a + blueprint once a site exists. CLI: `lk blueprint list|save|delete`, + `lk create --blueprint `. +- Portability: `lk blueprint export -o site.lkbp` (single tar.gz of + the blueprint dir) and `import` — enough to share blueprints in a team + without building a registry. + +### Phase 3 — Polish + +- Blueprint thumbnails: optional; capture the dashboard screenshot pipeline + (`scripts/capture-screenshots.mjs`) is dev-only, so v1 = a generated + initial-letter tile, not site screenshots. +- Router integration: clones/blueprint-sites are ordinary sites — Caddyfile + regen + hosts sync already hook site create/delete. + +## Risks + +- Cloning a running site: the snapshot reads a live DB — `wp db export` is + consistent enough for dev; document that cloning quiesces nothing. +- Blueprint staleness: a blueprint's WP core version is whatever the image + provides (content only stores `wp-content` + DB) — the create dialog shows + the recorded `wp_version` and warns if the local allowlist no longer has + it (falls back to nearest). +- Disk: blueprints duplicate snapshot bytes. `save_blueprint` hardlinks the + snapshot artifacts when the fs allows, copies otherwise. + +## Verification + +- Extend `examples/smoke.rs` with a `clone` subcommand: create → add a post + → clone → assert the post exists at the clone's URL and passwords differ. +- Unit tests: blueprint manifest serde, hardlink-or-copy fallback, slug + uniqueness against existing sites. +- Mock mode: sample blueprints in `src/mock/data.ts` so the NewSiteDialog + section is reviewable without Docker. diff --git a/docs/plans/21_cli-serverkit.md b/docs/plans/21_cli-serverkit.md new file mode 100644 index 0000000..c53ef0b --- /dev/null +++ b/docs/plans/21_cli-serverkit.md @@ -0,0 +1,74 @@ +# 21 — `lk` CLI: ServerKit connections, push/pull, shell completions + +Status: ⬜ planned + +Close out Track D: give the `lk` CLI full access to the ServerKit side of +the app — manage connections, list remote sites, push, pull — plus shell +completions. Everything is a thin wrapper over `localkit_lib` calls that +already exist; this plan is mostly CLI ergonomics and conventions. + +## Motivation + +The GUI can do everything ServerKit-related; the CLI can do none of it. +That blocks scripting the exact workflows the CLI exists for ("nightly +`lk pull db` before I start work", CI-flavored local refreshes) and leaves +Track D's checkboxes open. Because `sync.rs` and `serverkit.rs` already do +the heavy lifting and emit progress to stderr when there's no Tauri handle, +this is a high-value, low-risk surface expansion. + +## Design + +### Phase 1 — Connections (`src-tauri/lk/src/main.rs`) + +- `lk connection add ` — prompts for the API key with a hidden + TTY prompt (`rpassword`; `--key` flag / `LOCALKIT_API_KEY` env for + non-TTY), then immediately runs the same `test_connection` validation as + the GUI (health → key → extension probe) and refuses to store a key that + doesn't validate. +- `lk connection list` — table or `--json`: name, url, extension version / + features, last used. `lk connection test ` re-runs validation. + `lk connection remove ` — prompts (default No), `--yes` on non-TTY. +- Connections resolve by exact id or case-insensitive name, same rule as + sites; ambiguity is an error listing the matches. + +### Phase 2 — Sync commands + +- `lk sites --remote ` — remote site listing via the extension + (new read-only wrapper over `serverkit.rs`). +- `lk push --code|--db [--connection ]` and `lk pull + --db [--connection ]`. `--connection` is required only when the + site has no linked remote (plan 18's migration-5 columns) and more than + one connection exists. +- Progress: the library's `site::emit` already prints `[stage] message` to + stderr with no app handle; v2 byte progress (plan 19) prints a + `\r`-redrawn single-line percentage on TTY, plain lines when piped. +- Exit codes: 0 success, 1 error, 2 = remote rejected the operation + (distinguishable in scripts). Errors keep the `error: ` stderr + convention. +- `--json` on push/pull prints the resulting `SyncRecord`. + +### Phase 3 — Completions + doctor + +- `lk completions ` via `clap_complete` — static + subcommand/flag completion (dynamic site-name completion is a later + stretch; the generator hooks make it cheap to add). +- `lk doctor` gains connection checks: for each stored connection, DNS + + TLS + `/pair` reachability, printed as pass/fail lines — one command to + answer "is it me or the server". + +## Conventions (binding, per AGENTS.md) + +- stdout carries data only; all chrome/progress/✓ to stderr. +- `--json` per command, always pretty. +- No logic in the CLI crate — anything reusable goes into `localkit_lib` + (e.g. the "resolve connection by name" helper lives next to the site + resolver). + +## Verification + +- Against `examples/mock_localkit_ext.cjs` (extended in plans 18/19): + scripted run — `connection add` (env key) → `sites --remote` → `push + --code` → `pull --db` → assert exit codes and `--json` shapes. +- `clap_complete` output smoke: generate all four shells, assert non-empty + and stable (snapshot test). +- Manual: `lk doctor` with the mock server up/down. diff --git a/docs/plans/22_multi-stack-core.md b/docs/plans/22_multi-stack-core.md new file mode 100644 index 0000000..1bfa91f --- /dev/null +++ b/docs/plans/22_multi-stack-core.md @@ -0,0 +1,100 @@ +# 22 — Multi-stack core: kind/capability model + generic Docker apps + +Status: ⬜ planned + +Generalize LocalKit's core from "WordPress site manager" to "local project +manager" in two steps: a `kind` + capability model that makes every feature +stack-aware, then the first non-WP kind — bring-your-own-compose **Docker +apps**. Deliberately placed before plans 23–25 so everything built after +this is capability-aware from day one instead of retrofitted. The +PHP/Laravel stack and per-kind ServerKit sync are plan 26. + +## Motivation + +LocalKit assumes WordPress everywhere it matters: the terminal shells into +a hardcoded `wordpress` service, sync tars a hardcoded `wp-content/`, DB +ops go through wp-cli, one-click login uses a WP MU plugin, and the UI +shows WP affordances unconditionally. Yet most of the machinery — per-site +Compose projects, the shared router, terminals, logs, snapshots, tray — is +stack-agnostic in principle. A developer with a Laravel API or a stray +dockerized tool alongside their WP sites gets zero value today. Meanwhile +every plan we ship before this one adds more WP-shaped code to unwind +later. The goal: one capability system that every feature checks, with +WordPress as the polished reference implementation — not an `if` branch. + +## Design + +### Phase 1 — Kind + capability core (migration 7) + +- `sites.kind` column: `"wordpress" | "docker"` (default `"wordpress"` — + existing rows migrate cleanly; `"php"` arrives with plan 26). Sites also + gain `config_json` (per-kind settings: service names, sync path, app + port). +- Capability table in `src-tauri/src/site.rs` (const per kind, exposed via + `app_info` and on each `Site` payload): + `domains, terminal, logs, snapshots, db_gui, db_sync, code_sync, + one_click_login, wp_tools, search_replace`. WordPress = all true; + docker = `domains, terminal, logs, snapshots, code_sync`. +- De-hardcode the WP assumptions: + - `terminal.rs` execs into `config.service` (default `wordpress`); + - `sync.rs` code archives tar `config.sync_path` (default `wp-content/`); + - `router.rs` upstream reads `config.app_port` (default = site port); + - one-click login, Tools tab, WP Admin button, `lk wp` gate on + capability in both frontends — Tauri commands return a clean + "not supported for this site kind" error; the UI hides rather than + errors. +- **Grep-audit gate:** checklist of every `wordpress` / `wp-content` / + `wpcli` literal in `src-tauri/src` with a verdict (capability-gated, + config-driven, or legitimately WP-only). `cargo check` + the full WP + smoke example must pass unmodified before Phase 2 starts — WordPress is + the zero-change path by construction. + +### Phase 2 — Generic Docker app kind + +- Creation flow: "Import a Docker project" in NewSiteDialog — pick a + directory containing a compose file; LocalKit **copies** it into the + managed site dir (owned, not referenced — external dirs are a + backup/locking nightmare), asks which service is the app + its port, + writes `.env` and the record. Copy excludes `.git`, `node_modules`, + `vendor` via a default ignore list with an opt-out. +- Gets for free: start/stop/restart/delete, logs viewer, terminal (exec + into the chosen service), local domain (`.test` → app port, all + plan-16 conflict/fallback behavior included), tray actions, `lk` + lifecycle commands. +- Snapshots (plan 17): code-only by default; if a recognized db image + (`mysql`/`mariadb`/`postgres`) is among the services, `db_sync` + capability flips on and DB snapshots/dumps use the engine's native dump + tool. +- No WP tooling, no ServerKit sync (plan 26), no admin login — the value + is "all my local projects in one place, with domains and a tray". + +### Phase 3 — Frontend capability gating + +- `Site` type in `src/lib/types.ts` gains `kind` + `capabilities`; + SiteDetail renders tabs/sections from the capability list (Tools tab and + WP Admin button hidden for `docker`), Dashboard cards get a small kind + badge (WP / Docker), `buildCommands()` skips capability-less per-site + commands. +- Mock mode: one fake site per kind so gated UI is reviewable in + `npm run dev:mock`. + +## Risks + +- Scope creep — the guardrail: a kind ships only when every capability it + claims works; partial kinds are worse than no kinds. WordPress + regressions block merge, full stop. +- The de-hardcoding touches `terminal.rs`, `sync.rs`, `router.rs`, + `wordpress.rs` — hence the Phase 1 grep-audit gate; no "we'll catch it + later". +- Users importing huge compose projects: the ignore list covers the common + cases; the import dialog shows the copied size before confirming. + +## Verification + +- WP regression: existing `smoke` / `m4_smoke` examples pass unmodified. +- New `docker_smoke` example: import a trivial two-service compose fixture + → start → domain resolves → terminal opens in the right service → + stop → delete. +- `cargo test --lib site`: capability matrix tests (every kind × every + capability is an explicit, tested decision), compose-copy ignore list, + `config_json` serde defaults. diff --git a/docs/plans/23_reconciliation.md b/docs/plans/23_reconciliation.md new file mode 100644 index 0000000..3cba52f --- /dev/null +++ b/docs/plans/23_reconciliation.md @@ -0,0 +1,87 @@ +# 23 — Status reconciliation & crash recovery + +Status: ⬜ planned + +Keep the app's view of the world honest: a reconciler that continuously +settles the DB's site statuses against Docker's ground truth, plus a +recovery path for sites left half-created by a crash or kill mid-install. + +## Motivation + +Site status today is write-path only: commands set `running`/`stopped` in +SQLite when they succeed. Reality disagrees often — Docker Desktop +restarts, the user kills the app mid-create, containers get `docker stop`ed +from outside, a push dies halfway. The UI then shows "running" sites that +are dead, "stopped" sites that are up, and (worst) a site whose directory +and containers exist but whose WP install never finished, with no offered +path except manual cleanup. The tray menu reads the same DB status, so the +lie propagates everywhere. Rule: **inspect ground truth, settle forward, +never guess.** + +## Design + +### Phase 1 — Reconciler (`src-tauri/src/reconcile.rs`) + +- `reconcile_once(state) -> Vec`: for each site, compare + the DB status with `docker::compose_ps` ground truth: + - DB `running` but no containers → settle to `stopped` (external stop) — + unless the site's own event stream marked it running in the last 60 s + (grace window for slow starts). + - DB `stopped` but wordpress container `Up` → settle to `running` + (external start). + - Containers up but wordpress service restarting/unhealthy → `degraded` + (new status, amber badge — distinct from both running and stopped). +- **Forward-only semantics:** reconciliation may never downgrade a status + that a *newer* explicit command/event set. Each status write carries a + `status_updated_at` (new column, **migration 6**); the reconciler only + wins when its observation is newer. A late success can never be clobbered + by a stale inspect, and vice versa. +- Scheduling: once at app start (before the window's first data load, so + the dashboard opens honest), then every 60 s while running, debounced + against any in-flight lifecycle command per site (a site with an active + command is skipped — its events own the truth right now). +- After any settle: `tray::refresh(&app)` + a lightweight + `sites-changed` event so the frontend re-fetches; settles are logged + (`reconciled: site X running→stopped (external stop)`) at info level. + +### Phase 2 — Half-created site recovery + +- Marker: `site.rs` writes `.localkit-install-complete` (empty file) in the + site dir as the last create step. On reconcile, a site record whose dir + lacks the marker is flagged `incomplete`. +- UI: incomplete sites render with an amber "Setup incomplete" badge and a + choice dialog — **Resume setup** (re-run from the install stage: + containers exist, images pulled, so it's the wait + `wp core install` + tail of the create flow) or **Clean up** (delete path, which already + tolerates partial state). +- Same guard covers killed installs behind the "waiting for database" + stage: resume re-enters the existing wait loop rather than assuming + health. + +### Phase 3 — Docker daemon health + +- `docker::check` result cached for 30 s and exposed via `app_info`; when + the daemon drops, the sidebar shows a global "Docker unavailable" pill, + lifecycle commands short-circuit with the existing `friendly_error`, and + the reconciler suspends itself (no ground truth = no settles, definitely + no mass "stopped" flapping when Docker Desktop restarts). + +## Risks + +- `compose_ps` per site every 60 s is N subprocesses; batch by running one + `docker ps` filter pass and matching compose project names locally. Keep + `no_window` discipline. +- Race with in-flight creates: the per-site in-flight set must be shared + with *all* command paths (GUI commands, `lk`, tray spawns) — a + `DashSet` on `AppState` checked by the reconciler. +- `degraded` is a new status value — touchpoints: `StatusBadge`, dashboard + filters, tray menu labels, `lk list` output, mock data. + +## Verification + +- `cargo test --lib reconcile`: decision-table unit tests over a stubbed + compose-ps (every DB-status × container-state × recency combination, + including the forward-only guard). +- Manual: `docker stop` a site's container externally → within 60 s the UI + and tray show stopped; kill the app mid-create → relaunch → "Setup + incomplete" → Resume finishes the install. diff --git a/docs/plans/24_site-tools.md b/docs/plans/24_site-tools.md new file mode 100644 index 0000000..7e066e5 --- /dev/null +++ b/docs/plans/24_site-tools.md @@ -0,0 +1,88 @@ +# 24 — Site tools: database GUI, search-replace, debug mode, config editor + +Status: ⬜ planned + +A "Tools" tab on SiteDetail covering the four things every WP developer +reaches for an external app to do today: browse the database, run a +search-replace, toggle WP_DEBUG and read the debug log, and edit +`wp-config.php` / `.env` without leaving the app. + +## Motivation + +LocalKit covers the site lifecycle well, but the *inner loop* of WordPress +development still pushes users elsewhere: they install TablePlus/phpMyAdmin +for the database, open a terminal for `wp search-replace` (or worse, run a +serialization-unsafe SQL replace by hand), edit `wp-config.php` in an +editor to turn on debugging, and tail `debug.log` in another window. Each +is a small, well-understood feature that the existing infrastructure +(profile-gated compose services, the wpcli runner, the router, the file +system) already supports. Together they make SiteDetail the single place +the daily work happens. + +## Design + +### Phase 1 — Database GUI (Adminer sidecar) + +- Adminer (single-file PHP, ~0.5 MB — not phpMyAdmin's 50 MB image) as a + profile-gated `adminer` service in the site compose template + (`adminer:4-standalone`), off by default, toggled from Tools → Database. + Gated on the `db_gui` capability (plan 22), so non-WP kinds with a + database get it too. Port: `db_port + 1000` mapped at create time (deterministic, no + allocator changes), plus a router host `db-.test` when domains are + enabled — `render_caddyfile` gains one conditional block; when the router + is in fallback-port mode (plan 16) the same port-awareness applies. +- "Open database" button starts the profile service on first use + (`docker compose --profile tools up -d adminer`), then opens the URL with + `?server=db&username=root` prefilled (password copied to clipboard with a + toast — Adminer can't take it in the URL). +- Mock mode: the button opens a fake disabled state with the same copy. + +### Phase 2 — Search-replace (`src-tauri/src/wordpress.rs`) + +- `search_replace(site_id, from, to, dry_run) -> SearchReplaceResult` + wrapping `wp search-replace --all-tables --precise + --report-changed-only [--dry-run]` — the serialization-safe path, never + raw SQL. +- UI: Tools → Search & Replace: from/to fields, always runs dry-run first + and shows the per-table change counts, then an explicit Apply. Result + notes the pre-replace snapshot (plan 17 auto-snapshot, kind + `pre_search_replace`) with a restore shortcut. +- `lk wp` already covers the CLI case — no new subcommand needed. + +### Phase 3 — Debug mode + log viewer + +- `set_debug(site_id, enabled)`: `wp config set WP_DEBUG --raw` + + `WP_DEBUG_LOG` + `WP_DEBUG_DISPLAY false` (log to file, never to screen) + via the wpcli runner; status read via `wp config get`. +- Tools → Debug: toggle + an auto-refreshing tail of + `wp-content/debug.log` rendered in the same mono/log styling as the + container logs viewer (plain fs read — the file is bind-mounted), with a + "clear log" button. + +### Phase 4 — Config file editor + +- Tools → Config: a lightweight editor (existing JetBrains Mono textarea + styling, no Monaco dependency) for `wp-config.php` and the site `.env`, + with save → offers to restart the site when `.env` changed (required for + compose to pick it up; `wp-config` needs nothing). Danger styling and a + one-line "editing this can break the site" note; no diff/backup machinery + — snapshots (plan 17) are the safety net. + +## Risks + +- Adminer on the router adds an attack surface on a dev machine: bound to + localhost anyway (same trust level as the sites), and off by default. +- Search-replace on big databases can take minutes — run via the wpcli + runner with the standard site-event progress; the dry-run-first flow + means the user sees cost before committing. +- The config editor must not fight the compose/env templates: `.env` keys + LocalKit manages (ports, passwords) get inline "managed by LocalKit" + markers in the template so the editor can warn on those lines only. + +## Verification + +- Manual: enable Adminer → log in → browse; dry-run a replace → apply → + assert serialized widget survives; toggle debug → fatal in a must-use + test plugin appears in the log viewer; edit `.env` port → prompted + restart → site answers on the new port. +- Mock mode renders all four tool sections with fake data. diff --git a/docs/plans/25_release-polish-completion.md b/docs/plans/25_release-polish-completion.md new file mode 100644 index 0000000..c531008 --- /dev/null +++ b/docs/plans/25_release-polish-completion.md @@ -0,0 +1,89 @@ +# 25 — Release polish completion: updater, keyring, notifications, test suite + +Status: ⬜ planned + +Finish the genuinely remaining M5 work (plan 5 predates the CI/release +workflows, which shipped separately): in-app update awareness, OS-keyring +storage for ServerKit API keys, OS desktop notifications for long +operations, and a real automated test suite. + +## Motivation + +Releases already build and publish for all platforms via +`.github/workflows/release.yml`, but the *installed* app has no idea newer +versions exist — users only update by re-downloading manually. ServerKit +API keys sit in plaintext SQLite (accepted for v1, now the largest security +debt in the app). Long operations (create, push, pull) complete silently +when the window is unfocused or closed-to-tray. And the test surface is +still `cargo check` + a handful of unit tests + manual smoke examples, +which every plan above (16–24) will strain. These four items are the +difference between "works on my machine" and a distributable product. + +## Design + +### Phase 1 — Update awareness + +- `tauri-plugin-updater` requires signed releases; our releases are + unsigned. So: a lightweight checker instead — on launch (and daily), + GET the latest GitHub release tag via the API; if newer than + `env!("CARGO_PKG_VERSION")`, show a dismissible toast + a Settings → + General "Update available" row linking to the release page + (opener plugin). Snooze state + last-checked in `app_settings` (KV). +- Same check in `lk` (`lk doctor` prints "update available: vX.Y.Z"; never + auto-downloads). +- If releases become signed later, swapping the checker for the real + updater is a drop-in replacement behind the same Settings row. + +### Phase 2 — OS keyring for ServerKit API keys + +- `keyring` crate (Windows Credential Manager / macOS Keychain / Secret + Service) keyed `localkit/connection/`. +- `serverkit.rs` gains a `KeyStore` abstraction with two backends; read + path = keyring → SQLite fallback (legacy) → migrate-on-read (write to + keyring, null the column). New/changed keys only ever touch the keyring. +- Graceful degradation: keyring unavailable (headless Linux, locked + keychain) → fall back to SQLite with a one-time warning logged, never a + hard failure. `lk` on servers keeps working. +- `serverkit_connections.api_key` column stays (nullable) for downgrade + compat — no migration needed, just stop writing it. + +### Phase 3 — OS desktop notifications + +- `tauri-plugin-notification`: fire on completion of long operations + (site created, push/pull done or failed, restore done) **only when the + window is unfocused or closed-to-tray** — the toast system already owns + in-focus feedback, and double-notifying is worse than either alone. +- Settings → General toggle `osNotifications` (default on), per the + settings-store conventions. Clicking a notification focuses the window + (single-instance plugin already handles focus). + +### Phase 4 — Test suite + +- Rust (`cargo test --workspace`, already wired in CI): unit tests per + pure module — `site::slugify`/`unique_slug`/port allocation, `db` + migration forward-only invariants (apply 1→N twice, assert + `user_version`), `sync` archive builders, plus whatever plans 16–24 add + (probe parsing, chunker, reconcile decision table, retention pruning). +- Frontend (`vitest`, new dev-dep, added to the CI build job): + `lib/shortcuts.ts` canonicalizer, `lib/fuzzy.ts`, `lib/keybindings.ts` + resolver, settings store parsing (`"true"`→bool, migrations), toast + dedupe logic in `lib/errors.ts`. +- Keep the smoke examples as the E2E layer; the unit suites exist so most + regressions are caught without Docker. + +## Risks + +- Keyring prompts: macOS may show a keychain permission dialog on first + access — acceptable one-time cost; documented in Settings copy. +- Notification permission on macOS must be requested at runtime; treat + denial as "toggle off", don't nag. +- Vitest + jsdom for store tests: keep them DOM-free where possible (pure + logic), mock `window.__LOCALKIT_SETTINGS__` explicitly. + +## Verification + +- `cargo test --workspace` + `npm run test` green in CI (new step). +- Manual: install previous release → launch → update toast appears → link + opens release page. Add a connection → key visible in Windows Credential + Manager, absent from SQLite. Close to tray → run a push from `lk` → + completion notification appears. diff --git a/docs/plans/26_php-laravel-stack.md b/docs/plans/26_php-laravel-stack.md new file mode 100644 index 0000000..bfa8451 --- /dev/null +++ b/docs/plans/26_php-laravel-stack.md @@ -0,0 +1,88 @@ +# 26 — PHP/Laravel stack + per-kind ServerKit sync parity + +Status: ⬜ planned + +The second multi-stack kind: a generated **PHP/Laravel** site template with +database sync that doesn't depend on wp-cli — plus the ServerKit extension +changes that make push/pull/import work per site kind. Depends on plan 22 +(kind/capability core), plan 17 (snapshots), plan 18 (import flow), and +plan 19 (sync v2). + +## Motivation + +Plan 22 makes LocalKit stack-aware and covers ad-hoc Docker projects, but +the most common non-WP case on the server side deserves a first-class +template: plain PHP/Laravel apps. Today syncing one means hand-running +`mysqldump` and rsync. With the capability core in place, a `php` kind is +an additive increment: a compose template, engine-native DB sync, and +per-kind dispatch on both ends of the sync protocol — no new architecture. +Node/Python kinds remain deliberately out of scope; the capability model +makes them a follow-up plan of the same shape when there's demand. + +## Design + +### Phase 1 — PHP/Laravel stack template (`src-tauri/src/site.rs`) + +- New `kind: "php"` in the capability matrix: everything except the + WP-specific trio (`one_click_login`, `wp_tools`, `search_replace`); + `db_gui` true (plan 24's Adminer tooling applies), `db_sync` and + `code_sync` true. +- Generated compose, mirroring the WP template's conventions: `app` + (php-fpm, version from the existing `PHP_VERSIONS` allowlist), `web` + (nginx with a static + fastcgi config template), `db` (mariadb, + `db_port` allocation unchanged), profile-gated `adminer`. +- Creation dialog: empty docroot skeleton (Laravel-ready `public/` webroot) + or import existing code into the site dir (same ignore-list copy as + plan 22's Docker import). No framework installer inside the app — the + terminal is right there for `composer create-project`. + +### Phase 2 — Engine-native DB sync (`src-tauri/src/sync.rs`) + +- DB export/import per kind, dispatched on capability instead of wp-cli: + `php`/`docker`-with-db → `mysqldump` in-container for export, `mysql < + dump` via `compose_run_stdin` for import (postgres services: `pg_dump` / + `psql` — same dispatch table). +- No search-replace for `php`: URL config is the app's own concern. The + import/pull flow offers a best-effort `APP_URL` patch in the project's + `.env` (Laravel convention), off by default, clearly labeled + best-effort. +- Push/pull orchestration, snapshots (plan 17 kinds `pre_push`/`pre_pull`), + sync_history records, and site-event stages are kind-agnostic already + after plan 22 — this phase is dispatch + templates, not new flow. + +### Phase 3 — ServerKit parity (both repos) + +- Extension (`serverkit-localkit`, ServerKit repo): + - `/sites` payload gains `kind`; push/pull endpoints accept non-WP site + ids and dispatch per kind: code = tar of the app's project dir (not + `wp-content`), db = engine dump/restore instead of the WP container + assumptions. + - `/pair` `features` advertises supported kinds (e.g. `"kinds": + ["wordpress", "php"]`); LocalKit disables sync/import UI for kinds the + server's extension version doesn't know — never fails mid-flow. +- Import flow (plan 18) extends to `php`/`docker` kinds with the same + orchestration minus WP install steps; `lk import` gains the kinds + transparently. +- Sync v2 (plan 19) chunked protocol is kind-agnostic by design — only the + server-side processing step in `finish` dispatches per kind. + +## Risks + +- PHP matrix drift (8.1/8.2/8.3 extensions): keep the image's default + extension set; document that exotic extensions mean customizing the + imported compose (which plan 22 makes a supported path). +- `docker` kind + ServerKit sync: arbitrary compose projects can't be + matched to server apps reliably — v1 sync parity covers `php` only; + `docker` sites keep local-only sync (snapshots). +- Two repo lockstep again: the `kinds` advertisement keeps old client ↔ + new server and new client ↔ old server combinations safe in both + directions. + +## Verification + +- Extend `mock_localkit_ext.cjs` with a fake `php` site: full import → + push db → pull db cycle through `m4_smoke`, asserting engine-native dump + commands were used (mock logs them). +- `cargo test --lib sync`: per-kind dispatch table tests (every kind × + operation has an explicit, tested handler or a clean unsupported error). +- WP regression: all existing smoke examples pass unmodified. diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index 1b27240..6b948f6 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -23,6 +23,17 @@ The file numbers ARE the build order — each plan leans on the ones before it. | 13 | `13_settings-store` | ✅ | Unified settings store on `app_settings` KV + pre-paint injection; substrate for terminal settings and themes. | | 14 | `14_terminal-quick-wins` | ✅ shipped | Web-links, copy-on-select, ghost-text history, terminal font/scrollback settings (needs 13). | | 15 | `15_command-palette-shortcuts` | ✅ shipped | Command registry + palette (mod+K), global shortcuts, remappable bindings in Settings (needs 13). | +| 16 | `16_router-coexistence` | ⬜ | Port-80/443 conflict pre-flight + configurable router ports so domains survive alongside LocalWP & co. | +| 17 | `17_snapshots` | ⬜ | DB + wp-content snapshots with one-click restore; automatic before push/pull/delete. Safety net for 18–20. | +| 18 | `18_import-remote-site` | ⬜ | Clone a ServerKit site down as a *new* local site (needs the extension's missing pull/code endpoint). | +| 19 | `19_sync-v2-chunked` | ⬜ | Chunked resumable push/pull with byte progress + cancel (breaks the 100 MB / in-memory limits). | +| 20 | `20_clone-and-blueprints` | ⬜ | One-click site clone + save-site-as-blueprint creation flows (needs 17). | +| 21 | `21_cli-serverkit` | ⬜ | `lk connection/push/pull` + remote listing + shell completions (Track D). | +| 22 | `22_multi-stack-core` | ⬜ | Kind/capability site model + bring-your-own-compose Docker apps — before 23–25 so new features are capability-aware from day one. | +| 23 | `23_reconciliation` | ⬜ | Settle DB site status against Docker ground truth; recover half-created sites; Docker-health gating. | +| 24 | `24_site-tools` | ⬜ | Tools tab: Adminer sidecar, serialization-safe search-replace, WP_DEBUG + log viewer, config editor. | +| 25 | `25_release-polish-completion` | ⬜ | M5 remainder: update checker, OS keyring for API keys, OS notifications, real test suite. | +| 26 | `26_php-laravel-stack` | ⬜ | Generated PHP/Laravel stack + per-kind ServerKit sync parity (needs 22, 17–19). | Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferred @@ -38,7 +49,7 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre (plan 10) - ✅ Windows polish: hide subprocess console windows, visible first-run install progress (plan 9) -- ⬜ Site duplication / clone (nice-to-have, unplanned) +- ⬜ Site duplication / clone (plan 20, with blueprints) ## Track B — ServerKit (M3–M4) @@ -48,15 +59,15 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre - ✅ Push code (in-memory tar.gz of `wp-content/`), push DB (`wp db export`), pull DB (download → `wp db import` → `wp search-replace`) - ✅ Sync history per site (migration 3) -- ⬜ Pull a remote site down as a *new* local site (today pull targets an - existing local site) +- ⬜ Pull a remote site down as a *new* local site (plan 18; today pull + targets an existing local site) ## Track C — Product (M5–M6) - ⬜ `npm run tauri build` installers per platform -- ⬜ Auto-update (Tauri updater) -- ⬜ OS keyring for ServerKit API keys (plaintext SQLite accepted for v1) -- ⬜ Real test suite (today: `cargo check` + router hosts-block unit tests + +- ⬜ Update awareness (plan 25 — checker first, Tauri updater if releases get signed) +- ⬜ OS keyring for ServerKit API keys (plan 25; plaintext SQLite accepted for v1) +- ⬜ Real test suite (plan 25; today: `cargo check` + router hosts-block unit tests + the `smoke` / `m4_smoke` / `m6_smoke` examples) - ✅ Local domains: `http(s)://.test` via a shared Caddy router + managed hosts block + local CA trust (plan 6), layered on top of the @@ -73,8 +84,19 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre - ✅ `lk wp ` wp-cli passthrough, `lk env` (eval-able exports), `lk doctor`, `-o json` / `--quiet` / `--data-dir` global flags - ⬜ ServerKit from the CLI: `lk connection add/list`, `lk push`, `lk pull` - (library calls already exist; future) -- ⬜ Shell completions, self-update (future) + (plan 21; library calls already exist) +- ⬜ Shell completions (plan 21), self-update (future) + +## Track F — Multi-stack (M9) + +- ⬜ Kind/capability site model (`wordpress` | `docker`, `config_json`, + capability-gated features in both frontends) — plan 22, placed before the + remaining feature plans so they're capability-aware from day one +- ⬜ Generic Docker apps: import an existing compose project → lifecycle, + logs, terminal, local domain, snapshots (plan 22) +- ⬜ PHP/Laravel generated stack + engine-native DB sync + per-kind + ServerKit push/pull/import parity (plan 26) +- 🅿️ Node/Python kinds (unplanned; same capability shape when there's demand) ## Track E — UX ports from Faro (M12–M14) From 34cae9f7691b435b28a8d4d57b7dca8a54f8e548 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 11:02:55 -0400 Subject: [PATCH 02/67] router: port pre-flight probe before enabling local domains (plan 16 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalWP's nginx router binds 80/443 machine-wide and answers every unknown local host with its own "Site Not Found" page. Without a pre-flight check, enabling local domains would write `127.0.0.1 .test` hosts entries pointing every LocalKit site at *that* router while our Caddy failed to start — the user sees a foreign 404 and no LocalKit error at all. - `probe_ports` / `port_free`: plain `TcpListener::bind` on both 0.0.0.0 and 127.0.0.1 (Windows lets a wildcard bind succeed while loopback is taken, which is exactly the silent-hijack case). - `identify_port_owner`: best-effort process name via Get-NetTCPConnection (Windows) / lsof (unix); unknown owners fall back to the generic hint. - `RouterStatus.conflicts: Vec`, mirrored in types.ts + mock. - `set_enabled` short-circuits on a conflict *before* the hosts write, and `status()` re-probes whenever enabled && !running so reopening the app re-reports the same named cause. Runs before any hosts-file or Docker mutation, so it can never race our own containers; skipped entirely when our Caddy is already up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/Cargo.lock | 4 +- src-tauri/src/router.rs | 240 ++++++++++++++++++++++++++++++++++++++-- src/lib/types.ts | 7 ++ src/mock/data.ts | 1 + 4 files changed, 238 insertions(+), 14 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a3bbf96..82e0acb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2194,7 +2194,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lk" -version = "0.1.0" +version = "0.1.1" dependencies = [ "clap", "dirs 5.0.1", @@ -2207,7 +2207,7 @@ dependencies = [ [[package]] name = "localkit" -version = "0.1.0" +version = "0.1.1" dependencies = [ "chrono", "dirs 5.0.1", diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index fd3b3bf..a492be3 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -23,6 +23,9 @@ pub const TLD: &str = "test"; const KEY_ENABLED: &str = "domains_enabled"; const KEY_CA_TRUSTED: &str = "router_ca_trusted"; const KEY_LAST_ERROR: &str = "router_last_error"; +/// Default router host ports (the clean-URL mode). +pub const DEFAULT_HTTP_PORT: u16 = 80; +pub const DEFAULT_HTTPS_PORT: u16 = 443; /// Path of Caddy's local-CA root cert inside the container. const CA_CERT_CONTAINER_PATH: &str = "/data/caddy/pki/authorities/local/root.crt"; /// Managed-block markers in the OS hosts file. @@ -35,6 +38,16 @@ pub struct RouterStatus { pub running: bool, pub ca_trusted: bool, pub error: Option, + /// Router ports another program is holding (empty = free, or the router + /// itself is up and holding them legitimately). + pub conflicts: Vec, +} + +/// A router port held by some other program, with a best-effort owner name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortConflict { + pub port: u16, + pub process: Option, } pub fn router_dir(data_dir: &Path) -> PathBuf { @@ -117,7 +130,9 @@ async fn reload(dir: &Path) -> Result<(), String> { docker::compose_restart(dir).await } -/// Add a "what's probably holding the port" hint to bind failures. +/// Add a "what's probably holding the port" hint to bind failures. Used only +/// as a backstop — the pre-flight probe (`probe_ports`) catches the common +/// case *before* we touch the hosts file or Docker. fn port_conflict_hint(err: &str) -> String { let lower = err.to_lowercase(); if lower.contains("port is already allocated") @@ -126,15 +141,111 @@ fn port_conflict_hint(err: &str) -> String { || lower.contains("permission denied") && lower.contains("80") { format!( - "Could not start the local-domains router: ports 80/443 appear to be in use \ + "Could not start the local-domains router: its ports appear to be in use \ by another program (LocalWP's router, IIS, Skype, or another web server). \ - Stop whatever is bound to port 80/443 and try again.\n\nDetails: {err}" + Stop it, or switch LocalKit to fallback ports in Settings → Domains.\ + \n\nDetails: {err}" ) } else { format!("Could not start the local-domains router: {err}") } } +// --------------------------------------------------------------------------- +// Port pre-flight (plan 16) +// +// LocalWP's nginx router is the canonical conflict: it binds 80/443 +// machine-wide and answers *every* unknown local host with its own "Site Not +// Found" page, so without this probe a LocalKit site at `http://x.test/` +// silently hits Local's router while LocalKit's Caddy is down. Probing with a +// plain `TcpListener::bind` costs nothing and runs before any hosts-file or +// Docker mutation, so it can never race our own containers. +// --------------------------------------------------------------------------- + +/// Can we bind `port`? Checks both the wildcard and the loopback address: on +/// Windows a program bound only to `127.0.0.1:80` still wins loopback traffic +/// even though `0.0.0.0:80` binds fine, which is exactly the case that makes +/// a site silently answer from the other app's router. +fn port_free(port: u16) -> bool { + use std::net::{Ipv4Addr, TcpListener}; + TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).is_ok() + && TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_ok() +} + +/// Best-effort process name holding `port` (`None` when we can't tell — the +/// message then falls back to the generic "another web server" hint). +async fn identify_port_owner(port: u16) -> Option { + #[cfg(target_os = "windows")] + let output = { + let ps = format!( + "$c = Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue \ + | Select-Object -First 1; \ + if ($c) {{ (Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName }}" + ); + docker::no_window( + tokio::process::Command::new("powershell").args(["-NoProfile", "-Command", &ps]), + ) + .output() + .await + }; + #[cfg(not(target_os = "windows"))] + let output = docker::no_window(tokio::process::Command::new("lsof").args([ + "-nP", + &format!("-iTCP:{port}"), + "-sTCP:LISTEN", + "-F", + "c", + ])) + .output() + .await; + + let out = output.ok()?; + let stdout = String::from_utf8_lossy(&out.stdout); + let name = stdout + .lines() + // `lsof -F c` prefixes the command name with 'c'; PowerShell prints it bare. + .filter_map(|l| Some(l.trim()).filter(|l| !l.is_empty())) + .map(|l| l.strip_prefix('c').unwrap_or(l)) + .next()? + .to_string(); + Some(name).filter(|n| !n.is_empty()) +} + +/// Which of the router's ports are held by something else. +pub async fn probe_ports(http: u16, https: u16) -> Vec { + let mut conflicts = Vec::new(); + for port in [http, https] { + if !port_free(port) { + conflicts.push(PortConflict { + port, + process: identify_port_owner(port).await, + }); + } + } + conflicts +} + +/// User-facing explanation of a port conflict. Pure (unit-tested); the +/// remediation half depends on whether the user is already on fallback ports. +fn conflict_message(conflicts: &[PortConflict], on_default_ports: bool) -> String { + let held = conflicts + .iter() + .map(|c| match &c.process { + Some(p) => format!("port {} is held by {p}", c.port), + None => format!("port {} is in use", c.port), + }) + .collect::>() + .join(", "); + let fix = if on_default_ports { + "Quit the other program (LocalWP's router, IIS, Skype, or another web server), \ + or switch LocalKit to fallback ports (8080/8443) in Settings → Domains." + } else { + "Quit whatever is holding those ports, or pick different router ports in \ + Settings → Domains." + }; + format!("Local domains could not start: {held}. {fix}") +} + // --------------------------------------------------------------------------- // Hosts file management (`.test` does not auto-resolve — browsers AND the OS // resolver need `127.0.0.1 .test` entries). Edits are made inside a @@ -374,25 +485,39 @@ pub async fn status(state: &AppState) -> Result { db.get_setting(KEY_LAST_ERROR)?.filter(|s| !s.is_empty()), ) }; - let dir = router_dir(&state.data_dir); - let running = if dir.join("docker-compose.yml").exists() { - match docker::compose_ps(&dir).await { - Ok(containers) => containers - .iter() - .any(|c| c.service == "caddy" && c.state == "running"), - Err(_) => false, - } + let running = is_running(state).await; + // Diagnose a persistent conflict on every status read, so reopening the + // app while LocalWP still holds 80/443 shows the same named cause instead + // of a bare "router is not running". + let conflicts = if enabled && !running { + probe_ports(DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT).await } else { - false + Vec::new() }; Ok(RouterStatus { enabled, running, ca_trusted, error: last_error, + conflicts, }) } +/// Is our own Caddy container up? (Distinguishes "port 80 is busy because the +/// router owns it" from a real foreign conflict.) +async fn is_running(state: &AppState) -> bool { + let dir = router_dir(&state.data_dir); + if !dir.join("docker-compose.yml").exists() { + return false; + } + match docker::compose_ps(&dir).await { + Ok(containers) => containers + .iter() + .any(|c| c.service == "caddy" && c.state == "running"), + Err(_) => false, + } +} + /// Best-effort rewrite of `home`/`siteurl` for every running site. /// Returns messages for the sites that failed (never fails the caller). async fn rewrite_site_urls(state: &AppState, to_domains: bool) -> Vec { @@ -414,6 +539,21 @@ async fn rewrite_site_urls(state: &AppState, to_domains: bool) -> Vec { pub async fn set_enabled(state: &AppState, enabled: bool) -> Result { if enabled { + // Pre-flight BEFORE touching the hosts file: writing `127.0.0.1 + // .test` while another program owns port 80 would point every + // site at that program's router (LocalWP answers unknown hosts with + // its own 404), which looks like LocalKit is broken. + if !is_running(state).await { + let conflicts = probe_ports(DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT).await; + if !conflicts.is_empty() { + let msg = conflict_message(&conflicts, true); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + st.conflicts = conflicts; + return Ok(st); + } + } let sites = list_sites(state)?; let dir = write_files(&state.data_dir, &sites)?; // Hosts entries first: if the user declines elevation, nothing else @@ -647,6 +787,82 @@ mod tests { assert!(out.contains("127.0.0.1 x.test")); } + // --- plan 16: port pre-flight ----------------------------------------- + + #[test] + fn port_free_reports_true_for_an_unbound_port() { + // Bind an ephemeral port, learn its number, release it, then probe. + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(port_free(port)); + } + + #[test] + fn port_free_reports_false_while_a_port_is_held() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(!port_free(port), "a bound loopback port is not free"); + drop(listener); + } + + #[tokio::test] + async fn probe_returns_empty_when_ports_are_free() { + let a = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let b = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let (pa, pb) = (a.local_addr().unwrap().port(), b.local_addr().unwrap().port()); + drop(a); + drop(b); + assert_eq!(probe_ports(pa, pb).await, Vec::new()); + } + + #[tokio::test] + async fn probe_reports_the_held_port_only() { + let held = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let taken = held.local_addr().unwrap().port(); + let free_listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let free = free_listener.local_addr().unwrap().port(); + drop(free_listener); + + let conflicts = probe_ports(taken, free).await; + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].port, taken); + drop(held); + } + + #[test] + fn conflict_message_names_the_process_and_offers_fallback() { + let msg = conflict_message( + &[PortConflict { port: 80, process: Some("httpd.exe".into()) }], + true, + ); + assert!(msg.contains("port 80 is held by httpd.exe"), "{msg}"); + assert!(msg.contains("fallback ports (8080/8443)"), "{msg}"); + } + + #[test] + fn conflict_message_falls_back_when_the_owner_is_unknown() { + let msg = conflict_message( + &[ + PortConflict { port: 80, process: None }, + PortConflict { port: 443, process: None }, + ], + true, + ); + assert!(msg.contains("port 80 is in use"), "{msg}"); + assert!(msg.contains("port 443 is in use"), "{msg}"); + } + + #[test] + fn conflict_message_on_fallback_ports_does_not_suggest_fallback_again() { + let msg = conflict_message( + &[PortConflict { port: 8080, process: Some("node".into()) }], + false, + ); + assert!(msg.contains("port 8080 is held by node"), "{msg}"); + assert!(!msg.contains("8080/8443"), "must not loop the same advice: {msg}"); + } + #[test] fn staged_content_round_trips_through_temp_file() { // Mirrors what sync_hosts stages for the elevated writer. diff --git a/src/lib/types.ts b/src/lib/types.ts index 5002106..8dda1d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -101,11 +101,18 @@ export interface SyncRecord { created_at: string; } +/** A router port held by another program (plan 16 pre-flight probe). */ +export interface PortConflict { + port: number; + process: string | null; +} + export interface RouterStatus { enabled: boolean; running: boolean; ca_trusted: boolean; error: string | null; + conflicts: PortConflict[]; } export interface TerminalDataEvent { diff --git a/src/mock/data.ts b/src/mock/data.ts index 55b23c1..ce3fc95 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -143,6 +143,7 @@ export const routerStatus: RouterStatus = { running: true, ca_trusted: false, error: null, + conflicts: [], }; /** In-memory app_settings KV (e.g. run_in_background for the tray toggle). */ From 4389c2412818732adcacf9b6bf1fb09634692492 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 11:11:27 -0400 Subject: [PATCH 03/67] router: configurable host ports + port-aware site URLs (plan 16 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback mode: when another program owns 80/443, the router can publish on different host ports instead of losing to it. Container ports stay 80/443 — only the host mapping moves — so the Caddyfile and the hosts block are untouched (the browser supplies the port from the URL). - `RouterPorts` + `router_http_port` / `router_https_port` in the app_settings KV (no migration); `RouterStatus` carries them. - `set_ports` validates, brings the router down off the OLD ports before pre-flighting the new ones (otherwise a swap that reuses a port would see our own container as the conflict), then restarts and reruns the same `rewrite_site_urls` the enable toggle uses, so home/siteurl never drift. - `site_url` is port-aware: default ports keep the clean `https://slug.test`; fallback ports give `http://slug.test:8080` and stay on http deliberately — a non-standard https port re-prompts for a cert exception even with the CA trusted. - Collapsed the two hand-rolled copies of this rule (tray.rs, site.rs install URL) into `site_public_url`, so port-awareness lands everywhere at once and `enabled_and_trusted` drops to private. - Frontend mirror in lib/domains.ts + setRouterPorts ipc/store action; mock gains a fictional LocalWP holding 80/443 so the conflict path is exercisable in `npm run dev:mock`. Verified at runtime: a throwaway differently-named Caddy project built from the generated fallback template (host 8090/8453) served the *unmodified* port-blind Caddyfile and proxied `test.test` to the site with a response byte-identical to hitting its host port directly; an unmatched host got Caddy's own default instead. The real localkit-router was left alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/lib.rs | 13 ++- src-tauri/src/router.rs | 234 +++++++++++++++++++++++++++++++++++++--- src-tauri/src/site.rs | 10 +- src-tauri/src/tray.rs | 10 +- src/lib/domains.ts | 22 ++++ src/lib/ipc.ts | 2 + src/lib/types.ts | 3 + src/mock/core.ts | 46 +++++++- src/mock/data.ts | 10 ++ src/stores/router.ts | 16 +++ 10 files changed, 333 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd32bb5..a5d5329 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -253,7 +253,8 @@ fn list_sync_history(state: State, site_id: String) -> Result) -> Result, + http: u16, + https: u16, +) -> Result { + router::set_ports(&state, http, https).await +} + #[tauri::command] async fn set_domains_enabled( state: State<'_, AppState>, @@ -430,6 +440,7 @@ pub fn run() { list_sync_history, router_status, set_domains_enabled, + set_router_ports, trust_router_ca, get_app_setting, set_app_setting, diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index a492be3..8a06cd3 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -26,6 +26,11 @@ const KEY_LAST_ERROR: &str = "router_last_error"; /// Default router host ports (the clean-URL mode). pub const DEFAULT_HTTP_PORT: u16 = 80; pub const DEFAULT_HTTPS_PORT: u16 = 443; +/// Suggested fallback pair when another program owns 80/443. +pub const FALLBACK_HTTP_PORT: u16 = 8080; +pub const FALLBACK_HTTPS_PORT: u16 = 8443; +const KEY_HTTP_PORT: &str = "router_http_port"; +const KEY_HTTPS_PORT: &str = "router_https_port"; /// Path of Caddy's local-CA root cert inside the container. const CA_CERT_CONTAINER_PATH: &str = "/data/caddy/pki/authorities/local/root.crt"; /// Managed-block markers in the OS hosts file. @@ -41,6 +46,8 @@ pub struct RouterStatus { /// Router ports another program is holding (empty = free, or the router /// itself is up and holding them legitimately). pub conflicts: Vec, + pub http_port: u16, + pub https_port: u16, } /// A router port held by some other program, with a best-effort owner name. @@ -50,37 +57,83 @@ pub struct PortConflict { pub process: Option, } +/// Host ports the Caddy router publishes on. Container ports are always +/// 80/443 — only the host side moves, so the Caddyfile is port-blind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct RouterPorts { + pub http: u16, + pub https: u16, +} + +impl Default for RouterPorts { + fn default() -> Self { + Self { http: DEFAULT_HTTP_PORT, https: DEFAULT_HTTPS_PORT } + } +} + +impl RouterPorts { + /// Clean-URL mode: browsers imply 80/443, so no `:port` suffix is needed. + pub fn is_default(&self) -> bool { + self.http == DEFAULT_HTTP_PORT && self.https == DEFAULT_HTTPS_PORT + } + + fn validate(&self) -> Result<(), String> { + for port in [self.http, self.https] { + if port == 0 { + return Err("Router ports must be between 1 and 65535.".into()); + } + } + if self.http == self.https { + return Err("The HTTP and HTTPS router ports must be different.".into()); + } + Ok(()) + } +} + pub fn router_dir(data_dir: &Path) -> PathBuf { data_dir.join("router") } /// The URL a site is reachable at through the router. -pub fn site_url(slug: &str, ca_trusted: bool) -> String { +/// +/// On the default ports this is the clean `http(s)://.test`. On fallback +/// ports the browser needs the port spelled out, and we deliberately stay on +/// http: a non-standard https port would prompt for a second certificate +/// exception even after the CA is trusted. +pub fn site_url(slug: &str, ca_trusted: bool, ports: RouterPorts) -> String { + if !ports.is_default() { + return format!("http://{slug}.{TLD}:{}", ports.http); + } let scheme = if ca_trusted { "https" } else { "http" }; format!("{scheme}://{slug}.{TLD}") } /// The URL a site should be opened at (mirrors the frontend's `siteUrl`): /// its `*.test` domain when local domains are enabled, else `localhost:`. +/// The single source of truth for "where does this site live" — tray menu, +/// one-click login, WP install URL and the CLI all funnel through here. pub fn site_public_url(state: &AppState, site: &Site) -> String { let (domains_on, ca_trusted) = enabled_and_trusted(state); if domains_on { - site_url(&site.slug, ca_trusted) + site_url(&site.slug, ca_trusted, router_ports(state)) } else { format!("http://localhost:{}", site.port) } } -fn render_compose() -> String { - r#"name: localkit-router +fn render_compose(ports: RouterPorts) -> String { + // Container ports stay 80/443 — only the host mapping moves, so the + // Caddyfile (and the hosts block) are unaffected by fallback mode. + format!( + r#"name: localkit-router services: caddy: image: caddy:2 restart: unless-stopped ports: - - "80:80" - - "443:443" + - "{http}:80" + - "{https}:443" # Route to sites via their published host ports — no shared network, # no changes to per-site compose projects. extra_hosts: @@ -91,8 +144,10 @@ services: volumes: caddy-data: -"# - .to_string() +"#, + http = ports.http, + https = ports.https, + ) } fn render_caddyfile(sites: &[Site]) -> String { @@ -109,10 +164,10 @@ fn render_caddyfile(sites: &[Site]) -> String { } /// Write the router compose project + Caddyfile for the given sites. -fn write_files(data_dir: &Path, sites: &[Site]) -> Result { +fn write_files(data_dir: &Path, sites: &[Site], ports: RouterPorts) -> Result { let dir = router_dir(data_dir); std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create router directory: {e}"))?; - std::fs::write(dir.join("docker-compose.yml"), render_compose()) + std::fs::write(dir.join("docker-compose.yml"), render_compose(ports)) .map_err(|e| format!("failed to write router docker-compose.yml: {e}"))?; std::fs::write(dir.join("Caddyfile"), render_caddyfile(sites)) .map_err(|e| format!("failed to write Caddyfile: {e}"))?; @@ -460,9 +515,29 @@ fn list_sites(state: &AppState) -> Result, String> { db.list_sites() } +/// Configured router host ports (`app_settings` KV — no migration). Never +/// fails: unset or unparseable values fall back to 80/443. +pub fn router_ports(state: &AppState) -> RouterPorts { + let Ok(db) = state.db.lock() else { + return RouterPorts::default(); + }; + let read = |key: &str, fallback: u16| -> u16 { + db.get_setting(key) + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(fallback) + }; + RouterPorts { + http: read(KEY_HTTP_PORT, DEFAULT_HTTP_PORT), + https: read(KEY_HTTPS_PORT, DEFAULT_HTTPS_PORT), + } +} + /// (domains_enabled, ca_trusted) — used by site creation to pick the /// WordPress install URL. Never fails; defaults to (false, false). -pub fn enabled_and_trusted(state: &AppState) -> (bool, bool) { +fn enabled_and_trusted(state: &AppState) -> (bool, bool) { let Ok(db) = state.db.lock() else { return (false, false); }; @@ -486,11 +561,12 @@ pub async fn status(state: &AppState) -> Result { ) }; let running = is_running(state).await; + let ports = router_ports(state); // Diagnose a persistent conflict on every status read, so reopening the // app while LocalWP still holds 80/443 shows the same named cause instead // of a bare "router is not running". let conflicts = if enabled && !running { - probe_ports(DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT).await + probe_ports(ports.http, ports.https).await } else { Vec::new() }; @@ -500,6 +576,8 @@ pub async fn status(state: &AppState) -> Result { ca_trusted, error: last_error, conflicts, + http_port: ports.http, + https_port: ports.https, }) } @@ -522,11 +600,12 @@ async fn is_running(state: &AppState) -> bool { /// Returns messages for the sites that failed (never fails the caller). async fn rewrite_site_urls(state: &AppState, to_domains: bool) -> Vec { let ca_trusted = get_flag(state, KEY_CA_TRUSTED).unwrap_or(false); + let ports = router_ports(state); let sites = list_sites(state).unwrap_or_default(); let mut failures = Vec::new(); for site in sites.iter().filter(|s| s.status == "running") { let url = if to_domains { - site_url(&site.slug, ca_trusted) + site_url(&site.slug, ca_trusted, ports) } else { format!("http://localhost:{}", site.port) }; @@ -543,10 +622,11 @@ pub async fn set_enabled(state: &AppState, enabled: bool) -> Result.test` while another program owns port 80 would point every // site at that program's router (LocalWP answers unknown hosts with // its own 404), which looks like LocalKit is broken. + let ports = router_ports(state); if !is_running(state).await { - let conflicts = probe_ports(DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT).await; + let conflicts = probe_ports(ports.http, ports.https).await; if !conflicts.is_empty() { - let msg = conflict_message(&conflicts, true); + let msg = conflict_message(&conflicts, ports.is_default()); set_last_error(state, Some(&msg)); let mut st = status(state).await?; st.error = Some(msg); @@ -555,7 +635,7 @@ pub async fn set_enabled(state: &AppState, enabled: bool) -> Result Result { + let ports = RouterPorts { http, https }; + ports.validate()?; + if ports == router_ports(state) { + return status(state).await; + } + + let enabled = get_flag(state, KEY_ENABLED).unwrap_or(false); + let dir = router_dir(&state.data_dir); + // Free the old ports before probing the new ones — otherwise a swap that + // reuses one of them would see our own container as the conflict. + if enabled && dir.join("docker-compose.yml").exists() { + let _ = docker::compose_down(&dir, false).await; + } + if enabled { + let conflicts = probe_ports(ports.http, ports.https).await; + if !conflicts.is_empty() { + // Leave the old ports in settings: the router is down either way, + // but the user's previous working config is worth preserving. + let msg = conflict_message(&conflicts, ports.is_default()); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + st.conflicts = conflicts; + return Ok(st); + } + } + + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_setting(KEY_HTTP_PORT, &ports.http.to_string())?; + db.set_setting(KEY_HTTPS_PORT, &ports.https.to_string())?; + } + + if !enabled { + // Ports are recorded; the router starts on them at the next enable. + return status(state).await; + } + + let sites = list_sites(state)?; + let dir = write_files(&state.data_dir, &sites, ports)?; + if let Err(e) = docker::compose_up(&dir).await { + let msg = port_conflict_hint(&e); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + return Ok(st); + } + set_last_error(state, None); + let _ = reload(&dir).await; + let failures = rewrite_site_urls(state, true).await; + let mut st = status(state).await?; + if !failures.is_empty() { + st.error = Some(format!( + "Router restarted on ports {}/{}, but the WordPress URL rewrite failed for: {}", + ports.http, + ports.https, + failures.join("; ") + )); + } + Ok(st) +} + /// Reconcile the managed hosts block after site create/delete (the slug set /// changed). Elevated; best-effort — failures are recorded as the router's /// last error rather than failing the site operation. @@ -830,6 +978,58 @@ mod tests { drop(held); } + // --- plan 16: configurable ports -------------------------------------- + + #[test] + fn site_url_is_clean_on_default_ports() { + let d = RouterPorts::default(); + assert_eq!(site_url("acme", false, d), "http://acme.test"); + assert_eq!(site_url("acme", true, d), "https://acme.test"); + } + + #[test] + fn site_url_carries_the_port_in_fallback_mode() { + let fb = RouterPorts { http: FALLBACK_HTTP_PORT, https: FALLBACK_HTTPS_PORT }; + assert_eq!(site_url("acme", false, fb), "http://acme.test:8080"); + // Even with a trusted CA we stay on http: a non-standard https port + // would prompt for a second certificate exception. + assert_eq!(site_url("acme", true, fb), "http://acme.test:8080"); + } + + #[test] + fn render_compose_maps_host_ports_to_container_80_443() { + let yml = render_compose(RouterPorts { http: 8080, https: 8443 }); + assert!(yml.contains("\"8080:80\""), "{yml}"); + assert!(yml.contains("\"8443:443\""), "{yml}"); + // The default render is unchanged from the M6 template. + let default_yml = render_compose(RouterPorts::default()); + assert!(default_yml.contains("\"80:80\"")); + assert!(default_yml.contains("\"443:443\"")); + } + + #[test] + fn caddyfile_is_port_blind() { + // Only the host mapping moves — the Caddyfile never mentions host ports. + let sites: Vec = Vec::new(); + assert_eq!(render_caddyfile(&sites), render_caddyfile(&sites)); + assert!(!render_caddyfile(&sites).contains("8080")); + } + + #[test] + fn port_validation_rejects_zero_and_duplicates() { + assert!(RouterPorts::default().validate().is_ok()); + assert!(RouterPorts { http: 0, https: 443 }.validate().is_err()); + assert!(RouterPorts { http: 8080, https: 8080 }.validate().is_err()); + assert!(RouterPorts { http: 8080, https: 8443 }.validate().is_ok()); + } + + #[test] + fn is_default_only_for_80_443() { + assert!(RouterPorts::default().is_default()); + assert!(!RouterPorts { http: 8080, https: 443 }.is_default()); + assert!(!RouterPorts { http: 80, https: 8443 }.is_default()); + } + #[test] fn conflict_message_names_the_process_and_offers_fallback() { let msg = conflict_message( diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index 4deee53..81f082b 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -332,13 +332,9 @@ async fn do_create(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Re emit(app, &site.id, "waiting", "Waiting for WordPress to come online..."); wait_for_port(site.port, 180).await?; - // Install at the site's local domain when the router is enabled (M6). - let (domains_on, ca_trusted) = router::enabled_and_trusted(state); - let install_url = if domains_on { - router::site_url(&site.slug, ca_trusted) - } else { - format!("http://localhost:{}", site.port) - }; + // Install at the site's local domain when the router is enabled (M6), + // including the `:port` suffix in fallback mode (plan 16). + let install_url = router::site_public_url(state, site); let admin_pass = random_password(16); wordpress::install(&dir, site, &admin_pass, &install_url, app).await?; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 0118605..21d8e3d 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -99,14 +99,10 @@ fn sites(app: &AppHandle) -> Vec { } /// URL a site's "Open in browser" should hit: `.test` when local -/// domains are on, otherwise the always-working `localhost:`. +/// domains are on (port-aware in fallback mode), otherwise the always-working +/// `localhost:`. fn site_url(state: &AppState, site: &site::Site) -> String { - let (domains_on, trusted) = router::enabled_and_trusted(state); - if domains_on { - router::site_url(&site.slug, trusted) - } else { - format!("http://localhost:{}", site.port) - } + router::site_public_url(state, site) } fn build_menu(app: &AppHandle) -> tauri::Result> { diff --git a/src/lib/domains.ts b/src/lib/domains.ts index a808e81..30b5cb2 100644 --- a/src/lib/domains.ts +++ b/src/lib/domains.ts @@ -3,13 +3,35 @@ import type { RouterStatus } from "./types"; /** TLD for local domains (kept in sync with `router::TLD` in the backend). */ export const DOMAIN_TLD = "test"; +/** Default router host ports — mirrors `router::DEFAULT_*_PORT`. */ +export const DEFAULT_HTTP_PORT = 80; +export const DEFAULT_HTTPS_PORT = 443; +/** Suggested fallback pair when another program owns 80/443 (plan 16). */ +export const FALLBACK_HTTP_PORT = 8080; +export const FALLBACK_HTTPS_PORT = 8443; + +/** Is the router on the clean-URL ports? Mirrors `RouterPorts::is_default`. */ +export function isDefaultPorts(router: RouterStatus | null): boolean { + return ( + (router?.http_port ?? DEFAULT_HTTP_PORT) === DEFAULT_HTTP_PORT && + (router?.https_port ?? DEFAULT_HTTPS_PORT) === DEFAULT_HTTPS_PORT + ); +} + /** * The URL a site should be opened/displayed at: its `*.test` domain when * the router is enabled and running (https once the CA is trusted), otherwise * plain `http://localhost:`. + * + * Mirror of `router::site_url` / `site_public_url` — in fallback mode the port + * is spelled out and the scheme stays http, because a non-standard https port + * would prompt for a second certificate exception even with the CA trusted. */ export function siteUrl(slug: string, port: number, router: RouterStatus | null): string { if (router?.enabled && router.running) { + if (!isDefaultPorts(router)) { + return `http://${slug}.${DOMAIN_TLD}:${router.http_port}`; + } return `${router.ca_trusted ? "https" : "http"}://${slug}.${DOMAIN_TLD}`; } return `http://localhost:${port}`; diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 63c7dda..b5c5fdf 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -52,6 +52,8 @@ export const ipc = { routerStatus: () => invoke("router_status"), setDomainsEnabled: (enabled: boolean) => invoke("set_domains_enabled", { enabled }), + setRouterPorts: (http: number, https: number) => + invoke("set_router_ports", { http, https }), trustRouterCa: () => invoke("trust_router_ca"), getAppSetting: (key: string) => invoke("get_app_setting", { key }), setAppSetting: (key: string, value: string) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 8dda1d4..d330734 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -113,6 +113,9 @@ export interface RouterStatus { ca_trusted: boolean; error: string | null; conflicts: PortConflict[]; + /** Router host ports; 80/443 = clean-URL mode, anything else = fallback. */ + http_port: number; + https_port: number; } export interface TerminalDataEvent { diff --git a/src/mock/core.ts b/src/mock/core.ts index e4ae097..45c892f 100644 --- a/src/mock/core.ts +++ b/src/mock/core.ts @@ -4,12 +4,34 @@ // delete / create behave naturally while previewing. import * as data from "./data"; import { emit } from "./event"; -import type { ServerKitInfo, Site, SiteEvent } from "../lib/types"; +import type { PortConflict, RouterStatus, ServerKitInfo, Site, SiteEvent } from "../lib/types"; type Args = Record; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** Plan 16 pre-flight, mocked: which router ports the fake LocalWP holds. */ +function mockProbe(http: number, https: number): PortConflict[] { + return [http, https] + .filter((p) => p in data.heldPorts) + .map((port) => ({ port, process: data.heldPorts[port] })); +} + +/** Mirror of `router::conflict_message` + the short-circuited status. */ +function mockConflict(conflicts: PortConflict[]): RouterStatus { + const held = conflicts.map((c) => `port ${c.port} is held by ${c.process}`).join(", "); + const onDefaults = data.routerStatus.http_port === 80 && data.routerStatus.https_port === 443; + data.routerStatus.running = false; + data.routerStatus.conflicts = conflicts; + data.routerStatus.error = + `Local domains could not start: ${held}. ` + + (onDefaults + ? "Quit the other program (LocalWP's router, IIS, Skype, or another web server), " + + "or switch LocalKit to fallback ports (8080/8443) in Settings → Domains." + : "Quit whatever is holding those ports, or pick different router ports in Settings → Domains."); + return { ...data.routerStatus }; +} + // Fake interactive shells for the Terminal page (terminal_open/write/close). const mockShells = new Map(); const prompt = (slug: string) => @@ -238,9 +260,31 @@ async function dispatch(cmd: string, a: Args): Promise { case "set_domains_enabled": { const enabled = Boolean(a.enabled); + if (enabled) { + const conflicts = mockProbe(data.routerStatus.http_port, data.routerStatus.https_port); + if (conflicts.length > 0) return mockConflict(conflicts); + } data.routerStatus.enabled = enabled; data.routerStatus.running = enabled; data.routerStatus.error = null; + data.routerStatus.conflicts = []; + return { ...data.routerStatus }; + } + + case "set_router_ports": { + const http = Number(a.http); + const https = Number(a.https); + if (!http || !https) throw "Router ports must be between 1 and 65535."; + if (http === https) throw "The HTTP and HTTPS router ports must be different."; + if (data.routerStatus.enabled) { + const conflicts = mockProbe(http, https); + if (conflicts.length > 0) return mockConflict(conflicts); + data.routerStatus.running = true; + } + data.routerStatus.http_port = http; + data.routerStatus.https_port = https; + data.routerStatus.error = null; + data.routerStatus.conflicts = []; return { ...data.routerStatus }; } diff --git a/src/mock/data.ts b/src/mock/data.ts index ce3fc95..fc65171 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -144,8 +144,18 @@ export const routerStatus: RouterStatus = { ca_trusted: false, error: null, conflicts: [], + http_port: 80, + https_port: 443, }; +/** + * Plan 16: a fictional LocalWP-style router holding 80/443, so the conflict UX + * is exercisable in mock mode. The router starts *already running*, so the + * default screenshots are unaffected — toggle domains off then on to hit it, + * and "Use fallback ports" resolves it (8080/8443 are free here). + */ +export const heldPorts: Record = { 80: "httpd.exe", 443: "httpd.exe" }; + /** In-memory app_settings KV (e.g. run_in_background for the tray toggle). */ export const appSettings: Record = {}; diff --git a/src/stores/router.ts b/src/stores/router.ts index b9f8502..04b815c 100644 --- a/src/stores/router.ts +++ b/src/stores/router.ts @@ -7,6 +7,8 @@ interface RouterState { busy: boolean; refresh: () => Promise; setEnabled: (enabled: boolean) => Promise; + /** Change the router's host ports (fallback mode); returns an error string. */ + setPorts: (http: number, https: number) => Promise; trustCa: () => Promise; } @@ -36,6 +38,20 @@ export const useRouter = create((set) => ({ } }, + setPorts: async (http, https) => { + set({ busy: true }); + try { + const status = await ipc.setRouterPorts(http, https); + set({ status }); + return null; + } catch (e) { + await useRouter.getState().refresh(); + return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); + } finally { + set({ busy: false }); + } + }, + trustCa: async () => { set({ busy: true }); try { From 824da68c8cf5e0151edcadf19c1a50c99fb7f86c Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 11:24:04 -0400 Subject: [PATCH 04/67] router: conflict UX in Settings, SiteDetail and `lk doctor` (plan 16 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a false-negative in the phase-1 probe first: bind-probing alone is not a reliable "is this port busy" test on Windows. A socket bound with SO_REUSEADDR — which Docker's port publisher uses — lets us bind the *same* wildcard address again, so a genuinely occupied port reported free. Observed directly on this machine: a container published 8080, netstat showed it LISTENING, and `bind(0.0.0.0:8080)` still succeeded. `probe_port` now treats the OS listener table as the primary signal (it also names the owner) with the bind as corroboration, and a regression test pins the case. UX: - Settings → Domains: amber callout naming process + port, "Use fallback ports" (one click to 8080/8443 + retry enable) and "Retry". The status line reports the conflict *before* checking the enabled flag — a failed enable leaves the flag off, and "Local domains are off" sitting directly above an amber conflict callout reads as if nothing happened. - Validated HTTP/HTTPS port fields; HTTPS-trust card hides on fallback ports since a non-standard https port re-prompts regardless. - SiteDetail: dismissible banner for the *persistent* hazard — domains on, hosts entries written, router later lost its ports. That's when the user is actually looking at the other program's 404. It refreshes router status on mount: App.tsx only refreshed at startup, so a conflict appearing later (the other app launched while LocalKit was open) went unreported on exactly the page where it matters. - nav.ts gains `openSettings(section)` so the banner deep-links to Domains. - `lk doctor` reports router mode (default/fallback/disabled) and who owns the ports; a blocked router fails the check. Verified at runtime: `lk doctor` against a scratch data dir named the real holder of 80/443 and 8080 (wslrelay) and passed cleanly when disabled; new scripts/verify-router-conflict.mjs drives the plan's full manual matrix headlessly against the mock (15 checks) — it caught both UI bugs fixed above. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify-router-conflict.mjs | 210 +++++++++++++++++++++++++++++ src-tauri/lk/src/main.rs | 60 ++++++++- src-tauri/src/router.rs | 56 ++++++-- src/components/DomainsSettings.tsx | 188 ++++++++++++++++++++++++-- src/mock/core.ts | 31 ++++- src/pages/Settings.tsx | 6 +- src/pages/SiteDetail.tsx | 62 +++++++++ src/stores/nav.ts | 8 ++ 8 files changed, 591 insertions(+), 30 deletions(-) create mode 100644 scripts/verify-router-conflict.mjs diff --git a/scripts/verify-router-conflict.mjs b/scripts/verify-router-conflict.mjs new file mode 100644 index 0000000..4867d26 --- /dev/null +++ b/scripts/verify-router-conflict.mjs @@ -0,0 +1,210 @@ +// Headless runtime verification for plan 16 (router coexistence). +// +// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the +// port-conflict matrix from the plan: a fictional LocalWP holds 80/443, so +// enabling local domains must surface a NAMED conflict (not a silent +// failure), "Use fallback ports" must recover to 8080/8443, site URLs must +// gain the port, and the SiteDetail banner must appear while blocked. +// +// node scripts/verify-router-conflict.mjs +// +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const PORT = 1426; +const URL = `http://localhost:${PORT}/`; + +const CHROME_CANDIDATES = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", +]; +const chrome = CHROME_CANDIDATES.find((p) => existsSync(p)); +if (!chrome) { + console.error("No Chrome/Edge found."); + process.exit(1); +} + +const isWin = process.platform === "win32"; +let failures = 0; + +function ok(name, cond) { + if (cond) console.log(" ✓", name); + else { + failures++; + console.error(" ✗ FAIL:", name); + } +} + +async function waitForServer(url, ms = 60_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await sleep(500); + } + throw new Error(`Vite mock server never came up at ${url}`); +} + +function killTree(child) { + if (!child || child.killed) return; + if (isWin) { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } +} + +async function main() { + console.log("› starting mock Vite server…"); + const server = spawn("npm", ["run", "dev:mock"], { + cwd: ROOT, + shell: true, + stdio: "ignore", + detached: !isWin, + }); + + let browser; + try { + await waitForServer(URL); + browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + defaultViewport: { width: 1440, height: 1000 }, + }); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.warn(" page error:", e.message)); + await page.goto(URL, { waitUntil: "networkidle0" }); + + const bodyText = () => page.evaluate(() => document.body.innerText); + /** Click the first element whose trimmed text matches `text`. */ + const clickByText = (selector, text) => + page.evaluate( + (sel, t) => { + const el = [...document.querySelectorAll(sel)].find((e) => + e.textContent.trim().toLowerCase().includes(t.toLowerCase()) + ); + if (!el) return false; + el.click(); + return true; + }, + selector, + text + ); + + await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery")); + console.log("› dashboard loaded"); + + // Open Settings → Local domains. + await page.evaluate(() => { + const gear = [...document.querySelectorAll("button")].find( + (b) => (b.getAttribute("aria-label") || "").toLowerCase().includes("setting") + ); + gear?.click(); + }); + await sleep(400); + await clickByText("button", "Local domains"); + await sleep(500); + ok("Domains settings shows the default ports", (await bodyText()).includes("80/443")); + + // 1) Toggle domains OFF then ON — the mock LocalWP owns 80/443, so + // re-enabling must hit the pre-flight and report a NAMED conflict. + const toggle = 'button[aria-label="Enable local domains"]'; + await page.click(toggle); + await sleep(500); + await page.click(toggle); + await sleep(800); + + let text = await bodyText(); + ok("conflict names the holding process", text.includes("httpd.exe")); + ok("conflict names both ports", text.includes("port 80") && text.includes("port 443")); + ok("status reads as blocked, not a bare failure", text.includes("blocked by another program")); + ok("offers the fallback-ports action", text.includes("Use fallback ports")); + ok("offers Retry", /\bRetry\b/.test(text)); + + // 2) SiteDetail banner — the *persistent* hazard, which is a different + // state from the failed enable above: domains are ON (hosts entries + // written, WordPress URLs already rewritten to .test) and the + // router later lost its ports. That's when the user is actually + // staring at the other program's 404, so that's when the banner fires. + // A failed enable changes nothing, so it deliberately has no banner. + await page.evaluate(() => { + const mock = window.__LOCALKIT_MOCK__; + mock.routerStatus.enabled = true; + mock.routerStatus.running = false; + }); + await page.evaluate(() => { + document.querySelector('button[aria-label="Close settings"]')?.click(); + }); + await sleep(400); + // Click the "Details" button inside the Pixel Bakery card — matching on + // card text alone hits a wrapping div and silently stays on the dashboard. + await page.evaluate(() => { + const card = [...document.querySelectorAll("div")].find( + (d) => + d.textContent.includes("Pixel Bakery") && + [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details") + ); + [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click(); + }); + await sleep(900); + text = await bodyText(); + ok("navigated to SiteDetail", text.includes("Back to sites")); + ok("SiteDetail warns local domains are blocked", text.includes("Local domains are blocked")); + ok("SiteDetail names the holder", text.includes("httpd.exe")); + ok("SiteDetail still offers the working localhost URL", /localhost:\d+/.test(text)); + + // Dismiss is sticky for that conflict. + await clickByText("button", "Dismiss"); + await sleep(400); + ok("banner dismisses", !(await bodyText()).includes("Local domains are blocked")); + + // 3) One-click recovery: fallback ports resolve the conflict. + await page.evaluate(() => { + const gear = [...document.querySelectorAll("button")].find( + (b) => (b.getAttribute("aria-label") || "").toLowerCase().includes("setting") + ); + gear?.click(); + }); + await sleep(400); + await clickByText("button", "Local domains"); + await sleep(400); + await clickByText("button", "Use fallback ports"); + await sleep(1000); + + text = await bodyText(); + ok("router recovers onto the fallback ports", text.includes("8080/8443")); + ok("fallback mode is labelled", text.toLowerCase().includes("fallback")); + ok("conflict callout is gone", !text.includes("Another program is using")); + + // 4) Site URLs must now carry the port (the whole point of phase 2). + await page.evaluate(() => { + document.querySelector('button[aria-label="Close settings"]')?.click(); + }); + await sleep(500); + text = await bodyText(); + ok("dashboard site URLs carry the fallback port", /\.test:8080/.test(text)); + + console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`); + } finally { + if (browser) await browser.close(); + killTree(server); + } + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src-tauri/lk/src/main.rs b/src-tauri/lk/src/main.rs index b006d1e..0522e10 100644 --- a/src-tauri/lk/src/main.rs +++ b/src-tauri/lk/src/main.rs @@ -15,7 +15,7 @@ //! is required when not on a TTY. use std::io::IsTerminal; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use clap::{Parser, Subcommand, ValueEnum}; @@ -446,12 +446,70 @@ async fn cmd_doctor(data_dir_override: Option) -> Result<(), String> { ); ok &= writable; + ok &= doctor_router(&data_dir).await; + if !ok { return Err("one or more checks failed".into()); } Ok(()) } +/// Local-domains section of `doctor` (plan 16): active router mode + who owns +/// the router ports, so "my .test sites show someone else's 404" has a +/// copy-paste answer. Best-effort — a missing DB just means "not configured". +async fn doctor_router(data_dir: &Path) -> bool { + let Ok(db) = Db::open(&data_dir.join("localkit.db")) else { + check_line(true, "local domains not configured yet (no database)"); + return true; + }; + let state = AppState { + db: Mutex::new(db), + data_dir: data_dir.to_path_buf(), + terminals: localkit_lib::terminal::PtyManager::new(), + }; + + let ports = router::router_ports(&state); + let mode = if ports.is_default() { "default" } else { "fallback" }; + let Ok(status) = router::status(&state).await else { + check_line(false, "local domains status unavailable"); + return false; + }; + + if !status.enabled { + check_line(true, "local domains disabled — sites use localhost:"); + return true; + } + + check_line( + status.running, + &format!( + "local domains enabled — router on ports {}/{} ({mode}), {}", + ports.http, + ports.https, + if status.running { "running" } else { "NOT running" } + ), + ); + + if status.running { + // Our own Caddy owns the ports; say so rather than probing and + // reporting LocalKit as its own conflict. + eprintln!(" ports {}/{} held by LocalKit's router", ports.http, ports.https); + return true; + } + + for c in router::probe_ports(ports.http, ports.https).await { + match c.process { + Some(p) => eprintln!(" port {} held by {p}", c.port), + None => eprintln!(" port {} in use by an unidentified process", c.port), + } + } + eprintln!( + " {} quit the other program, or set fallback ports in Settings → Local domains", + info("→") + ); + false +} + // --------------------------------------------------------------------------- // State / data dir // --------------------------------------------------------------------------- diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index 8a06cd3..0dc76b2 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -221,12 +221,35 @@ fn port_conflict_hint(err: &str) -> String { /// Windows a program bound only to `127.0.0.1:80` still wins loopback traffic /// even though `0.0.0.0:80` binds fine, which is exactly the case that makes /// a site silently answer from the other app's router. -fn port_free(port: u16) -> bool { +/// +/// NOT sufficient on its own — see `probe_port`. +fn bind_free(port: u16) -> bool { use std::net::{Ipv4Addr, TcpListener}; TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).is_ok() && TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_ok() } +/// Is `port` held by something else? Combines two independent signals, +/// because neither is reliable alone: +/// +/// - the OS listener table (`Get-NetTCPConnection` / `lsof`) — authoritative, +/// and it names the owner; +/// - a probe bind — catches listeners the query misses or can't see. +/// +/// Bind-probing alone is a false-negative trap on Windows: a socket bound +/// with SO_REUSEADDR (Docker's port publisher does exactly this) lets us bind +/// the *same* address again, so a genuinely busy port reports free. Verified +/// on a machine where a container published 8080: the wildcard bind succeeded +/// while `netstat` showed it LISTENING. +async fn probe_port(port: u16) -> Option { + let process = identify_port_owner(port).await; + if process.is_some() || !bind_free(port) { + Some(PortConflict { port, process }) + } else { + None + } +} + /// Best-effort process name holding `port` (`None` when we can't tell — the /// message then falls back to the generic "another web server" hint). async fn identify_port_owner(port: u16) -> Option { @@ -270,11 +293,8 @@ async fn identify_port_owner(port: u16) -> Option { pub async fn probe_ports(http: u16, https: u16) -> Vec { let mut conflicts = Vec::new(); for port in [http, https] { - if !port_free(port) { - conflicts.push(PortConflict { - port, - process: identify_port_owner(port).await, - }); + if let Some(conflict) = probe_port(port).await { + conflicts.push(conflict); } } conflicts @@ -938,19 +958,35 @@ mod tests { // --- plan 16: port pre-flight ----------------------------------------- #[test] - fn port_free_reports_true_for_an_unbound_port() { + fn bind_free_reports_true_for_an_unbound_port() { // Bind an ephemeral port, learn its number, release it, then probe. let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); - assert!(port_free(port)); + assert!(bind_free(port)); } #[test] - fn port_free_reports_false_while_a_port_is_held() { + fn bind_free_reports_false_while_a_port_is_held() { let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); - assert!(!port_free(port), "a bound loopback port is not free"); + assert!(!bind_free(port), "a bound loopback port is not free"); + drop(listener); + } + + #[tokio::test] + async fn probe_catches_a_wildcard_listener_that_still_allows_rebinding() { + // The Windows SO_REUSEADDR trap: a wildcard listener can be re-bound, + // so `bind_free` alone reports the port free. The OS listener table + // is what actually catches it, so `probe_port` must still flag it. + let listener = std::net::TcpListener::bind(("0.0.0.0", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!( + probe_port(port).await.is_some(), + "a port with a live wildcard listener must be reported as in use \ + (bind_free said free={})", + bind_free(port) + ); drop(listener); } diff --git a/src/components/DomainsSettings.tsx b/src/components/DomainsSettings.tsx index 8fe038f..b230189 100644 --- a/src/components/DomainsSettings.tsx +++ b/src/components/DomainsSettings.tsx @@ -1,13 +1,29 @@ import { useEffect, useState } from "react"; import { useRouter } from "../stores/router"; +import { + DEFAULT_HTTP_PORT, + DEFAULT_HTTPS_PORT, + FALLBACK_HTTP_PORT, + FALLBACK_HTTPS_PORT, + isDefaultPorts, +} from "../lib/domains"; +import type { PortConflict } from "../lib/types"; + +/** "port 80 (httpd.exe) and port 443 (httpd.exe)" */ +export function describeConflicts(conflicts: PortConflict[]): string { + return conflicts + .map((c) => `port ${c.port}${c.process ? ` (${c.process})` : ""}`) + .join(" and "); +} export default function DomainsSettings() { const status = useRouter((s) => s.status); const busy = useRouter((s) => s.busy); const refresh = useRouter((s) => s.refresh); const setEnabled = useRouter((s) => s.setEnabled); + const setPorts = useRouter((s) => s.setPorts); const trustCa = useRouter((s) => s.trustCa); - const [trustError, setTrustError] = useState(null); + const [actionError, setActionError] = useState(null); useEffect(() => { void refresh(); @@ -16,13 +32,26 @@ export default function DomainsSettings() { const enabled = status?.enabled ?? false; const running = status?.running ?? false; const caTrusted = status?.ca_trusted ?? false; + const conflicts = status?.conflicts ?? []; + const httpPort = status?.http_port ?? DEFAULT_HTTP_PORT; + const httpsPort = status?.https_port ?? DEFAULT_HTTPS_PORT; + const onDefaults = isDefaultPorts(status); - const doTrust = async () => { - setTrustError(null); - const err = await trustCa(); - if (err) setTrustError(err); + const run = async (fn: () => Promise) => { + setActionError(null); + const err = await fn(); + if (err) setActionError(err); }; + /** One click out of the conflict: move to 8080/8443 and retry enabling. */ + const useFallbackPorts = () => + run(async () => { + const err = await setPorts(FALLBACK_HTTP_PORT, FALLBACK_HTTPS_PORT); + if (err) return err; + if (!useRouter.getState().status?.enabled) await setEnabled(true); + return null; + }); + return (

@@ -53,7 +82,8 @@ export default function DomainsSettings() { one-time administrator approval so LocalKit can manage a small marked block in your hosts file. .test is reserved for testing (RFC 2606) and never collides with LocalWP's .local{" "} - domains. A small Caddy router container listens on ports 80/443 while enabled. + domains. A small Caddy router container listens on ports {httpPort}/{httpsPort} while + enabled.

{/* Router status */} @@ -66,27 +96,82 @@ export default function DomainsSettings() { ? "bg-zinc-600" : running ? "bg-emerald-400" - : "bg-red-400" + : conflicts.length > 0 + ? "bg-amber-400" + : "bg-red-400" }`} /> {status === null ? ( Checking router status… + ) : conflicts.length > 0 ? ( + // Before the enabled check: a failed *enable* leaves the flag off + // (the backend short-circuits before setting it), and "Local domains + // are off" directly above an amber conflict callout reads as if + // nothing happened. + Router is blocked by another program ) : !enabled ? ( Local domains are off — sites use localhost:<port>. ) : running ? ( - Router is running on ports 80/443 + + Router is running on ports {httpPort}/{httpsPort} + {!onDefaults && (fallback)} + ) : ( Router is not running )}
- {status?.error &&

{status.error}

} - {trustError &&

{trustError}

} + {/* Port conflict — the LocalWP case. Named cause + two ways out, so the + user is never left staring at a foreign 404 with no explanation. */} + {conflicts.length > 0 && ( +
+

+ Another program is using {describeConflicts(conflicts)} +

+

+ Local domains need those ports. Only one program can own them at a time — LocalWP's + router, IIS, Skype and other web servers are the usual culprits. Quit it and retry, or + run LocalKit's router on fallback ports instead (your sites become{" "} + http://<site>.test:{FALLBACK_HTTP_PORT}). +

+
+ {onDefaults && ( + + )} + +
+
+ )} + + {status?.error && conflicts.length === 0 && ( +

{status.error}

+ )} + {actionError &&

{actionError}

} + + run(() => setPorts(h, s))} + /> - {/* HTTPS trust */} - {enabled && running && ( + {/* HTTPS trust — only meaningful on the standard 443, since a + non-standard https port re-prompts for a cert exception anyway. */} + {enabled && running && onDefaults && (
@@ -103,7 +188,7 @@ export default function DomainsSettings() { ) : (
); } + +/** Two validated number fields; Apply is enabled only for a valid change. */ +function RouterPortFields({ + http, + https, + busy, + onApply, +}: { + http: number; + https: number; + busy: boolean; + onApply: (http: number, https: number) => void; +}) { + const [draftHttp, setDraftHttp] = useState(String(http)); + const [draftHttps, setDraftHttps] = useState(String(https)); + + // Re-sync when the backend changes the ports under us (fallback one-click). + useEffect(() => { + setDraftHttp(String(http)); + setDraftHttps(String(https)); + }, [http, https]); + + const h = Number(draftHttp); + const s = Number(draftHttps); + const valid = + Number.isInteger(h) && h > 0 && h < 65536 && Number.isInteger(s) && s > 0 && s < 65536 && h !== s; + const changed = h !== http || s !== https; + const invalidReason = + !Number.isInteger(h) || h <= 0 || h > 65535 || !Number.isInteger(s) || s <= 0 || s > 65535 + ? "Ports must be between 1 and 65535." + : h === s + ? "The HTTP and HTTPS ports must be different." + : null; + + return ( +
+

Router ports

+

+ Host ports the router listens on. {DEFAULT_HTTP_PORT}/{DEFAULT_HTTPS_PORT} give clean{" "} + http://<site>.test URLs; any other pair appends the + port. Changing these restarts the router and updates each running site's WordPress URL. +

+
+ + + +
+ {changed && invalidReason &&

{invalidReason}

} +
+ ); +} diff --git a/src/mock/core.ts b/src/mock/core.ts index 45c892f..b009124 100644 --- a/src/mock/core.ts +++ b/src/mock/core.ts @@ -17,11 +17,17 @@ function mockProbe(http: number, https: number): PortConflict[] { .map((port) => ({ port, process: data.heldPorts[port] })); } -/** Mirror of `router::conflict_message` + the short-circuited status. */ -function mockConflict(conflicts: PortConflict[]): RouterStatus { +/** + * Mirror of `router::conflict_message` + the short-circuited status. + * `keepEnabled` distinguishes the two real cases: a failed *enable* leaves the + * flag off (the backend never reaches `set_flag`), while a conflict found by a + * later status poll happens with domains already enabled. + */ +function mockConflict(conflicts: PortConflict[], keepEnabled = false): RouterStatus { const held = conflicts.map((c) => `port ${c.port} is held by ${c.process}`).join(", "); const onDefaults = data.routerStatus.http_port === 80 && data.routerStatus.https_port === 443; data.routerStatus.running = false; + if (!keepEnabled) data.routerStatus.enabled = false; data.routerStatus.conflicts = conflicts; data.routerStatus.error = `Local domains could not start: ${held}. ` + @@ -45,6 +51,13 @@ function slugify(name: string): string { .replace(/^-+|-+$/g, ""); } +// Mock-only escape hatch: lets the headless verification scripts put the fake +// backend into states the UI alone can't reach (e.g. "the router died while +// domains were enabled"). Never present in the real app. +if (typeof window !== "undefined") { + (window as unknown as { __LOCALKIT_MOCK__?: typeof data }).__LOCALKIT_MOCK__ = data; +} + export async function invoke(cmd: string, args: Args = {}): Promise { // Small latency so loading states behave like the real backend — but // terminal keystrokes must echo immediately. @@ -255,8 +268,18 @@ async function dispatch(cmd: string, a: Args): Promise { case "list_sync_history": return data.syncHistory[String(a.siteId)] ?? []; - case "router_status": - return { ...data.routerStatus }; + case "router_status": { + // Mirrors `router::status`: re-diagnose whenever domains are enabled but + // the router is down, so a conflict that appeared *after* enabling (the + // real hazard — LocalWP launched later, or won the boot race) keeps + // reporting its named cause. + const s = data.routerStatus; + if (s.enabled && !s.running) { + const conflicts = mockProbe(s.http_port, s.https_port); + if (conflicts.length > 0) return mockConflict(conflicts, true); + } + return { ...s }; + } case "set_domains_enabled": { const enabled = Boolean(a.enabled); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 167c762..f42c341 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import DomainsSettings from "../components/DomainsSettings"; import KeyboardSettings from "../components/KeyboardSettings"; import { CloseIcon, GlobeIcon, KeyboardIcon, ServerIcon, SlidersIcon, TerminalIcon } from "../components/icons"; -type SectionId = "general" | "terminal" | "keyboard" | "domains" | "serverkit"; +import type { SettingsSection as SectionId } from "../stores/nav"; const SECTIONS: { id: SectionId; label: string; icon: React.ReactNode }[] = [ { id: "general", label: "General", icon: }, @@ -21,7 +21,9 @@ const SECTIONS: { id: SectionId; label: string; icon: React.ReactNode }[] = [ export default function Settings() { const setSettingsOpen = useNav((s) => s.setSettingsOpen); - const [active, setActive] = useState("general"); + // Seeded from nav so callers can deep-link (e.g. the router-conflict banner + // on SiteDetail opens straight to Local domains). + const [active, setActive] = useState(useNav.getState().settingsSection); const { overlayProps, panelProps } = useDialog(() => setSettingsOpen(false)); const activeLabel = SECTIONS.find((s) => s.id === active)?.label; diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index 11eb6b4..d8a2203 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -9,6 +9,7 @@ import type { SiteDetail as SiteDetailData, WpUser } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; import CopyButton from "../components/CopyButton"; import PushPanel from "../components/PushPanel"; +import { describeConflicts } from "../components/DomainsSettings"; export default function SiteDetail({ id }: { id: string }) { const navigate = useNav((s) => s.navigate); @@ -154,6 +155,8 @@ export default function SiteDetail({ id }: { id: string }) { + +
{/* URL */}
@@ -318,3 +321,62 @@ export default function SiteDetail({ id }: { id: string }) {
); } + +/** + * Plan 16: local domains are on but the router is blocked on its ports. + * + * This is the case where a toast is not enough — WordPress still has + * `home`/`siteurl` pointing at `.test`, so the user is most likely + * staring at the *other* program's "Site Not Found" page and has no way to + * know two apps are fighting over port 80. Dismissible, but it comes back if + * the conflict changes. + */ +function RouterConflictBanner({ slug, port }: { slug: string; port: number }) { + const status = useRouter((s) => s.status); + const refresh = useRouter((s) => s.refresh); + const openSettings = useNav((s) => s.openSettings); + const [dismissed, setDismissed] = useState(null); + + // App.tsx only refreshes router status at startup, so a conflict that + // appears later (the other app launched while LocalKit was open) would go + // unreported on exactly the page where it matters. Re-check on arrival. + useEffect(() => { + void refresh(); + }, [refresh, slug]); + + const conflicts = status?.conflicts ?? []; + if (!status?.enabled || conflicts.length === 0) return null; + + const key = conflicts.map((c) => `${c.port}:${c.process ?? "?"}`).join(","); + if (dismissed === key) return null; + + return ( +
+
+

+ Local domains are blocked — another program is using{" "} + {describeConflicts(conflicts)} +

+

+ {slug}.test currently reaches that program, not this + site — which is why you may be seeing someone else's “not found” page. This site is still + served directly at{" "} + http://localhost:{port}. +

+ +
+ +
+ ); +} diff --git a/src/stores/nav.ts b/src/stores/nav.ts index 545b6b3..27d462d 100644 --- a/src/stores/nav.ts +++ b/src/stores/nav.ts @@ -2,12 +2,18 @@ import { create } from "zustand"; export type Page = { name: "sites" } | { name: "site"; id: string } | { name: "terminal"; siteId?: string }; +/** Sections of the settings modal (mirrors `SectionId` in pages/Settings.tsx). */ +export type SettingsSection = "general" | "terminal" | "keyboard" | "domains" | "serverkit"; + interface NavState { page: Page; navigate: (page: Page) => void; /** Settings is a modal, not a page. */ settingsOpen: boolean; setSettingsOpen: (open: boolean) => void; + /** Section the modal opens on (deep-link, e.g. the router-conflict banner). */ + settingsSection: SettingsSection; + openSettings: (section?: SettingsSection) => void; /** New-site dialog (opened from the dashboard button or the mod+N command). */ newSiteOpen: boolean; setNewSiteOpen: (open: boolean) => void; @@ -24,6 +30,8 @@ export const useNav = create((set) => ({ navigate: (page) => set({ page }), settingsOpen: false, setSettingsOpen: (open) => set({ settingsOpen: open }), + settingsSection: "general", + openSettings: (section = "general") => set({ settingsOpen: true, settingsSection: section }), newSiteOpen: false, setNewSiteOpen: (open) => set({ newSiteOpen: open }), paletteOpen: false, From 9a187c05e5d3c76c423168f04d1d99652e04e557 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 11:30:12 -0400 Subject: [PATCH 05/67] sync: send the site's real public URL as local_url, not localhost: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 16's Risks section states that push/pull search-replace uses the port-aware `site_public_url` so local<->remote round-trips. It didn't — both paths hardcoded `http://localhost:`, which is wrong whenever local domains are on (not just in fallback mode): - push: the server rewrites local -> remote using this URL, so with domains enabled the site's actual `.test` URLs were never matched and stayed baked into the remote database. - pull: importing knocked the site off its domain back onto localhost even though local domains were still enabled. Both now use `router::site_public_url`, making the plan's stated invariant true. Verified against a scratch data dir in all three modes: domains off -> http://localhost:8099 domains on, 80/443 -> http://urlcheck.test domains on, 8080/8443 -> http://urlcheck.test:8080 Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/sync.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 0967090..369ea6c 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -6,7 +6,7 @@ use std::path::Path; use tauri::{AppHandle, Emitter}; use uuid::Uuid; -use crate::{serverkit, site, wordpress, AppState}; +use crate::{router, serverkit, site, wordpress, AppState}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncRecord { @@ -147,7 +147,11 @@ pub async fn push_db( let _ = std::fs::remove_file(&dump_path); let sql = sql?; - let local_url = format!("http://localhost:{}", site.port); + // Whatever the site is actually served at — its `.test` domain (with the + // port in fallback mode) when local domains are on. The server rewrites + // local -> remote with this, so a hardcoded localhost: would leave + // `.test` URLs baked into the remote database. + let local_url = router::site_public_url(state, &site); emit(app, site_id, "push", "Uploading database dump..."); run(app, state, connection_id, site_id, "push", "db", async move { serverkit::push_db(&conn.url, &conn.api_key, remote_site_id, &local_url, sql).await?; @@ -177,7 +181,9 @@ pub async fn pull_db( .read_to_end(&mut sql) .map_err(|e| format!("failed to decompress remote dump: {e}"))?; - let local_url = format!("http://localhost:{}", site.port); + // Same rule on the way back in: pulling must land the site on its current + // public URL, not silently knock it off its domain onto localhost. + let local_url = router::site_public_url(state, &site); emit(app, site_id, "pull", "Importing database into local site..."); run(app, state, connection_id, site_id, "pull", "db", async move { wordpress::import_db(&site.dir(), &sql).await?; From 47eddbacaa67b75ffd4cbe365330343e9f058cdc Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 11:32:45 -0400 Subject: [PATCH 06/67] =?UTF-8?q?docs:=20router=20coexistence=20=E2=80=94?= =?UTF-8?q?=20README=20troubleshooting,=20AGENTS.md,=20roadmap=20(plan=201?= =?UTF-8?q?6=20phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README gains a Troubleshooting section leading with the LocalWP case: why only one program can own 80/443, what LocalKit does about it, and what fallback mode changes (and doesn't — hosts entries are port-blind, and .test vs .local means the hostnames never collide). - AGENTS.md router convention block records the port settings keys, that `site_url` is port-aware, that `site_public_url` is the single source of truth for a site's URL, and the Windows SO_REUSEADDR trap that makes bind-only port probing unsound. Also documents the new verify script and the widened `cargo test --lib router` coverage. - ROADMAP marks plan 16 shipped; the plan file records the four places implementation diverged from it (probe method, sync.rs, the enabled-flag gotcha, headless verification). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 30 ++++++++++++++++++++++++- README.md | 34 +++++++++++++++++++++++++++++ docs/plans/16_router-coexistence.md | 26 +++++++++++++++++++++- docs/plans/ROADMAP.md | 6 ++++- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae0bcc0..005d7d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,11 @@ src-tauri/ Rust backend (also a cargo workspace root) - `node scripts/verify-shortcuts.mjs` — headless runtime check of the plan-15 keyboard system against the mock server (palette, shortcuts, editable guard, rebinding/conflicts/persistence) +- `node scripts/verify-router-conflict.mjs` — headless runtime check of the + plan-16 port-conflict UX against the mock server (named conflict, fallback + one-click recovery, port-bearing site URLs, SiteDetail banner). The mock + fakes a LocalWP holding 80/443; `window.__LOCALKIT_MOCK__` (mock builds + only) lets the script reach states the UI can't drive on its own. - `cd src-tauri && cargo run --example smoke -- ` — end-to-end lifecycle smoke test against real Docker (no Tauri runtime needed); uses a scratch data dir under the OS temp dir. @@ -89,7 +94,8 @@ src-tauri/ Rust backend (also a cargo workspace root) mock serverkit-localkit extension (`node examples/mock_localkit_ext.cjs` first, port 9872); requires the smoke site to exist. - `cd src-tauri && cargo test --lib router` — unit tests for the M6 hosts-file - block logic (insert/replace/remove idempotency, CRLF preservation). + block logic (insert/replace/remove idempotency, CRLF preservation) plus the + plan-16 port probe, compose port mapping and `site_url` formatting. - `cd src-tauri && cargo run --example m6_smoke` — M6 router E2E against the smoke site; **interactive only** (hosts-file writes trigger UAC/elevation prompts twice). Run `smoke -- create` first, `smoke -- cleanup` after. @@ -184,6 +190,28 @@ src-tauri/ Rust backend (also a cargo workspace root) Windows — no admin) and records success in settings. Caddyfile regenerates + reloads on site create/start/stop/delete; hosts sync on create/delete only (no UAC spam on start/stop). +- **Router coexistence (plan 16):** host ports are configurable via the + `app_settings` keys `router_http_port` / `router_https_port` (default + 80/443; `router::router_ports`). Container ports stay 80/443 — only the + host mapping moves — so the Caddyfile and the hosts block stay port-blind. + `site_url` is therefore **port-aware**: default ports give the clean + `https://.test`, any other pair gives `http://.test:` + and deliberately stays on http (a non-standard https port re-prompts for a + cert exception even with the CA trusted). **`router::site_public_url` is + the single source of truth** for "where does this site live" — tray menu, + WP install URL, one-click login and sync's `local_url` all funnel through + it; never hand-roll the domain-vs-localhost rule again. Frontend mirror: + `lib/domains.ts` (`siteUrl`, `isDefaultPorts`). + Before enabling (and on every `status()` where `enabled && !running`), + `probe_ports` checks who owns the ports. **Probing must consult the OS + listener table** (`Get-NetTCPConnection` / `lsof`), not just a trial bind: + on Windows a socket bound with SO_REUSEADDR (Docker's port publisher does + this) lets you re-bind the same wildcard address, so bind-only probing + reports a busy port as free. Conflicts surface as `RouterStatus.conflicts` + and drive the Settings callout, the SiteDetail banner and `lk doctor`. + Note a failed *enable* leaves `domains_enabled` off (the backend + short-circuits before setting it), so UI must not gate conflict reporting + on the enabled flag. - **System tray (M8):** `tray.rs` owns the tray icon/menu (Tauri 2 built-in `TrayIconBuilder` — no extra crate) plus the close-to-tray interception in `run()`'s `on_window_event`. The `run_in_background` flag lives in diff --git a/README.md b/README.md index 0b334e2..0b9034f 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,40 @@ docs/ --- +## 🩺 Troubleshooting + +### "LocalWP / Local by Flywheel is installed" — my `.test` sites show someone else's 404 + +Only one program on your machine can own ports **80** and **443**, and +LocalKit's local-domains router needs them. LocalWP's nginx router binds both +machine-wide *and* answers every unknown local hostname with its own "Site Not +Found" page — so if it wins the port, `http://mysite.test` renders **Local's** +404 rather than anything from LocalKit. + +LocalKit detects this before it can bite: + +- Enabling local domains runs a port pre-flight first. If something else holds + 80/443 it names the process and stops, rather than writing hosts entries that + would point your sites at the other program. +- Settings → **Local domains** shows the conflict with two ways out: **Use + fallback ports** (one click; the router moves to 8080/8443) or **Retry** after + you quit the other app. +- `lk doctor` prints the active router mode and who owns the ports. + +On fallback ports your sites are reachable at +`http://.test:8080` — the hosts entries are port-blind, so nothing else +changes and both apps can run side by side. LocalKit deliberately uses the +`.test` TLD (RFC 2606) while Local uses `.local`, so the hostnames themselves +never collide; the fight is only ever over the ports. + +Prefer clean `http://.test` URLs? Quit the other program, set the router +back to 80/443 in Settings → Local domains, and hit Retry. + +> **Note:** switching ports restarts the router and rewrites each running +> site's WordPress `home`/`siteurl`, so bookmarks and absolute URLs follow. + +--- + ## 🗺️ Roadmap - **M1 — Local site lifecycle** ✅ create/start/stop/delete, compose projects, port allocation diff --git a/docs/plans/16_router-coexistence.md b/docs/plans/16_router-coexistence.md index 8fde7ac..58761ce 100644 --- a/docs/plans/16_router-coexistence.md +++ b/docs/plans/16_router-coexistence.md @@ -1,6 +1,30 @@ # 16 — Router coexistence: port-conflict pre-flight + configurable router ports -Status: ⬜ planned +Status: ✅ shipped + +> Implementation notes (deviations from the plan as written): +> +> - **The probe cannot be bind-only.** Phase 1 specified +> `TcpListener::bind` as the conflict test. On Windows that produces false +> negatives: a socket bound with `SO_REUSEADDR` — which Docker's port +> publisher uses — lets us bind the *same* wildcard address again, so an +> occupied port reports free. Verified directly: a container published +> 8080, `netstat` showed it LISTENING, `bind(0.0.0.0:8080)` still +> succeeded. `probe_port` now treats the OS listener table +> (`Get-NetTCPConnection` / `lsof`) as the primary signal, with the bind as +> corroboration. +> - **`sync.rs` did not use `site_public_url`.** The Risks section assumed it +> already did; both push and pull hardcoded `http://localhost:`, so +> with local domains on, push baked `.test` URLs into the remote DB +> and pull knocked the site off its domain. Fixed as part of this plan. +> - **A failed enable leaves `domains_enabled` off**, so the UI must not gate +> conflict reporting on the enabled flag. The SiteDetail banner instead +> targets the persistent hazard (domains on, router later lost its ports) +> and refreshes router status on mount, since `App.tsx` only refreshed at +> startup. +> - Phase 3's UX is verified headlessly by +> `scripts/verify-router-conflict.mjs` (15 checks over the plan's manual +> matrix), which is what caught the last two items. Make local domains survive alongside other tools that also claim ports 80/443 and the `.test`/`.local` hosts space (LocalWP's nginx router is the canonical diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index 6b948f6..c25584b 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -23,7 +23,7 @@ The file numbers ARE the build order — each plan leans on the ones before it. | 13 | `13_settings-store` | ✅ | Unified settings store on `app_settings` KV + pre-paint injection; substrate for terminal settings and themes. | | 14 | `14_terminal-quick-wins` | ✅ shipped | Web-links, copy-on-select, ghost-text history, terminal font/scrollback settings (needs 13). | | 15 | `15_command-palette-shortcuts` | ✅ shipped | Command registry + palette (mod+K), global shortcuts, remappable bindings in Settings (needs 13). | -| 16 | `16_router-coexistence` | ⬜ | Port-80/443 conflict pre-flight + configurable router ports so domains survive alongside LocalWP & co. | +| 16 | `16_router-coexistence` | ✅ shipped | Port-80/443 conflict pre-flight + configurable router ports so domains survive alongside LocalWP & co. | | 17 | `17_snapshots` | ⬜ | DB + wp-content snapshots with one-click restore; automatic before push/pull/delete. Safety net for 18–20. | | 18 | `18_import-remote-site` | ⬜ | Clone a ServerKit site down as a *new* local site (needs the extension's missing pull/code endpoint). | | 19 | `19_sync-v2-chunked` | ⬜ | Chunked resumable push/pull with byte progress + cancel (breaks the 100 MB / in-memory limits). | @@ -72,6 +72,10 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre - ✅ Local domains: `http(s)://.test` via a shared Caddy router + managed hosts block + local CA trust (plan 6), layered on top of the always-working `localhost:` URLs +- ✅ Router coexistence (plan 16): port pre-flight that names the process + holding 80/443, configurable router ports with one-click fallback to + 8080/8443, port-aware `site_public_url`, conflict UX in Settings + + SiteDetail + `lk doctor` - ✅ System tray + background mode: close-to-tray, tray menu with quick site actions, single-instance focus (plan 8) From 87b4c6e05e0d5c1fc4c3a8d9d1f1337b481ffdfd Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 14:25:06 -0400 Subject: [PATCH 07/67] router: one-shot OS listener-table query for port checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `probe_port` answers "who holds this port" and costs a subprocess per call. Port *allocation* needs the whole set at once (site.rs walks upward from 8081), so add `listening_ports()`: one PowerShell/lsof spawn, no owner lookup. Same authority as `probe_port`, for the same plan-16 reason — a bare bind test misses ports published by Docker, whose publisher binds the wildcard address with SO_REUSEADDR. Parsing is pure and unit-tested against both shapes: Windows prints a bare port per line, `lsof -F n` prints `n:` with IPv6 literals carrying extra colons. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/router.rs | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index 0dc76b2..e0bffea 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -289,6 +289,64 @@ async fn identify_port_owner(port: u16) -> Option { Some(name).filter(|n| !n.is_empty()) } +/// Every TCP port in LISTEN state on the host, from one OS query. +/// +/// `probe_port` answers "who holds *this* port" and costs a subprocess per +/// call; port *allocation* needs the whole set at once (site.rs walks upward +/// from 8081), so it gets this instead — one spawn, no owner lookup. +/// +/// Same authority as `probe_port` and for the same reason: a bare bind test +/// misses ports published by Docker (SO_REUSEADDR lets the wildcard address be +/// re-bound), which would hand out a port that then fails at `compose up`. +/// Best effort — an empty set on error just falls back to bind-only checks. +pub async fn listening_ports() -> std::collections::HashSet { + #[cfg(target_os = "windows")] + let output = docker::no_window(tokio::process::Command::new("powershell").args([ + "-NoProfile", + "-Command", + "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue \ + | Select-Object -ExpandProperty LocalPort", + ])) + .output() + .await; + #[cfg(not(target_os = "windows"))] + let output = docker::no_window(tokio::process::Command::new("lsof").args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-F", + "n", + ])) + .output() + .await; + + match output { + Ok(out) => parse_listening_ports(&String::from_utf8_lossy(&out.stdout)), + Err(_) => std::collections::HashSet::new(), + } +} + +/// Parse the listener query output. Windows prints one bare port per line; +/// `lsof -F n` prints `n:` (addr may be `*`, IPv4, or a bracketed +/// IPv6 literal), interleaved with other `-F` field lines we ignore. +fn parse_listening_ports(stdout: &str) -> std::collections::HashSet { + stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let candidate = match line.strip_prefix('n') { + // lsof: take everything after the last ':' (IPv6 has several). + Some(addr) => addr.rsplit(':').next()?, + None => line, + }; + candidate.parse::().ok() + }) + .collect() +} + /// Which of the router's ports are held by something else. pub async fn probe_ports(http: u16, https: u16) -> Vec { let mut conflicts = Vec::new(); @@ -897,6 +955,30 @@ mod tests { names.iter().map(|s| s.to_string()).collect() } + #[test] + fn parses_the_windows_listener_table() { + // `Select-Object -ExpandProperty LocalPort`: one bare port per line. + let ports = parse_listening_ports("80\r\n443\r\n8081\r\n18081\r\n"); + assert_eq!(ports.len(), 4); + assert!(ports.contains(&8081) && ports.contains(&18081)); + } + + #[test] + fn parses_lsof_listener_output() { + // `lsof -F n` interleaves `p` lines; IPv6 names carry extra colons. + let ports = parse_listening_ports("p123\nn*:8081\nn127.0.0.1:18081\np456\nn[::1]:443\n"); + assert_eq!(ports.len(), 3); + assert!(ports.contains(&8081)); + assert!(ports.contains(&18081)); + assert!(ports.contains(&443), "IPv6 literal should not eat the port"); + } + + #[test] + fn listener_parsing_ignores_junk() { + let ports = parse_listening_ports("\nLocalPort\n-------\n\nnot-a-port\n99999999\n8082\n"); + assert_eq!(ports, std::collections::HashSet::from([8082])); + } + const SAMPLE: &str = "# Copyright (c) Microsoft Corp.\r\n\ \r\n\ 127.0.0.1 localhost\r\n\ From 71abebc10882bbd7220ff3d78e903a4befb879a5 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 14:25:20 -0400 Subject: [PATCH 08/67] snapshot: engine, retention and destructive-flow wiring (plan 17 p1+p2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A snapshot is a directory, not a DB row — no migration: /snapshots/// manifest.json db.sql.gz wp-content.tar.gz `create` exports the DB through wpcli (which brings the db service up via depends_on, so a stopped site snapshots fine) and reuses the tar.gz builder push_code already used — moved here, so a snapshot is also restorable by hand with tar. Payloads are written before the manifest: a half-written snapshot has no manifest, so `list` skips it instead of offering a broken restore. `restore` reads and decompresses both archives before touching anything, snapshots the current state (restoring is destructive too), auto-starts a stopped site for the import, swaps wp-content's *contents* (the dir itself is bind-mounted — removing it breaks the mount) and flushes the cache. Wired into every destructive flow: push_db/pull_db abort if the pre-sync snapshot fails (never mutate without a net), delete takes a pre_delete one and keeps the snapshot dir unless the caller opts out. Delete's snapshot is best effort — a site with a broken Docker stack must still be deletable. Retention: auto kinds capped at the newest 5 per site per kind, manual never pruned. That rule is a pure function with unit tests, alongside manifest/gzip round-trips. Also fixes port allocation, which the smoke run hit immediately: it bind-probed 127.0.0.1 only, so a port published by a running container (SO_REUSEADDR) read as free and creation died at `compose up` after the image pull. It now consults the listener table and checks the DB port too — only the site port was ever checked. Verified against real Docker with the new snapshot_smoke example: snapshot -> delete post 1 + a canary file in wp-content -> restore -> both back, pre_restore snapshot present. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/examples/smoke.rs | 3 +- src-tauri/examples/snapshot_smoke.rs | 164 +++++++++ src-tauri/src/lib.rs | 78 ++++- src-tauri/src/site.rs | 62 +++- src-tauri/src/snapshot.rs | 498 +++++++++++++++++++++++++++ src-tauri/src/sync.rs | 47 +-- 6 files changed, 820 insertions(+), 32 deletions(-) create mode 100644 src-tauri/examples/snapshot_smoke.rs create mode 100644 src-tauri/src/snapshot.rs diff --git a/src-tauri/examples/smoke.rs b/src-tauri/examples/smoke.rs index b87613d..e230231 100644 --- a/src-tauri/examples/smoke.rs +++ b/src-tauri/examples/smoke.rs @@ -129,7 +129,8 @@ async fn start(state: &AppState) -> Result<(), String> { async fn delete(state: &AppState) -> Result<(), String> { let s = find_site(state)?; let dir = s.dir(); - site::delete(state, &s.id).await?; + // Keep the snapshots so `snapshot_smoke` can assert they survive the site. + site::delete(None, state, &s.id, false).await?; assert!(!dir.exists(), "site dir still exists after delete"); let db = state.db.lock().map_err(|e| e.to_string())?; assert!(db.list_sites()?.is_empty(), "db rows left after delete"); diff --git a/src-tauri/examples/snapshot_smoke.rs b/src-tauri/examples/snapshot_smoke.rs new file mode 100644 index 0000000..c38275f --- /dev/null +++ b/src-tauri/examples/snapshot_smoke.rs @@ -0,0 +1,164 @@ +//! End-to-end smoke test for snapshots + restore (plan 17). +//! Runs outside the Tauri runtime (no AppHandle; progress prints to stderr). +//! +//! Usage: +//! cargo run --example smoke -- create # once, to have a site +//! cargo run --example snapshot_smoke # or `-- run` +//! cargo run --example snapshot_smoke -- clean +//! +//! Shares the `smoke` example's data dir and site, so it exercises the same +//! WordPress install the lifecycle smoke test builds. + +use std::sync::Mutex; + +use localkit_lib::{db::Db, docker, site, snapshot, AppState}; + +const SMOKE_SLUG: &str = "smoke-test"; +/// Dropped into wp-content to prove the code archive round-trips, not just the DB. +const CANARY: &str = "localkit-snapshot-canary.txt"; + +fn make_state() -> AppState { + let data_dir = std::env::temp_dir().join("localkit-smoke"); + std::fs::create_dir_all(&data_dir).expect("create smoke data dir"); + let db = Db::open(&data_dir.join("localkit.db")).expect("open smoke db"); + AppState { + db: Mutex::new(db), + data_dir, + terminals: localkit_lib::terminal::PtyManager::new(), + } +} + +fn find_site(state: &AppState) -> Result { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.list_sites()? + .into_iter() + .find(|s| s.slug == SMOKE_SLUG || s.slug.starts_with(&format!("{SMOKE_SLUG}-"))) + .ok_or_else(|| "smoke site not found — run `cargo run --example smoke -- create` first".into()) +} + +async fn wp(s: &site::Site, args: &[&str]) -> Result { + let mut full: Vec<&str> = vec!["wp"]; + full.extend_from_slice(args); + docker::compose_run(&s.dir(), "wpcli", &full).await +} + +/// Does post 1 still exist? (`wp post get` fails once it is really gone.) +async fn post_exists(s: &site::Site) -> bool { + wp(s, &["post", "get", "1", "--field=ID"]) + .await + .map(|out| out.trim() == "1") + .unwrap_or(false) +} + +async fn run(state: &AppState) -> Result<(), String> { + let s = find_site(state)?; + println!("site: {} ({})", s.name, s.slug); + + // The DB import needs the stack up. + if s.status != "running" { + println!("starting the site..."); + site::start(state, &s.id).await?; + } + + // --- arrange: a known post + a known file in wp-content ----------------- + if !post_exists(&s).await { + wp(&s, &["post", "create", "--post_title=Hello world!", "--post_status=publish"]).await?; + } + let canary = s.dir().join("wp-content").join(CANARY); + std::fs::write(&canary, b"present at snapshot time\n") + .map_err(|e| format!("failed to write canary: {e}"))?; + assert!(post_exists(&s).await, "post 1 should exist before the snapshot"); + + // --- snapshot ----------------------------------------------------------- + let snap = snapshot::create( + None, + state, + &s.id, + snapshot::KIND_MANUAL, + Some("snapshot smoke test".into()), + ) + .await?; + println!( + "SNAPSHOT id={} kind={} db={} B code={} B", + snap.id, snap.kind, snap.db_bytes, snap.code_bytes + ); + assert!(snap.db_bytes > 0, "empty database dump"); + assert!(snap.code_bytes > 0, "empty wp-content archive"); + + // --- break it ----------------------------------------------------------- + wp(&s, &["post", "delete", "1", "--force"]).await?; + std::fs::remove_file(&canary).map_err(|e| format!("failed to remove canary: {e}"))?; + assert!(!post_exists(&s).await, "post 1 should be gone after the delete"); + assert!(!canary.exists(), "canary should be gone after the delete"); + println!("broke the site: post 1 deleted, {CANARY} removed"); + + // --- restore ------------------------------------------------------------ + let message = snapshot::restore(None, state, &s.id, &snap.id).await?; + println!("RESTORE {message}"); + assert!(post_exists(&s).await, "post 1 should be back after the restore"); + assert!(canary.exists(), "{CANARY} should be back after the restore"); + + // Restoring is destructive too, so it snapshots first. + let all = snapshot::list(state, &s.id)?; + assert!( + all.iter().any(|x| x.kind == snapshot::KIND_PRE_RESTORE), + "restore should have taken a pre_restore snapshot" + ); + // Newest first. + assert_eq!(all[0].kind, snapshot::KIND_PRE_RESTORE); + println!( + "snapshots on disk: {}", + all.iter() + .map(|x| format!("{}({})", x.kind, x.id)) + .collect::>() + .join(", ") + ); + + // --- delete one --------------------------------------------------------- + snapshot::delete(state, &s.id, &snap.id)?; + let after = snapshot::list(state, &s.id)?; + assert!( + !after.iter().any(|x| x.id == snap.id), + "deleted snapshot still listed" + ); + assert!( + !snapshot::site_snapshots_dir(&state.data_dir, &s.id) + .join(&snap.id) + .exists(), + "deleted snapshot directory still on disk" + ); + + let _ = std::fs::remove_file(&canary); + println!("SNAPSHOT SMOKE OK"); + Ok(()) +} + +/// Drop every snapshot the smoke run left behind. +fn clean(state: &AppState) -> Result<(), String> { + let root = snapshot::snapshots_root(&state.data_dir); + if root.exists() { + std::fs::remove_dir_all(&root).map_err(|e| format!("failed to clean snapshots: {e}"))?; + println!("cleaned {}", root.display()); + } + Ok(()) +} + +#[tokio::main] +async fn main() { + let cmd = std::env::args().nth(1).unwrap_or_else(|| "run".to_string()); + let status = docker::check().await; + if !status.available { + eprintln!("docker unavailable: {:?}", status.error); + std::process::exit(2); + } + let state = make_state(); + let result = match cmd.as_str() { + "run" => run(&state).await, + "clean" => clean(&state), + other => Err(format!("unknown command: {other}")), + }; + if let Err(e) = result { + eprintln!("SNAPSHOT SMOKE {cmd} FAILED: {e}"); + std::process::exit(1); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a5d5329..d57a211 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod docker; pub mod router; pub mod serverkit; pub mod site; +pub mod snapshot; pub mod sync; pub mod terminal; pub mod tray; @@ -84,8 +85,14 @@ async fn stop_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Re } #[tauri::command] -async fn delete_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<(), String> { - site::delete(&state, &id).await?; +async fn delete_site( + app: AppHandle, + state: State<'_, AppState>, + id: String, + delete_snapshots: Option, +) -> Result<(), String> { + // Default: keep the snapshots (including the pre_delete one this takes). + site::delete(Some(&app), &state, &id, delete_snapshots.unwrap_or(false)).await?; tray::refresh(&app); Ok(()) } @@ -136,6 +143,69 @@ async fn site_wp_users( wordpress::users(&s.dir()).await } +// --------------------------------------------------------------------------- +// Snapshots (plan 17) — point-in-time DB + wp-content copies with restore +// --------------------------------------------------------------------------- + +#[tauri::command] +fn list_snapshots(state: State, site_id: String) -> Result, String> { + snapshot::list(&state, &site_id) +} + +#[tauri::command] +async fn create_snapshot( + app: AppHandle, + state: State<'_, AppState>, + site_id: String, + note: Option, +) -> Result { + // Standalone snapshots own their terminal event: `create` deliberately + // stays silent on done/error so it can nest inside push/pull/delete. + match snapshot::create(Some(&app), &state, &site_id, snapshot::KIND_MANUAL, note).await { + Ok(snap) => { + site::emit( + Some(&app), + &site_id, + "done", + &format!("Snapshot of {} taken", snap.site_name), + ); + Ok(snap) + } + Err(e) => { + site::emit(Some(&app), &site_id, "error", &format!("Snapshot failed: {e}")); + Err(e) + } + } +} + +#[tauri::command] +async fn restore_snapshot( + app: AppHandle, + state: State<'_, AppState>, + site_id: String, + snapshot_id: String, +) -> Result<(), String> { + let result = snapshot::restore(Some(&app), &state, &site_id, &snapshot_id).await; + // Restore can auto-start a stopped site, so the tray must be rebuilt + // either way (a failure may still have started it). + tray::refresh(&app); + match result { + Ok(message) => { + site::emit(Some(&app), &site_id, "done", &message); + Ok(()) + } + Err(e) => { + site::emit(Some(&app), &site_id, "error", &format!("Restore failed: {e}")); + Err(e) + } + } +} + +#[tauri::command] +fn delete_snapshot(state: State, site_id: String, snapshot_id: String) -> Result<(), String> { + snapshot::delete(&state, &site_id, &snapshot_id) +} + // --------------------------------------------------------------------------- // ServerKit connections (M3, read-only) // --------------------------------------------------------------------------- @@ -428,6 +498,10 @@ pub fn run() { wp_cli_info, login_site, site_wp_users, + list_snapshots, + create_snapshot, + restore_snapshot, + delete_snapshot, save_serverkit_connection, list_serverkit_connections, delete_serverkit_connection, diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index 81f082b..d96fa27 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -119,16 +119,29 @@ fn unique_slug(state: &AppState, base: &str) -> Result { Err("could not generate a unique slug".into()) } -/// Pick a free host port starting at BASE_PORT: not used by another site and -/// not already bound on the host. -fn free_port(state: &AppState) -> Result { +/// Pick a free host port starting at BASE_PORT: not used by another site, and +/// with neither it nor its DB port already held on the host. +/// +/// The host check consults the OS listener table, not just a trial bind. A +/// bind-only test is the plan-16 SO_REUSEADDR trap all over again: Docker's +/// port publisher binds the wildcard address with SO_REUSEADDR, so binding +/// 127.0.0.1:8081 still succeeds while a container is published on 8081 — we +/// would hand out that port and creation would die at `compose up`, after the +/// image pull, with a raw Docker error. Both ports matter: only the site port +/// was ever checked, so a free site port with a taken DB port failed the same +/// way. +async fn free_port(state: &AppState) -> Result { let used = { let db = state.db.lock().map_err(|e| e.to_string())?; db.used_ports()? }; + let listening = router::listening_ports().await; + let free = |port: u16| -> bool { + !listening.contains(&port) && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() + }; let mut port = BASE_PORT; loop { - if !used.contains(&port) && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + if !used.contains(&port) && free(port) && free(port + DB_PORT_OFFSET) { return Ok(port); } port += 1; @@ -269,7 +282,7 @@ pub async fn create( } let slug = unique_slug(state, &slugify(&name))?; - let port = free_port(state)?; + let port = free_port(state).await?; let dir = site_dir(&state.data_dir, &slug); let site = Site { @@ -423,14 +436,51 @@ pub async fn stop(state: &AppState, id: &str) -> Result { get(state, id) } -pub async fn delete(state: &AppState, id: &str) -> Result<(), String> { +/// Delete a site. Unless `delete_snapshots` is set, a `pre_delete` snapshot is +/// taken first and the site's snapshot directory survives the deletion — the +/// only copy of the data once the containers, volumes and files are gone +/// (plan 17; also the groundwork for a future "restore deleted site"). +/// +/// The snapshot is best effort: a site whose Docker stack is broken must still +/// be deletable, so a snapshot failure is reported through the event stream +/// rather than blocking the delete. +pub async fn delete( + app: Option<&AppHandle>, + state: &AppState, + id: &str, + delete_snapshots: bool, +) -> Result<(), String> { let site = get(state, id)?; let dir = site.dir(); + + if !delete_snapshots && dir.exists() { + emit(app, id, "snapshot", "Taking a snapshot before deleting..."); + if let Err(e) = crate::snapshot::create( + app, + state, + id, + crate::snapshot::KIND_PRE_DELETE, + Some(format!("before deleting {}", site.name)), + ) + .await + { + emit( + app, + id, + "snapshot", + &format!("Could not snapshot before deleting ({e}) — deleting anyway"), + ); + } + } + if dir.exists() { // Best effort: even if Docker is down we still remove local state. let _ = docker::compose_down(&dir, true).await; std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to remove site directory: {e}"))?; } + if delete_snapshots { + let _ = crate::snapshot::delete_all(&state.data_dir, id); + } { let db = state.db.lock().map_err(|e| e.to_string())?; db.delete_site(id)?; diff --git a/src-tauri/src/snapshot.rs b/src-tauri/src/snapshot.rs new file mode 100644 index 0000000..4c29ef8 --- /dev/null +++ b/src-tauri/src/snapshot.rs @@ -0,0 +1,498 @@ +//! Point-in-time site snapshots + one-click restore (plan 17). +//! +//! A snapshot is a directory on disk — no SQLite table, so no migration: +//! +//! ```text +//! /snapshots/// +//! manifest.json the Snapshot struct below +//! db.sql.gz `wp db export -`, gzipped +//! wp-content.tar.gz the bind-mounted wp-content dir +//! ``` +//! +//! The archive format is deliberately the same one `sync::push_code` uploads +//! (`build_wp_content_tgz` lives here and is shared), so a snapshot is +//! restorable by hand with `tar -xzf` if LocalKit is not around. +//! +//! Snapshots are taken automatically before every destructive operation +//! (push, pull, delete, and restore itself) — see `kind` below. + +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +use crate::{docker, site, wordpress, AppState}; + +/// Manual snapshots are never auto-pruned; every other kind is capped at +/// `RETENTION` per site per kind after each create. +pub const KIND_MANUAL: &str = "manual"; +pub const KIND_PRE_PUSH: &str = "pre_push"; +pub const KIND_PRE_PULL: &str = "pre_pull"; +pub const KIND_PRE_DELETE: &str = "pre_delete"; +pub const KIND_PRE_RESTORE: &str = "pre_restore"; + +pub const KINDS: &[&str] = &[ + KIND_MANUAL, + KIND_PRE_PUSH, + KIND_PRE_PULL, + KIND_PRE_DELETE, + KIND_PRE_RESTORE, +]; + +/// How many auto snapshots to keep per site per kind. +const RETENTION: usize = 5; + +const DB_FILE: &str = "db.sql.gz"; +const CODE_FILE: &str = "wp-content.tar.gz"; +const MANIFEST_FILE: &str = "manifest.json"; + +/// `manifest.json` — everything the UI/CLI needs without touching the archives. +/// Kept richer than strictly necessary (name/slug/wp_version) so a snapshot +/// stays meaningful after its site row is gone (deleted site). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snapshot { + /// Sortable timestamp id; also the directory name. + pub id: String, + pub site_id: String, + pub site_name: String, + pub site_slug: String, + pub created_at: String, + /// manual | pre_push | pre_pull | pre_delete | pre_restore + pub kind: String, + pub note: String, + pub db_bytes: u64, + pub code_bytes: u64, + pub wp_version: String, +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +pub fn snapshots_root(data_dir: &Path) -> PathBuf { + data_dir.join("snapshots") +} + +pub fn site_snapshots_dir(data_dir: &Path, site_id: &str) -> PathBuf { + snapshots_root(data_dir).join(site_id) +} + +fn snapshot_dir(data_dir: &Path, site_id: &str, id: &str) -> PathBuf { + site_snapshots_dir(data_dir, site_id).join(id) +} + +/// Timestamp id, filesystem-safe on Windows (no colons). +fn new_id() -> String { + chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f").to_string() +} + +// --------------------------------------------------------------------------- +// Archive helpers (shared with sync::push_code) +// --------------------------------------------------------------------------- + +/// Bundle the site's wp-content directory as a tar.gz in memory. +pub(crate) fn build_wp_content_tgz(site_dir: &Path) -> Result, String> { + let wp_content = site_dir.join("wp-content"); + if !wp_content.is_dir() { + return Err("wp-content directory not found in the local site".into()); + } + let mut buf = Vec::new(); + { + let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast()); + let mut builder = tar::Builder::new(enc); + builder + .append_dir_all("wp-content", &wp_content) + .map_err(|e| format!("failed to bundle wp-content: {e}"))?; + builder + .finish() + .map_err(|e| format!("failed to finalize archive: {e}"))?; + } + Ok(buf) +} + +fn gzip(bytes: &[u8]) -> Result, String> { + use std::io::Write; + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + enc.write_all(bytes) + .map_err(|e| format!("failed to compress database dump: {e}"))?; + enc.finish() + .map_err(|e| format!("failed to compress database dump: {e}")) +} + +fn gunzip(bytes: &[u8]) -> Result, String> { + let mut out = Vec::new(); + flate2::read::GzDecoder::new(bytes) + .read_to_end(&mut out) + .map_err(|e| format!("failed to decompress snapshot dump: {e}"))?; + Ok(out) +} + +// --------------------------------------------------------------------------- +// Retention (pure — unit tested) +// --------------------------------------------------------------------------- + +/// Ids to prune after a create: for every *auto* kind keep the newest +/// `RETENTION`, drop the rest. `manual` snapshots are never auto-pruned — +/// they are the ones the user deliberately took. +pub fn prunable(snapshots: &[Snapshot]) -> Vec { + let mut sorted: Vec<&Snapshot> = snapshots.iter().collect(); + // Newest first. `id` is a fixed-width timestamp, so lexical == chronological. + sorted.sort_by(|a, b| b.id.cmp(&a.id)); + + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + let mut out = Vec::new(); + for s in sorted { + if s.kind == KIND_MANUAL { + continue; + } + let n = seen.entry(s.kind.as_str()).or_insert(0); + *n += 1; + if *n > RETENTION { + out.push(s.id.clone()); + } + } + out +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +/// All snapshots for a site, newest first. A snapshot directory whose manifest +/// is missing or unreadable is skipped rather than failing the whole listing. +pub fn list(state: &AppState, site_id: &str) -> Result, String> { + let dir = site_snapshots_dir(&state.data_dir, site_id); + if !dir.is_dir() { + return Ok(vec![]); + } + let entries = + std::fs::read_dir(&dir).map_err(|e| format!("failed to read snapshots directory: {e}"))?; + let mut out = Vec::new(); + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + if let Ok(text) = std::fs::read_to_string(entry.path().join(MANIFEST_FILE)) { + if let Ok(snap) = serde_json::from_str::(&text) { + out.push(snap); + } + } + } + out.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(out) +} + +fn read_manifest(data_dir: &Path, site_id: &str, id: &str) -> Result { + let path = snapshot_dir(data_dir, site_id, id).join(MANIFEST_FILE); + let text = std::fs::read_to_string(&path).map_err(|_| format!("snapshot `{id}` not found"))?; + serde_json::from_str(&text).map_err(|e| format!("snapshot `{id}` has an unreadable manifest: {e}")) +} + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +/// Take a snapshot of a site: DB dump + wp-content archive + manifest. +/// +/// Emits `snapshot`-stage progress only — never `done`/`error`, because this +/// also runs *inside* longer operations (push/pull/delete) whose own progress +/// toast must not be resolved early. Standalone callers emit the terminal +/// stage themselves. +/// +/// Works on a stopped site: `docker compose run wpcli` brings the `db` service +/// up (and waits for its healthcheck) through the compose `depends_on`. +pub async fn create( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + kind: &str, + note: Option, +) -> Result { + if !KINDS.contains(&kind) { + return Err(format!("unknown snapshot kind: {kind}")); + } + let s = site::get(state, site_id)?; + let dir = s.dir(); + if !dir.exists() { + return Err(format!("site directory not found: {}", dir.display())); + } + + site::emit(app, site_id, "snapshot", "Exporting database..."); + let sql = export_db(&dir).await?; + let db_gz = gzip(sql.as_bytes())?; + + site::emit(app, site_id, "snapshot", "Archiving wp-content..."); + let code_tgz = build_wp_content_tgz(&dir)?; + + let snap = Snapshot { + id: new_id(), + site_id: s.id.clone(), + site_name: s.name.clone(), + site_slug: s.slug.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + kind: kind.to_string(), + note: note.unwrap_or_default(), + db_bytes: db_gz.len() as u64, + code_bytes: code_tgz.len() as u64, + wp_version: s.wp_version.clone(), + }; + + let out = snapshot_dir(&state.data_dir, site_id, &snap.id); + std::fs::create_dir_all(&out) + .map_err(|e| format!("failed to create snapshot directory: {e}"))?; + // Write the payloads before the manifest: a half-written snapshot has no + // manifest, so `list` skips it instead of offering a broken restore. + std::fs::write(out.join(DB_FILE), &db_gz) + .map_err(|e| format!("failed to write database dump: {e}"))?; + std::fs::write(out.join(CODE_FILE), &code_tgz) + .map_err(|e| format!("failed to write wp-content archive: {e}"))?; + let manifest = + serde_json::to_string_pretty(&snap).map_err(|e| format!("failed to write manifest: {e}"))?; + std::fs::write(out.join(MANIFEST_FILE), manifest) + .map_err(|e| format!("failed to write manifest: {e}"))?; + + prune(state, site_id); + Ok(snap) +} + +/// `wp db export -` with a short retry loop: on a stopped site the first call +/// races the database container's first boot (same reason `wordpress::install` +/// retries). +async fn export_db(dir: &Path) -> Result { + const ATTEMPTS: u32 = 5; + let mut last = String::new(); + for attempt in 1..=ATTEMPTS { + match docker::compose_run(dir, "wpcli", &["wp", "db", "export", "-"]).await { + Ok(sql) if !sql.trim().is_empty() => return Ok(sql), + Ok(_) => last = "the database export came back empty".into(), + Err(e) => last = e, + } + if attempt < ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + } + Err(format!("failed to export the database: {last}")) +} + +/// Apply the retention policy. Best effort — a failed prune must never fail +/// the snapshot that triggered it. +fn prune(state: &AppState, site_id: &str) { + let Ok(all) = list(state, site_id) else { return }; + for id in prunable(&all) { + let _ = std::fs::remove_dir_all(snapshot_dir(&state.data_dir, site_id, &id)); + } +} + +// --------------------------------------------------------------------------- +// Restore +// --------------------------------------------------------------------------- + +/// Roll a site back to a snapshot: DB import + wp-content replace + cache +/// flush. Takes a `pre_restore` snapshot first — restoring is destructive too. +/// +/// Returns a user-facing summary (mentions the auto-start when it happened). +pub async fn restore( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + snapshot_id: &str, +) -> Result { + let snap = read_manifest(&state.data_dir, site_id, snapshot_id)?; + let s = site::get(state, site_id)?; + let dir = s.dir(); + let src = snapshot_dir(&state.data_dir, site_id, snapshot_id); + + // Read the archives before mutating anything: a corrupt snapshot should + // fail while the site is still intact. + let db_gz = std::fs::read(src.join(DB_FILE)) + .map_err(|e| format!("failed to read the snapshot's database dump: {e}"))?; + let sql = gunzip(&db_gz)?; + let code_tgz = std::fs::read(src.join(CODE_FILE)) + .map_err(|e| format!("failed to read the snapshot's wp-content archive: {e}"))?; + + site::emit(app, site_id, "restore", "Taking a pre-restore snapshot..."); + create( + app, + state, + site_id, + KIND_PRE_RESTORE, + Some(format!("before restoring {}", snap.created_at)), + ) + .await + .map_err(|e| format!("pre-restore snapshot failed, nothing was changed: {e}"))?; + + // The DB import needs the stack up; the user asked to go back to this + // snapshot, so start the site rather than refusing. + let mut started = false; + if !is_running(&dir).await { + site::emit(app, site_id, "restore", "Starting the site..."); + site::start(state, site_id).await?; + started = true; + } + + site::emit(app, site_id, "restore", "Importing database..."); + wordpress::import_db(&dir, &sql).await?; + + site::emit(app, site_id, "restore", "Restoring wp-content..."); + restore_wp_content(&dir, &code_tgz)?; + + // Object/transient caches can outlive the import; best effort. + let _ = docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"]).await; + + let when = &snap.created_at; + Ok(if started { + format!("{} restored to the snapshot from {when} (the site was stopped, so it was started)", s.name) + } else { + format!("{} restored to the snapshot from {when}", s.name) + }) +} + +async fn is_running(dir: &Path) -> bool { + docker::compose_ps(dir) + .await + .map(|cs| { + cs.iter() + .any(|c| c.service == "wordpress" && c.state == "running") + }) + .unwrap_or(false) +} + +/// Replace `wp-content` with the archived copy. The directory itself is kept +/// (it is bind-mounted into the running containers — removing it would break +/// the mount); only its contents are swapped. +fn restore_wp_content(site_dir: &Path, tgz: &[u8]) -> Result<(), String> { + let wp_content = site_dir.join("wp-content"); + if wp_content.is_dir() { + let entries = std::fs::read_dir(&wp_content) + .map_err(|e| format!("failed to read wp-content: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + let removed = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + removed.map_err(|e| { + format!("failed to clear wp-content ({}): {e}", path.display()) + })?; + } + } else { + std::fs::create_dir_all(&wp_content) + .map_err(|e| format!("failed to create wp-content: {e}"))?; + } + // Entries are prefixed `wp-content/`, so unpack at the site root. + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz)); + archive + .unpack(site_dir) + .map_err(|e| format!("failed to restore wp-content: {e}")) +} + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +pub fn delete(state: &AppState, site_id: &str, snapshot_id: &str) -> Result<(), String> { + let dir = snapshot_dir(&state.data_dir, site_id, snapshot_id); + if !dir.is_dir() { + return Err(format!("snapshot `{snapshot_id}` not found")); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to delete snapshot: {e}")) +} + +/// Drop every snapshot for a site — only ever called when the user explicitly +/// ticks "also delete snapshots" while deleting the site. +pub fn delete_all(data_dir: &Path, site_id: &str) -> Result<(), String> { + let dir = site_snapshots_dir(data_dir, site_id); + if !dir.is_dir() { + return Ok(()); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to delete snapshots: {e}")) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn snap(id: &str, kind: &str) -> Snapshot { + Snapshot { + id: id.into(), + site_id: "site-1".into(), + site_name: "Site One".into(), + site_slug: "site-one".into(), + created_at: "2026-07-20T10:00:00Z".into(), + kind: kind.into(), + note: String::new(), + db_bytes: 1024, + code_bytes: 2048, + wp_version: "6.7".into(), + } + } + + /// Ids sort chronologically, so "newest RETENTION" is a lexical top-N. + fn auto_run(kind: &str, n: usize) -> Vec { + (1..=n) + .map(|i| snap(&format!("20260720-{i:06}-000"), kind)) + .collect() + } + + #[test] + fn keeps_everything_under_the_cap() { + let all = auto_run(KIND_PRE_PULL, RETENTION); + assert!(prunable(&all).is_empty()); + } + + #[test] + fn prunes_oldest_auto_snapshots_beyond_the_cap() { + let all = auto_run(KIND_PRE_PULL, RETENTION + 3); + let pruned = prunable(&all); + assert_eq!(pruned.len(), 3); + // The three oldest go, the newest RETENTION stay. + assert!(pruned.contains(&"20260720-000001-000".to_string())); + assert!(pruned.contains(&"20260720-000003-000".to_string())); + assert!(!pruned.contains(&"20260720-000004-000".to_string())); + } + + #[test] + fn retention_is_per_kind() { + let mut all = auto_run(KIND_PRE_PULL, RETENTION); + all.extend(auto_run(KIND_PRE_PUSH, RETENTION)); + // Both kinds are at the cap on their own; neither is over it together. + assert!(prunable(&all).is_empty()); + } + + #[test] + fn manual_snapshots_are_never_pruned() { + let all = auto_run(KIND_MANUAL, RETENTION * 3); + assert!(prunable(&all).is_empty()); + } + + #[test] + fn manual_snapshots_do_not_shield_auto_ones() { + let mut all = auto_run(KIND_MANUAL, 20); + all.extend(auto_run(KIND_PRE_DELETE, RETENTION + 1)); + assert_eq!(prunable(&all).len(), 1); + } + + #[test] + fn manifest_round_trips() { + let original = snap("20260720-120000-000", KIND_PRE_PUSH); + let text = serde_json::to_string_pretty(&original).unwrap(); + let back: Snapshot = serde_json::from_str(&text).unwrap(); + assert_eq!(back.id, original.id); + assert_eq!(back.kind, KIND_PRE_PUSH); + assert_eq!(back.site_slug, "site-one"); + assert_eq!(back.db_bytes, 1024); + assert_eq!(back.wp_version, "6.7"); + } + + #[test] + fn gzip_round_trips() { + let payload = b"-- MySQL dump\nCREATE TABLE wp_posts;\n"; + let back = gunzip(&gzip(payload).unwrap()).unwrap(); + assert_eq!(back, payload); + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 369ea6c..64d342c 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -2,11 +2,10 @@ use serde::{Deserialize, Serialize}; use std::io::Read; -use std::path::Path; use tauri::{AppHandle, Emitter}; use uuid::Uuid; -use crate::{router, serverkit, site, wordpress, AppState}; +use crate::{router, serverkit, site, snapshot, wordpress, AppState}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncRecord { @@ -46,26 +45,6 @@ fn load(state: &AppState, connection_id: &str, site_id: &str) -> Result<(serverk Ok((conn, site)) } -/// Bundle the site's wp-content directory as a tar.gz in memory. -fn build_wp_content_tgz(site_dir: &Path) -> Result, String> { - let wp_content = site_dir.join("wp-content"); - if !wp_content.is_dir() { - return Err("wp-content directory not found in the local site".into()); - } - let mut buf = Vec::new(); - { - let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast()); - let mut builder = tar::Builder::new(enc); - builder - .append_dir_all("wp-content", &wp_content) - .map_err(|e| format!("failed to bundle wp-content: {e}"))?; - builder - .finish() - .map_err(|e| format!("failed to finalize archive: {e}"))?; - } - Ok(buf) -} - async fn run( app: Option<&AppHandle>, state: &AppState, @@ -122,7 +101,7 @@ pub async fn push_code( ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; emit(app, site_id, "push", "Bundling wp-content..."); - let tgz = build_wp_content_tgz(&site.dir())?; + let tgz = snapshot::build_wp_content_tgz(&site.dir())?; let size_mb = tgz.len() as f64 / 1_048_576.0; emit(app, site_id, "push", &format!("Uploading wp-content ({size_mb:.1} MB)...")); run(app, state, connection_id, site_id, "push", "code", async move { @@ -132,6 +111,26 @@ pub async fn push_code( .await } +/// Local safety net before a sync mutates something (plan 17). +/// +/// A failure here aborts the sync: never mutate without a net. The note +/// records which connection/remote the operation was aimed at, so the +/// snapshot list reads as a history of "what did I sync, and against what". +async fn pre_sync_snapshot( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + kind: &str, + conn: &serverkit::ServerKitConnection, + remote_site_id: i64, +) -> Result<(), String> { + let note = format!("{} (#{remote_site_id} on {})", conn.label, conn.url); + snapshot::create(app, state, site_id, kind, Some(note)) + .await + .map(|_| ()) + .map_err(|e| format!("pre-sync snapshot failed, nothing was synced: {e}")) +} + pub async fn push_db( app: Option<&AppHandle>, state: &AppState, @@ -140,6 +139,7 @@ pub async fn push_db( remote_site_id: i64, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PUSH, &conn, remote_site_id).await?; emit(app, site_id, "push", "Exporting local database..."); let dump_path = std::env::temp_dir().join(format!("localkit-dump-{}.sql", site.slug)); wordpress::export_db(&site.dir(), &dump_path).await?; @@ -172,6 +172,7 @@ pub async fn pull_db( remote_url: Option, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id).await?; emit(app, site_id, "pull", "Downloading remote database dump..."); let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote_site_id).await?; From 48e447b0aae6297365fb362b5ec3f0d2ba1dcb41 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 14:27:44 -0400 Subject: [PATCH 09/67] lk: snapshot list|create|restore|delete + delete --delete-snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the CLI conventions: stdout carries data only (the new snapshot's id from `create`, the table from `list`), chrome and the [stage] progress the library prints go to stderr, `--json` is pretty and raw, and restore/delete prompt with a No default and demand --yes off a TTY. `list` deliberately tolerates a site that no longer exists — deleting one keeps its snapshots, and the manifests carry name/slug for exactly this, so `lk snapshot list ` still answers after the delete. Restore and delete still require a live site; there is nothing to restore into. Without this the delete hint pointed at a command that would have errored. Extracts the confirm prompt (three call sites now) and adds a table printer, human byte sizes and RFC3339-to-seconds formatting, all unit tested. The table pads before colorizing, or ANSI codes break column alignment. Verified against the smoke site: create/list/--json/restore/delete, the non-TTY guard exiting 1, and listing a deleted site's snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/lk/src/main.rs | 318 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 296 insertions(+), 22 deletions(-) diff --git a/src-tauri/lk/src/main.rs b/src-tauri/lk/src/main.rs index 0522e10..05eb3a8 100644 --- a/src-tauri/lk/src/main.rs +++ b/src-tauri/lk/src/main.rs @@ -19,7 +19,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use clap::{Parser, Subcommand, ValueEnum}; -use localkit_lib::{db::Db, docker, router, site, wordpress, AppState}; +use localkit_lib::{db::Db, docker, router, site, snapshot, wordpress, AppState}; // --------------------------------------------------------------------------- // clap surface @@ -85,14 +85,22 @@ enum Cmd { Restart { site: String }, /// Delete a site (removes containers, volumes, and files). + /// A restorable snapshot is kept unless --delete-snapshots is passed. /// Prompts for confirmation unless --yes; --yes is required non-interactively. Delete { site: String, /// Skip the confirmation prompt #[arg(long)] yes: bool, + /// Also delete this site's snapshots (they are kept by default) + #[arg(long)] + delete_snapshots: bool, }, + /// Manage point-in-time snapshots (database + wp-content) of a site + #[command(subcommand)] + Snapshot(SnapshotCmd), + /// Show site details, including DB credentials Info { site: String, @@ -145,6 +153,49 @@ enum Cmd { Doctor, } +#[derive(Subcommand)] +enum SnapshotCmd { + /// List a site's snapshots, newest first + List { + site: String, + /// Output machine-readable JSON + #[arg(long)] + json: bool, + }, + + /// Take a snapshot now. Prints the new snapshot id on stdout. + Create { + site: String, + /// Optional note stored in the snapshot's manifest + #[arg(long)] + note: Option, + /// Output the created snapshot as machine-readable JSON + #[arg(long)] + json: bool, + }, + + /// Restore a site to a snapshot (destructive — snapshots first, then + /// replaces the database and wp-content). Prompts unless --yes. + Restore { + site: String, + /// Snapshot id from `lk snapshot list` + snapshot: String, + /// Skip the confirmation prompt + #[arg(long)] + yes: bool, + }, + + /// Delete one snapshot. Prompts unless --yes. + Delete { + site: String, + /// Snapshot id from `lk snapshot list` + snapshot: String, + /// Skip the confirmation prompt + #[arg(long)] + yes: bool, + }, +} + // --------------------------------------------------------------------------- // main / dispatch // --------------------------------------------------------------------------- @@ -197,7 +248,12 @@ async fn run(cli: &Cli) -> Result<(), String> { println!("{}", site_url(&s)); Ok(()) } - Cmd::Delete { site: q, yes } => cmd_delete(&state, q, *yes).await, + Cmd::Delete { + site: q, + yes, + delete_snapshots, + } => cmd_delete(&state, q, *yes, *delete_snapshots).await, + Cmd::Snapshot(sub) => cmd_snapshot(&state, sub).await, Cmd::Info { site: q, json } => cmd_info(&state, q, *json), Cmd::Logs { site: q, tail } => { let s = resolve(&state, q)?; @@ -303,33 +359,172 @@ async fn cmd_create( Ok(()) } -async fn cmd_delete(state: &AppState, query: &str, yes: bool) -> Result<(), String> { +/// Does `query` name a deleted site whose snapshots are still on disk? +/// Only an exact site id can match — there is no sites row left to map a +/// slug through. +fn orphan_snapshots_exist(state: &AppState, query: &str) -> bool { + snapshot::site_snapshots_dir(&state.data_dir, query).is_dir() +} + +/// Destructive-command gate: prompt with a No default unless `--yes`, and +/// require `--yes` when there is no TTY to prompt on. +fn confirm(yes: bool, question: &str, non_tty_hint: &str) -> Result<(), String> { + if yes { + return Ok(()); + } + if !std::io::stdout().is_terminal() { + return Err(non_tty_hint.to_string()); + } + eprint!("{} {question} [y/N] ", warn("!")); + let mut line = String::new(); + use std::io::BufRead; + // EOF/no-tty falls through to the No path. + let read = std::io::stdin().lock().read_line(&mut line); + if read.is_err() || !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { + return Err("aborted".into()); + } + Ok(()) +} + +async fn cmd_delete( + state: &AppState, + query: &str, + yes: bool, + delete_snapshots: bool, +) -> Result<(), String> { let s = resolve(state, query)?; - if !yes { - if !std::io::stdout().is_terminal() { - return Err(format!( - "`lk delete` removes `{}` permanently. pass --yes to confirm.", - s.slug - )); - } - eprint!( - "{} delete `{}`? this removes its containers, volumes, and files. [y/N] ", - warn("!"), + let tail = if delete_snapshots { + "this removes its containers, volumes, files AND snapshots." + } else { + "this removes its containers, volumes, and files (a snapshot is kept)." + }; + confirm( + yes, + &format!("delete `{}`? {tail}", s.slug), + &format!( + "`lk delete` removes `{}` permanently. pass --yes to confirm.", s.slug + ), + )?; + site::delete(None, state, &s.id, delete_snapshots).await?; + eprintln!("{} {} deleted", ok("✓"), bold(&s.name)); + if !delete_snapshots { + eprintln!( + "{} snapshots kept — `lk snapshot list {}` still lists them", + info("→"), + s.id ); - let mut line = String::new(); - use std::io::BufRead; - // EOF/no-tty falls through to the No path. - let read = std::io::stdin().lock().read_line(&mut line); - if read.is_err() || !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - return Err("aborted".into()); - } } - site::delete(state, &s.id).await?; - eprintln!("{} {} deleted", ok("✓"), bold(&s.name)); Ok(()) } +async fn cmd_snapshot(state: &AppState, cmd: &SnapshotCmd) -> Result<(), String> { + match cmd { + SnapshotCmd::List { site: q, json } => { + // Listing tolerates a site that no longer exists: deleting a site + // keeps its snapshots, and their manifests carry the name/slug, so + // `lk snapshot list ` stays useful afterwards. (Restore + // and delete still require a live site — there is nothing to + // restore *into*.) + let (id, label) = match resolve(state, q) { + Ok(s) => (s.id, s.slug), + Err(_) if orphan_snapshots_exist(state, q) => (q.to_string(), q.to_string()), + Err(e) => return Err(e), + }; + let snaps = snapshot::list(state, &id)?; + if *json { + return print_json(&snaps); + } + if snaps.is_empty() { + eprintln!( + "{} no snapshots for `{label}` yet. take one with `lk snapshot create {label}`.", + info("→"), + ); + return Ok(()); + } + let rows: Vec<[String; 5]> = snaps + .iter() + .map(|x| { + [ + x.id.clone(), + short_time(&x.created_at), + x.kind.clone(), + format!("{} + {}", human_bytes(x.db_bytes), human_bytes(x.code_bytes)), + x.note.clone(), + ] + }) + .collect(); + print_table(&["ID", "CREATED", "KIND", "DB + CODE", "NOTE"], &rows); + Ok(()) + } + + SnapshotCmd::Create { site: q, note, json } => { + let s = resolve(state, q)?; + let snap = snapshot::create( + None, + state, + &s.id, + snapshot::KIND_MANUAL, + note.clone(), + ) + .await?; + if *json { + print_json(&snap)?; + } else { + // stdout carries the id (scriptable); chrome stays on stderr. + println!("{}", snap.id); + } + eprintln!( + "{} snapshot of {} taken ({} database, {} wp-content)", + ok("✓"), + bold(&s.name), + human_bytes(snap.db_bytes), + human_bytes(snap.code_bytes) + ); + Ok(()) + } + + SnapshotCmd::Restore { + site: q, + snapshot: id, + yes, + } => { + let s = resolve(state, q)?; + confirm( + *yes, + &format!( + "restore `{}` to snapshot {id}? this replaces its database and wp-content \ + (a pre-restore snapshot is taken first).", + s.slug + ), + &format!( + "`lk snapshot restore` overwrites `{}`. pass --yes to confirm.", + s.slug + ), + )?; + let message = snapshot::restore(None, state, &s.id, id).await?; + eprintln!("{} {message}", ok("✓")); + Ok(()) + } + + SnapshotCmd::Delete { + site: q, + snapshot: id, + yes, + } => { + let s = resolve(state, q)?; + confirm( + *yes, + &format!("delete snapshot {id} of `{}`? this cannot be undone.", s.slug), + &format!("`lk snapshot delete` removes snapshot {id} permanently. pass --yes to confirm."), + )?; + snapshot::delete(state, &s.id, id)?; + eprintln!("{} snapshot {id} deleted", ok("✓")); + Ok(()) + } + } +} + fn cmd_info(state: &AppState, query: &str, json: bool) -> Result<(), String> { let s = resolve(state, query)?; let d = site::detail(state, &s.id)?; @@ -590,6 +785,59 @@ fn site_url(s: &site::Site) -> String { format!("http://localhost:{}", s.port) } +/// RFC3339 down to seconds for table display — the stored timestamps carry +/// sub-second precision and an offset, which is noise in a column. +/// `--json` keeps the full value. +fn short_time(rfc3339: &str) -> String { + match rfc3339.split_once('T') { + Some((date, rest)) => { + let time: String = rest.chars().take(8).collect(); + format!("{date} {time}") + } + None => rfc3339.to_string(), + } +} + +/// Byte counts for humans — snapshot archives run from KB to GB. +fn human_bytes(n: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = n as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{n} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +/// Left-aligned column table with dimmed headers (stdout — it is data). +fn print_table(headers: &[&str; N], rows: &[[String; N]]) { + let mut w = [0usize; N]; + for (i, h) in headers.iter().enumerate() { + w[i] = h.len(); + } + for r in rows { + for (i, c) in r.iter().enumerate() { + w[i] = w[i].max(c.len()); + } + } + for (i, h) in headers.iter().enumerate() { + // Pad first, then colorize, so ANSI codes don't break alignment. + print!("{} ", dim(&format!("{: String { let mut out = String::new(); @@ -738,6 +986,32 @@ mod tests { assert_eq!(out, "export DB_HOST=\"127.0.0.1\"\n"); } + #[test] + fn short_time_drops_subseconds_and_offset() { + assert_eq!( + short_time("2026-07-20T18:23:53.160418100+00:00"), + "2026-07-20 18:23:53" + ); + } + + #[test] + fn short_time_passes_through_anything_unexpected() { + assert_eq!(short_time("not a timestamp"), "not a timestamp"); + } + + #[test] + fn human_bytes_stays_exact_under_a_kilobyte() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + } + + #[test] + fn human_bytes_scales_up() { + assert_eq!(human_bytes(1024), "1.0 KB"); + assert_eq!(human_bytes(1024 * 1024 * 3 / 2), "1.5 MB"); + assert_eq!(human_bytes(5 * 1024 * 1024 * 1024), "5.0 GB"); + } + #[test] fn exports_powershell() { let out = render_exports( From fbe5fbd6c0c427866c97c1246a13f82bedfe3c00 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 14:30:14 -0400 Subject: [PATCH 10/67] ui: snapshots panel, delete dialog, palette command (plan 17 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SiteDetail gains a Snapshots section (not a tab — the page has no tab bar) listing when/kind/size/note with Restore and Delete per row and a Take-snapshot control with an optional note. Kinds render as readable badges — violet for the manual ones the user took, zinc for the automatic ones — so the table doubles as "what did I do to this site". Deleting a site now opens a real dialog instead of window.confirm, because deletion has a choice in it: the pre_delete snapshot is kept by default and the checkbox is the only way to actually drop the data. Ticking it escalates the button from "Delete site" to "Delete everything". The success toast says a snapshot was kept — without that the user has no reason to believe the delete was reversible. Mock mode mirrors the backend rather than faking the surface: DB push/pull leave pre_push/pre_pull behind, restore takes a pre_restore first and auto-starts a stopped site, delete_site honours deleteSnapshots. So the panel is exercisable with no Docker. Verified headless against the mock server with the new scripts/verify-snapshots.mjs (23 checks): listing, take-with-note, restore-snapshots-first, delete, pull leaving a snapshot, and both delete-dialog paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify-snapshots.mjs | 283 ++++++++++++++++++++++++++++ src/components/DeleteSiteDialog.tsx | 79 ++++++++ src/components/SnapshotsPanel.tsx | 201 ++++++++++++++++++++ src/lib/commands.tsx | 12 ++ src/lib/ipc.ts | 11 +- src/lib/types.ts | 22 +++ src/mock/core.ts | 108 ++++++++++- src/mock/data.ts | 62 ++++++ src/pages/SiteDetail.tsx | 26 ++- src/stores/sites.ts | 13 +- 10 files changed, 804 insertions(+), 13 deletions(-) create mode 100644 scripts/verify-snapshots.mjs create mode 100644 src/components/DeleteSiteDialog.tsx create mode 100644 src/components/SnapshotsPanel.tsx diff --git a/scripts/verify-snapshots.mjs b/scripts/verify-snapshots.mjs new file mode 100644 index 0000000..afa3945 --- /dev/null +++ b/scripts/verify-snapshots.mjs @@ -0,0 +1,283 @@ +// Headless runtime verification for plan 17 (snapshots & one-click restore). +// +// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the +// snapshot UX: the panel lists existing snapshots with their kind badges, +// taking one with a note prepends it, restoring confirms and reports back, +// deleting removes it, a DB pull leaves a `pre_pull` snapshot behind, and the +// delete-site dialog leads with the kept snapshot while offering the opt-out. +// +// node scripts/verify-snapshots.mjs +// +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const PORT = 1426; +const URL = `http://localhost:${PORT}/`; + +const CHROME_CANDIDATES = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", +]; +const chrome = CHROME_CANDIDATES.find((p) => existsSync(p)); +if (!chrome) { + console.error("No Chrome/Edge found."); + process.exit(1); +} + +const isWin = process.platform === "win32"; +let failures = 0; + +function ok(name, cond) { + if (cond) console.log(" ✓", name); + else { + failures++; + console.error(" ✗ FAIL:", name); + } +} + +async function waitForServer(url, ms = 60_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await sleep(500); + } + throw new Error(`Vite mock server never came up at ${url}`); +} + +function killTree(child) { + if (!child || child.killed) return; + if (isWin) { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } +} + +async function main() { + console.log("› starting mock Vite server…"); + const server = spawn("npm", ["run", "dev:mock"], { + cwd: ROOT, + shell: true, + stdio: "ignore", + detached: !isWin, + }); + + let browser; + try { + await waitForServer(URL); + browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + defaultViewport: { width: 1440, height: 1200 }, + }); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.warn(" page error:", e.message)); + // window.confirm blocks headless; auto-accept so Restore/Delete proceed. + page.on("dialog", (d) => d.accept()); + await page.goto(URL, { waitUntil: "networkidle0" }); + + const bodyText = () => page.evaluate(() => document.body.innerText); + const clickByText = (selector, text) => + page.evaluate( + (sel, t) => { + const el = [...document.querySelectorAll(sel)].find((e) => + e.textContent.trim().toLowerCase().includes(t.toLowerCase()) + ); + if (!el) return false; + el.click(); + return true; + }, + selector, + text + ); + /** Snapshot rows as [when, kind, size, note] tuples. */ + const rows = () => + page.evaluate(() => { + const heading = [...document.querySelectorAll("h2")].find( + (h) => h.textContent.trim() === "Snapshots" + ); + const table = heading?.closest("section")?.querySelector("table"); + if (!table) return []; + return [...table.querySelectorAll("tbody tr")].map((tr) => + [...tr.querySelectorAll("td")].map((td) => td.innerText.trim()) + ); + }); + + await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery")); + console.log("› dashboard loaded"); + + // Open Pixel Bakery's detail page (it has seeded snapshots). + await page.evaluate(() => { + const card = [...document.querySelectorAll("div")].find( + (d) => + d.textContent.includes("Pixel Bakery") && + [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details") + ); + [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click(); + }); + await sleep(900); + + let text = await bodyText(); + ok("navigated to SiteDetail", text.includes("Back to sites")); + // Panel headings are CSS-uppercased, and innerText applies text-transform. + ok("Snapshots panel is present", /snapshots/i.test(text)); + + // 1) Existing snapshots list with human labels, not raw kinds. + let table = await rows(); + ok("seeded snapshots are listed", table.length === 3); + ok( + "kinds render as readable badges", + table.some((r) => r[1] === "Manual") && + table.some((r) => r[1] === "Before pull") && + table.some((r) => r[1] === "Before push") + ); + ok( + "sizes are human-readable", + table.every((r) => /^\d+(\.\d+)? (B|KB|MB|GB)$/.test(r[2])) + ); + ok("notes are shown", table.some((r) => r[3].includes("before the checkout rewrite"))); + ok("newest is first", table[0][1] === "Before pull"); + + // 2) Take a snapshot with a note. + await page.evaluate(() => { + const input = [...document.querySelectorAll("input")].find( + (i) => i.placeholder && i.placeholder.startsWith("Note") + ); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + ).set; + setter.call(input, "verification run"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await clickByText("button", "Take snapshot"); + await sleep(900); + + table = await rows(); + ok("taking a snapshot prepends a row", table.length === 4); + ok("the new snapshot is Manual", table[0][1] === "Manual"); + ok("the note is stored", table[0][3] === "verification run"); + ok("success is toasted", (await bodyText()).includes("Snapshot of Pixel Bakery taken")); + + // 3) Restore the newest snapshot — confirms, then snapshots first. + await page.evaluate(() => { + const heading = [...document.querySelectorAll("h2")].find( + (h) => h.textContent.trim() === "Snapshots" + ); + const row = heading.closest("section").querySelector("tbody tr"); + [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "Restore").click(); + }); + await sleep(3200); + + text = await bodyText(); + table = await rows(); + ok("restore reports back", text.includes("restored to the snapshot from")); + ok("restore snapshots the current state first", table.length === 5); + ok("that snapshot is labelled Before restore", table[0][1] === "Before restore"); + + // 4) Delete a snapshot. + const before = table.length; + await page.evaluate(() => { + const heading = [...document.querySelectorAll("h2")].find( + (h) => h.textContent.trim() === "Snapshots" + ); + const row = heading.closest("section").querySelector("tbody tr"); + [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "Delete").click(); + }); + await sleep(900); + ok("deleting a snapshot removes its row", (await rows()).length === before - 1); + ok("deletion is toasted", (await bodyText()).includes("Snapshot deleted")); + + // 5) A DB pull must leave a pre_pull snapshot behind (plan 17 phase 2). + const countBeforePull = (await rows()).length; + await page.select("select", "conn-prod").catch(() => {}); + await page.evaluate(() => { + const selects = [...document.querySelectorAll("select")]; + const set = (el, value) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + ).set; + setter.call(el, value); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const conn = selects.find((s) => s.innerHTML.includes("Production")); + if (conn) set(conn, "conn-prod"); + }); + await sleep(700); + await page.evaluate(() => { + const selects = [...document.querySelectorAll("select")]; + const remote = selects.find((s) => s.innerHTML.includes("pixel-bakery")); + if (remote) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + ).set; + setter.call(remote, "27"); + remote.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + await sleep(400); + await clickByText("button", "Pull DB"); + await sleep(2200); + + table = await rows(); + ok("a DB pull leaves a snapshot behind", table.length === countBeforePull + 1); + ok("it is labelled Before pull", table[0][1] === "Before pull"); + ok("it names the connection it pulled from", table[0][3].includes("Production")); + + // 6) Delete-site dialog: leads with the kept snapshot, offers the opt-out. + await clickByText("button", "Delete"); + await sleep(500); + text = await bodyText(); + ok("delete dialog promises a snapshot", text.includes("A restorable snapshot will be kept")); + ok("delete dialog offers the opt-out", text.includes("Also delete this site's snapshots")); + ok( + "default action keeps the snapshots", + await page.evaluate(() => + [...document.querySelectorAll("button")].some((b) => b.textContent.trim() === "Delete site") + ) + ); + // Ticking the box escalates the button copy — the destructive path reads + // differently from the safe one. + await page.evaluate(() => { + const box = [...document.querySelectorAll('input[type="checkbox"]')].find((c) => + c.closest("label")?.textContent.includes("Also delete") + ); + box.click(); + }); + await sleep(300); + ok( + "opting out escalates the confirm button", + await page.evaluate(() => + [...document.querySelectorAll("button")].some( + (b) => b.textContent.trim() === "Delete everything" + ) + ) + ); + + console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`); + } finally { + if (browser) await browser.close(); + killTree(server); + } + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/components/DeleteSiteDialog.tsx b/src/components/DeleteSiteDialog.tsx new file mode 100644 index 0000000..0d6a04f --- /dev/null +++ b/src/components/DeleteSiteDialog.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useDialog } from "../hooks/useDialog"; + +/** + * Delete confirmation for a site (plan 17). + * + * Replaces the old `window.confirm` because deletion now has a choice in it: + * a `pre_delete` snapshot is kept by default, and the checkbox is the only way + * to actually get rid of the data. The copy leads with the safety net so + * deleting stops feeling like a one-way door. + */ +export default function DeleteSiteDialog({ + siteName, + busy, + onClose, + onConfirm, +}: { + siteName: string; + busy: boolean; + onClose: () => void; + onConfirm: (deleteSnapshots: boolean) => void; +}) { + const { overlayProps, panelProps } = useDialog(onClose); + const [deleteSnapshots, setDeleteSnapshots] = useState(false); + + return ( +
+
+

Delete “{siteName}”?

+

+ This removes its containers, database volume and files. A restorable snapshot will be + kept. +

+ + + +
+ + +
+
+
+ ); +} diff --git a/src/components/SnapshotsPanel.tsx b/src/components/SnapshotsPanel.tsx new file mode 100644 index 0000000..00b799b --- /dev/null +++ b/src/components/SnapshotsPanel.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from "react"; +import { ipc } from "../lib/ipc"; +import { toastError } from "../lib/errors"; +import { toast } from "../stores/toast"; +import { useSites } from "../stores/sites"; +import type { Snapshot, SnapshotKind } from "../lib/types"; + +/** + * Snapshots panel on the site detail page (plan 17). + * + * Snapshots are the undo button for everything destructive: push, pull and + * delete each leave one behind automatically, and restoring takes one first. + * The table is therefore also a history — the kind badge says *why* each one + * exists. + */ + +/** Human label + badge colour per kind. Violet = user action, zinc = automatic. */ +const KIND_META: Record = { + manual: { label: "Manual", className: "bg-violet-500/15 text-violet-300" }, + pre_push: { label: "Before push", className: "bg-zinc-700/50 text-zinc-300" }, + pre_pull: { label: "Before pull", className: "bg-zinc-700/50 text-zinc-300" }, + pre_delete: { label: "Before delete", className: "bg-zinc-700/50 text-zinc-300" }, + pre_restore: { label: "Before restore", className: "bg-zinc-700/50 text-zinc-300" }, +}; + +export function formatBytes(bytes: number): string { + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`; +} + +export default function SnapshotsPanel({ siteId }: { siteId: string }) { + const progress = useSites((s) => s.progress); + + const [snapshots, setSnapshots] = useState([]); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(null); + + const refresh = useCallback(() => { + ipc + .listSnapshots(siteId) + .then(setSnapshots) + .catch(() => setSnapshots([])); + }, [siteId]); + + useEffect(refresh, [refresh]); + + // Push/pull/delete take snapshots of their own — pick them up when the + // operation that created them finishes. + useEffect(() => { + if (progress && (progress.stage === "done" || progress.stage === "error")) { + refresh(); + setBusy(null); + } + }, [progress, refresh]); + + const create = async () => { + setBusy("create"); + try { + await ipc.createSnapshot(siteId, note.trim() || undefined); + setNote(""); + refresh(); + } catch (e) { + // The site-event stream already toasts the failure; this catches + // rejections it didn't (deduped inside toastError). + toastError(e, "Create snapshot"); + } finally { + setBusy(null); + } + }; + + const restore = async (snap: Snapshot) => { + const when = new Date(snap.created_at).toLocaleString(); + const confirmed = window.confirm( + `Restore this site to the snapshot from ${when}?\n\n` + + "Its database and wp-content will be replaced. A snapshot of the current " + + "state is taken first, so this is reversible." + ); + if (!confirmed) return; + setBusy(snap.id); + try { + await ipc.restoreSnapshot(siteId, snap.id); + refresh(); + } catch (e) { + toastError(e, "Restore snapshot"); + } finally { + setBusy(null); + } + }; + + const remove = async (snap: Snapshot) => { + const when = new Date(snap.created_at).toLocaleString(); + if (!window.confirm(`Delete the snapshot from ${when}? This cannot be undone.`)) return; + setBusy(snap.id); + try { + await ipc.deleteSnapshot(siteId, snap.id); + toast.success("Snapshot deleted", when); + refresh(); + } catch (e) { + toastError(e, "Delete snapshot"); + } finally { + setBusy(null); + } + }; + + return ( +
+
+

Snapshots

+
+ setNote(e.target.value)} + placeholder="Note (optional)…" + className="w-56 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-sm text-zinc-100 outline-none focus:border-violet-600" + /> + +
+
+ +

+ A copy of the database and wp-content. One is taken + automatically before every push, pull, delete and restore. +

+ + {snapshots.length === 0 ? ( +

+ No snapshots yet — take one before you try something risky. +

+ ) : ( + + + + + + + + + + + {snapshots.map((s) => { + const meta = KIND_META[s.kind] ?? { + label: s.kind, + className: "bg-zinc-700/50 text-zinc-300", + }; + return ( + + + + + + + + ); + })} + +
WhenKindSizeNote +
+ {new Date(s.created_at).toLocaleString()} + + + {meta.label} + + + {formatBytes(s.db_bytes + s.code_bytes)} + + {s.note || "—"} + + + +
+ )} +
+ ); +} diff --git a/src/lib/commands.tsx b/src/lib/commands.tsx index bab7187..221f682 100644 --- a/src/lib/commands.tsx +++ b/src/lib/commands.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ipc } from "./ipc"; import { toastError } from "./errors"; +import { toast } from "../stores/toast"; import { useNav } from "../stores/nav"; import { useSettings } from "../stores/settings"; import { useSites } from "../stores/sites"; @@ -124,6 +125,17 @@ function siteCommands(site: SiteWithStatus): Command[] { run: () => nav.navigate({ name: "terminal", siteId: site.id }), }, ]; + cmds.push({ + id: `site.${site.id}.snapshot`, + title: "Create snapshot", + group: site.name, + run: () => { + void ipc + .createSnapshot(site.id) + .then(() => toast.success("Snapshot taken", site.name)) + .catch((e) => toastError(e, "Create snapshot")); + }, + }); if (running) { cmds.push({ id: `site.${site.id}.wp-admin`, diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index b5c5fdf..8bc4c3f 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -11,6 +11,7 @@ import type { SiteDetail, SiteEvent, SiteWithStatus, + Snapshot, SyncRecord, TerminalDataEvent, TerminalExitEvent, @@ -28,11 +29,19 @@ export const ipc = { invoke("create_site", { name, wpVersion, phpVersion }), startSite: (id: string) => invoke("start_site", { id }), stopSite: (id: string) => invoke("stop_site", { id }), - deleteSite: (id: string) => invoke("delete_site", { id }), + deleteSite: (id: string, deleteSnapshots = false) => + invoke("delete_site", { id, deleteSnapshots }), siteLogs: (id: string, tail = 200) => invoke("site_logs", { id, tail }), wpCliInfo: (id: string) => invoke("wp_cli_info", { id }), loginSite: (id: string, userId?: number) => invoke("login_site", { id, userId }), siteWpUsers: (id: string) => invoke("site_wp_users", { id }), + listSnapshots: (siteId: string) => invoke("list_snapshots", { siteId }), + createSnapshot: (siteId: string, note?: string) => + invoke("create_snapshot", { siteId, note }), + restoreSnapshot: (siteId: string, snapshotId: string) => + invoke("restore_snapshot", { siteId, snapshotId }), + deleteSnapshot: (siteId: string, snapshotId: string) => + invoke("delete_snapshot", { siteId, snapshotId }), saveServerkitConnection: (label: string, url: string, apiKey: string) => invoke("save_serverkit_connection", { label, url, apiKey }), listServerkitConnections: () => invoke("list_serverkit_connections"), diff --git a/src/lib/types.ts b/src/lib/types.ts index d330734..d230b2e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -101,6 +101,28 @@ export interface SyncRecord { created_at: string; } +/** Snapshot kinds (plan 17); everything but `manual` is taken automatically. */ +export type SnapshotKind = + | "manual" + | "pre_push" + | "pre_pull" + | "pre_delete" + | "pre_restore"; + +/** A point-in-time copy of a site: DB dump + wp-content archive on disk. */ +export interface Snapshot { + id: string; + site_id: string; + site_name: string; + site_slug: string; + created_at: string; + kind: SnapshotKind; + note: string; + db_bytes: number; + code_bytes: number; + wp_version: string; +} + /** A router port held by another program (plan 16 pre-flight probe). */ export interface PortConflict { port: number; diff --git a/src/mock/core.ts b/src/mock/core.ts index b009124..b989746 100644 --- a/src/mock/core.ts +++ b/src/mock/core.ts @@ -4,7 +4,15 @@ // delete / create behave naturally while previewing. import * as data from "./data"; import { emit } from "./event"; -import type { PortConflict, RouterStatus, ServerKitInfo, Site, SiteEvent } from "../lib/types"; +import type { + PortConflict, + RouterStatus, + ServerKitInfo, + Site, + SiteEvent, + Snapshot, + SnapshotKind, +} from "../lib/types"; type Args = Record; @@ -43,6 +51,27 @@ const mockShells = new Map(); const prompt = (slug: string) => `\x1b[35mroot\x1b[0m@\x1b[34m${slug}\x1b[0m:\x1b[36m/var/www/html\x1b[0m# `; +/** Add a snapshot to the fake store, newest first (mirrors the real listing). */ +function pushSnapshot(siteId: string, kind: SnapshotKind, note: string): Snapshot { + const site = data.sites.find((s) => s.id === siteId); + const now = new Date(); + const snap: Snapshot = { + // Same shape as the Rust id: a sortable timestamp. + id: now.toISOString().replace(/[-:T]/g, "").slice(0, 14) + `-${now.getMilliseconds()}`, + site_id: siteId, + site_name: site?.name ?? siteId, + site_slug: site?.slug ?? siteId, + created_at: now.toISOString(), + kind, + note, + db_bytes: 2_000_000 + Math.floor(Math.random() * 3_000_000), + code_bytes: 40_000_000 + Math.floor(Math.random() * 200_000_000), + wp_version: site?.wp_version ?? "6.7", + }; + (data.snapshots[siteId] ??= []).unshift(snap); + return snap; +} + function slugify(name: string): string { return name .toLowerCase() @@ -144,8 +173,72 @@ async function dispatch(cmd: string, a: Args): Promise { } case "delete_site": { - const i = data.sites.findIndex((s) => s.id === a.id); - if (i >= 0) data.sites.splice(i, 1); + const siteId = String(a.id); + const i = data.sites.findIndex((s) => s.id === siteId); + if (i >= 0) { + // Mirrors site::delete — a pre_delete snapshot first, and the + // snapshots outlive the site unless the caller opted out. + if (a.deleteSnapshots) { + delete data.snapshots[siteId]; + } else { + pushSnapshot(siteId, "pre_delete", `before deleting ${data.sites[i].name}`); + } + data.sites.splice(i, 1); + } + return null; + } + + case "list_snapshots": + return data.snapshots[String(a.siteId)] ?? []; + + case "create_snapshot": { + const siteId = String(a.siteId); + const site = data.sites.find((s) => s.id === siteId); + if (!site) throw `site not found: ${siteId}`; + const snap = pushSnapshot(siteId, "manual", String(a.note ?? "")); + emit("site-event", { + id: siteId, + stage: "done", + message: `Snapshot of ${site.name} taken`, + } satisfies SiteEvent); + return snap; + } + + case "restore_snapshot": { + const siteId = String(a.siteId); + const site = data.sites.find((s) => s.id === siteId); + if (!site) throw `site not found: ${siteId}`; + const snap = (data.snapshots[siteId] ?? []).find((s) => s.id === a.snapshotId); + if (!snap) throw `snapshot \`${a.snapshotId}\` not found`; + // Restoring is destructive, so the real backend snapshots first. + pushSnapshot(siteId, "pre_restore", `before restoring ${snap.created_at}`); + const started = site.live_status !== "running"; + site.status = site.live_status = "running"; + void (async () => { + for (const message of [ + "Taking a pre-restore snapshot…", + "Importing database…", + "Restoring wp-content…", + ]) { + emit("site-event", { id: siteId, stage: "restore", message } satisfies SiteEvent); + await sleep(700); + } + emit("site-event", { + id: siteId, + stage: "done", + message: started + ? `${site.name} restored to the snapshot from ${snap.created_at} (the site was stopped, so it was started)` + : `${site.name} restored to the snapshot from ${snap.created_at}`, + } satisfies SiteEvent); + })(); + return null; + } + + case "delete_snapshot": { + const list = data.snapshots[String(a.siteId)] ?? []; + const i = list.findIndex((s) => s.id === a.snapshotId); + if (i < 0) throw `snapshot \`${a.snapshotId}\` not found`; + list.splice(i, 1); return null; } @@ -238,6 +331,15 @@ async function dispatch(cmd: string, a: Args): Promise { const siteId = String(a.siteId); const site = data.sites.find((s) => s.id === siteId); const id = `sync-${Date.now()}`; + // Plan 17: DB syncs overwrite a database, so they snapshot first. + if (kind === "db") { + const conn = data.connections.find((c) => c.id === a.connectionId); + pushSnapshot( + siteId, + direction === "push" ? "pre_push" : "pre_pull", + `${conn?.label ?? "server"} (#${a.remoteSiteId} on ${conn?.url ?? "?"})` + ); + } void (async () => { emit("site-event", { id: siteId, diff --git a/src/mock/data.ts b/src/mock/data.ts index fc65171..c696beb 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -8,6 +8,7 @@ import type { ServerKitConnection, SiteDetail, SiteWithStatus, + Snapshot, SyncRecord, WpInfo, } from "../lib/types"; @@ -198,6 +199,67 @@ export const remoteSites: Record = { ], }; +/** + * Plan 17 snapshots. Pixel Bakery shows the full mix — a manual one plus the + * automatic ones push/pull/delete leave behind — so the kind badges and the + * retention story are visible without Docker. Hiking Blog has none, which is + * the empty state. + */ +export const snapshots: Record = { + "site-pixel-bakery": [ + { + id: "20260719-084500-120", + site_id: "site-pixel-bakery", + site_name: "Pixel Bakery", + site_slug: "pixel-bakery", + created_at: "2026-07-19T08:45:00Z", + kind: "pre_pull", + note: "Production (#27 on https://panel.acme-hosting.example)", + db_bytes: 4_312_770, + code_bytes: 224_512_900, + wp_version: "6.7", + }, + { + id: "20260718-142200-880", + site_id: "site-pixel-bakery", + site_name: "Pixel Bakery", + site_slug: "pixel-bakery", + created_at: "2026-07-18T14:22:00Z", + kind: "manual", + note: "before the checkout rewrite", + db_bytes: 4_298_115, + code_bytes: 223_998_042, + wp_version: "6.7", + }, + { + id: "20260715-135500-410", + site_id: "site-pixel-bakery", + site_name: "Pixel Bakery", + site_slug: "pixel-bakery", + created_at: "2026-07-15T13:55:00Z", + kind: "pre_push", + note: "Production (#27 on https://panel.acme-hosting.example)", + db_bytes: 4_105_663, + code_bytes: 219_774_301, + wp_version: "6.7", + }, + ], + "site-acme-corporate": [ + { + id: "20260716-101200-005", + site_id: "site-acme-corporate", + site_name: "Acme Corporate", + site_slug: "acme-corporate", + created_at: "2026-07-16T10:12:00Z", + kind: "manual", + note: "", + db_bytes: 1_884_204, + code_bytes: 48_220_118, + wp_version: "6.5", + }, + ], +}; + export const syncHistory: Record = { "site-pixel-bakery": [ { diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index d8a2203..1fee187 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -9,6 +9,8 @@ import type { SiteDetail as SiteDetailData, WpUser } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; import CopyButton from "../components/CopyButton"; import PushPanel from "../components/PushPanel"; +import SnapshotsPanel from "../components/SnapshotsPanel"; +import DeleteSiteDialog from "../components/DeleteSiteDialog"; import { describeConflicts } from "../components/DomainsSettings"; export default function SiteDetail({ id }: { id: string }) { @@ -30,6 +32,7 @@ export default function SiteDetail({ id }: { id: string }) { const [loginUserId, setLoginUserId] = useState(null); const [loggingIn, setLoggingIn] = useState(false); const [loginError, setLoginError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); const logRef = useRef(null); const loadDetail = useCallback(() => { @@ -142,11 +145,7 @@ export default function SiteDetail({ id }: { id: string }) { Terminal + + + + + ); +} + +function Row({ + label, + value, + mono, + warn, +}: { + label: string; + value: string; + mono?: boolean; + warn?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +/** + * Frontend mirror of `sync::match_version`: drop the remote's patch level and + * match major.minor against the image allowlist, else fall back to newest. + * Kept in sync by hand — it only drives the readout, and the backend's copy + * is what actually picks the image. + */ +function matchVersion(available: string[], remote: string | null): { chosen: string; exact: boolean } { + const newest = available[0]; + if (!remote?.trim()) return { chosen: newest, exact: false }; + const [major, minor] = remote.trim().split("."); + const majorMinor = minor === undefined ? remote.trim() : `${major}.${minor}`; + return available.includes(majorMinor) + ? { chosen: majorMinor, exact: true } + : { chosen: newest, exact: false }; +} diff --git a/src/components/ServerKitSettings.tsx b/src/components/ServerKitSettings.tsx index 7919895..ad9b292 100644 --- a/src/components/ServerKitSettings.tsx +++ b/src/components/ServerKitSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useServerKit } from "../stores/serverkit"; -import type { ServerKitConnection } from "../lib/types"; +import type { RemoteWpSite, ServerKitConnection } from "../lib/types"; export default function ServerKitSettings() { const connections = useServerKit((s) => s.connections); @@ -180,6 +180,7 @@ function ConnectionCard({ conn }: { conn: ServerKitConnection }) { Status WP Envs + @@ -192,6 +193,13 @@ function ConnectionCard({ conn }: { conn: ServerKitConnection }) { {s.wp_version ?? "—"} {s.environment_count} + + + ))} @@ -202,3 +210,39 @@ function ConnectionCard({ conn }: { conn: ServerKitConnection }) { ); } + +/** + * Per-site "clone this down here". Disabled — with the reason in the tooltip — + * rather than hidden when the site or server can't support it: a missing + * button reads as a bug, a disabled one explains itself. + */ +function ImportButton({ + connectionId, + site, + canImport, +}: { + connectionId: string; + site: RemoteWpSite; + canImport: boolean | null; +}) { + const openImport = useServerKit((s) => s.openImport); + + const blocked = site.multisite + ? "Multisite installs cannot be imported." + : canImport === false + ? "The serverkit-localkit extension on this server is too old to import sites." + : canImport === null + ? "Checking what this server supports…" + : null; + + return ( + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 8b664b3..00bc6d8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -112,6 +112,16 @@ export function ServerIcon(props: IconProps) { ); } +/** Chain link — marks a site imported from a ServerKit server (plan 18). */ +export function LinkIcon(props: IconProps) { + return ( + + + + + ); +} + export function KeyboardIcon(props: IconProps) { return ( diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 8bc4c3f..80b81c8 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -57,6 +57,8 @@ export const ipc = { invoke("push_site_db", { connectionId, siteId, remoteSiteId }), pullSiteDb: (connectionId: string, siteId: string, remoteSiteId: number, remoteUrl: string | null) => invoke("pull_site_db", { connectionId, siteId, remoteSiteId, remoteUrl }), + importRemoteSite: (connectionId: string, remoteSiteId: number, name?: string) => + invoke("import_remote_site", { connectionId, remoteSiteId, name }), listSyncHistory: (siteId: string) => invoke("list_sync_history", { siteId }), routerStatus: () => invoke("router_status"), setDomainsEnabled: (enabled: boolean) => diff --git a/src/lib/types.ts b/src/lib/types.ts index d230b2e..ac91265 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,9 @@ export interface Site { admin_user: string; admin_pass: string; created_at: string; + /** Plan 18 — set together on sites imported from a ServerKit server. */ + connection_id: string | null; + remote_site_id: number | null; } export interface SiteWithStatus extends Site { @@ -79,14 +82,22 @@ export interface ServerKitInfo { staging: boolean; api_key_valid: boolean; localkit_extension: boolean; + /** Extension capabilities (plan 18). Absent name = unsupported, not unknown. */ + features: string[]; } +/** Capability names reported by the extension's `GET /pair`. */ +export const FEATURE_PULL_CODE = "pull-code"; + export interface RemoteWpSite { id: number; name: string; url: string | null; status: string; wp_version: string | null; + php_version: string | null; + /** Multisite installs cannot be imported — one compose project, one site. */ + multisite: boolean; environment_count: number; } diff --git a/src/mock/core.ts b/src/mock/core.ts index b989746..ed5d886 100644 --- a/src/mock/core.ts +++ b/src/mock/core.ts @@ -144,6 +144,8 @@ async function dispatch(cmd: string, a: Args): Promise { admin_user: "admin", admin_pass: "generated-demo-pass", created_at: new Date().toISOString(), + connection_id: null, + remote_site_id: null, }; data.sites.push({ ...site, live_status: "creating", db_password: "m4ri4-n3w-0000" }); // Flip to running once the fake install finishes. @@ -301,6 +303,7 @@ async function dispatch(cmd: string, a: Args): Promise { staging: false, api_key_valid: true, localkit_extension: true, + features: ["sites", "push-code", "push-db", "pull-db", "pull-code"], }; return info; } @@ -316,6 +319,8 @@ async function dispatch(cmd: string, a: Args): Promise { url: `https://${slugify(String(a.name))}.example`, status: "running", wp_version: "6.7", + php_version: "8.3", + multisite: false, environment_count: 1, }; list.push(site); @@ -323,6 +328,80 @@ async function dispatch(cmd: string, a: Args): Promise { return site; } + case "import_remote_site": { + const connectionId = String(a.connectionId); + const remoteId = Number(a.remoteSiteId); + const remote = (data.remoteSites[connectionId] ?? []).find((s) => s.id === remoteId); + if (!remote) throw `Remote site #${remoteId} was not found.`; + if (remote.multisite) { + throw `"${remote.name}" is a WordPress multisite install, which LocalKit cannot import.`; + } + if (data.sites.some((s) => s.connection_id === connectionId && s.remote_site_id === remoteId)) { + throw `"${remote.name}" was already imported. Pull its database into that site instead.`; + } + + const name = String(a.name ?? "").trim() || remote.name; + const slug = slugify(name); + const port = Math.max(...data.sites.map((s) => s.port), 8080) + 1; + const id = `site-${slug}`; + const conn = data.connections.find((c) => c.id === connectionId); + // Same stages the Rust import emits, so the progress toast reads the same. + const stages: Array<[string, string]> = [ + ["files", "Writing project files…"], + ["pulling", "Downloading WordPress images (first run can take a few minutes)…"], + ["code", "Downloading remote wp-content…"], + ["code", "Extracting wp-content (48.2 MB)…"], + ["containers", "Starting Docker containers…"], + ["waiting", "Waiting for WordPress to come online…"], + ["install", "Downloading remote database…"], + ["install", "Importing remote database…"], + ["install", "Rewriting URLs remote -> local…"], + ["done", `${name} imported from ${conn?.label ?? "server"} — now running at http://localhost:${port}`], + ]; + void (async () => { + for (const [stage, message] of stages) { + emit("site-event", { id, stage, message } satisfies SiteEvent); + await sleep(700); + } + const s = data.sites.find((x) => x.id === id); + if (s) s.status = s.live_status = "running"; + })(); + + const site: Site = { + id, + name, + slug, + path: `${data.appInfo.sites_dir}\\${slug}`, + port, + wp_version: data.appInfo.wp_versions.includes(remote.wp_version ?? "") + ? String(remote.wp_version) + : data.appInfo.wp_versions[0], + php_version: data.appInfo.php_versions.includes(remote.php_version ?? "") + ? String(remote.php_version) + : data.appInfo.php_versions[0], + status: "creating", + // The imported database keeps the remote's accounts, and no password + // of ours — mirrors what the backend records. + admin_user: "admin", + admin_pass: "", + created_at: new Date().toISOString(), + connection_id: connectionId, + remote_site_id: remoteId, + }; + data.sites.push({ ...site, live_status: "creating", db_password: "m4ri4-imp0rt-0001" }); + (data.syncHistory[id] ??= []).unshift({ + id: `sync-${Date.now()}`, + site_id: id, + connection_id: connectionId, + direction: "pull", + kind: "import", + status: "success", + message: `${name} imported from ${conn?.label ?? "server"} via mock.`, + created_at: new Date().toISOString(), + }); + return site; + } + case "push_site_code": case "push_site_db": case "pull_site_db": { diff --git a/src/mock/data.ts b/src/mock/data.ts index c696beb..8c41c58 100644 --- a/src/mock/data.ts +++ b/src/mock/data.ts @@ -40,6 +40,8 @@ export const sites: MockSite[] = [ admin_pass: "cr0iss4nt-velvet-42", created_at: "2026-07-02T09:14:00Z", db_password: "m4ri4-pix3l-9917", + connection_id: null, + remote_site_id: null, }, { id: "site-acme-corporate", @@ -55,6 +57,10 @@ export const sites: MockSite[] = [ admin_pass: "acme-roadrunner-88", created_at: "2026-06-18T15:40:00Z", db_password: "m4ri4-acm3-5542", + // Imported from the Production connection (plan 18) — drives the link + // badge on the dashboard. + connection_id: "conn-prod", + remote_site_id: 12, }, { id: "site-hiking-blog", @@ -70,6 +76,8 @@ export const sites: MockSite[] = [ admin_pass: "summit-trail-2026", created_at: "2026-05-30T11:02:00Z", db_password: "m4ri4-h1k3-3308", + connection_id: null, + remote_site_id: null, }, { id: "site-client-demo", @@ -85,6 +93,8 @@ export const sites: MockSite[] = [ admin_pass: "d3mo-spr1ng-7741", created_at: "2026-07-19T01:58:00Z", db_password: "m4ri4-d3m0-1120", + connection_id: null, + remote_site_id: null, }, ]; @@ -178,6 +188,8 @@ export const remoteSites: Record = { url: "https://acme-corporate.example", status: "running", wp_version: "6.5", + php_version: "8.1", + multisite: false, environment_count: 2, }, { @@ -186,6 +198,8 @@ export const remoteSites: Record = { url: "https://pixelbakery.example", status: "running", wp_version: "6.7", + php_version: "8.3", + multisite: false, environment_count: 3, }, { @@ -194,8 +208,32 @@ export const remoteSites: Record = { url: null, status: "stopped", wp_version: "6.6", + php_version: "8.2", + multisite: false, environment_count: 1, }, + // Exercises the two Import-blocked states: a multisite (never importable) + // and a version pair with no exact local image (importable, with a warning). + { + id: 44, + name: "agency-network", + url: "https://network.agency.example", + status: "running", + wp_version: "6.7", + php_version: "8.2", + multisite: true, + environment_count: 0, + }, + { + id: 51, + name: "legacy-shop", + url: "https://legacy-shop.example", + status: "running", + wp_version: "6.2", + php_version: "7.4", + multisite: false, + environment_count: 0, + }, ], }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2604b60..31a376a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,18 +1,27 @@ +import { useEffect } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { siteUrl } from "../lib/domains"; import { useNav } from "../stores/nav"; import { useSiteView } from "../stores/settings"; import { useRouter } from "../stores/router"; +import { useServerKit } from "../stores/serverkit"; import { useSites } from "../stores/sites"; import type { SiteWithStatus } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; -import { GridIcon, ListIcon, PlusIcon } from "../components/icons"; +import { GridIcon, LinkIcon, ListIcon, PlusIcon } from "../components/icons"; export default function Dashboard() { const sites = useSites((s) => s.sites); const loading = useSites((s) => s.loading); const [siteView, setSiteView] = useSiteView(); const setNewSiteOpen = useNav((s) => s.setNewSiteOpen); + const refreshConnections = useServerKit((s) => s.refresh); + + // Imported sites name their origin connection, so the labels have to be + // loaded even if the user never opens Settings → ServerKit. + useEffect(() => { + void refreshConnections(); + }, [refreshConnections]); return (
@@ -97,6 +106,27 @@ function useSiteActions(site: SiteWithStatus) { const ghostBtn = "rounded-md border border-zinc-700 px-2.5 py-1 text-xs font-medium text-zinc-300 hover:border-zinc-500 hover:text-zinc-100 disabled:opacity-50"; + +/** + * Subtle marker on sites that came from a ServerKit server (plan 18). Renders + * nothing for hand-made sites, and falls back to the raw connection id if the + * connection has since been deleted — a site's origin is a fact about the + * site, not a live lookup. + */ +function ImportedBadge({ site }: { site: SiteWithStatus }) { + const connections = useServerKit((s) => s.connections); + if (!site.connection_id) return null; + const label = connections.find((c) => c.id === site.connection_id)?.label ?? site.connection_id; + return ( + + + {label} + + ); +} const dangerBtn = "rounded-md border border-red-900 px-2.5 py-1 text-xs font-medium text-red-400 hover:border-red-700 disabled:opacity-50"; @@ -125,9 +155,12 @@ function GridCard({ site }: { site: SiteWithStatus }) {

{a.url}

-

- WordPress {site.wp_version} · PHP {site.php_version} -

+
+ + WordPress {site.wp_version} · PHP {site.php_version} + + +
{running && ( @@ -182,12 +215,15 @@ function ListRow({ site }: { site: SiteWithStatus }) { return ( - +
+ + +
{a.url} diff --git a/src/stores/serverkit.ts b/src/stores/serverkit.ts index 94ab83d..c525f42 100644 --- a/src/stores/serverkit.ts +++ b/src/stores/serverkit.ts @@ -1,6 +1,9 @@ import { create } from "zustand"; import { ipc } from "../lib/ipc"; -import type { RemoteWpSite, ServerKitConnection, ServerKitInfo } from "../lib/types"; +import { toastError } from "../lib/errors"; +import { useSites } from "./sites"; +import { FEATURE_PULL_CODE } from "../lib/types"; +import type { RemoteWpSite, ServerKitConnection, ServerKitInfo, Site } from "../lib/types"; function errMsg(e: unknown): string { return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); @@ -10,6 +13,13 @@ export interface RemoteSitesState { loading: boolean; sites: RemoteWpSite[] | null; error: string | null; + /** + * Whether this server's extension can serve `pull/code`. `null` while + * unknown (the probe rides along with the site listing) — the Import button + * stays disabled until it is explicitly `true`, because finding out + * mid-import means a half-built local site. + */ + canImport: boolean | null; } interface ServerKitState { @@ -21,11 +31,17 @@ interface ServerKitState { error: string | null; /** Per-connection remote site lists, keyed by connection id. */ remote: Record; + /** Connection id + remote site currently open in the Import dialog. */ + importing: { connectionId: string; site: RemoteWpSite } | null; + importBusy: boolean; refresh: () => Promise; test: (url: string, apiKey: string) => Promise; save: (label: string, url: string, apiKey: string) => Promise; remove: (id: string) => Promise; fetchRemoteSites: (id: string) => Promise; + openImport: (connectionId: string, site: RemoteWpSite) => void; + closeImport: () => void; + importSite: (name?: string) => Promise; clearTestResult: () => void; } @@ -37,6 +53,8 @@ export const useServerKit = create((set, get) => ({ busyId: null, error: null, remote: {}, + importing: null, + importBusy: false, refresh: async () => { try { @@ -97,16 +115,62 @@ export const useServerKit = create((set, get) => ({ }, fetchRemoteSites: async (id) => { - set((s) => ({ remote: { ...s.remote, [id]: { loading: true, sites: null, error: null } } })); + set((s) => ({ + remote: { + ...s.remote, + [id]: { loading: true, sites: null, error: null, canImport: null }, + }, + })); try { const sites = await ipc.listRemoteWpSites(id); - set((s) => ({ remote: { ...s.remote, [id]: { loading: false, sites, error: null } } })); + // Capability probe rides along with the listing: the Import buttons + // render in the same pass, so they must already know whether this + // server's extension is new enough to serve wp-content. + const conn = get().connections.find((c) => c.id === id); + let canImport = false; + if (conn) { + try { + const info = await ipc.testServerkitConnection(conn.url, conn.api_key); + canImport = info.features.includes(FEATURE_PULL_CODE); + } catch { + // A failed probe is not a failed listing — just no Import. + canImport = false; + } + } + set((s) => ({ + remote: { ...s.remote, [id]: { loading: false, sites, error: null, canImport } }, + })); } catch (e) { set((s) => ({ - remote: { ...s.remote, [id]: { loading: false, sites: null, error: errMsg(e) } }, + remote: { + ...s.remote, + [id]: { loading: false, sites: null, error: errMsg(e), canImport: null }, + }, })); } }, + openImport: (connectionId, site) => set({ importing: { connectionId, site } }), + closeImport: () => set({ importing: null }), + + importSite: async (name) => { + const target = get().importing; + if (!target) return null; + set({ importBusy: true }); + try { + const site = await ipc.importRemoteSite(target.connectionId, target.site.id, name); + // The dashboard is where the new site lives; the progress toast is + // already telling the story, so no extra success toast here. + await useSites.getState().refresh(); + set({ importing: null }); + return site; + } catch (e) { + toastError(e, "Import site"); + return null; + } finally { + set({ importBusy: false }); + } + }, + clearTestResult: () => set({ testResult: null }), })); From aad2f36cbc1caff6bdb212a67705f105e3c87032 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 15:19:48 -0400 Subject: [PATCH 17/67] verify-import + docs: plan 18 in AGENTS.md, README and the roadmap scripts/verify-import.mjs walks the import UX headlessly against the mock server (20 checks): per-row Import buttons, the multisite refusal and its tooltip, the version-match readout and the mismatch warning, the progress stages, the dashboard origin badge, and the duplicate-import refusal. AGENTS.md records what a future agent would otherwise have to rediscover: why pre_import runs before provisioning, why `wp core install` is never run on an import, the safe-extract policy, that an absent extension feature means unsupported rather than unknown, and that wait_for_port is not a readiness signal (Docker publishes the port at container *create*). The plan file now records what shipped, the four places the implementation deviated from the sketch and why, the two problems only running it could find, and the two things deliberately left to plans 19 and 23. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 51 ++++- README.md | 9 +- docs/plans/18_import-remote-site.md | 46 +++- docs/plans/ROADMAP.md | 11 +- scripts/verify-import.mjs | 323 ++++++++++++++++++++++++++++ 5 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 scripts/verify-import.mjs diff --git a/AGENTS.md b/AGENTS.md index e57f404..d9ee7b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ src/ React 18 + TS + Vite frontend components/ Sidebar, StatusBadge, CopyButton, NewSiteDialog, CommandPalette, KeyboardSettings, KeyboardShortcutsDialog, SnapshotsPanel, - DeleteSiteDialog, + DeleteSiteDialog, ImportSiteDialog, icons.tsx (inline SVGs, 1.75px rounded strokes) assets/logo.png Vite-bundled brand logo (sidebar); master at assets/logo.png mock/ in-browser mocks of @tauri-apps/* for `vite --mode mock` @@ -63,7 +63,8 @@ src-tauri/ Rust backend (also a cargo workspace root) running `docker compose exec wordpress bash`; events `terminal://data` / `terminal://exit` src/serverkit.rs ServerKit API client (X-API-Key) + connection model - src/sync.rs push/pull orchestration + SyncRecord (sync_history) + src/sync.rs push/pull orchestration + SyncRecord (sync_history) + + plan 18 import (clone a remote site to a new local one) src/snapshot.rs plan 17 snapshots: DB dump + wp-content archive on disk (no DB table), restore, retention tauri.conf.json v2 schema; capabilities/default.json grants opener plugin; @@ -103,7 +104,13 @@ src-tauri/ Rust backend (also a cargo workspace root) `smoke -- create` first. - `cd src-tauri && cargo run --example m4_smoke` — M4 push/pull E2E against a mock serverkit-localkit extension (`node examples/mock_localkit_ext.cjs` - first, port 9872); requires the smoke site to exist. + first, port 9872); requires the smoke site to exist. Since plan 18 it also + imports a remote site as a real new local site (containers and all) and + deletes it again, and asserts the multisite refusal provisions nothing. +- `node scripts/verify-import.mjs` — headless runtime check of the plan-18 + import UX against the mock server (per-row Import buttons, the multisite + refusal and its tooltip, the version-match readout and mismatch warning, + the progress stages, the dashboard origin badge, the duplicate refusal). - `cd src-tauri && cargo test --lib router` — unit tests for the M6 hosts-file block logic (insert/replace/remove idempotency, CRLF preservation) plus the plan-16 port probe, listener-table parsing, compose port mapping and @@ -115,7 +122,7 @@ src-tauri/ Rust backend (also a cargo workspace root) prompts twice). Run `smoke -- create` first, `smoke -- cleanup` after. - `cd src-tauri && cargo run -p lk -- ` — headless CLI (`lk list | create | start | stop | restart | delete | info | logs | wp | env | login | - snapshot list|create|restore|delete | doctor`); shares the GUI's data dir, + snapshot list|create|restore|delete | import | doctor`); shares the GUI's data dir, so use `--data-dir` (or `LOCALKIT_DATA_DIR`) for throwaway tests. See docs/plans/7_cli.md. - CI: `.github/workflows/ci.yml` runs on push/PR to `main`/`dev` — `npm run @@ -309,7 +316,41 @@ src-tauri/ Rust backend (also a cargo workspace root) `wp search-replace` remote → local. Ops emit `site-event` stages and record rows in `sync_history` (migration 3). Connections live in `serverkit_connections` (migration 2); **API keys in plaintext SQLite** — - accepted for v1, revisit with a keyring later. + accepted for v1, revisit with a keyring later. `sync::emit` delegates to + `site::emit`, so sync progress prints to stderr in the CLI/examples instead + of vanishing. Bulk transfers use `serverkit::transfer_client` (30 min), not + the 15 s probe client — reqwest's `timeout` is a *total* request budget, so + the short one aborts any payload bigger than a fast link can move in 15 s. +- **Import (plan 18):** `sync::import_site` clones a remote site into a *new* + local site. Order is the design: `pre_import` checks everything knowable + before provisioning (extension advertises `pull-code`, remote exists, not + multisite, not already imported from that same remote via the migration-5 + `connection_id`/`remote_site_id` columns), so a predictable failure leaves + no half-built site; after `site::reserve` any failure runs `site::cleanup`. + **`wp core install` is never run** — the imported database IS the site, and + `admin_user` is read back from its first administrator (no password stored; + one-click login does not need one). `extract_wp_content` treats the archive + as hostile: plain files/dirs under `wp-content/` only — absolute paths, + `..`, symlinks and hardlinks are refused, never sanitized. Version drift is + a warning, not an error (`match_version` drops the remote patch level and + matches `major.minor` against the allowlist, falling back to newest). + Permalinks are flushed after import or every imported page 404s. Optional + post-import wp-cli steps are wrapped in `optional()` (2 min timeout): they + run after the data has landed, so hanging on one would discard a completed + import. +- **Extension capabilities:** `GET /pair` returns a `features` array; probe it + with `serverkit::has_feature`. **Absent means unsupported, not unknown** — + gate the UI on it rather than discovering a 404 mid-operation. Add new + server capabilities to `FEATURES` in the extension's `localkit.py` (append + only; never rename an entry, clients match the literal string). +- **`site::create` is split** into `reserve` (validate + unique slug + free + ports + insert the `creating` row) and `write_project_files`, so the import + flow allocates through the same race-free path instead of a parallel copy. + `wordpress::wait_for_config` exists because `site::wait_for_port` is not a + readiness signal: Docker publishes the host port when the container is + *created*, so wp-cli can race the image entrypoint still writing + wp-config.php. Anything shelling into wp-cli right after `compose up` must + wait on it (`site::create` only survives because its install step retries). - **Snapshots (plan 17):** `snapshot.rs`. A snapshot is a *directory*, not a DB row — no migration: `/snapshots///` holding `manifest.json` + `db.sql.gz` + `wp-content.tar.gz`. The manifest carries diff --git a/README.md b/README.md index a2c1661..c4fa011 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ npm run tauri build | | | |---|---| | **Push Code**
Push your local `wp-content` straight to a remote site on your ServerKit server. | **Push / Pull Database**
Push the DB, or pull a remote DB into your local site with automatic URL search-replace. | -| **Sync History**
Every sync op is recorded per site, with its result. | **Connections**
Save, test, and delete server connections; browse remote sites and provision new ones — all through the `serverkit-localkit` extension. | +| **Import a Remote Site**
Clone any site on your server down as a *new* local site — wp-content, database and URL rewriting in one step, from the app or `lk import`. | **Sync History**
Every sync op is recorded per site, with its result. | +| **Connections**
Save, test, and delete server connections; browse remote sites and provision new ones — all through the `serverkit-localkit` extension. | **Capability-Aware**
The app asks the extension what it supports, so features an older server can't do are disabled with a reason instead of failing halfway. | ### 🖥️ Desktop & CLI @@ -137,6 +138,7 @@ lk wp my-blog plugin list # wp-cli passthrough lk env my-blog # eval-able exports: eval $(lk env my-blog) lk snapshot create my-blog # point-in-time DB + wp-content copy lk snapshot restore my-blog --yes # roll back to one +lk import Production client-blog # clone a server site down as a new local site lk doctor # diagnose Docker / compose / data dir lk list --json # machine-readable output ``` @@ -212,6 +214,9 @@ docs/ - Connection test = public `/api/v1/system/health` + key validation against `/api/v1/setup-health/account` + a `/api/v1/localkit/pair` probe that detects the extension. - All sync endpoints live in the `serverkit-localkit` extension (`/api/v1/localkit/...`); without it, LocalKit tells you exactly what's missing. - **Push code** = in-memory tar.gz of `wp-content/` → multipart POST. **Push DB** = `wp db export` → multipart POST. **Pull DB** = download dump → `wp db import` → `wp search-replace` remote URL → local URL. +- **Import** provisions a new local site, then lands the remote `wp-content` (via the extension's `pull/code`) and database on it. WordPress is never installed over the imported database — the database *is* the site, so its posts, users and settings come across intact. Log in with the remote's own accounts (`lk login`, or the app's WP Admin button). +- The app gates Import on the capabilities the extension reports from `GET /pair`, so a server running an older extension shows the button disabled with the reason rather than failing mid-import. Multisite installs are refused outright. +- Downloaded archives are extracted under a strict policy — only plain files and directories under `wp-content/`; absolute paths, `..`, and symlinks are rejected. - Every sync op is recorded in the per-site sync history with its result. - API keys are stored in **plaintext** in LocalKit's local SQLite DB — accepted for v1, keyring storage is on the roadmap. @@ -256,7 +261,7 @@ back to 80/443 in Settings → Local domains, and hit Retry. - **M1 — Local site lifecycle** ✅ create/start/stop/delete, compose projects, port allocation - **M2 — WordPress install & detail** ✅ wp-cli install, credentials, logs, wp info - **M3 — ServerKit connection** ✅ save/test connections, extension detection, browse remote sites -- **M4 — Push / pull** ✅ push code, push DB, pull DB with URL rewrite, sync history +- **M4 — Push / pull** ✅ push code, push DB, pull DB with URL rewrite, sync history, import a remote site as a new local site - **M5 — Release polish** ⬜ installers, auto-update, OS keyring for API keys, test suite - **M6 — Local domains** ✅ `http(s)://.test` via a shared Caddy router, managed hosts block + local CA trust (plan 6) - **M7 — CLI (`lk`)** ✅ headless companion binary: lifecycle, wp passthrough, `env`, `doctor`, JSON output (plan 7) diff --git a/docs/plans/18_import-remote-site.md b/docs/plans/18_import-remote-site.md index 87a17ad..c9637c8 100644 --- a/docs/plans/18_import-remote-site.md +++ b/docs/plans/18_import-remote-site.md @@ -1,6 +1,6 @@ # 18 — Import a ServerKit site as a new local site -Status: ⬜ planned +Status: ✅ shipped One-click "clone to local" for any site on a connected ServerKit server: provision a fresh local site, pull down the remote `wp-content` and database, @@ -98,3 +98,47 @@ for code and orchestrates the whole flow behind one button. paths, symlink escapes) against fixture archives. - Manual E2E against a real ServerKit box: import → one-click login works → edit a theme file locally → push code back. + +## What shipped + +All three phases, plus `scripts/verify-import.mjs` (headless UI check against +the mock server, mirroring the other plans' `verify-*.mjs`). + +Deviations from the plan above, and why: + +- **Feature names.** `GET /pair` reports `["sites", "push-code", "push-db", + "pull-db", "pull-code"]` — hyphenated and split per direction, rather than + the sketch's `["sites", "push", "pull-db", "pull-code"]`. A single `push` + could not express a server that gained one direction but not the other. +- **`/sites` enrichment.** `url` and `wp_version` were already in the hub + payload; only `php_version` (regexed off the compose image tag, not a + per-site container shell) and an explicit `site_url` alias were added. + `multisite` was already there and is what the refusal reads. +- **Version matching returns a warning, not just an event.** `match_version` + is a pure, unit-tested function shared by the backend, and mirrored in the + Import dialog so the user sees the mismatch *before* committing. +- **`pre_import` is stricter than sketched.** It refuses a second import from + the same remote outright rather than offering "pull into existing" inline — + the error names the local site to pull into, which is the same guidance + without a second flow to build. + +Two things found by running it that the plan did not anticipate: + +- **`wait_for_port` is not a readiness signal.** Docker publishes the host + port when the container is *created*, so the first wp-cli call raced the + image entrypoint still writing wp-config.php and died with "'wp-config.php' + not found". `site::create` never noticed because its install step retries + for a minute. Fixed with `wordpress::wait_for_config`. +- **A hung `docker compose run` can discard a finished import.** Observed a + container Docker reported as "Up" with no processes inside it. The optional + post-import steps (permalink/cache flush, admin lookup) are now bounded by + `optional()`, since they run after the data has already landed. + +Deferred, deliberately: + +- **Large-site warning.** The plan wanted the Import button to warn when the + remote reports a huge `wp-content`; the extension does not report a size, + and adding one belongs with plan 19's chunked transfer work. +- **A killed import leaves a `creating` row with live containers.** In-process + failures clean up, but a SIGKILL cannot. That is plan 23's (reconciliation) + job, not a second half-measure here. diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index 5f1a192..31c5d15 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -25,7 +25,7 @@ The file numbers ARE the build order — each plan leans on the ones before it. | 15 | `15_command-palette-shortcuts` | ✅ shipped | Command registry + palette (mod+K), global shortcuts, remappable bindings in Settings (needs 13). | | 16 | `16_router-coexistence` | ✅ shipped | Port-80/443 conflict pre-flight + configurable router ports so domains survive alongside LocalWP & co. | | 17 | `17_snapshots` | ✅ shipped | DB + wp-content snapshots with one-click restore; automatic before push/pull/delete. Safety net for 18–20. | -| 18 | `18_import-remote-site` | ⬜ | Clone a ServerKit site down as a *new* local site (needs the extension's missing pull/code endpoint). | +| 18 | `18_import-remote-site` | ✅ shipped | Clone a ServerKit site down as a *new* local site; adds the extension's `pull/code` endpoint + a `features` capability contract. | | 19 | `19_sync-v2-chunked` | ⬜ | Chunked resumable push/pull with byte progress + cancel (breaks the 100 MB / in-memory limits). | | 20 | `20_clone-and-blueprints` | ⬜ | One-click site clone + save-site-as-blueprint creation flows (needs 17). | | 21 | `21_cli-serverkit` | ⬜ | `lk connection/push/pull` + remote listing + shell completions (Track D). | @@ -62,8 +62,11 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre - ✅ Push code (in-memory tar.gz of `wp-content/`), push DB (`wp db export`), pull DB (download → `wp db import` → `wp search-replace`) - ✅ Sync history per site (migration 3) -- ⬜ Pull a remote site down as a *new* local site (plan 18; today pull - targets an existing local site) +- ✅ Pull a remote site down as a *new* local site (plan 18): the extension's + new `pull/code` endpoint, safe-extract policy, no-`core install` import, + migration-5 origin columns, Import UI + `lk import` +- ✅ Extension capability contract (`GET /pair` → `features`), so the UI + disables what an older server cannot do instead of failing mid-operation ## Track C — Product (M5–M6) @@ -90,6 +93,8 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre / `info` / `logs` - ✅ `lk wp ` wp-cli passthrough, `lk env` (eval-able exports), `lk doctor`, `-o json` / `--quiet` / `--data-dir` global flags +- ✅ `lk import ` (plan 18) — the first ServerKit + command in the CLI; the rest lands with plan 21 - ⬜ ServerKit from the CLI: `lk connection add/list`, `lk push`, `lk pull` (plan 21; library calls already exist) - ⬜ Shell completions (plan 21), self-update (future) diff --git a/scripts/verify-import.mjs b/scripts/verify-import.mjs new file mode 100644 index 0000000..e476b81 --- /dev/null +++ b/scripts/verify-import.mjs @@ -0,0 +1,323 @@ +// Headless runtime verification for plan 18 (import a remote site as a new +// local site). +// +// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the +// import UX: Settings → ServerKit lists the remote sites with per-row Import +// buttons, multisite rows are refused up front, the dialog reports the version +// match (and warns when there is no exact image), importing streams the same +// progress stages the backend emits, and the new site lands on the dashboard +// carrying its origin badge. +// +// node scripts/verify-import.mjs +// +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const PORT = 1426; +const URL = `http://localhost:${PORT}/`; + +const CHROME_CANDIDATES = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", +]; +const chrome = CHROME_CANDIDATES.find((p) => existsSync(p)); +if (!chrome) { + console.error("No Chrome/Edge found."); + process.exit(1); +} + +const isWin = process.platform === "win32"; +let failures = 0; + +function ok(name, cond) { + if (cond) console.log(" ✓", name); + else { + failures++; + console.error(" ✗ FAIL:", name); + } +} + +async function waitForServer(url, ms = 60_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await sleep(500); + } + throw new Error(`Vite mock server never came up at ${url}`); +} + +/** + * Open Settings (sidebar gear) and select the ServerKit section from the + * left rail — the modal opens on whatever section nav last deep-linked to. + */ +async function openServerKitSettings(page) { + await page.evaluate(() => { + const gear = [...document.querySelectorAll("button")].find( + (b) => b.getAttribute("aria-label") === "Settings" + ); + gear?.click(); + }); + await sleep(600); + await page.evaluate(() => { + const rail = document.querySelector('[aria-label="Settings"] nav'); + const btn = [...(rail?.querySelectorAll("button") ?? [])].find((b) => + b.textContent.trim().toLowerCase().includes("serverkit") + ); + btn?.click(); + }); + await sleep(600); +} + +function killTree(child) { + if (!child || child.killed) return; + if (isWin) { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } +} + +async function main() { + console.log("› starting mock Vite server…"); + const server = spawn("npm", ["run", "dev:mock"], { + cwd: ROOT, + shell: true, + stdio: "ignore", + detached: !isWin, + }); + + let browser; + try { + await waitForServer(URL); + browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + defaultViewport: { width: 1440, height: 1200 }, + }); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.warn(" page error:", e.message)); + page.on("dialog", (d) => d.accept()); + await page.goto(URL, { waitUntil: "networkidle0" }); + + const bodyText = () => page.evaluate(() => document.body.innerText); + const clickByText = (selector, text) => + page.evaluate( + (sel, t) => { + const el = [...document.querySelectorAll(sel)].find((e) => + e.textContent.trim().toLowerCase().includes(t.toLowerCase()) + ); + if (!el) return false; + el.click(); + return true; + }, + selector, + text + ); + /** The remote-site table as {name, wp, importable, tooltip} records. */ + const remoteRows = () => + page.evaluate(() => { + const table = [...document.querySelectorAll("table")].find((t) => + t.textContent.includes("acme-corporate") + ); + if (!table) return []; + return [...table.querySelectorAll("tbody tr")].map((tr) => { + const cells = [...tr.querySelectorAll("td")].map((td) => td.innerText.trim()); + const btn = tr.querySelector("button"); + return { + name: cells[0], + wp: cells[3], + importable: btn ? !btn.disabled : false, + tooltip: btn?.title ?? "", + }; + }); + }); + + await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery")); + console.log("› dashboard loaded"); + + // 0) The dashboard already marks the seeded imported site. + let text = await bodyText(); + ok("imported site shows its origin connection", text.includes("Production")); + + // 1) Settings → ServerKit → expand the connection. + await openServerKitSettings(page); + // Panel headings are CSS-uppercased, and innerText applies text-transform. + ok("settings opened on the ServerKit section", /serverkit connections/i.test(await bodyText())); + + await clickByText("button", "View WP sites"); + await page.waitForFunction( + () => document.body.innerText.includes("agency-network"), + { timeout: 15_000 } + ); + await sleep(600); + console.log("› remote sites listed"); + + // 2) Import buttons: present per row, refused for multisite. + const rows = await remoteRows(); + ok("every remote site row has an Import control", rows.length === 5); + const network = rows.find((r) => r.name === "agency-network"); + const bakery = rows.find((r) => r.name === "pixel-bakery"); + ok("importable sites offer Import", bakery?.importable === true); + ok("multisite rows are refused", network?.importable === false); + ok( + "the refusal explains itself in the tooltip", + /multisite/i.test(network?.tooltip ?? "") + ); + + // 3) Version mismatch warning — legacy-shop is WP 6.2 / PHP 7.4, neither + // of which LocalKit has an image for. + await page.evaluate(() => { + const table = [...document.querySelectorAll("table")].find((t) => + t.textContent.includes("legacy-shop") + ); + const row = [...table.querySelectorAll("tbody tr")].find((tr) => + tr.textContent.includes("legacy-shop") + ); + row.querySelector("button").click(); + }); + await sleep(600); + + text = await bodyText(); + ok("the import dialog opens", text.includes("Import “legacy-shop”")); + ok("it reports the WordPress version match", text.includes("6.2 → 6.7")); + ok("it reports the PHP version match", text.includes("7.4 → 8.3")); + ok( + "it warns when there is no exact image", + text.includes("does not have an exact image match") + ); + ok("it promises not to touch the remote", text.includes("remote site is not modified")); + + // Cancel — then import a site that matches exactly, so the warning's + // absence is also verified. + await clickByText("button", "Cancel"); + await sleep(400); + ok("cancel closes the dialog", !(await bodyText()).includes("Import “legacy-shop”")); + + // 4) Import pixel-bakery (WP 6.7 / PHP 8.3 — both exact) under a new name. + await page.evaluate(() => { + const table = [...document.querySelectorAll("table")].find((t) => + t.textContent.includes("pixel-bakery") + ); + const row = [...table.querySelectorAll("tbody tr")].find((tr) => + tr.textContent.includes("pixel-bakery") + ); + row.querySelector("button").click(); + }); + await sleep(600); + + text = await bodyText(); + ok("exact version matches raise no warning", !text.includes("does not have an exact image match")); + ok("the name defaults to the remote site's", await page.evaluate(() => { + const input = [...document.querySelectorAll("input")].find( + (i) => i.value === "pixel-bakery" + ); + return Boolean(input); + })); + + await page.evaluate(() => { + const input = [...document.querySelectorAll("input")].find((i) => i.value === "pixel-bakery"); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + ).set; + setter.call(input, "Bakery Copy"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await sleep(300); + await clickByText("button", "Import site"); + + // 5) Progress streams the backend's stages, then lands on the new site. + await page.waitForFunction( + () => document.body.innerText.includes("Downloading remote wp-content"), + { timeout: 15_000 } + ); + ok("progress reports the code download stage", true); + await page.waitForFunction( + () => document.body.innerText.includes("Rewriting URLs remote -> local"), + { timeout: 20_000 } + ); + ok("progress reports the URL rewrite stage", true); + await page.waitForFunction( + () => document.body.innerText.includes("Bakery Copy imported from Production"), + { timeout: 20_000 } + ); + ok("the import resolves with a success message", true); + + // 6) Back on the dashboard, the new site carries its origin. + await clickByText("button", "Back to sites").catch(() => {}); + await page.evaluate(() => { + const link = [...document.querySelectorAll("button, a")].find( + (b) => b.textContent.trim() === "Sites" + ); + link?.click(); + }); + await sleep(900); + + const dash = await page.evaluate(() => { + const cards = [...document.querySelectorAll("div")].filter((d) => + d.textContent.includes("Bakery Copy") + ); + const card = cards[cards.length - 1]; + return { + present: Boolean(card), + badge: Boolean( + [...document.querySelectorAll("span")].find( + (s) => + s.title?.includes("Imported from Production") && + s.textContent.includes("Production") + ) + ), + }; + }); + ok("the imported site appears on the dashboard", dash.present); + ok("it carries the imported-from badge", dash.badge); + + // 7) Re-importing the same remote site is refused. + await openServerKitSettings(page); + await clickByText("button", "View WP sites"); + await page.waitForFunction(() => document.body.innerText.includes("pixel-bakery"), { + timeout: 15_000, + }); + await sleep(500); + await page.evaluate(() => { + const table = [...document.querySelectorAll("table")].find((t) => + t.textContent.includes("pixel-bakery") + ); + const row = [...table.querySelectorAll("tbody tr")].find((tr) => + tr.textContent.includes("pixel-bakery") + ); + row.querySelector("button").click(); + }); + await sleep(500); + await clickByText("button", "Import site"); + await sleep(1200); + ok( + "a second import of the same remote site is refused", + (await bodyText()).includes("already imported") + ); + + console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`); + } finally { + if (browser) await browser.close(); + killTree(server); + } + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 2cfd3e586c5a8aea3297ff11e942134a2c9ee4b5 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 15:27:55 -0400 Subject: [PATCH 18/67] transfer.rs: chunk/hash/cancel core for sync v2 (plan 19 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The substrate the chunked transfer protocol sits on, kept free of HTTP so the offset math and the resume rule are unit-testable without a server: - 8 MiB chunk planning (`chunks`) and the resume subtraction (`remaining`), which ignores offsets the client never planned rather than trusting a server that echoes back a differently-chunked attempt. - `HashWriter` hashes a payload while it is written, so a multi-GB wp-content archive is hashed in the same pass that builds it. - `Staged` payloads live in a temp file and delete themselves on drop, on every error path — a failed push must not leave a copy of the site in the temp dir. `adopt` takes over a file wp-cli wrote. - `CancelRegistry` / `CancelToken` per site id, with the stale-flag trap covered: a dropped token never deregisters the transfer that replaced it. AppState gains `transfers`; all seven constructors updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/examples/m4_smoke.rs | 1 + src-tauri/examples/m6_smoke.rs | 1 + src-tauri/examples/smoke.rs | 1 + src-tauri/examples/snapshot_smoke.rs | 1 + src-tauri/lk/src/main.rs | 2 + src-tauri/src/lib.rs | 4 + src-tauri/src/transfer.rs | 571 +++++++++++++++++++++++++++ 9 files changed, 583 insertions(+) create mode 100644 src-tauri/src/transfer.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 82e0acb..4db38e1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2218,6 +2218,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tar", "tauri", "tauri-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff2c4bd..c72b529 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,4 +32,5 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } tar = "0.4" flate2 = "1" +sha2 = "0.10" portable-pty = "0.8" diff --git a/src-tauri/examples/m4_smoke.rs b/src-tauri/examples/m4_smoke.rs index cf790d0..5bf347a 100644 --- a/src-tauri/examples/m4_smoke.rs +++ b/src-tauri/examples/m4_smoke.rs @@ -22,6 +22,7 @@ fn make_state() -> AppState { db: Mutex::new(db), data_dir, terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), } } diff --git a/src-tauri/examples/m6_smoke.rs b/src-tauri/examples/m6_smoke.rs index 30a96ae..1af82f6 100644 --- a/src-tauri/examples/m6_smoke.rs +++ b/src-tauri/examples/m6_smoke.rs @@ -23,6 +23,7 @@ fn make_state() -> AppState { db: Mutex::new(db), data_dir, terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), } } diff --git a/src-tauri/examples/smoke.rs b/src-tauri/examples/smoke.rs index e230231..3c8cb7b 100644 --- a/src-tauri/examples/smoke.rs +++ b/src-tauri/examples/smoke.rs @@ -21,6 +21,7 @@ fn make_state() -> AppState { db: Mutex::new(db), data_dir, terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), } } diff --git a/src-tauri/examples/snapshot_smoke.rs b/src-tauri/examples/snapshot_smoke.rs index c38275f..5cb3813 100644 --- a/src-tauri/examples/snapshot_smoke.rs +++ b/src-tauri/examples/snapshot_smoke.rs @@ -25,6 +25,7 @@ fn make_state() -> AppState { db: Mutex::new(db), data_dir, terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), } } diff --git a/src-tauri/lk/src/main.rs b/src-tauri/lk/src/main.rs index 846a9c0..b25ac3b 100644 --- a/src-tauri/lk/src/main.rs +++ b/src-tauri/lk/src/main.rs @@ -779,6 +779,7 @@ async fn doctor_router(data_dir: &Path) -> bool { db: Mutex::new(db), data_dir: data_dir.to_path_buf(), terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), }; let ports = router::router_ports(&state); @@ -835,6 +836,7 @@ fn make_state(cli: &Cli) -> Result { db: Mutex::new(db), data_dir, terminals: localkit_lib::terminal::PtyManager::new(), + transfers: Default::default(), }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcb7c6f..70fe493 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod site; pub mod snapshot; pub mod sync; pub mod terminal; +pub mod transfer; pub mod tray; pub mod wordpress; @@ -22,6 +23,8 @@ pub struct AppState { pub db: Mutex, pub data_dir: PathBuf, pub terminals: terminal::PtyManager, + /// Cancel flags for in-flight chunked syncs, keyed by site id (plan 19). + pub transfers: transfer::CancelRegistry, } #[derive(Serialize)] @@ -478,6 +481,7 @@ pub fn run() { db: Mutex::new(db), data_dir, terminals: terminal::PtyManager::new(), + transfers: Default::default(), }) .setup(move |app| { // Main window is built in code (not tauri.conf.json) so the diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs new file mode 100644 index 0000000..ba299c5 --- /dev/null +++ b/src-tauri/src/transfer.rs @@ -0,0 +1,571 @@ +//! Chunked, resumable transfer primitives (plan 19). +//! +//! Sync v1 built the whole payload in a `Vec`, POSTed it in one request and +//! hoped: bounded by the server's 100 MB body limit, no progress beyond coarse +//! stages, and a dropped connection at 99% meant starting over. This module is +//! the substrate that replaces it — everything here is deliberately pure or +//! filesystem-only, with no HTTP in sight, so the offset math and the resume +//! rule can be unit-tested without a server. +//! +//! The shape of a v2 transfer: +//! +//! 1. **Stage** the payload to a temp file, hashing it in the same pass +//! (`stage` / `adopt`) — a `Staged` deletes itself on drop, so no failure +//! path leaks a multi-hundred-MB file into the temp dir. +//! 2. **Plan** the upload as `Chunk`s, subtracting whatever the server already +//! confirmed (`remaining`) — that subtraction *is* resume. +//! 3. **Send** each chunk, checking a `CancelToken` between them. +//! +//! Chunk size is a const, not a setting: 8 MiB keeps request counts low on +//! LAN-ish links without making the progress bar jumpy, and a knob here would +//! only ever be turned to a worse value. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; + +/// Bytes per chunk. See the module docs for why this is not configurable. +pub const CHUNK_SIZE: u64 = 8 * 1024 * 1024; + +/// Error text a cancelled transfer fails with. Callers compare against this +/// (`is_cancel`) to report "cancelled" instead of "failed" — a user pressing +/// Cancel is not an error condition, it just travels the error path. +pub const CANCELLED: &str = "cancelled"; + +/// Was this error a user cancel rather than a real failure? +pub fn is_cancel(e: &str) -> bool { + e == CANCELLED +} + +// --------------------------------------------------------------------------- +// Chunk planning +// --------------------------------------------------------------------------- + +/// One byte range of a staged payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Chunk { + pub offset: u64, + pub len: u64, +} + +impl Chunk { + pub fn end(&self) -> u64 { + self.offset + self.len + } +} + +/// Every chunk of a `total`-byte payload, in order. +/// +/// A zero-byte payload yields no chunks — `finish` still runs, and the server +/// verifies the hash of nothing against the hash of nothing. That is the +/// correct behavior for an empty archive even though the callers above refuse +/// to produce one. +pub fn chunks(total: u64, chunk_size: u64) -> Vec { + assert!(chunk_size > 0, "chunk_size must be positive"); + let mut out = Vec::new(); + let mut offset = 0u64; + while offset < total { + out.push(Chunk { + offset, + // The last chunk is short; every other one is full. + len: chunk_size.min(total - offset), + }); + offset += chunk_size; + } + out +} + +/// The chunks still to send, given the offsets the server says it already has. +/// +/// This subtraction is the whole of resume: `init` reports what survived a +/// previous attempt, and the client simply skips those. Unknown offsets in +/// `confirmed` (a server confirming something we never sent, or a chunk size +/// that changed between attempts) are ignored rather than trusted — the plan +/// is always derived from *our* view of the payload. +pub fn remaining(total: u64, chunk_size: u64, confirmed: &[u64]) -> Vec { + chunks(total, chunk_size) + .into_iter() + .filter(|c| !confirmed.contains(&c.offset)) + .collect() +} + +/// Total bytes covered by a chunk list — what "already done" means for the +/// progress readout when a resumed transfer starts part-way through. +pub fn bytes_of(chunks: &[Chunk]) -> u64 { + chunks.iter().map(|c| c.len).sum() +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/// A writer that hashes everything passing through it. +/// +/// Used so a payload is hashed *while* it is built rather than in a second +/// read pass — for a multi-GB `wp-content` archive that halves the disk IO and +/// keeps peak memory at one buffer. +pub struct HashWriter { + inner: W, + hasher: Sha256, + written: u64, +} + +impl HashWriter { + pub fn new(inner: W) -> Self { + Self { inner, hasher: Sha256::new(), written: 0 } + } + + /// Consume the writer, returning the wrapped writer, the hex digest and the + /// byte count. + pub fn finish(self) -> (W, String, u64) { + (self.inner, hex(&self.hasher.finalize()), self.written) + } +} + +impl Write for HashWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Hash only what the inner writer actually accepted, or a short write + // would poison the digest with bytes that never reached the file. + let n = self.inner.write(buf)?; + self.hasher.update(&buf[..n]); + self.written += n as u64; + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + hex(&h.finalize()) +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(s, "{b:02x}"); + } + s +} + +// --------------------------------------------------------------------------- +// Staged payloads +// --------------------------------------------------------------------------- + +/// A payload written to a temp file, with its size and hash. +/// +/// The file is removed on drop, including on every error path — a failed push +/// must not leave a copy of the site's `wp-content` sitting in the temp dir. +#[derive(Debug)] +pub struct Staged { + path: PathBuf, + total: u64, + sha256: String, +} + +impl Staged { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn total(&self) -> u64 { + self.total + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } + + /// Read one chunk back out for sending. + /// + /// Blocking IO on purpose: a chunk is at most `CHUNK_SIZE` off local disk, + /// and the rest of this codebase reads files the same way. The alternative + /// (holding an async file handle across the whole upload) buys nothing and + /// complicates the resume path. + pub fn read_chunk(&self, chunk: Chunk) -> Result, String> { + let mut f = std::fs::File::open(&self.path) + .map_err(|e| format!("failed to reopen the staged payload: {e}"))?; + f.seek(SeekFrom::Start(chunk.offset)) + .map_err(|e| format!("failed to seek the staged payload: {e}"))?; + let mut buf = vec![0u8; chunk.len as usize]; + f.read_exact(&mut buf) + .map_err(|e| format!("failed to read the staged payload: {e}"))?; + Ok(buf) + } + + /// Take ownership of a file someone else wrote (e.g. `wp db export`), + /// hashing it in place. The file is deleted on drop just like a staged one. + pub fn adopt(path: PathBuf) -> Result { + let mut f = std::fs::File::open(&path) + .map_err(|e| format!("failed to open the staged payload: {e}"))?; + let mut hasher = Sha256::new(); + let mut total = 0u64; + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = f + .read(&mut buf) + .map_err(|e| format!("failed to read the staged payload: {e}"))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + total += n as u64; + } + Ok(Self { path, total, sha256: hex(&hasher.finalize()) }) + } +} + +impl Drop for Staged { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Write a payload to a temp file through a hashing writer. +/// +/// `tag` only exists to make a stray file identifiable if the process is killed +/// hard enough to skip `Drop`. +pub fn stage(tag: &str, build: F) -> Result +where + F: FnOnce(&mut dyn Write) -> Result<(), String>, +{ + let path = std::env::temp_dir().join(format!( + "localkit-{tag}-{}-{}.tmp", + std::process::id(), + uuid::Uuid::new_v4() + )); + let file = std::fs::File::create(&path) + .map_err(|e| format!("failed to create a staging file: {e}"))?; + + // Hash first, buffer second: every byte still passes through the hasher, + // and the buffer keeps the 8 KiB tar writes off the syscall path. + let mut writer = HashWriter::new(std::io::BufWriter::new(file)); + let built = build(&mut writer); + let (mut inner, sha256, total) = writer.finish(); + let flushed = inner + .flush() + .map_err(|e| format!("failed to finish the staging file: {e}")); + + // A `Staged` exists from here on, so any error below still cleans up. + let staged = Staged { path, total, sha256 }; + built?; + flushed?; + Ok(staged) +} + +// --------------------------------------------------------------------------- +// Cancellation +// --------------------------------------------------------------------------- + +/// Per-site cancel flags for in-flight transfers. +/// +/// Keyed by site id because that is what the UI has to offer a Cancel button +/// against — one sync per site at a time is already the assumption everywhere +/// else (the pinned progress toast, `busyId`). +#[derive(Clone, Default)] +pub struct CancelRegistry { + inner: Arc>>>, +} + +impl CancelRegistry { + /// Register a cancellable operation for `site_id`. + /// + /// Replaces any token left behind by a previous operation, so a stale flag + /// can never cancel a fresh transfer the moment it starts. + pub fn begin(&self, site_id: &str) -> CancelToken { + let flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut map) = self.inner.lock() { + map.insert(site_id.to_string(), flag.clone()); + } + CancelToken { site_id: site_id.to_string(), flag, registry: self.clone() } + } + + /// Ask the transfer for `site_id` to stop. Returns whether one was running. + pub fn cancel(&self, site_id: &str) -> bool { + match self.inner.lock() { + Ok(map) => match map.get(site_id) { + Some(flag) => { + flag.store(true, Ordering::SeqCst); + true + } + None => false, + }, + Err(_) => false, + } + } +} + +/// Handle held by the running transfer; deregisters itself on drop. +pub struct CancelToken { + site_id: String, + flag: Arc, + registry: CancelRegistry, +} + +impl CancelToken { + /// `Err(CANCELLED)` once the user has asked to stop. Called between chunks, + /// which is also the only place a transfer can stop cleanly: a half-sent + /// chunk is simply never confirmed, and the server reaps the transfer. + pub fn check(&self) -> Result<(), String> { + if self.flag.load(Ordering::SeqCst) { + Err(CANCELLED.to_string()) + } else { + Ok(()) + } + } + + pub fn cancelled(&self) -> bool { + self.flag.load(Ordering::SeqCst) + } +} + +impl Drop for CancelToken { + fn drop(&mut self) { + if let Ok(mut map) = self.registry.inner.lock() { + // Only remove our own token — a newer transfer for the same site + // may already have replaced it, and dropping that one would leave + // it uncancellable. + if map.get(&self.site_id).is_some_and(|f| Arc::ptr_eq(f, &self.flag)) { + map.remove(&self.site_id); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- chunk math --------------------------------------------------------- + + #[test] + fn chunks_cover_the_payload_exactly() { + for total in [0u64, 1, 9, 10, 11, 99, 100, 101] { + let cs = 10; + let plan = chunks(total, cs); + assert_eq!(bytes_of(&plan), total, "coverage gap at total={total}"); + // Contiguous, in order, no overlaps. + let mut expected = 0; + for c in &plan { + assert_eq!(c.offset, expected, "gap/overlap at total={total}"); + assert!(c.len > 0 && c.len <= cs); + expected = c.end(); + } + assert_eq!(expected, total); + } + } + + #[test] + fn the_last_chunk_is_short_and_the_rest_are_full() { + let plan = chunks(25, 10); + assert_eq!( + plan, + vec![ + Chunk { offset: 0, len: 10 }, + Chunk { offset: 10, len: 10 }, + Chunk { offset: 20, len: 5 }, + ] + ); + } + + #[test] + fn an_exact_multiple_produces_no_empty_trailing_chunk() { + let plan = chunks(20, 10); + assert_eq!(plan.len(), 2); + assert_eq!(plan.last().unwrap().len, 10); + } + + #[test] + fn an_empty_payload_has_no_chunks() { + assert!(chunks(0, CHUNK_SIZE).is_empty()); + } + + // -- resume ------------------------------------------------------------- + + #[test] + fn confirmed_offsets_are_skipped() { + let left = remaining(25, 10, &[0, 20]); + assert_eq!(left, vec![Chunk { offset: 10, len: 10 }]); + } + + #[test] + fn nothing_confirmed_means_everything_is_sent() { + assert_eq!(remaining(25, 10, &[]), chunks(25, 10)); + } + + #[test] + fn everything_confirmed_means_nothing_is_sent() { + assert!(remaining(25, 10, &[0, 10, 20]).is_empty()); + } + + #[test] + fn offsets_the_client_never_planned_are_ignored() { + // A server echoing an offset from a differently-chunked attempt must + // not be able to make us skip a real chunk. + let left = remaining(25, 10, &[5, 15, 99, 1_000_000]); + assert_eq!(left, chunks(25, 10), "a bogus offset suppressed a real chunk"); + } + + // -- hashing ------------------------------------------------------------ + + #[test] + fn hash_writer_matches_a_one_shot_hash() { + let payload: Vec = (0..10_000u32).map(|i| (i % 251) as u8).collect(); + let mut w = HashWriter::new(Vec::new()); + // Written in uneven pieces: the digest must not depend on write sizes. + for piece in payload.chunks(777) { + w.write_all(piece).unwrap(); + } + let (out, digest, written) = w.finish(); + assert_eq!(out, payload); + assert_eq!(written, payload.len() as u64); + assert_eq!(digest, sha256_hex(&payload)); + } + + #[test] + fn sha256_is_the_known_answer() { + // Anchors the hex encoding against a value that is not self-generated. + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + // -- staging ------------------------------------------------------------ + + #[test] + fn staging_hashes_and_sizes_what_it_wrote() { + let payload: Vec = (0..5_000u32).map(|i| (i % 97) as u8).collect(); + let staged = stage("test-stage", |w| { + w.write_all(&payload).map_err(|e| e.to_string()) + }) + .unwrap(); + + assert_eq!(staged.total(), payload.len() as u64); + assert_eq!(staged.sha256(), sha256_hex(&payload)); + assert_eq!(std::fs::read(staged.path()).unwrap(), payload); + } + + #[test] + fn reassembling_the_chunks_reproduces_the_payload() { + let payload: Vec = (0..30_000u32).map(|i| (i % 253) as u8).collect(); + let staged = stage("test-chunks", |w| { + w.write_all(&payload).map_err(|e| e.to_string()) + }) + .unwrap(); + + // Send them out of order, exactly as a resumed transfer would. + let plan = chunks(staged.total(), 4096); + let mut assembled = vec![0u8; payload.len()]; + for chunk in plan.iter().rev() { + let bytes = staged.read_chunk(*chunk).unwrap(); + assert_eq!(bytes.len(), chunk.len as usize); + assembled[chunk.offset as usize..chunk.end() as usize].copy_from_slice(&bytes); + } + assert_eq!(assembled, payload); + assert_eq!(sha256_hex(&assembled), staged.sha256()); + } + + #[test] + fn a_staged_file_is_removed_on_drop() { + let path = { + let staged = stage("test-drop", |w| w.write_all(b"x").map_err(|e| e.to_string())).unwrap(); + staged.path().to_path_buf() + }; + assert!(!path.exists(), "the staged file outlived its Staged"); + } + + #[test] + fn a_failed_build_still_cleans_up() { + let err = stage("test-fail", |w| { + w.write_all(b"partial").map_err(|e| e.to_string())?; + Err("bundling blew up".into()) + }) + .unwrap_err(); + assert_eq!(err, "bundling blew up"); + // Nothing to assert on the path (we never got one) beyond: no panic, + // and the temp dir is not accumulating — covered by the drop test. + } + + #[test] + fn adopting_a_file_hashes_it_and_takes_ownership() { + let path = std::env::temp_dir().join(format!("localkit-adopt-{}.sql", std::process::id())); + std::fs::write(&path, b"SELECT 1;").unwrap(); + let (total, digest) = { + let staged = Staged::adopt(path.clone()).unwrap(); + (staged.total(), staged.sha256().to_string()) + }; + assert_eq!(total, 9); + assert_eq!(digest, sha256_hex(b"SELECT 1;")); + assert!(!path.exists(), "adopt did not take ownership of the file"); + } + + // -- cancellation ------------------------------------------------------- + + #[test] + fn a_token_reports_cancellation() { + let reg = CancelRegistry::default(); + let token = reg.begin("site-a"); + assert!(token.check().is_ok()); + + assert!(reg.cancel("site-a"), "cancel did not find the running transfer"); + assert!(token.cancelled()); + assert_eq!(token.check().unwrap_err(), CANCELLED); + assert!(is_cancel(&token.check().unwrap_err())); + } + + #[test] + fn cancelling_an_idle_site_is_a_no_op() { + let reg = CancelRegistry::default(); + assert!(!reg.cancel("nobody-home")); + } + + #[test] + fn a_token_deregisters_itself() { + let reg = CancelRegistry::default(); + drop(reg.begin("site-a")); + assert!(!reg.cancel("site-a"), "a finished transfer is still cancellable"); + } + + #[test] + fn cancels_do_not_leak_across_sites() { + let reg = CancelRegistry::default(); + let a = reg.begin("site-a"); + let b = reg.begin("site-b"); + reg.cancel("site-a"); + assert!(a.cancelled()); + assert!(!b.cancelled(), "cancelling one site stopped another"); + } + + #[test] + fn a_stale_flag_cannot_cancel_the_next_transfer() { + let reg = CancelRegistry::default(); + let first = reg.begin("site-a"); + reg.cancel("site-a"); + assert!(first.cancelled()); + + // Second run of the same site: fresh flag, and dropping the old token + // must not deregister the new one. + let second = reg.begin("site-a"); + drop(first); + assert!(!second.cancelled(), "a stale flag cancelled a fresh transfer"); + assert!(reg.cancel("site-a"), "the fresh transfer was deregistered by the stale token"); + assert!(second.cancelled()); + } +} From 1645865630e6a3154a6091c51734df3a83ed88cd Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 15:34:52 -0400 Subject: [PATCH 19/67] mock ext: sync-v2 chunked push + ranged pulls (plan 19 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock now implements the v2 contract so m4_smoke can drive it: init/chunk/finish with an in-memory chunk store, resume keyed on (site_id, kind, sha256, total, chunk_size), idempotent re-sends, and Range/If-Range downloads pinned per download session. Two mock-only routes exist for assertions the real extension has no reason to expose: __stats counts chunks actually sent (that counter is what proves a resume re-sent only what was lost) and __control injects a mid-upload failure, which is a deterministic stand-in for killing the client rather than racing a real kill. v1 push routes stay — they are the fallback path for old servers and must keep working. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/examples/mock_localkit_ext.cjs | 291 +++++++++++++++++++---- 1 file changed, 249 insertions(+), 42 deletions(-) diff --git a/src-tauri/examples/mock_localkit_ext.cjs b/src-tauri/examples/mock_localkit_ext.cjs index 5d92a23..8740208 100644 --- a/src-tauri/examples/mock_localkit_ext.cjs +++ b/src-tauri/examples/mock_localkit_ext.cjs @@ -3,20 +3,49 @@ // - Validates X-API-Key (good-key); invalid key -> 401 {'error': ...} on ALL routes. // - Stores the SQL uploaded via POST /push/db; GET /pull/db returns it gzipped // with the local URL rewritten to the remote URL (simulating a remote DB). +// - Implements sync v2 (plan 19): chunked resumable push with an in-memory +// chunk store, and Range/session downloads on the pull side. +// +// Two mock-only routes exist so m4_smoke can make assertions the real +// extension has no reason to expose: +// GET /__stats — request counters (how many chunks actually got sent) +// POST /__control — fault injection: refuse chunk PUTs after N succeed, +// which is how the smoke simulates a client dying +// mid-upload deterministically instead of racing a kill. const http = require("http"); const zlib = require("zlib"); +const crypto = require("crypto"); const GOOD_KEY = "good-key"; const LOCAL_URL = "http://localhost:8081"; const REMOTE_URL = "https://blog.example.com"; -// Capabilities of the real extension (plan 18); LocalKit gates Import on this. -const FEATURES = ["sites", "push-code", "push-db", "pull-db", "pull-code"]; +// Capabilities of the real extension; LocalKit gates Import on pull-code and +// the chunked transfer path on sync-v2. +const FEATURES = ["sites", "push-code", "push-db", "pull-db", "pull-code", "sync-v2"]; // Canary file the import E2E looks for after extracting the remote wp-content. const CANARY_PATH = "wp-content/themes/remote-theme/style.css"; const CANARY_BODY = "/* pulled from the remote site */\n"; let storedSql = null; let receivedTgz = null; +// --- v2 transfer state ----------------------------------------------------- +/** transfer_id -> {siteId, kind, total, chunkSize, sha256, localUrl, buf, received:Map} */ +const transfers = new Map(); +/** `${session}:${kind}:${siteId}` -> Buffer — a download pinned for resuming. */ +const downloadSessions = new Map(); + +const stats = newStats(); +function newStats() { + return { inits: 0, resumedInits: 0, chunkPuts: 0, chunkBytes: 0, duplicates: 0, finishes: 0, rangeGets: 0 }; +} +function resetStats() { + for (const k of Object.keys(stats)) stats[k] = 0; +} +/** Fault injection: once `failChunksAfter` chunks have landed, refuse the rest. */ +const control = { failChunksAfter: null, chunksSinceControl: 0 }; + +const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); + // --- minimal tar writer ---------------------------------------------------- // Node ships zlib but no tar, and the archive shape is the contract under // test, so the 512-byte ustar blocks are written out by hand. @@ -72,7 +101,69 @@ function parseMultipart(body, boundary) { return out; } -const server = http.createServer((req, res) => { +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }); +} + +/** Apply the v1 processing rules to an assembled code payload. */ +function acceptCodeArchive(buf) { + if (buf[0] !== 0x1f || buf[1] !== 0x8b) return { error: "not gzip" }; + let tar; + try { + tar = zlib.gunzipSync(buf); + } catch (e) { + return { error: `not gzip: ${e.message}` }; + } + if (!tar.includes(Buffer.from("wp-content"))) return { error: "No wp-content found in the archive" }; + receivedTgz = buf.length; + return null; +} + +/** Send binary with ETag + Range support, mirroring Flask's conditional=True. */ +function sendBinary(req, res, buf) { + const etag = `"${sha256(buf).slice(0, 32)}"`; + const range = req.headers.range; + const ifRange = req.headers["if-range"]; + const base = { "Content-Type": "application/gzip", ETag: etag, "Accept-Ranges": "bytes" }; + + // If-Range that no longer matches means the body changed under the client; + // per RFC 9110 the correct answer is the whole thing, not a partial one. + if (range && (!ifRange || ifRange === etag)) { + const m = /^bytes=(\d+)-(\d*)$/.exec(range); + if (m) { + const start = parseInt(m[1], 10); + const end = m[2] ? Math.min(parseInt(m[2], 10), buf.length - 1) : buf.length - 1; + if (start >= buf.length || start > end) { + res.writeHead(416, { ...base, "Content-Range": `bytes */${buf.length}` }); + return res.end(); + } + stats.rangeGets += 1; + const slice = buf.subarray(start, end + 1); + res.writeHead(206, { + ...base, + "Content-Length": slice.length, + "Content-Range": `bytes ${start}-${end}/${buf.length}`, + }); + return res.end(slice); + } + } + res.writeHead(200, { ...base, "Content-Length": buf.length }); + res.end(buf); +} + +/** The bytes a pull should serve, pinned per session so ranges stay coherent. */ +function pinnedExport(session, kind, siteId, build) { + if (!session) return build(); + const key = `${session}:${kind}:${siteId}`; + if (!downloadSessions.has(key)) downloadSessions.set(key, build()); + return downloadSessions.get(key); +} + +const server = http.createServer(async (req, res) => { const json = (code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); @@ -82,6 +173,20 @@ const server = http.createServer((req, res) => { } const url = new URL(req.url, "http://x"); + // --- mock-only test hooks ------------------------------------------------ + if (url.pathname === "/api/v1/localkit/__stats") { + return json(200, { ...stats, transfers: transfers.size }); + } + if (url.pathname === "/api/v1/localkit/__control" && req.method === "POST") { + const body = await readBody(req); + const cfg = body.length ? JSON.parse(body.toString()) : {}; + control.failChunksAfter = cfg.failChunksAfter ?? null; + control.chunksSinceControl = 0; + if (cfg.resetStats) resetStats(); + if (cfg.forgetTransfers) transfers.clear(); + return json(200, { ok: true, ...control }); + } + if (url.pathname === "/api/v1/localkit/pair") { return json(200, { status: "ok", service: "serverkit-localkit", panel: "ServerKit", version: "1.7.0", user: "admin", canonical_domain: "panel.example.com", canonical_origin: "https://panel.example.com", features: FEATURES }); } @@ -96,58 +201,160 @@ const server = http.createServer((req, res) => { } if (url.pathname === "/api/v1/localkit/sites" && req.method === "POST") { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => json(201, { success: true, site: { id: 3, name: JSON.parse(body).name.toLowerCase().replace(/ /g, "-") }, http_port: 8090 })); - return; + const body = await readBody(req); + return json(201, { success: true, site: { id: 3, name: JSON.parse(body.toString()).name.toLowerCase().replace(/ /g, "-") }, http_port: 8090 }); } - if (url.pathname === "/api/v1/localkit/push/code" && req.method === "POST") { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - const body = Buffer.concat(chunks); - const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1]; - const { fields, file } = parseMultipart(body, boundary); - if (!fields.site_id || !file) return json(400, { error: "site_id and file required" }); - if (file.data[0] !== 0x1f || file.data[1] !== 0x8b) return json(400, { error: "not gzip" }); - const tar = zlib.gunzipSync(file.data); - if (!tar.includes(Buffer.from("wp-content"))) return json(400, { error: "No wp-content found in the archive" }); - receivedTgz = file.data.length; - json(200, { success: true, message: "wp-content pushed to the site" }); + // --- sync v2: chunked push ---------------------------------------------- + + const initMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/init$/.exec(url.pathname); + if (initMatch && req.method === "POST") { + const kind = initMatch[1]; + const body = await readBody(req); + const data = body.length ? JSON.parse(body.toString()) : {}; + if (!data.site_id) return json(400, { error: "site_id is required" }); + if (!/^[0-9a-f]{64}$/.test(data.sha256 || "")) { + return json(400, { error: "sha256 must be a hex-encoded SHA-256 digest" }); + } + stats.inits += 1; + + // Resume: an existing transfer of the identical payload keeps its chunks. + for (const [id, t] of transfers) { + if (t.kind === kind && t.siteId === data.site_id && t.sha256 === data.sha256 + && t.total === data.total_bytes && t.chunkSize === data.chunk_size) { + stats.resumedInits += 1; + return json(200, { + transfer_id: id, + chunk_size: t.chunkSize, + received: [...t.received.keys()].sort((a, b) => a - b), + resumed: true, + }); + } + } + + const id = crypto.randomBytes(16).toString("hex"); + transfers.set(id, { + kind, + siteId: data.site_id, + total: data.total_bytes, + chunkSize: data.chunk_size, + sha256: data.sha256, + localUrl: data.local_url || "", + buf: Buffer.alloc(data.total_bytes), + received: new Map(), }); - return; + return json(201, { transfer_id: id, chunk_size: data.chunk_size, received: [], resumed: false }); + } + + const chunkMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/chunk$/.exec(url.pathname); + if (chunkMatch && req.method === "PUT") { + const kind = chunkMatch[1]; + const t = transfers.get(url.searchParams.get("transfer_id")); + if (!t || t.kind !== kind) return json(404, { error: "Unknown or expired transfer" }); + + const offset = Number(url.searchParams.get("offset")); + const chunkSha = url.searchParams.get("sha256"); + const expected = Math.min(t.chunkSize, t.total - offset); + if (!Number.isInteger(offset) || offset < 0 || offset >= t.total || offset % t.chunkSize !== 0) { + return json(400, { error: `offset ${offset} is not a chunk boundary of this transfer` }); + } + + if (t.received.get(offset) === chunkSha) { + stats.duplicates += 1; + return json(200, { received: [...t.received.keys()].sort((a, b) => a - b), duplicate: true }); + } + + const body = await readBody(req); + + // Fault injection stands in for "the client's connection died here". + if (control.failChunksAfter != null && control.chunksSinceControl >= control.failChunksAfter) { + return json(503, { error: "mock: injected chunk failure" }); + } + + if (body.length !== expected) { + return json(400, { error: `chunk at offset ${offset} must be ${expected} bytes, got ${body.length}` }); + } + if (sha256(body) !== chunkSha) { + return json(400, { error: `chunk at offset ${offset} failed its checksum` }); + } + body.copy(t.buf, offset); + t.received.set(offset, chunkSha); + stats.chunkPuts += 1; + stats.chunkBytes += body.length; + control.chunksSinceControl += 1; + return json(200, { received: [...t.received.keys()].sort((a, b) => a - b), duplicate: false }); + } + + const finishMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/finish$/.exec(url.pathname); + if (finishMatch && req.method === "POST") { + const kind = finishMatch[1]; + const body = await readBody(req); + const data = body.length ? JSON.parse(body.toString()) : {}; + const id = data.transfer_id; + const t = transfers.get(id); + if (!t || t.kind !== kind) return json(404, { error: "Unknown or expired transfer" }); + + const missing = []; + for (let o = 0; o < t.total; o += t.chunkSize) if (!t.received.has(o)) missing.push(o); + if (missing.length) { + return json(409, { + error: `${missing.length} chunk(s) are still missing`, + received: [...t.received.keys()].sort((a, b) => a - b), + missing, + }); + } + if (sha256(t.buf) !== t.sha256) { + transfers.delete(id); + return json(400, { error: "The assembled upload failed its checksum — nothing was applied." }); + } + + stats.finishes += 1; + if (kind === "code") { + const bad = acceptCodeArchive(t.buf); + if (bad) return json(400, bad); + transfers.delete(id); + return json(200, { success: true, message: "wp-content pushed to the site" }); + } + storedSql = t.buf.toString(); + transfers.delete(id); + return json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true }); + } + + // --- v1 push (still exercised: it is the fallback for old servers) ------- + + if (url.pathname === "/api/v1/localkit/push/code" && req.method === "POST") { + const body = await readBody(req); + const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1]; + const { fields, file } = parseMultipart(body, boundary); + if (!fields.site_id || !file) return json(400, { error: "site_id and file required" }); + const bad = acceptCodeArchive(file.data); + if (bad) return json(400, bad); + return json(200, { success: true, message: "wp-content pushed to the site" }); } if (url.pathname === "/api/v1/localkit/push/db" && req.method === "POST") { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - const body = Buffer.concat(chunks); - const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1]; - const { fields, file } = parseMultipart(body, boundary); - if (!fields.site_id || !file) return json(400, { error: "site_id and file required" }); - storedSql = file.data.toString(); - json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true }); - }); - return; + const body = await readBody(req); + const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1]; + const { fields, file } = parseMultipart(body, boundary); + if (!fields.site_id || !file) return json(400, { error: "site_id and file required" }); + storedSql = file.data.toString(); + return json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true }); } + // --- pull (Range + session, plan 19 phase 3) ----------------------------- + if (url.pathname === "/api/v1/localkit/pull/code" && req.method === "GET") { - if (!url.searchParams.get("site_id")) return json(400, { error: "site_id is required" }); - const gz = remoteWpContentTgz(); - res.writeHead(200, { "Content-Type": "application/gzip" }); - res.end(gz); - return; + const siteId = url.searchParams.get("site_id"); + if (!siteId) return json(400, { error: "site_id is required" }); + return sendBinary(req, res, pinnedExport(url.searchParams.get("session"), "code", siteId, remoteWpContentTgz)); } if (url.pathname === "/api/v1/localkit/pull/db" && req.method === "GET") { if (!storedSql) return json(404, { error: "Site not found" }); - const remoteSql = storedSql.split(LOCAL_URL).join(REMOTE_URL); - const gz = zlib.gzipSync(Buffer.from(remoteSql)); - res.writeHead(200, { "Content-Type": "application/gzip" }); - res.end(gz); - return; + const siteId = url.searchParams.get("site_id"); + return sendBinary(req, res, pinnedExport(url.searchParams.get("session"), "db", siteId, () => + zlib.gzipSync(Buffer.from(storedSql.split(LOCAL_URL).join(REMOTE_URL))) + )); } json(404, { error: "Not found" }); From 94bcda837303dd3047255b7ccf28195cfdbd48a1 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 15:44:19 -0400 Subject: [PATCH 20/67] sync v2 client: chunked push, resumable pulls, cancel (plan 19 phases 1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One client, both servers: sync.rs picks the protocol once at the top via `supports_v2` and routes to a v1 or v2 function. v1 stays a single isolated function per operation — the two protocols share their inputs and their success message and nothing else, so there are no `if v2` branches sprinkled through the flow. A failed /pair probe answers "no", because falling back to v1 always works. Push (v2): the wp-content tar/gzip pipeline now runs straight into a staging file via snapshot::write_wp_content_tgz, so the archive never exists as a Vec — that, not the chunking, is what makes a site with a real uploads/ directory pushable. Chunks go up 8 MiB at a time with a per-chunk timeout (reqwest's total-request budget IS the per-chunk one when each request is a chunk), 3 retries on transport/5xx only, and resume driven entirely by the offsets `init` reports back. Pull (v2): downloads stream to a self-deleting temp file with Range + If-Range resume. A 200 in reply to a ranged request means the export changed underneath us, so the partial file is thrown away rather than spliced into nonsense. The dump then streams decompress -> pipe -> `wp db import` through the new docker::compose_run_reader, and the import untars straight off the file — neither has to hold the payload in memory anymore. Cancel: AppState.transfers hands each operation a CancelToken checked between chunks. `cancel_sync(site_id)` sets the flag. A cancel is not a failure — it gets its own "cancelled" stage and history status instead of flashing a red error. Progress: SiteEvent gains optional bytes_done/bytes_total, emitted once per chunk. The backend sends raw counters and a bare label; formatting is the frontend's job (site::emit_bytes formats for stderr when there is no frontend). pull_db v2 snapshots AFTER the download rather than before: the download is now cancellable, and a cancelled pull should not leave a pointless pre_pull snapshot behind. v1 keeps the old order since it cannot stop. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/docker.rs | 36 +++- src-tauri/src/lib.rs | 12 ++ src-tauri/src/serverkit.rs | 326 +++++++++++++++++++++++++++++++++++ src-tauri/src/site.rs | 44 ++++- src-tauri/src/snapshot.rs | 41 +++-- src-tauri/src/sync.rs | 344 +++++++++++++++++++++++++++++++------ src-tauri/src/transfer.rs | 149 +++++++++++++--- src-tauri/src/wordpress.rs | 13 ++ 8 files changed, 874 insertions(+), 91 deletions(-) diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs index 77b3a6d..2e6c7f9 100644 --- a/src-tauri/src/docker.rs +++ b/src-tauri/src/docker.rs @@ -137,6 +137,27 @@ pub async fn compose_run_stdin( service: &str, args: &[&str], input: &[u8], +) -> Result { + compose_run_reader(dir, service, args, &mut &input[..]).await +} + +/// Like `compose_run_stdin`, but pumps from a reader instead of a slice. +/// +/// This is what keeps a pulled database off the heap (plan 19): the caller +/// hands over a `GzDecoder` on the downloaded file, and the dump streams +/// decompress -> pipe -> `wp db import` a megabyte at a time. Materializing a +/// multi-GB dump as a `Vec` just to write it to a pipe was the other half +/// of sync v1's memory problem. +/// +/// The read side is blocking on purpose: it is a 1 MiB read off local disk +/// between two awaits, which is how the rest of this codebase treats file IO. +/// `Send` on the reader is not optional — it is held across an await, and every +/// future in this crate has to stay `Send` to reach a Tauri command. +pub async fn compose_run_reader( + dir: &Path, + service: &str, + args: &[&str], + input: &mut (dyn std::io::Read + Send), ) -> Result { use tokio::io::AsyncWriteExt; if !dir.exists() { @@ -157,7 +178,20 @@ pub async fn compose_run_stdin( .map_err(|e| format!("failed to run docker compose: {e}"))?; if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(input).await; + let mut buf = vec![0u8; 1 << 20]; + loop { + match input.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + // A closed pipe means the command died; stop pumping and + // let wait_with_output report why. + if stdin.write_all(&buf[..n]).await.is_err() { + break; + } + } + Err(e) => return Err(format!("failed to read the input stream: {e}")), + } + } let _ = stdin.shutdown().await; } let output = child diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70fe493..3e75109 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -325,6 +325,17 @@ fn list_sync_history(state: State, site_id: String) -> Result, site_id: String) -> bool { + state.transfers.cancel(&site_id) +} + /// Clone a remote ServerKit site down as a brand-new local site (plan 18). #[tauri::command] async fn import_remote_site( @@ -532,6 +543,7 @@ pub fn run() { pull_site_db, import_remote_site, list_sync_history, + cancel_sync, router_status, set_domains_enabled, set_router_ports, diff --git a/src-tauri/src/serverkit.rs b/src-tauri/src/serverkit.rs index 4efd13b..76bbbba 100644 --- a/src-tauri/src/serverkit.rs +++ b/src-tauri/src/serverkit.rs @@ -22,6 +22,19 @@ pub struct ServerKitConnection { /// extension simply omits one, and the matching UI is disabled instead of /// failing halfway through an operation. pub const FEATURE_PULL_CODE: &str = "pull-code"; +/// Chunked, resumable transfers (plan 19). Absent = talk v1 to this server. +pub const FEATURE_SYNC_V2: &str = "sync-v2"; + +/// Per-chunk request budget. reqwest's `timeout` is a *total* request budget, +/// so with one chunk per request this is exactly the per-chunk timeout the +/// plan calls for: the whole operation is bounded by liveness, not duration, +/// and a two-hour upload never trips a clock as long as chunks keep landing. +const CHUNK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// How many times one chunk is retried before the transfer gives up. A failed +/// transfer is resumable anyway, so this only saves the user from having to +/// press the button again after a momentary blip. +const CHUNK_ATTEMPTS: u32 = 3; /// Result of a successful connection test. #[derive(Debug, Clone, Serialize)] @@ -441,6 +454,319 @@ async fn download( .map_err(|e| format!("failed to download {what}: {e}")) } +// --------------------------------------------------------------------------- +// Sync v2 — chunked, resumable transfers (plan 19) +// --------------------------------------------------------------------------- + +/// Reports transfer progress as `(bytes_done, bytes_total)`. +/// +/// A plain `dyn Fn` rather than a generic parameter: it crosses two async +/// functions and several await points, and monomorphizing that buys nothing +/// when it fires once per 8 MiB. +pub type ProgressFn<'a> = &'a (dyn Fn(u64, u64) + Send + Sync); + +#[derive(Deserialize)] +struct InitResponse { + transfer_id: String, + #[serde(default)] + chunk_size: Option, + /// Offsets the server already holds — subtracting these from our own + /// chunk plan is the entirety of resume. + #[serde(default)] + received: Vec, +} + +/// Map an extension error response onto something a user can act on. +fn api_error(what: &str, code: u16, body: &str) -> String { + match code { + 404 => extract_error(body).unwrap_or_else(|| { + "The serverkit-localkit extension is not installed on this ServerKit server (404).".into() + }), + 401 | 403 => "The API key was rejected (or lacks admin rights). Check the key.".into(), + 413 => "The upload is too large for the server (ServerKit limit is 100MB).".into(), + _ => extract_error(body).unwrap_or_else(|| format!("{what} failed with HTTP {code}.")), + } +} + +/// Upload a staged payload in chunks, resuming whatever a previous attempt left +/// behind (`POST init` → `PUT chunk`… → `POST finish`). +/// +/// `kind` is `"code"` or `"db"`. The server only processes anything in +/// `finish`, and only after the whole-file hash verifies — so abandoning this +/// mid-way (cancel, crash, dropped link) can never leave the remote site half +/// updated. +#[allow(clippy::too_many_arguments)] +pub async fn push_chunked( + url: &str, + api_key: &str, + kind: &str, + remote_site_id: i64, + local_url: Option<&str>, + staged: &crate::transfer::Staged, + cancel: &crate::transfer::CancelToken, + progress: ProgressFn<'_>, +) -> Result { + use crate::transfer; + + let base = normalize_base_url(url)?; + let http = build_client(CHUNK_TIMEOUT)?; + let total = staged.total(); + + let mut init_body = serde_json::json!({ + "site_id": remote_site_id, + "total_bytes": total, + "chunk_size": transfer::CHUNK_SIZE, + "sha256": staged.sha256(), + "filename": if kind == "code" { "wp-content.tar.gz" } else { "dump.sql" }, + }); + if let Some(u) = local_url { + init_body["local_url"] = serde_json::Value::String(u.to_string()); + } + + let init: InitResponse = post_json( + &http, + &base, + &format!("/api/v1/localkit/push/{kind}/init"), + api_key, + &init_body, + "Starting the upload", + ) + .await?; + + // The server owns the offsets, so if it reports a chunk size, that is the + // one the plan has to be built from. + let chunk_size = init.chunk_size.filter(|c| *c > 0).unwrap_or(transfer::CHUNK_SIZE); + let plan = transfer::remaining(total, chunk_size, &init.received); + let mut done = total.saturating_sub(transfer::bytes_of(&plan)); + progress(done, total); + + for chunk in plan { + cancel.check()?; + let bytes = staged.read_chunk(chunk)?; + let chunk_sha = transfer::sha256_hex(&bytes); + put_chunk(&http, &base, api_key, kind, &init.transfer_id, chunk, &chunk_sha, bytes, cancel) + .await?; + done += chunk.len; + progress(done, total); + } + + cancel.check()?; + // `finish` runs the server-side extract/import, which can take minutes on a + // big site — it gets the generous transfer budget, not the chunk one. + post_json( + &transfer_client()?, + &base, + &format!("/api/v1/localkit/push/{kind}/finish"), + api_key, + &serde_json::json!({ "transfer_id": init.transfer_id }), + "Finishing the upload", + ) + .await +} + +async fn post_json( + http: &reqwest::Client, + base: &str, + path: &str, + api_key: &str, + body: &serde_json::Value, + what: &str, +) -> Result { + let resp = http + .post(format!("{base}{path}")) + .header("X-API-Key", api_key) + .json(body) + .send() + .await + .map_err(|e| request_error(base, &e))?; + let code = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + if !(200..300).contains(&code) { + return Err(api_error(what, code, &text)); + } + serde_json::from_str(&text).map_err(|e| format!("{what}: unexpected server response ({e})")) +} + +/// PUT one chunk, retrying transient failures. +/// +/// Only transport errors and 5xx are retried: a 4xx means the server rejected +/// what we sent (bad offset, failed checksum), and sending the identical bytes +/// again would fail identically. +#[allow(clippy::too_many_arguments)] +async fn put_chunk( + http: &reqwest::Client, + base: &str, + api_key: &str, + kind: &str, + transfer_id: &str, + chunk: crate::transfer::Chunk, + chunk_sha: &str, + bytes: Vec, + cancel: &crate::transfer::CancelToken, +) -> Result<(), String> { + let url = format!("{base}/api/v1/localkit/push/{kind}/chunk"); + let mut last = String::new(); + for attempt in 1..=CHUNK_ATTEMPTS { + cancel.check()?; + let result = http + .put(&url) + .header("X-API-Key", api_key) + .header("Content-Type", "application/octet-stream") + .query(&[ + ("transfer_id", transfer_id.to_string()), + ("offset", chunk.offset.to_string()), + ("sha256", chunk_sha.to_string()), + ]) + .body(bytes.clone()) + .send() + .await; + + match result { + Ok(resp) => { + let code = resp.status().as_u16(); + if (200..300).contains(&code) { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + last = api_error(&format!("Uploading the chunk at {}", chunk.offset), code, &body); + if code < 500 { + return Err(last); + } + } + Err(e) => last = request_error(base, &e), + } + + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + } + Err(last) +} + +/// Download to a temp file with byte progress, resume and cancel. +/// +/// No protocol invention: HTTP `Range` already is the chunked protocol in this +/// direction. `session` pins one materialized export on the server so that the +/// ranges of an interrupted download all come from the same bytes; a server +/// that ignores it (or has reaped the session) answers a range request with +/// the whole body, and the `200` branch below simply starts over. +pub async fn download_resumable( + url: &str, + api_key: &str, + path: &str, + remote_site_id: i64, + what: &str, + cancel: &crate::transfer::CancelToken, + progress: ProgressFn<'_>, +) -> Result { + use std::io::Write; + + let base = normalize_base_url(url)?; + let http = build_client(CHUNK_TIMEOUT)?; + let session = uuid::Uuid::new_v4().simple().to_string(); + let temp = crate::transfer::TempFile::new("download")?; + + let mut etag: Option = None; + let mut last = String::new(); + + for attempt in 1..=CHUNK_ATTEMPTS { + cancel.check()?; + let have = temp.len(); + + let mut req = http + .get(format!("{base}{path}")) + .query(&[ + ("site_id", remote_site_id.to_string()), + ("session", session.clone()), + ]) + .header("X-API-Key", api_key); + if have > 0 { + req = req.header("Range", format!("bytes={have}-")); + // If-Range makes the resume safe: if the export changed under us, + // the server owes us a 200 with the whole body rather than a tail + // that would splice into nonsense. + if let Some(tag) = &etag { + req = req.header("If-Range", tag.clone()); + } + } + + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + last = request_error(&base, &e); + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + continue; + } + }; + + let code = resp.status().as_u16(); + if code != 200 && code != 206 { + let body = resp.text().await.unwrap_or_default(); + return Err(api_error(&format!("Downloading the {what}"), code, &body)); + } + + etag = resp + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + // A 200 to a ranged request means "here is everything" — the partial + // file is worthless, so throw it away rather than appending a second + // copy of the head onto it. + let mut done = if code == 206 { have } else { 0 }; + if code == 200 && have > 0 { + temp.truncate()?; + } + let total = resp.content_length().map(|len| done + len).unwrap_or(0); + progress(done, total); + + let mut file = std::fs::OpenOptions::new() + .write(true) + .append(true) + .open(temp.path()) + .map_err(|e| format!("failed to open the download file: {e}"))?; + + let mut resp = resp; + let mut stalled = None; + loop { + if cancel.cancelled() { + return Err(crate::transfer::CANCELLED.to_string()); + } + match resp.chunk().await { + Ok(Some(bytes)) => { + file.write_all(&bytes) + .map_err(|e| format!("failed to write the download: {e}"))?; + done += bytes.len() as u64; + progress(done, total.max(done)); + } + Ok(None) => break, + Err(e) => { + // Mid-stream failure: flush what we have and resume from + // there on the next attempt. + stalled = Some(format!("failed to download the {what}: {e}")); + break; + } + } + } + file.flush().map_err(|e| format!("failed to write the download: {e}"))?; + drop(file); + + match stalled { + None => return Ok(temp), + Some(e) => { + last = e; + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + } + } + } + Err(last) +} + /// Provision a new remote WordPress site (`POST /api/v1/localkit/sites`). pub async fn create_remote_site( url: &str, diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index 7d0dd82..bb16128 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -74,12 +74,45 @@ pub struct SiteEvent { pub id: String, pub stage: String, pub message: String, + /// Byte counters, present only during a chunked transfer (plan 19). + /// Absent everywhere else, so every non-transfer stage keeps rendering + /// as the plain stage message it always was. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_done: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_total: Option, } /// Emit a progress event to the frontend. `app` is optional so the lifecycle /// can also be driven from tests / example binaries / the `lk` CLI without a /// Tauri runtime; in that case progress is printed to stderr instead. pub(crate) fn emit(app: Option<&AppHandle>, id: &str, stage: &str, message: &str) { + dispatch(app, id, stage, message, None, None); +} + +/// Emit a transfer-progress event carrying byte counters. +/// +/// These fire once per chunk, so the frontend gets a real byte readout instead +/// of one coarse "Uploading..." that sits there for ten minutes. +pub(crate) fn emit_bytes( + app: Option<&AppHandle>, + id: &str, + stage: &str, + message: &str, + done: u64, + total: u64, +) { + dispatch(app, id, stage, message, Some(done), Some(total)); +} + +fn dispatch( + app: Option<&AppHandle>, + id: &str, + stage: &str, + message: &str, + bytes_done: Option, + bytes_total: Option, +) { match app { Some(app) => { let _ = app.emit( @@ -88,10 +121,19 @@ pub(crate) fn emit(app: Option<&AppHandle>, id: &str, stage: &str, message: &str id: id.to_string(), stage: stage.to_string(), message: message.to_string(), + bytes_done, + bytes_total, }, ); } - None => eprintln!("[{stage}] {message}"), + None => match (bytes_done, bytes_total) { + (Some(done), Some(total)) => eprintln!( + "[{stage}] {message} ({} / {})", + crate::transfer::human_bytes(done), + crate::transfer::human_bytes(total) + ), + _ => eprintln!("[{stage}] {message}"), + }, } } diff --git a/src-tauri/src/snapshot.rs b/src-tauri/src/snapshot.rs index 4c29ef8..6de332e 100644 --- a/src-tauri/src/snapshot.rs +++ b/src-tauri/src/snapshot.rs @@ -92,22 +92,39 @@ fn new_id() -> String { /// Bundle the site's wp-content directory as a tar.gz in memory. pub(crate) fn build_wp_content_tgz(site_dir: &Path) -> Result, String> { + let mut buf = Vec::new(); + write_wp_content_tgz(site_dir, &mut buf)?; + Ok(buf) +} + +/// Stream the same archive into an arbitrary writer. +/// +/// This is the form sync v2 uses (plan 19): the tar/gzip pipeline runs +/// straight into a staging file, so a site with a real `uploads/` directory +/// never has to exist as a `Vec` first. `build_wp_content_tgz` is now just +/// this with a `Vec` on the end. +pub(crate) fn write_wp_content_tgz( + site_dir: &Path, + out: &mut dyn std::io::Write, +) -> Result<(), String> { let wp_content = site_dir.join("wp-content"); if !wp_content.is_dir() { return Err("wp-content directory not found in the local site".into()); } - let mut buf = Vec::new(); - { - let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast()); - let mut builder = tar::Builder::new(enc); - builder - .append_dir_all("wp-content", &wp_content) - .map_err(|e| format!("failed to bundle wp-content: {e}"))?; - builder - .finish() - .map_err(|e| format!("failed to finalize archive: {e}"))?; - } - Ok(buf) + let enc = flate2::write::GzEncoder::new(out, flate2::Compression::fast()); + let mut builder = tar::Builder::new(enc); + builder + .append_dir_all("wp-content", &wp_content) + .map_err(|e| format!("failed to bundle wp-content: {e}"))?; + // Finish both layers explicitly: letting the encoder write its trailer on + // drop would discard the error, and a truncated gzip only shows up much + // later as an unreadable archive. + builder + .into_inner() + .map_err(|e| format!("failed to finalize archive: {e}"))? + .finish() + .map_err(|e| format!("failed to finalize archive: {e}"))?; + Ok(()) } fn gzip(bytes: &[u8]) -> Result, String> { diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 6d7268f..1730378 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -7,7 +7,7 @@ use std::path::{Component, Path, PathBuf}; use tauri::AppHandle; use uuid::Uuid; -use crate::{docker, router, serverkit, site, snapshot, wordpress, AppState}; +use crate::{docker, router, serverkit, site, snapshot, transfer, wordpress, AppState}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncRecord { @@ -68,6 +68,27 @@ async fn run( ); Ok(()) } + // A user pressing Cancel travels the error path but is not a failure: + // it gets its own terminal stage and history status so the UI can say + // "cancelled" in neutral colours instead of flashing a red error. + Err(e) if transfer::is_cancel(&e) => { + let message = format!("{direction} {kind} cancelled"); + emit(app, site_id, "cancelled", &message); + record( + state, + &SyncRecord { + id: Uuid::new_v4().to_string(), + site_id: site_id.to_string(), + connection_id: connection_id.to_string(), + direction: direction.to_string(), + kind: kind.to_string(), + status: "cancelled".to_string(), + message: message.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + Err(message) + } Err(e) => { emit(app, site_id, "error", &format!("{direction} {kind} failed: {e}")); record( @@ -88,6 +109,39 @@ async fn run( } } +// --------------------------------------------------------------------------- +// Protocol selection (plan 19) +// --------------------------------------------------------------------------- + +/// Does this server speak the chunked protocol? +/// +/// One client, both servers: a server without `sync-v2` gets the v1 monolithic +/// path untouched. A failed probe answers "no" on purpose — falling back to v1 +/// always works, so a blip on `/pair` must not fail the push outright. +async fn supports_v2(conn: &serverkit::ServerKitConnection) -> bool { + serverkit::has_feature(&conn.url, &conn.api_key, serverkit::FEATURE_SYNC_V2) + .await + .unwrap_or(false) +} + +/// Byte-progress reporter for a transfer stage. +/// +/// The event carries raw counters and a bare label; formatting the +/// "148 MB / 312 MB" readout is the frontend's job (and `site::emit_bytes` +/// does it for stderr when there is no frontend). +fn reporter<'a>( + app: Option<&'a AppHandle>, + site_id: &'a str, + stage: &'a str, + label: &'a str, +) -> impl Fn(u64, u64) + Send + Sync + 'a { + move |done, total| site::emit_bytes(app, site_id, stage, label, done, total) +} + +// --------------------------------------------------------------------------- +// Push code +// --------------------------------------------------------------------------- + pub async fn push_code( app: Option<&AppHandle>, state: &AppState, @@ -96,17 +150,74 @@ pub async fn push_code( remote_site_id: i64, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; - emit(app, site_id, "push", "Bundling wp-content..."); - let tgz = snapshot::build_wp_content_tgz(&site.dir())?; - let size_mb = tgz.len() as f64 / 1_048_576.0; - emit(app, site_id, "push", &format!("Uploading wp-content ({size_mb:.1} MB)...")); - run(app, state, connection_id, site_id, "push", "code", async move { - serverkit::push_code(&conn.url, &conn.api_key, remote_site_id, tgz).await?; - Ok(format!("{} code pushed to remote site #{remote_site_id}", site.name)) + let v2 = supports_v2(&conn).await; + let cancel = state.transfers.begin(site_id); + run(app, state, connection_id, site_id, "push", "code", async { + if v2 { + push_code_v2(app, &conn, &site, site_id, remote_site_id, &cancel).await + } else { + push_code_v1(app, &conn, &site, site_id, remote_site_id).await + } }) .await } +/// Sync v1: build the whole archive in memory, POST it in one multipart +/// request. Deliberately left as one isolated function rather than a set of +/// `if v2` branches sprinkled through the v2 flow — the two protocols share +/// nothing but their inputs and their success message. +async fn push_code_v1( + app: Option<&AppHandle>, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + site_id: &str, + remote_site_id: i64, +) -> Result { + emit(app, site_id, "push", "Bundling wp-content..."); + let tgz = snapshot::build_wp_content_tgz(&site.dir())?; + let size = transfer::human_bytes(tgz.len() as u64); + emit(app, site_id, "push", &format!("Uploading wp-content ({size})...")); + serverkit::push_code(&conn.url, &conn.api_key, remote_site_id, tgz).await?; + Ok(format!("{} code pushed to remote site #{remote_site_id}", site.name)) +} + +/// Sync v2: tar straight into a staging file, then upload it in chunks. +/// +/// The archive never exists as a `Vec`, which is what makes a site with a +/// real `uploads/` directory pushable at all — and the chunking is what gets +/// it past the server's 100 MB request limit. +async fn push_code_v2( + app: Option<&AppHandle>, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + site_id: &str, + remote_site_id: i64, + cancel: &transfer::CancelToken, +) -> Result { + emit(app, site_id, "push", "Bundling wp-content..."); + let dir = site.dir(); + let staged = transfer::stage("wp-content", |w| snapshot::write_wp_content_tgz(&dir, w))?; + cancel.check()?; + + let size = transfer::human_bytes(staged.total()); + let progress = reporter(app, site_id, "push", "Pushing wp-content"); + serverkit::push_chunked( + &conn.url, + &conn.api_key, + "code", + remote_site_id, + None, + &staged, + cancel, + &progress, + ) + .await?; + Ok(format!( + "{} code pushed to remote site #{remote_site_id} ({size})", + site.name + )) +} + /// Local safety net before a sync mutates something (plan 17). /// /// A failure here aborts the sync: never mutate without a net. The note @@ -127,6 +238,10 @@ async fn pre_sync_snapshot( .map_err(|e| format!("pre-sync snapshot failed, nothing was synced: {e}")) } +// --------------------------------------------------------------------------- +// Push database +// --------------------------------------------------------------------------- + pub async fn push_db( app: Option<&AppHandle>, state: &AppState, @@ -136,21 +251,34 @@ pub async fn push_db( ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PUSH, &conn, remote_site_id).await?; - emit(app, site_id, "push", "Exporting local database..."); - let dump_path = std::env::temp_dir().join(format!("localkit-dump-{}.sql", site.slug)); - wordpress::export_db(&site.dir(), &dump_path).await?; - let sql = std::fs::read(&dump_path).map_err(|e| format!("failed to read dump: {e}")); - let _ = std::fs::remove_file(&dump_path); - let sql = sql?; // Whatever the site is actually served at — its `.test` domain (with the // port in fallback mode) when local domains are on. The server rewrites // local -> remote with this, so a hardcoded localhost: would leave // `.test` URLs baked into the remote database. let local_url = router::site_public_url(state, &site); - emit(app, site_id, "push", "Uploading database dump..."); - run(app, state, connection_id, site_id, "push", "db", async move { - serverkit::push_db(&conn.url, &conn.api_key, remote_site_id, &local_url, sql).await?; + let v2 = supports_v2(&conn).await; + let cancel = state.transfers.begin(site_id); + run(app, state, connection_id, site_id, "push", "db", async { + let dump = export_dump(app, &site, site_id).await?; + if v2 { + let progress = reporter(app, site_id, "push", "Pushing database"); + serverkit::push_chunked( + &conn.url, + &conn.api_key, + "db", + remote_site_id, + Some(&local_url), + &dump, + &cancel, + &progress, + ) + .await?; + } else { + emit(app, site_id, "push", "Uploading database dump..."); + let sql = std::fs::read(dump.path()).map_err(|e| format!("failed to read dump: {e}"))?; + serverkit::push_db(&conn.url, &conn.api_key, remote_site_id, &local_url, sql).await?; + } Ok(format!( "{} database pushed to remote site #{remote_site_id} (URLs rewritten to remote)", site.name @@ -159,6 +287,27 @@ pub async fn push_db( .await } +/// Export the local database to a self-deleting staged file. +/// +/// Staged rather than read into a `Vec` because v2 uploads it chunk by chunk +/// straight off disk; the v1 path reads it back, which is what it did before. +async fn export_dump( + app: Option<&AppHandle>, + site: &site::Site, + site_id: &str, +) -> Result { + emit(app, site_id, "push", "Exporting local database..."); + // A TempFile from the start, so a failed export cleans up after itself + // instead of leaving a partial dump behind. + let dump = transfer::TempFile::new(&format!("dump-{}", site.slug))?; + wordpress::export_db(&site.dir(), dump.path()).await?; + transfer::Staged::adopt_temp(dump) +} + +// --------------------------------------------------------------------------- +// Pull database +// --------------------------------------------------------------------------- + pub async fn pull_db( app: Option<&AppHandle>, state: &AppState, @@ -168,27 +317,59 @@ pub async fn pull_db( remote_url: Option, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; - pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id).await?; - emit(app, site_id, "pull", "Downloading remote database dump..."); - let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote_site_id).await?; + let v2 = supports_v2(&conn).await; - // Decompress the .sql.gz dump. - let mut sql = Vec::new(); - flate2::read::GzDecoder::new(&gz[..]) - .read_to_end(&mut sql) - .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + // v1 snapshots before the download because it has no way to stop one + // half-way. v2 does: the download is cancellable, so the snapshot is taken + // after it, once something is actually about to be overwritten — a + // cancelled pull then leaves no pointless `pre_pull` snapshot behind. + if !v2 { + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id).await?; + } // Same rule on the way back in: pulling must land the site on its current // public URL, not silently knock it off its domain onto localhost. let local_url = router::site_public_url(state, &site); - emit(app, site_id, "pull", "Importing database into local site..."); - run(app, state, connection_id, site_id, "pull", "db", async move { - wordpress::import_db(&site.dir(), &sql).await?; - wordpress::update_site_urls(&site.dir(), &local_url).await?; + let cancel = state.transfers.begin(site_id); + let dir = site.dir(); + + run(app, state, connection_id, site_id, "pull", "db", async { + if v2 { + emit(app, site_id, "pull", "Downloading remote database dump..."); + let progress = reporter(app, site_id, "pull", "Pulling database"); + let gz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/db", + remote_site_id, + "database dump", + &cancel, + &progress, + ) + .await?; + cancel.check()?; + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id) + .await?; + emit(app, site_id, "pull", "Importing database into local site..."); + // Streams decompress -> pipe -> `wp db import`; the dump never + // exists decompressed in memory. + wordpress::import_db_from_gz(&dir, gz.path()).await?; + } else { + emit(app, site_id, "pull", "Downloading remote database dump..."); + let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote_site_id).await?; + let mut sql = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut sql) + .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + emit(app, site_id, "pull", "Importing database into local site..."); + wordpress::import_db(&dir, &sql).await?; + } + + wordpress::update_site_urls(&dir, &local_url).await?; let mut msg = format!("Remote database imported into {}", site.name); - if let Some(remote) = remote_url.filter(|u| !u.is_empty() && *u != local_url) { + if let Some(remote) = remote_url.as_deref().filter(|u| !u.is_empty() && *u != local_url) { emit(app, site_id, "pull", "Rewriting URLs remote -> local..."); - wordpress::search_replace(&site.dir(), &remote, &local_url).await?; + wordpress::search_replace(&dir, remote, &local_url).await?; msg = format!("{msg} (URLs rewritten to local)"); } Ok(msg) @@ -247,7 +428,11 @@ fn safe_entry_path(name: &Path) -> Result { /// /// Entries are prefixed `wp-content/`, matching what `push_code` uploads and /// what a snapshot archives — one archive format in both directions. -fn extract_wp_content(tgz: &[u8], site_dir: &Path) -> Result { +/// +/// Takes a reader rather than a byte slice so the import can untar straight +/// off the downloaded file (plan 19): a 4 GB remote `wp-content` should never +/// need 4 GB of RAM to land. +fn extract_wp_content(tgz: R, site_dir: &Path) -> Result { use tar::EntryType; let dest = site_dir @@ -363,6 +548,8 @@ pub async fn import_site( let (wp_version, wp_exact) = match_version(site::WP_VERSIONS, remote.wp_version.as_deref()); let (php_version, php_exact) = match_version(site::PHP_VERSIONS, remote.php_version.as_deref()); + let v2 = supports_v2(&conn).await; + let site = site::reserve( state, name, @@ -372,8 +559,12 @@ pub async fn import_site( ) .await?; + // The cancel token is keyed on the *new* site's id — that is what the UI + // shows progress against, so it is what a Cancel button can address. + let cancel = state.transfers.begin(&site.id); + // From here on a failure owns cleanup — the site row and directory exist. - match do_import(app, state, &conn, &site, &remote, (wp_exact, php_exact)).await { + match do_import(app, state, &conn, &site, &remote, (wp_exact, php_exact), v2, &cancel).await { Ok(message) => { emit(app, &site.id, "done", &message); record( @@ -392,11 +583,17 @@ pub async fn import_site( site::get(state, &site.id) } Err(e) => { - emit(app, &site.id, "error", &format!("Import failed: {e}")); + let cancelled = transfer::is_cancel(&e); + let message = if cancelled { + "Import cancelled".to_string() + } else { + format!("Import failed: {e}") + }; + emit(app, &site.id, if cancelled { "cancelled" } else { "error" }, &message); // The sync record is keyed to a site that is about to disappear, // so the failure is reported through the event stream only. let _ = site::cleanup(state, &site).await; - Err(e) + Err(message) } } } @@ -447,6 +644,7 @@ async fn pre_import( } /// The provisioning half of an import. Returns the success message. +#[allow(clippy::too_many_arguments)] async fn do_import( app: Option<&AppHandle>, state: &AppState, @@ -454,6 +652,8 @@ async fn do_import( site: &site::Site, remote: &serverkit::RemoteWpSite, exact: (bool, bool), + v2: bool, + cancel: &transfer::CancelToken, ) -> Result { let dir = site.dir(); let id = site.id.as_str(); @@ -485,11 +685,30 @@ async fn do_import( docker::compose_pull(&dir, &["wordpress", "db", "wpcli"]).await?; emit(app, id, "code", "Downloading remote wp-content..."); - let tgz = serverkit::pull_code(&conn.url, &conn.api_key, remote.id).await?; - let size_mb = tgz.len() as f64 / 1_048_576.0; - emit(app, id, "code", &format!("Extracting wp-content ({size_mb:.1} MB)...")); - let files = extract_wp_content(&tgz, &dir)?; - drop(tgz); + let files = if v2 { + let progress = reporter(app, id, "code", "Downloading wp-content"); + let tgz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/code", + remote.id, + "wp-content archive", + cancel, + &progress, + ) + .await?; + cancel.check()?; + let size = transfer::human_bytes(tgz.len()); + emit(app, id, "code", &format!("Extracting wp-content ({size})...")); + let file = std::fs::File::open(tgz.path()) + .map_err(|e| format!("failed to reopen the downloaded archive: {e}"))?; + extract_wp_content(std::io::BufReader::new(file), &dir)? + } else { + let tgz = serverkit::pull_code(&conn.url, &conn.api_key, remote.id).await?; + let size = transfer::human_bytes(tgz.len() as u64); + emit(app, id, "code", &format!("Extracting wp-content ({size})...")); + extract_wp_content(&tgz[..], &dir)? + }; // The archive may have brought its own mu-plugins directory over the one // written a moment ago; one-click login must survive the import. wordpress::ensure_login_plugin(&dir)?; @@ -505,19 +724,34 @@ async fn do_import( wordpress::wait_for_config(&dir, 24).await?; emit(app, id, "install", "Downloading remote database..."); - let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote.id).await?; - let mut sql = Vec::new(); - flate2::read::GzDecoder::new(&gz[..]) - .read_to_end(&mut sql) - .map_err(|e| format!("failed to decompress remote dump: {e}"))?; - drop(gz); - // No `wp core install` anywhere in here: the imported database IS the // site. Installing would overwrite the content this whole flow exists to // bring down. - emit(app, id, "install", "Importing remote database..."); - wordpress::import_db(&dir, &sql).await?; - drop(sql); + if v2 { + let progress = reporter(app, id, "install", "Downloading database"); + let gz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/db", + remote.id, + "database dump", + cancel, + &progress, + ) + .await?; + cancel.check()?; + emit(app, id, "install", "Importing remote database..."); + wordpress::import_db_from_gz(&dir, gz.path()).await?; + } else { + let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote.id).await?; + let mut sql = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut sql) + .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + drop(gz); + emit(app, id, "install", "Importing remote database..."); + wordpress::import_db(&dir, &sql).await?; + } let local_url = router::site_public_url(state, site); emit(app, id, "install", "Rewriting URLs remote -> local..."); @@ -727,7 +961,7 @@ mod tests { file_entry(b, "wp-content/plugins/hello.php", b" String { // Staged payloads // --------------------------------------------------------------------------- +/// A temp file that deletes itself on drop. +/// +/// Every large payload in a v2 transfer lives in one of these — the staged +/// upload on the way out, the streamed download on the way in — so no error +/// path can leave a copy of someone's site sitting in the temp dir. +#[derive(Debug)] +pub struct TempFile { + path: PathBuf, +} + +impl TempFile { + /// Create an empty temp file. `tag` only exists to make a stray file + /// identifiable if the process is killed hard enough to skip `Drop`. + pub fn new(tag: &str) -> Result { + let path = std::env::temp_dir().join(format!( + "localkit-{tag}-{}-{}.tmp", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::File::create(&path) + .map_err(|e| format!("failed to create a temporary file: {e}"))?; + Ok(Self { path }) + } + + /// Adopt a file someone else created; it is deleted on drop just the same. + pub fn adopt(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Bytes currently on disk — for a download, how much has landed so far. + pub fn len(&self) -> u64 { + std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Throw away whatever has been written; used when a server answers a + /// resume request with the whole body instead of the range we asked for. + pub fn truncate(&self) -> Result<(), String> { + std::fs::File::create(&self.path) + .map(|_| ()) + .map_err(|e| format!("failed to reset the temporary file: {e}")) + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + /// A payload written to a temp file, with its size and hash. /// /// The file is removed on drop, including on every error path — a failed push /// must not leave a copy of the site's `wp-content` sitting in the temp dir. #[derive(Debug)] pub struct Staged { - path: PathBuf, + file: TempFile, total: u64, sha256: String, } impl Staged { pub fn path(&self) -> &Path { - &self.path + self.file.path() } pub fn total(&self) -> u64 { @@ -191,7 +248,7 @@ impl Staged { /// (holding an async file handle across the whole upload) buys nothing and /// complicates the resume path. pub fn read_chunk(&self, chunk: Chunk) -> Result, String> { - let mut f = std::fs::File::open(&self.path) + let mut f = std::fs::File::open(self.path()) .map_err(|e| format!("failed to reopen the staged payload: {e}"))?; f.seek(SeekFrom::Start(chunk.offset)) .map_err(|e| format!("failed to seek the staged payload: {e}"))?; @@ -204,6 +261,12 @@ impl Staged { /// Take ownership of a file someone else wrote (e.g. `wp db export`), /// hashing it in place. The file is deleted on drop just like a staged one. pub fn adopt(path: PathBuf) -> Result { + Self::adopt_temp(TempFile::adopt(path)) + } + + /// Promote an already-owned temp file to a staged payload. + pub fn adopt_temp(file: TempFile) -> Result { + let path = file.path().to_path_buf(); let mut f = std::fs::File::open(&path) .map_err(|e| format!("failed to open the staged payload: {e}"))?; let mut hasher = Sha256::new(); @@ -219,30 +282,19 @@ impl Staged { hasher.update(&buf[..n]); total += n as u64; } - Ok(Self { path, total, sha256: hex(&hasher.finalize()) }) - } -} - -impl Drop for Staged { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); + Ok(Self { file, total, sha256: hex(&hasher.finalize()) }) } } /// Write a payload to a temp file through a hashing writer. -/// -/// `tag` only exists to make a stray file identifiable if the process is killed -/// hard enough to skip `Drop`. pub fn stage(tag: &str, build: F) -> Result where F: FnOnce(&mut dyn Write) -> Result<(), String>, { - let path = std::env::temp_dir().join(format!( - "localkit-{tag}-{}-{}.tmp", - std::process::id(), - uuid::Uuid::new_v4() - )); - let file = std::fs::File::create(&path) + // The TempFile exists before anything is written, so every error path from + // here on cleans up after itself. + let temp = TempFile::new(tag)?; + let file = std::fs::File::create(temp.path()) .map_err(|e| format!("failed to create a staging file: {e}"))?; // Hash first, buffer second: every byte still passes through the hasher, @@ -254,11 +306,33 @@ where .flush() .map_err(|e| format!("failed to finish the staging file: {e}")); - // A `Staged` exists from here on, so any error below still cleans up. - let staged = Staged { path, total, sha256 }; built?; flushed?; - Ok(staged) + Ok(Staged { file: temp, total, sha256 }) +} + +/// Human-readable byte count for progress messages. +/// +/// Deliberately coarse — this only ever ends up in a one-line status like +/// "Pushing code — 148 MB / 312 MB", where a third decimal place is noise. +pub fn human_bytes(n: u64) -> String { + const KB: f64 = 1024.0; + let n = n as f64; + if n < KB { + return format!("{n:.0} B"); + } + let units = ["KB", "MB", "GB", "TB"]; + let mut value = n / KB; + let mut unit = 0; + while value >= KB && unit + 1 < units.len() { + value /= KB; + unit += 1; + } + if value >= 100.0 { + format!("{value:.0} {}", units[unit]) + } else { + format!("{value:.1} {}", units[unit]) + } } // --------------------------------------------------------------------------- @@ -516,6 +590,37 @@ mod tests { assert!(!path.exists(), "adopt did not take ownership of the file"); } + #[test] + fn a_temp_file_tracks_its_length_and_resets() { + let temp = TempFile::new("test-temp").unwrap(); + assert!(temp.is_empty()); + std::fs::write(temp.path(), b"0123456789").unwrap(); + assert_eq!(temp.len(), 10); + temp.truncate().unwrap(); + assert_eq!(temp.len(), 0, "truncate left bytes behind"); + } + + #[test] + fn a_temp_file_is_removed_on_drop() { + let path = { + let temp = TempFile::new("test-temp-drop").unwrap(); + temp.path().to_path_buf() + }; + assert!(!path.exists(), "the temp file outlived its TempFile"); + } + + // -- formatting --------------------------------------------------------- + + #[test] + fn byte_counts_read_the_way_a_progress_line_should() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(999), "999 B"); + assert_eq!(human_bytes(1024), "1.0 KB"); + assert_eq!(human_bytes(1_048_576), "1.0 MB"); + assert_eq!(human_bytes(157_286_400), "150 MB"); + assert_eq!(human_bytes(3_221_225_472), "3.0 GB"); + } + // -- cancellation ------------------------------------------------------- #[test] diff --git a/src-tauri/src/wordpress.rs b/src-tauri/src/wordpress.rs index 055b1b5..1d76ea0 100644 --- a/src-tauri/src/wordpress.rs +++ b/src-tauri/src/wordpress.rs @@ -328,6 +328,19 @@ pub async fn import_db(dir: &Path, sql: &[u8]) -> Result<(), String> { .map(|_| ()) } +/// Import a gzipped dump straight off disk, decompressing into the pipe. +/// +/// The streaming counterpart of `import_db`, used by the pull/import flows so +/// a remote database never has to exist decompressed in memory (plan 19). +pub async fn import_db_from_gz(dir: &Path, gz_path: &Path) -> Result<(), String> { + let file = std::fs::File::open(gz_path) + .map_err(|e| format!("failed to open the downloaded dump: {e}"))?; + let mut reader = flate2::read::GzDecoder::new(std::io::BufReader::new(file)); + docker::compose_run_reader(dir, "wpcli", &["wp", "db", "import", "-"], &mut reader) + .await + .map(|_| ()) +} + /// Serialization-safe URL rewrite across all tables. pub async fn search_replace(dir: &Path, from: &str, to: &str) -> Result<(), String> { wp(dir, &["search-replace", from, to, "--all-tables"]) From 2b6fff929207e7841f4f1e1af998fb4407b4d766 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Mon, 20 Jul 2026 15:51:15 -0400 Subject: [PATCH 21/67] sync v2 UI: byte progress, Cancel button, cancelled stage (plan 19 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress toast now reads "Pushing wp-content — 148 MB / 312 MB" and carries a Cancel button while bytes are moving. The backend sends raw counters and a bare label; humanBytes/progressTitle in sites.ts compose the readout, mirroring transfer::human_bytes so the CLI's stderr output and the GUI read identically. Cancel is offered only on byte-carrying stages — those are the chunked transfers, which stop cleanly between chunks. It is set on every event rather than just the first, because a push opens its toast on "Bundling wp-content..." and only becomes cancellable once chunks flow (and stops being cancellable when the server moves on to importing). Adding a third terminal stage surfaced a real bug: PushPanel and SnapshotsPanel both hardcoded `done | error`, so a cancelled transfer left `busy` set and disabled all three push buttons permanently. Both now use the exported isTerminalStage — one list, one source of truth. `cancelled` also gets a neutral zinc badge in sync history instead of falling through to red. scripts/verify-sync-progress.mjs is the headless runtime check: byte readout advances monotonically with a fixed total, Cancel resolves the toast neutrally and really stops the transfer, history records `cancelled`, and an uninterrupted transfer still completes green. Its click helper reports disabled buttons rather than silently no-opping — which is what caught the bug above. The mock now drives a realistic 312 MB chunked transfer and honors cancel_sync, so `npm run dev:mock` exercises all of it without Docker. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify-sync-progress.mjs | 270 ++++++++++++++++++++++++++++++ src/components/PushPanel.tsx | 17 +- src/components/SnapshotsPanel.tsx | 4 +- src/components/Toasts.tsx | 8 + src/lib/ipc.ts | 2 + src/lib/types.ts | 7 + src/mock/core.ts | 65 +++++-- src/stores/sites.ts | 82 ++++++++- src/stores/toast.ts | 15 +- 9 files changed, 438 insertions(+), 32 deletions(-) create mode 100644 scripts/verify-sync-progress.mjs diff --git a/scripts/verify-sync-progress.mjs b/scripts/verify-sync-progress.mjs new file mode 100644 index 0000000..10a80f1 --- /dev/null +++ b/scripts/verify-sync-progress.mjs @@ -0,0 +1,270 @@ +// Headless runtime verification for plan 19 (chunked sync: byte progress + cancel). +// +// Spins up the mock Vite build (no Tauri runtime, no Docker, no ServerKit) and +// walks the transfer UX a chunked sync is supposed to produce: the progress +// toast counts real bytes instead of sitting on one static line, it offers a +// Cancel button only while bytes are actually moving, cancelling stops the +// transfer and resolves neutrally rather than as a red failure, and the sync +// history records it as `cancelled` rather than `error`. +// +// node scripts/verify-sync-progress.mjs +// +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const PORT = 1426; +const URL = `http://localhost:${PORT}/`; + +const CHROME_CANDIDATES = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", +]; +const chrome = CHROME_CANDIDATES.find((p) => existsSync(p)); +if (!chrome) { + console.error("No Chrome/Edge found."); + process.exit(1); +} + +const isWin = process.platform === "win32"; +let failures = 0; + +function ok(name, cond) { + if (cond) console.log(" ✓", name); + else { + failures++; + console.error(" ✗ FAIL:", name); + } +} + +async function waitForServer(url, ms = 60_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await sleep(500); + } + throw new Error(`Vite mock server never came up at ${url}`); +} + +function killTree(child) { + if (!child || child.killed) return; + if (isWin) { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + } +} + +async function main() { + console.log("› starting mock Vite server…"); + const server = spawn("npm", ["run", "dev:mock"], { + cwd: ROOT, + shell: true, + stdio: "ignore", + detached: !isWin, + }); + + let browser; + try { + await waitForServer(URL); + browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + defaultViewport: { width: 1440, height: 1200 }, + }); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.warn(" page error:", e.message)); + await page.goto(URL, { waitUntil: "networkidle0" }); + + const bodyText = () => page.evaluate(() => document.body.innerText); + // Clicking a *disabled* button silently does nothing, which would turn a + // real regression into a confusing timeout further down. Report it. + const clickByText = (selector, text) => + page.evaluate( + (sel, t) => { + const el = [...document.querySelectorAll(sel)].find((e) => + e.textContent.trim().toLowerCase().includes(t.toLowerCase()) + ); + if (!el) return "missing"; + if (el.disabled) return "disabled"; + el.click(); + return "clicked"; + }, + selector, + text + ); + const click = async (selector, text) => (await clickByText(selector, text)) === "clicked"; + /** Text of the pinned progress toast, or "" when none is up. */ + const toastText = () => + page.evaluate(() => { + const el = document.querySelector(".fixed.bottom-4.right-4 > div"); + return el ? el.innerText.trim() : ""; + }); + /** Sync-history rows as [when, op, result, message] tuples. */ + const historyRows = () => + page.evaluate(() => { + const heading = [...document.querySelectorAll("h3")].find((h) => + h.textContent.trim().toLowerCase().startsWith("sync history") + ); + const table = heading?.parentElement?.querySelector("table"); + if (!table) return []; + return [...table.querySelectorAll("tbody tr")].map((tr) => + [...tr.querySelectorAll("td")].map((td) => td.innerText.trim()) + ); + }); + /** Tailwind classes on the result cell of the newest history row. */ + const newestResultClass = () => + page.evaluate(() => { + const heading = [...document.querySelectorAll("h3")].find((h) => + h.textContent.trim().toLowerCase().startsWith("sync history") + ); + const tr = heading?.parentElement?.querySelector("table tbody tr"); + return tr ? tr.querySelectorAll("td")[2].className : ""; + }); + + await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery")); + console.log("› dashboard loaded"); + + await page.evaluate(() => { + const card = [...document.querySelectorAll("div")].find( + (d) => + d.textContent.includes("Pixel Bakery") && + [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details") + ); + [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click(); + }); + await sleep(900); + ok("navigated to SiteDetail", (await bodyText()).includes("Back to sites")); + + // Pick a connection + remote site so the push buttons enable. + await page.evaluate(() => { + const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + ).set; + setter.call(el, value); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const selects = [...document.querySelectorAll("select")]; + for (const sel of selects) { + const real = [...sel.options].find((o) => o.value && o.value !== ""); + if (real) setValue(sel, real.value); + } + }); + await sleep(800); + // The remote-site select only populates after the connection is chosen. + await page.evaluate(() => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value" + ).set; + for (const sel of document.querySelectorAll("select")) { + const real = [...sel.options].find((o) => o.value && o.value !== ""); + if (real && !sel.value) { + setter.call(sel, real.value); + sel.dispatchEvent(new Event("change", { bubbles: true })); + } + } + }); + await sleep(400); + + // --- 1. byte progress ------------------------------------------------- + console.log("› push code (byte progress)"); + ok("Push code is clickable", await click("button", "Push code")); + await sleep(700); + + let first = await toastText(); + ok("a progress toast appeared", first.length > 0); + ok( + "the transfer reports bytes, not just a stage", + /\d+(\.\d+)?\s?(B|KB|MB|GB)\s*\/\s*\d+(\.\d+)?\s?(B|KB|MB|GB)/.test(first) + ); + ok("the byte readout names the payload", /wp-content/i.test(first)); + ok("a running transfer offers Cancel", first.includes("Cancel")); + + await sleep(600); + const second = await toastText(); + ok("the byte count actually advances", second !== first); + + const bytesOf = (t) => { + const m = /([\d.]+)\s?(B|KB|MB|GB)\s*\/\s*([\d.]+)\s?(B|KB|MB|GB)/.exec(t); + if (!m) return null; + const scale = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }; + return [parseFloat(m[1]) * scale[m[2]], parseFloat(m[3]) * scale[m[4]]]; + }; + const a = bytesOf(first); + const b = bytesOf(second); + ok("progress moves forward, never backward", a && b && b[0] > a[0]); + ok("the total stays fixed across updates", a && b && a[1] === b[1]); + ok("done never exceeds total", b && b[0] <= b[1]); + + // --- 2. cancel -------------------------------------------------------- + console.log("› cancel mid-transfer"); + ok("clicked Cancel", await click("button", "Cancel")); + await sleep(900); + + const resolved = await toastText(); + ok("the toast resolves on cancel", /cancelled/i.test(resolved)); + ok("the spinner is gone once cancelled", !resolved.includes("Cancel\n")); + ok( + "a cancel is not styled as an error", + await page.evaluate(() => { + const el = document.querySelector(".fixed.bottom-4.right-4 > div"); + return el ? !el.className.includes("red") : false; + }) + ); + + // The transfer really stopped: no "Pushed code" success follows. + await sleep(1500); + ok( + "a cancelled transfer never completes", + !/pushed code/i.test(await bodyText()) + ); + + // --- 3. history ------------------------------------------------------- + console.log("› sync history"); + const rows = await historyRows(); + ok("the cancel is recorded in sync history", rows.length > 0 && rows[0][2] === "cancelled"); + ok("it is recorded as a push code op", rows.length > 0 && /push\s+code/i.test(rows[0][1])); + const cls = await newestResultClass(); + ok("cancelled renders neutral, not red", cls.includes("zinc") && !cls.includes("red")); + + // --- 4. a completed transfer still resolves green --------------------- + console.log("› push db to completion"); + ok("Push DB is clickable", await click("button", "Push DB")); + // 312 MB in 8 MiB steps at ~80ms/step ≈ 3.2s. + await page.waitForFunction( + () => /Pushed db/i.test(document.body.innerText), + { timeout: 20_000 } + ); + ok("an uninterrupted transfer completes", /Pushed db/i.test(await bodyText())); + const after = await historyRows(); + ok("success is recorded", after.some((r) => r[2] === "success")); + const okCls = await newestResultClass(); + ok("success stays emerald", okCls.includes("emerald")); + } finally { + if (browser) await browser.close(); + killTree(server); + } + + console.log(failures ? `\n${failures} failure(s)` : "\nAll sync-progress checks passed"); + process.exit(failures ? 1 : 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/components/PushPanel.tsx b/src/components/PushPanel.tsx index 5961f90..083efaa 100644 --- a/src/components/PushPanel.tsx +++ b/src/components/PushPanel.tsx @@ -2,9 +2,15 @@ import { useCallback, useEffect, useState } from "react"; import { ipc } from "../lib/ipc"; import { toastError } from "../lib/errors"; import { useServerKit } from "../stores/serverkit"; -import { useSites } from "../stores/sites"; +import { isTerminalStage, useSites } from "../stores/sites"; import type { RemoteWpSite, SyncRecord } from "../lib/types"; +/** Sync-history result colours; anything unrecognised falls through to red. */ +const STATUS_CLASSES: Record = { + success: "text-emerald-400", + cancelled: "text-zinc-400", +}; + /** "Push to ServerKit" panel on the site detail page (M4). */ export default function PushPanel({ siteId, running }: { siteId: string; running: boolean }) { const connections = useServerKit((s) => s.connections); @@ -30,9 +36,11 @@ export default function PushPanel({ siteId, running }: { siteId: string; running refreshHistory(); }, [refreshConnections, refreshHistory]); - // Refresh history when a sync operation for this site finishes. + // Refresh history when a sync operation for this site finishes — including + // a cancel, which is just as terminal as a failure. Missing that stage here + // would leave `busy` set and every push button disabled for good. useEffect(() => { - if (progress && (progress.stage === "done" || progress.stage === "error")) { + if (progress && isTerminalStage(progress.stage)) { refreshHistory(); setBusy(null); } @@ -204,7 +212,8 @@ export default function PushPanel({ siteId, running }: { siteId: string; running {h.direction} {h.kind} - + {/* A cancel was deliberate — neutral, not red like a failure. */} + {h.status} diff --git a/src/components/SnapshotsPanel.tsx b/src/components/SnapshotsPanel.tsx index 00b799b..fc8ff90 100644 --- a/src/components/SnapshotsPanel.tsx +++ b/src/components/SnapshotsPanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { ipc } from "../lib/ipc"; import { toastError } from "../lib/errors"; import { toast } from "../stores/toast"; -import { useSites } from "../stores/sites"; +import { isTerminalStage, useSites } from "../stores/sites"; import type { Snapshot, SnapshotKind } from "../lib/types"; /** @@ -53,7 +53,7 @@ export default function SnapshotsPanel({ siteId }: { siteId: string }) { // Push/pull/delete take snapshots of their own — pick them up when the // operation that created them finishes. useEffect(() => { - if (progress && (progress.stage === "done" || progress.stage === "error")) { + if (progress && isTerminalStage(progress.stage)) { refresh(); setBusy(null); } diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index f1656f8..b07a141 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -26,6 +26,14 @@ export default function Toasts() {

{t.title}

{t.message &&

{t.message}

} + {t.action && ( + + )}
+ +
+ + + ); +} diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index d829aca..fa57ee4 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -27,6 +27,8 @@ export const ipc = { getSite: (id: string) => invoke("get_site", { id }), createSite: (name: string, wpVersion: string, phpVersion: string) => invoke("create_site", { name, wpVersion, phpVersion }), + cloneSite: (id: string, newName: string) => + invoke("clone_site", { id, newName }), startSite: (id: string) => invoke("start_site", { id }), stopSite: (id: string) => invoke("stop_site", { id }), deleteSite: (id: string, deleteSnapshots = false) => diff --git a/src/mock/core.ts b/src/mock/core.ts index acac050..271dda1 100644 --- a/src/mock/core.ts +++ b/src/mock/core.ts @@ -162,6 +162,56 @@ async function dispatch(cmd: string, a: Args): Promise { return site; } + case "clone_site": { + const source = data.sites.find((s) => s.id === a.id); + if (!source) throw `site not found: ${a.id}`; + const name = String(a.newName ?? "").trim(); + if (!name) throw "Site name is required"; + const slug = slugify(name); + const port = Math.max(...data.sites.map((s) => s.port), 8080) + 1; + const id = `site-${slug}`; + // Same stages the Rust clone emits; the `snapshot` one carries the + // *source* id (that's the site being read), the rest the new clone. + const stages: Array<[string, string, string]> = [ + [a.id as string, "snapshot", "Exporting database…"], + [a.id as string, "snapshot", "Archiving wp-content…"], + [id, "files", "Writing project files…"], + [id, "containers", "Starting Docker containers…"], + [id, "waiting", "Waiting for WordPress to come online…"], + [id, "import", `Copying ${source.name}'s content…`], + [id, "import", "Rewriting URLs to the clone…"], + [id, "done", `${name} cloned from ${source.name} — now running at http://localhost:${port}`], + ]; + void (async () => { + for (const [eid, stage, message] of stages) { + emit("site-event", { id: eid, stage, message } satisfies SiteEvent); + await sleep(700); + } + const s = data.sites.find((x) => x.id === id); + if (s) s.status = s.live_status = "running"; + })(); + + const clone: Site = { + id, + name, + slug, + path: `${data.appInfo.sites_dir}\\${slug}`, + port, + wp_version: source.wp_version, + php_version: source.php_version, + status: "creating", + // The cloned database carries the source's login, so its admin + // credentials work on the copy; ports and DB secrets are fresh. + admin_user: source.admin_user, + admin_pass: source.admin_pass, + created_at: new Date().toISOString(), + connection_id: null, + remote_site_id: null, + }; + data.sites.push({ ...clone, live_status: "creating", db_password: "m4ri4-cl0ne-0001" }); + return clone; + } + case "start_site": { const site = data.sites.find((s) => s.id === a.id); if (!site) throw `site not found: ${a.id}`; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 31a376a..3fb7d71 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { siteUrl } from "../lib/domains"; import { useNav } from "../stores/nav"; @@ -8,6 +8,7 @@ import { useServerKit } from "../stores/serverkit"; import { useSites } from "../stores/sites"; import type { SiteWithStatus } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; +import CloneSiteDialog from "../components/CloneSiteDialog"; import { GridIcon, LinkIcon, ListIcon, PlusIcon } from "../components/icons"; export default function Dashboard() { @@ -16,6 +17,8 @@ export default function Dashboard() { const [siteView, setSiteView] = useSiteView(); const setNewSiteOpen = useNav((s) => s.setNewSiteOpen); const refreshConnections = useServerKit((s) => s.refresh); + // Which site the "name your copy" dialog is open for (plan 20). + const [cloneTarget, setCloneTarget] = useState(null); // Imported sites name their origin connection, so the labels have to be // loaded even if the user never opens Settings → ServerKit. @@ -71,9 +74,16 @@ export default function Dashboard() { ) : siteView === "grid" ? ( - + ) : ( - + + )} + + {cloneTarget && ( + setCloneTarget(null)} + /> )} ); @@ -130,17 +140,29 @@ function ImportedBadge({ site }: { site: SiteWithStatus }) { const dangerBtn = "rounded-md border border-red-900 px-2.5 py-1 text-xs font-medium text-red-400 hover:border-red-700 disabled:opacity-50"; -function GridView({ sites }: { sites: SiteWithStatus[] }) { +function GridView({ + sites, + onClone, +}: { + sites: SiteWithStatus[]; + onClone: (site: SiteWithStatus) => void; +}) { return (
{sites.map((site) => ( - + ))}
); } -function GridCard({ site }: { site: SiteWithStatus }) { +function GridCard({ + site, + onClone, +}: { + site: SiteWithStatus; + onClone: (site: SiteWithStatus) => void; +}) { const a = useSiteActions(site); const running = site.live_status === "running"; return ( @@ -178,6 +200,13 @@ function GridCard({ site }: { site: SiteWithStatus }) { + @@ -186,7 +215,13 @@ function GridCard({ site }: { site: SiteWithStatus }) { ); } -function ListView({ sites }: { sites: SiteWithStatus[] }) { +function ListView({ + sites, + onClone, +}: { + sites: SiteWithStatus[]; + onClone: (site: SiteWithStatus) => void; +}) { return (
@@ -201,7 +236,7 @@ function ListView({ sites }: { sites: SiteWithStatus[] }) { {sites.map((site) => ( - + ))}
@@ -209,7 +244,13 @@ function ListView({ sites }: { sites: SiteWithStatus[] }) { ); } -function ListRow({ site }: { site: SiteWithStatus }) { +function ListRow({ + site, + onClone, +}: { + site: SiteWithStatus; + onClone: (site: SiteWithStatus) => void; +}) { const a = useSiteActions(site); const running = site.live_status === "running"; return ( @@ -249,6 +290,13 @@ function ListRow({ site }: { site: SiteWithStatus }) { + diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index 1fee187..b4a07f5 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -11,6 +11,7 @@ import CopyButton from "../components/CopyButton"; import PushPanel from "../components/PushPanel"; import SnapshotsPanel from "../components/SnapshotsPanel"; import DeleteSiteDialog from "../components/DeleteSiteDialog"; +import CloneSiteDialog from "../components/CloneSiteDialog"; import { describeConflicts } from "../components/DomainsSettings"; export default function SiteDetail({ id }: { id: string }) { @@ -33,6 +34,7 @@ export default function SiteDetail({ id }: { id: string }) { const [loggingIn, setLoggingIn] = useState(false); const [loginError, setLoginError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); + const [cloneOpen, setCloneOpen] = useState(false); const logRef = useRef(null); const loadDetail = useCallback(() => { @@ -144,6 +146,14 @@ export default function SiteDetail({ id }: { id: string }) { > Terminal + +
+ + + ); +} + +/** Theme + plugin chips for a blueprint (active plugins first, then a "+N"). */ +function BlueprintChips({ blueprint }: { blueprint: Blueprint }) { + const active = blueprint.plugins.filter((p) => p.status === "active"); + const shown = active.slice(0, 3); + const extra = blueprint.plugins.length - shown.length; + const chip = "rounded-full border border-zinc-700 bg-zinc-800/60 px-2 py-0.5 text-[11px] text-zinc-300"; + return ( +
+ {blueprint.theme && 🎨 {blueprint.theme}} + {shown.map((p) => ( + + {p.name} + + ))} + {extra > 0 && +{extra} more} +
+ ); +} + +/** Selectable blueprint row in the blank-site view. */ +function BlueprintRow({ + blueprint, + onUse, + onDelete, +}: { + blueprint: Blueprint; + onUse: () => void; + onDelete: () => void; +}) { + return ( +
+
+ +
+ + +
+
+ {blueprint.description && ( +

{blueprint.description}

+ )} +
+ +
+
+ ); +} + +/** The chosen-blueprint summary shown in place of the version selects. */ +function BlueprintSummary({ + blueprint, + onClear, +}: { + blueprint: Blueprint; + onClear: () => void; +}) { + return ( +
+
+
+

+ Based on {blueprint.name} +

+

+ from {blueprint.source_site_name} · WP {blueprint.wp_version} · PHP{" "} + {blueprint.php_version} +

+ +
+ {blueprint.description && ( +

{blueprint.description}

+ )} +
+
); diff --git a/src/components/SaveBlueprintDialog.tsx b/src/components/SaveBlueprintDialog.tsx new file mode 100644 index 0000000..79f388f --- /dev/null +++ b/src/components/SaveBlueprintDialog.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { useBlueprints } from "../stores/blueprints"; +import { useDialog } from "../hooks/useDialog"; + +/** + * "Save as blueprint" dialog (plan 20). Captures a name + optional description; + * the backend snapshots the site, copies the artifacts into the blueprint and + * records its plugin/theme list, streaming progress through the pinned toast. + */ +export default function SaveBlueprintDialog({ + source, + onClose, +}: { + source: { id: string; name: string }; + onClose: () => void; +}) { + const save = useBlueprints((s) => s.save); + const { overlayProps, panelProps } = useDialog(onClose); + + const [name, setName] = useState(`${source.name} blueprint`); + const [description, setDescription] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async () => { + setBusy(true); + setError(null); + try { + await save(source.id, name, description || undefined); + } catch (e) { + setError(typeof e === "string" ? e : String(e)); + setBusy(false); + return; + } + onClose(); + }; + + return ( +
+
+

Save “{source.name}” as a blueprint

+

+ Captures this site's database, files and plugin list as a reusable template. New sites can + be created from it with one click. +

+ +
+ +