Merged
Conversation
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.
…phase 1) 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 <slug>.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<PortConflict>`, 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…phase 3) 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) <noreply@anthropic.com>
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:<port>`, 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 `<slug>.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) <noreply@anthropic.com>
… (plan 16 phase 4) - 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) <noreply@anthropic.com>
`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<addr>:<port>` with IPv6 literals carrying extra colons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A snapshot is a directory, not a DB row — no migration:
<data dir>/snapshots/<site_id>/<ts>/
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) <noreply@anthropic.com>
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 <site id>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
AGENTS.md gains the snapshot.rs module entry, a conventions block (the directory-not-a-DB-row layout, manifest-written-last, create staying silent on done/error because it nests, wp-content contents swapped never the bind-mounted dir, snapshot-before-every-destructive-flow, the retention rule) and a port-allocation note so the SO_REUSEADDR trap is written down where the next reader will look. New verification commands are listed alongside the existing ones. The plan file records what was built differently and why: commands take (site_id, snapshot_id), SiteDetail got a section rather than a tab (there is no tab bar), and the pre-delete snapshot is best effort where push/pull's is blocking — a site with a broken Docker stack must stay deletable, or the safety net strands the user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picks the row under the Pixel Bakery group header rather than pressing Enter on the first fuzzy hit — every site contributes a "Create snapshot" command, so Enter snapshots whichever site happens to rank first (Hiking Blog, as it turned out) and the assertion silently watched the wrong table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client half of the plan-18 groundwork. - serverkit::pull_code downloads a remote wp-content tar.gz; pull_db and it now share one `download` helper. - GET /pair is parsed for the extension's `features` array, exposed as ServerKitInfo.features plus a `has_feature` probe. An extension that omits a name is treated as not supporting it — the Import flow must find out before it provisions anything, not halfway through. - Bulk transfers get their own 30-minute client. reqwest's `timeout` is a total request budget, so the 15s probe timeout would have aborted any push or pull whose payload outgrew a fast link. - Migration 5 adds nullable connection_id/remote_site_id to sites, tested against a hand-seeded v4 database so the ALTER TABLE upgrade path (not just a fresh create) is what's covered. - site::create is split into `reserve` + `write_project_files` so the import flow reserves slugs and ports through the same race-free path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se 2) sync::import_site provisions a fresh local site and lands the remote wp-content + database on it. `wp core install` is deliberately never run: the imported database IS the site, and installing over it would replace the content the whole flow exists to fetch. Order matters here. pre_import checks everything knowable before anything is provisioned — extension has pull-code, remote site exists, not a multisite, not already imported from this same remote — so a predictable failure never leaves a half-built site on the dashboard. Once the site row and directory exist, any failure cleans both up. extract_wp_content treats the archive as hostile input: entries must be plain files or directories under wp-content/, and absolute paths, `..`, symlinks and hardlinks are refused rather than sanitized. Every "sanitize and carry on" branch would be a way to write outside the site directory. Tested against archives assembled byte by byte, because tar::Builder refuses to emit the `..` entry the check exists to catch. Version drift is a warning, not an error: the remote's patch level is dropped and major.minor matched against the image allowlist, falling back to newest with a mismatch event. Permalinks are flushed after the import or every imported page 404s on the new host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mock extension now serves GET /pull/code (a hand-written ustar archive — node ships zlib but no tar, and the archive shape is the contract under test), advertises `features`, and lists php_version/multisite. m4_smoke imports remote site #1 as a real new local site, asserts the remote wp-content landed, the login MU plugin survived it, URLs were rewritten, the origin was recorded and a history row written, then deletes it. Running it surfaced a real race: site::wait_for_port returns as soon as the port answers, but Docker publishes the host port when the container is *created*, so the first wp-cli call raced the wordpress entrypoint still writing wp-config.php. site::create never noticed because its install step retries for a minute. wordpress::wait_for_config makes the wait explicit, and import waits on it before touching wp-cli. Also asserts the multisite refusal leaves no site row behind — the point of doing those checks before provisioning rather than during. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lan 18 phase 3) `lk import <connection> <remote-site> [--name]` resolves connections by id or label and remote sites by numeric id or name, the same shape as site resolution elsewhere. Verified end to end against the mock extension: the remote theme lands in wp-content, the origin is recorded, history gets an import row, and the duplicate/multisite/unknown-connection guards all refuse with a useful message and exit 1. sync::emit now delegates to site::emit, so with no Tauri handle (CLI, examples) stages print to stderr instead of vanishing — a multi-minute import must not look like a hang. UI: Import button per remote site row, disabled with the reason in the tooltip rather than hidden (a missing button reads as a bug). The `pull-code` capability probe rides along with the site listing so the buttons know on first render whether the server is new enough. The dialog shows the version readout because importing a PHP 8.3 site onto 8.1 is cheaper to warn about here than to debug in a broken copy. It renders from App.tsx, not Settings, so closing Settings can't orphan it. Also bounds the optional post-import wp-cli steps (permalink/cache flush, admin lookup) with a timeout. Those run after the data is already in place, and a hung `docker compose run` there would discard a completed import — which is exactly what happened during testing when Docker left a container reporting "Up" with no processes inside it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 1-3) 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<u8> — 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) <noreply@anthropic.com>
…ase 2) 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) <noreply@anthropic.com>
…ion) The assertion the plan asks for, against real Docker and the mock: - A 110 MB incompressible filler is written into the smoke site's wp-content so the archive is 16 chunks. The site's own wp-content is ~12 MB — one chunk — and a one-chunk transfer cannot demonstrate resume at all. Incompressible on purpose, or gzip collapses it back to a single chunk and the test quietly proves nothing. - The mock is told to refuse chunks after 2 land (a deterministic stand-in for the client dying mid-upload; racing a real kill would flake). The push fails and, critically, never reaches finish — nothing applied. - The retry re-sends ONLY the 14 missing chunks and the server reports the init as a resume. A successful finish IS the whole-file hash check: the server refuses to process a payload whose assembled sha256 does not match what init declared. - The same 123 MB archive pushed over v1 is refused with the 100 MB limit error — so the fixture proves the wall was real and that v2 clears it, rather than just asserting v2 works. - With sync-v2 withdrawn from /pair, a payload that fits still goes up the v1 multipart path: one client, both servers. The mock gains __stats counters (lastTotalChunks is what makes the resume arithmetic checkable), a syncV2 knob, and the panel's 100 MB body limit on its v1 routes — which it should have mirrored all along. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the sync-v2 contract as a convention (protocol selection happens once at the top; v1 stays one isolated function; CHUNK_SIZE is a const; processing only runs on hash-verified bytes; downloads need the ?session= pin because pulls materialize per request; cancel is not a failure), the extended site-event payload, transfer.rs, and both new verification entry points. The plan is marked shipped with its one deferred item stated plainly: finish still processes inline, so a client disconnecting during server-side processing cannot re-attach. The mitigation (a failed processing run keeps the transfer, so a retry resumes to finish) and the reason for deferring (needs job infrastructure the extension does not have) are written down rather than left implied. README's sync notes described the v1 in-memory single-POST mechanics, which are now the fallback path, not the default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshot the source (new transient `clone_source` kind, hidden from the user-facing snapshot list and pruned the moment the clone finishes), reserve a fresh target (unique slug, fresh ports, fresh DB password + WP salts — secrets are never copied), lay the source's data down via snapshot::restore_into, then search-replace the baked-in URLs to the clone's own. Admin login carries over because the copied database holds it. Emits the create/import site-event stages so the progress toast works unchanged; a failure cleans up wholesale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CloneSiteDialog collects the new name (prefilled "<source> copy") and opens the clone on success; progress rides the existing pinned site-event toast. Wired into the SiteDetail header and both dashboard views, plus ipc.cloneSite, a sites-store clone action, and a mock clone_site command that streams the same stages so the flow is reviewable in dev:mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`lk clone <site> <new-name>` is a thin wrapper over site::clone_site. The smoke example gains a `clone` subcommand: marker post on the source → clone → assert the post rode along, the clone answers HTTP, its DB password and port are fresh, its admin login carried over, and the transient clone_source snapshot was pruned. Verified end-to-end against real Docker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… phase 2) New blueprint.rs stores a site recipe under <data>/blueprints/<slug>/ as blueprint.json + db.sql.gz + wp-content.tar.gz. `save` snapshots the site (transient blueprint_source kind, hidden from the user's snapshot list), hardlinks the snapshot's artifacts across so bytes aren't duplicated (copy fallback), and captures the plugin/theme list as display metadata. `create_site` is the clone create-half sourced from the blueprint dir: reserve a fresh site (versions matched to the current allowlist via the now-shared sync::match_version), lay the archives down through the new snapshot::restore_archives_into, and rewrite the URL read back out of the imported database. Unit tests cover manifest serde, the flat wire shape, hardlink-or-copy (+ copy fallback), and slug uniqueness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`smoke -- blueprint` saves the smoke site as a blueprint, asserts its three artifacts landed and the transient snapshot was pruned, stamps a new site out of it, and asserts the source's marker post rode along. Verified end-to-end against real Docker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…plan 20 phase 2) NewSiteDialog gains a blueprint list (plugin/theme chips, source, description) that switches into create-from mode when one is selected; SiteDetail gets a "Save as blueprint" button + dialog; the dashboard empty state points at blueprints when any exist. Backed by a blueprints Zustand store, ipc wrappers, types, and mock data + commands (save/list/delete/create-from) that stream the same site-event stages so both flows are reviewable in dev:mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Walks the New Site "From blueprint" section (chips, select, create-from), a one-click Clone under a new name, and Save-as-blueprint round-tripping into the dialog — mirroring the other verify-*.mjs harnesses. All checks pass against the mock server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…22 phase 3)
Every WordPress-shaped affordance now checks the site's capabilities instead of
assuming WP, and the New Site dialog gains an "Import a Docker project" flow.
- types.ts: Site gains kind/config/capabilities; AppInfo gains the kind matrix;
DockerProjectInspection/DockerService for the import dialog.
- SiteDetail: WP Admin, the credentials + database panels, wp-cli info, clone,
save-as-blueprint and ServerKit push are gated on capabilities and hidden for
a docker app; snapshots/logs/terminal remain; a kind badge in the header.
- Dashboard: a WP/Docker kind badge per card/row, Clone hidden for docker, a
kind-aware stack line (versions for WP, app service for docker).
- domains.ts sitePort(): a docker app's URL uses its published app_port.
- commands.tsx: per-site palette commands gate on capabilities (no WP Admin for
docker); NewSiteDialog: a WordPress/Docker tab toggle, the docker tab drives
inspect (services, app service/port, detected DB, copy size, ignore-list
opt-out) → import.
- mock: a docker site ("Analytics API") + inspect/import mocks + the kind matrix
in app_info, so the gated UI is reviewable in `npm run dev:mock`.
Verified: npm run build clean; new scripts/verify-multistack.mjs drives the mock
app headless — 30 checks green (kind badges, docker detail hides all WP-only
sections while keeping snapshots/logs/terminal, WP detail unchanged, and the
Docker import tab inspect→import flow).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the multi-stack core as shipped: the kind/capability convention, dockerapp.rs + the docker-config inspect in docker.rs, migration 6, the docker_smoke example and verify-multistack.mjs, and Track F marked done. The plan header notes what shipped vs deferred (migration 6 not 7, docker code-only until native dumps, clone/blueprint/ServerKit WordPress-only, typed folder path) and carries the Phase-1 grep-audit gate verdicts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add status_updated_at to sites (migration 7): every command status write stamps it with now, and a new settle_status() compare-and-swaps on it so the reconciler can never clobber a newer command/event write. Empty default sorts as "long ago", so a legacy row is always safe to settle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docker::project_container_states does one `docker ps` pass grouped by compose project label (localkit-<slug>), replacing N per-site compose-ps calls. New reconcile.rs holds the pure, unit-tested decision table (classify + decide, every db-status x observation x recency), the forward-only settle via db.settle_status, a `degraded` observation, and an InFlight registry (on AppState) the reconciler skips. No ground truth (Docker down) -> zero settles: the reconciler suspends, no mass flapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n 23 phase 1) Every lifecycle path (start/stop/delete/create/clone/import/blueprint/ restore, across GUI/lk/tray) now holds an InFlight guard so the reconciler skips a site while its status is in flight. lib.rs spawns the reconcile loop (one pass at startup so the dashboard opens honest, then every 60s); after any settle it refreshes the tray and emits sites-changed, which the frontend listens for to re-fetch. Verified live via a new smoke `reconcile` subcommand: external stop/start settle correctly and the grace window shields a fresh command write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New amber `degraded` status (up but unhealthy/restarting) surfaced across every touchpoint: StatusBadge (amber, pulsing dot), dashboard + SiteDetail + command palette treat it as "up" (Open/Stop offered, WP-only affordances still gated on healthy running), tray dot ◐ + Stop, `lk list` warn cell, and a mock site. site::list now classifies via reconcile::classify so a running-but-unhealthy container reads as degraded in the live view too. Verified in the mock UI: amber "degraded" badge, Open + Stop present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docker::check_cached caches the daemon probe for 30s (force bypass for the Settings re-check). A new useDocker store polls it (mount + 30s + on sites-changed) and the sidebar shows a global amber "Docker unavailable" pill when the daemon is down, clearing itself when it recovers. The reconciler already suspends on a failed ground-truth probe, so a Docker Desktop restart never mass-flaps sites to stopped. Verified in the mock: pill appears with the friendly-error tooltip when Docker reports down. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every successful create/import/clone/blueprint now writes a .localkit-install-complete marker as its last step; a startup backfill marks already-complete sites so legacy rows aren't flagged. A site whose dir lacks the marker (and isn't in flight) reports `incomplete` on SiteWithStatus/SiteDetail. The dashboard shows an amber "Setup incomplete" badge + Resume setup / Clean up in place of the usual actions. site::resume re-runs the create tail (containers up, wait, install-if-needed) and marks it complete; imported sites that never landed their data refuse resume (clean up + re-import). Verified live via a new smoke `recover` subcommand and in the mock UI (badge + Resume clears it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lk gains `resume <site>` (finish a half-created site) and `lk list` shows `incomplete` (amber) for a marker-less site — full CLI parity with the GUI recovery flow. AGENTS.md documents the reconciler/forward-only/in-flight/ degraded/marker/Docker-health conventions and migration 7; ROADMAP and the plan header mark plan 23 shipped. Verified: lk resume finishes the smoke site and re-writes the marker; lk list flips incomplete→running. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase 2) Adds the Tools tab on SiteDetail and its first tool: a dry-run-first, serialization-safe search-replace. - wordpress::search_replace_report runs `wp search-replace --all-tables --precise --report-changed-only [--dry-run]` and parses the report into a total + per-column breakdown. The parser handles both wp-cli output shapes — the tab-separated form we actually get (stdout is a pipe, not a TTY) and the bordered ASCII grid — with column positions read from the header. - site_search_replace command: gated on the `search_replace` capability, dry run mutates nothing; Apply takes a `pre_search_replace` snapshot first (new snapshot kind) so it is reversible, and emits the standard site-event stages. - Frontend: a Tools tab appears on SiteDetail when the kind supports at least one tool (WordPress does; a code-only docker app has none, so it stays a flat page). SearchReplacePanel previews change counts, then Apply with a shortcut back to the Snapshots panel. - Verified: parser unit tests; `smoke -- tools` against real Docker (dry-run finds home/siteurl without writing, Apply rewrites + snapshots, URL restored); scripts/verify-site-tools.mjs headless mock check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tools → Debug toggles WP_DEBUG and reads the debug log.
- wordpress::set_debug flips WP_DEBUG + WP_DEBUG_LOG (WP_DEBUG_DISPLAY pinned
false — errors to the log, never to the screen); debug_status reads WP_DEBUG
back (`wp config get` evaluates it → "1" when on). read/clear operate on the
bind-mounted wp-content/debug.log directly.
- New docker::compose_run_root: `wp config set` edits wp-config.php, which the
wordpress image writes as root into the wp-data volume — the cli image's
www-data user can't touch it ("wp-config.php is not writable"). Run those
writes as root, with wp-cli's --allow-root. (Discovered against real Docker.)
- Frontend: DebugPanel with a switch + an auto-refreshing tail of debug.log in
the container-log styling + a Clear button; gated on wp_tools.
- Verified: `smoke -- tools` toggles WP_DEBUG on/off against real Docker;
verify-site-tools.mjs covers the toggle seeding + clearing the log viewer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tools → Config: a plain textarea editor for wp-config.php and the site .env, with danger styling and no diff/backup machinery (snapshots are the net). - .env is a plain host file (site::read_env_file / write_env_file). Saving it offers a restart — site::restart runs `compose up -d`, which recreates any service whose resolved config (including .env) changed; a plain `compose restart` would NOT pick env edits up. - wp-config.php lives in the wp-data volume, not on the host, so it is copied in/out of the running wordpress container with `docker compose cp` — the copy runs through the daemon as root, so it can overwrite the root-owned file. The command layer gates wp-config on the site running (the container must exist). (First tried piping into `sh -c 'cat > …'` as root; that hung — docker's stdin EOF didn't reach `cat`, leaving orphaned containers. compose cp has no stdin and is robust; caught against real Docker.) - Frontend: ConfigEditorPanel (file switch, dirty/revert, restart prompt on .env), a `restart` action on the sites store. - Verified: `smoke -- config` (fast, cp-based) round-trips wp-config.php without breaking the site + reads/writes .env; verify-site-tools.mjs covers the editor loading both files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tools → Database: a one-button Adminer GUI, and marks plan 24 shipped. - Profile-gated `adminer:4-standalone` service added to the site compose template, published on db_port + 1000 (Site::adminer_port) — deterministic, no allocator change, off by default. open_site_database rewrites the deterministic compose file first (so sites created before this feature get the service), starts it via `docker compose --profile tools up -d adminer`, and returns the URL prefilled with ?server=db&username=wordpress&db=wordpress. The frontend copies the wordpress DB user's password to the clipboard — root's password is random (MYSQL_RANDOM_ROOT_PASSWORD), so the plan's username=root can't apply. - Router: render_caddyfile carries a db-<slug>.test route for db_gui sites, with matching db-<slug> hosts entries; adminer_public_url returns the domain when local domains are on, else localhost:<adminer_port>. - Frontend: DatabasePanel (first in the Tools tab). SiteTools now hosts all four panels: Database, Search & Replace, Debug, Config. - Docs: AGENTS.md site-tools convention + command refs, ROADMAP + plan 24 marked shipped with the reconciliation notes. - Verified: router + site unit tests (db-route, adminer port/compose); `smoke -- adminer` starts Adminer against real Docker and asserts it serves 200 on db_port + 1000; verify-site-tools.mjs covers Open database's toast. Full `cargo test --workspace` green (145 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move ServerKit API keys out of plaintext SQLite into the OS keyring (Windows Credential Manager / macOS Keychain / Linux Secret Service), the largest remaining security debt in the app. - new keystore.rs: keyring-backed store/retrieve/delete keyed localkit/connection/<id>, with graceful degradation — keyring unavailable (headless Linux, locked keychain, LOCALKIT_DISABLE_KEYRING) is a no-op that falls back to SQLite, never a hard failure. - db.rs: insert stores the key in the keyring and blanks the api_key column; get/list resolve keyring-first and migrate a legacy plaintext key on read; delete cleans up the keyring entry. No migration — the column stays as the fallback/downgrade path, we just stop writing to it. - CI + release Linux jobs gain libdbus-1-dev for the secret-service backend. - tests: keystore no-op contract + connection key round-trip (backend- agnostic, self-cleaning). Verified: lk connection add lands the key in Windows Credential Manager and NOT in the SQLite file; test reads it back to probe the server; remove deletes both the row and the credential. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unsigned releases rule out tauri-plugin-updater, so this is a checker, not an updater: ask GitHub for the latest release tag and compare it to the compiled-in version. Never downloads — links to the release page. - update.rs: check() GETs /releases/latest; pure, unit-tested is_newer (numeric compare, strips leading v, ignores pre-release suffix, junk never false-alarms). New check_for_update command. - lk doctor gains an informational "update available: vX.Y.Z" line (best-effort, never flips the exit code). - frontend: Settings → General "Updates" row (View release opener), a once-per-version launch toast, throttle + snooze in the settings KV (lib/update.ts). Mock returns an update so it's exercisable in dev:mock. Verified: lk doctor against the real GitHub API reports v0.1.2 available over the current v0.1.1 with the correct release URL; npm run build and cargo check --workspace both clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fire an OS notification when a long operation (create, push/pull, restore, ...) finishes while the window is unfocused or closed to tray — the in-app toast already owns in-focus feedback, so notify only when it can't be seen, and never both. - tauri-plugin-notification registered; notification:default capability. - lib/notify.ts: notifyIfBackground gates on document.hasFocus() + the osNotifications setting; requests permission once and remembers a denial. - wired into sites.ts handleEvent on the done/error terminal stages. - Settings → General "Desktop notifications" toggle (default on). - mock/notification.ts + vite alias so dev:mock and the build stay clean; @tauri-apps/plugin-notification added as a dep. Verified: cargo check (plugin compiles) + npm run build clean; the mock app mounts with no runtime errors and the notifications toggle renders/flips/persists (headless puppeteer against dev:mock). The OS notification itself needs the interactive GUI (backgrounded window). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stand up a real test surface so most regressions are caught without Docker; the smoke examples stay as the E2E layer. - Rust: add site::slugify unit tests (the last plan-named pure fn without coverage; match_version, the reconcile classify×decide table, the transfer chunker, snapshot retention and db migrations were already tested). cargo test --workspace is green (125 tests). - Frontend: vitest + jsdom (new dev-deps) with a config that aliases the Tauri API surface to src/mock/* so tests never hit a real IPC bridge. Suites for lib/fuzzy, lib/shortcuts (canonicalizer + labels), lib/keybindings (override resolver + conflicts), lib/errors (toast dedupe) and settings-store parsing — 28 tests. - CI runs `npm run test` alongside the build + cargo checks. - docs: AGENTS.md build/test commands + ROADMAP/plan-25 status. Verified: cargo test --workspace (125 passed) and npm run test (28 passed) both green; npm run build clean with the test files in-tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `php` site kind: a generated php-fpm + nginx + mariadb stack with a profile-gated Adminer, mirroring the WordPress template's conventions. It claims every capability WordPress does except the WP-only trio (one-click login, wp_tools, search-replace) — keeping engine-native db_sync + db_gui via its bundled mariadb. - site.rs: KIND_PHP + Capabilities::PHP + for_kind dispatch; render_compose is now kind-aware (dispatches to php::render_compose so Adminer's on-demand compose rewrite works for php too); db_name/db_user read from .env with WP defaults preserved (SiteDetail + Adminer no longer hardcode "wordpress"). - php.rs: compose/Dockerfile/nginx/.env/skeleton templates + create_php_site (empty Laravel-ready skeleton or import a code folder into app/). The app image builds pdo_mysql + Composer in so a fresh Laravel app can reach the db. - docker.rs: compose_build for the built app image. - dockerapp.rs: copy_tree made pub(crate) for reuse by the php import. - lib.rs: create_php_site command + php in app_info kinds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- lk: `create --kind php [--from <folder>]` — empty skeleton or import a folder; rejects --from for non-php kinds and unknown kinds. - smoke.rs: `php` subcommand creates a php stack site, asserts the built php-fpm+nginx webroot serves 200 and the skeleton page's PDO probe reports the bundled mariadb reachable (proves pdo_mysql + DB wiring end to end), then deletes it. Verified against real Docker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- NewSiteDialog: a third "PHP / Laravel" tab — pick a PHP version, then either an empty Laravel-ready skeleton or import an existing folder. - ipc.createPhpSite + sites.createPhpSite store action + KIND_PHP type. - mock: create_php_site case, PHP_CAPS, a php entry in app_info.kinds, a seeded "Checkout API" php sample site (kind-aware siteDetail db creds). - AGENTS.md: php.rs module, php smoke subcommand, php kind/capabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add dbsync — the single database export/import dispatch keyed on kind + engine. WordPress keeps its wp-cli path; php (and any db_sync kind) dumps engine-native inside the db container: mariadb-dump/mariadb, mysqldump/mysql, pg_dump/psql. The password is handed over as MYSQL_PWD/PGPASSWORD (never on a command line), and the db is brought up + waited on first so a stopped-site snapshot still dumps (wp-cli got this free via depends_on). - dbsync.rs: export_sql/import_sql + a unit-tested arg dispatch table (every engine has explicit dump+import handlers or a clean unsupported error). - docker.rs: compose_up_wait_service, compose_exec_env, compose_exec_env_stdin_reader (env-passed password, stdin-piped import). - snapshot.rs: create/restore route through dbsync (php now gets real DB snapshots); the old wp-cli-only export_db moved into dbsync::wp_export. - smoke.rs php: engine-native snapshot round-trip — write a marker row, snapshot, wipe it, restore, assert it's back. Verified against real Docker (mariadb-dump exported 863 bytes, restore recovered the row). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the ServerKit sync client to sync php sites, not just WordPress: - serverkit.rs: /pair advertises `kinds`; /sites carries `kind` (both default to wordpress for a pre-plan-26 server via normalize_kinds). `supports_kind` gates non-WordPress kinds on the advertisement. - sync.rs: `require_syncable` gates push/pull on capability + kind (wordpress|php; docker stays local-only) + server support, checked before provisioning. DB export/import go engine-native for php via dbsync; the URL step is kind-specific (WP search-replace vs php best-effort APP_URL). `extract_code(root)` generalizes the safe-extract to any archive root, and `do_import_php` clones a remote php site down (download app/ code, generate infra against the real layout, engine-native DB import — no wp-cli). - dbsync.rs: export_to_file + streaming import_from_gz for push/pull. - php.rs: ensure_dirs/write_infra split out for the import path; patch_app_url (best-effort Laravel APP_URL); config() shared with import. Verified against the mock: the gate refuses a php push when the server drops php, then engine-native push-db → push-code → pull-db round-trips a marker row (no wp-cli anywhere). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- mock_localkit_ext.cjs: /pair advertises `kinds` (togglable via __control to exercise the old-server gate), /sites gains a php remote (id 4) with a `kind`, and acceptCodeArchive accepts an app/-rooted archive as well as wp-content. - m4_smoke.rs: step 8 drives the php stack cycle — create a real php site, assert a server without php refuses the push, then engine-native push-db → push-code → wipe → pull-db and assert the marker row round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- types.ts: ServerKitInfo.kinds + RemoteWpSite.kind. - ImportSiteDialog: a php remote shows a "PHP / Laravel" kind row, hides the WordPress version row, and warns only on a php image mismatch. - mock: kinds in test_serverkit_connection, kind on every remote site, a php remote (checkout-service), and a kind-aware import_remote_site. - AGENTS.md: per-kind ServerKit sync, engine-native dbsync, php import, the mock/m4_smoke php coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roadmap + plan-26 status updated: PHP/Laravel stack, engine-native DB sync, and per-kind ServerKit sync/import parity shipped on the LocalKit side. Notes the one deviation (the app image builds pdo_mysql + Composer so a Laravel app can reach the bundled db) and the one follow-up (server-side php hosting awaits a php backend; the extension advertises kinds: ['wordpress'] until then). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an async `remove_site_dir` helper in `site.rs` that retries directory deletion to handle transient Windows file locks after `docker compose down`, and use it in both site cleanup and delete paths. In `wordpress.rs`, replace stdin-based `wp db import -` with a staged transient SQL file under `wp-content` and import via `wp db import <file>`, including the gzipped path by streaming decompression to that file. This avoids platform-specific stdin EOF hangs while ensuring staged files are cleaned up.
- Copy the create-pr skill into .claude/skills/ (same as the other projects) and gitignore its /.pr output directory - Add CLAUDE.md as a thin pointer to AGENTS.md plus the git workflow (dev branch, commit locally, never push/merge/open PRs) - README: multi-stack + clone/blueprints + site-tools + reconciliation feature rows, tray/keyring/update rows, refreshed lk CLI examples, keyring sync note (plaintext claim was stale), roadmap through M9, current src-tauri layout - AGENTS.md: state the git workflow convention explicitly
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
LocalKit spent its whole life assuming every site was WordPress — this PR is where it finally learns otherwise. Plans 16 through 26 land together: a kind/capability model (WordPress, generic Docker, and a generated PHP/Laravel stack), snapshots with one-click restore, resumable chunked sync, clone/blueprints, remote import, a status reconciler that stops the UI from lying, an in-app Tools tab, and the release polish that makes it shippable. The
lkCLI grows in lockstep, so none of it is GUI-only.Highlights
.lkbpexport/import to share starter stacks across a team.wp-config.php/.enveditor.lkCLI gains the full ServerKit surface (connections, push, pull, remote listing, import, resume) plus shell completions for bash/zsh/fish/PowerShell.Technical changes
Data model (
db.rs, forward-only migrations)connection_id+remote_site_idonsites, recording an imported site's origin so future pulls default to the right remote.kind(wordpress|docker|php, defaultwordpress) +config_json;'{}'deserializes to the WordPressSiteConfigdefaults, so every existing row migrates to the stack it already is.status_updated_atbacking the reconciler's forward-only compare-and-swap; the empty default sorts before any RFC3339 stamp, so legacy rows are always safe to settle and no command write can be clobbered by a stale observation.Router coexistence (plan 16)
router.rs:probe_ports/identify_port_owner/listening_portstreat the OS listener table (Get-NetTCPConnection/lsof) as the primary conflict signal — a bareTcpListener::bindfalse-negatives against Docker'sSO_REUSEADDRpublisher on Windows (a bound-and-published port reads as free).set_enabledruns the probe before touching the hosts file and short-circuits with a namedPortConflict { port, process }, surfaced throughRouterStatus.conflicts;status()re-probes whileenabled && !runningso the diagnosis persists across restarts.router_http_port/router_https_port(app_settingsKV);render_composemaps<host>:80/<host>:443(container ports unchanged), andsite_urlis port-aware — cleanhttps://slug.testat defaults,http://slug.test:8080in fallback mode.sync.rspush and pull now use the port-awaresite_public_urlinstead of a hardcodedhttp://localhost:<port>, so domains round-trip through search-replace in both directions.Snapshots (plan 17)
snapshot.rs: per-sitesnapshots/<id>/holdingdb.sql.gz+wp-content.tar.gz+manifest.json;create/list/restore/delete, auto-start of a stopped DB service for export, and retention pruning (newest 5 per kind,manualnever pruned) implemented as a pure, unit-tested function over the manifest list.sync.rsandsite.rstakepre_push/pre_pull/pre_deletesnapshots; push/pull abort if the snapshot fails ("never mutate without a net"), while delete is best-effort so a broken Docker stack is still removable.free_portnow consultsrouter::listening_portsand checks the DB port as well — closing the same SO_REUSEADDR false-free trap at create time that plan 16 documented for the router.Import a remote site (plan 18)
sync.rs::pull_new_site: provisions a fresh site, downloads the newpull/code+pull/db, extracts under a client-side safe-extract policy (rejects absolute paths,.., and symlinks escaping the target), imports the DB, runs port-aware search-replace, and deliberately skipswp core install— the imported DB is the site.wordpress::wait_for_configfixes a readiness race where Docker published the host port before the entrypoint had writtenwp-config.php; optional post-import steps (permalink/cache flush, admin lookup) are bounded byoptional()so a hungdocker compose runcan't discard finished work.GET /pairfeaturescapability contract gates the Import button on extension support instead of failing mid-flow;serverkit.rsgains the pull/code client and enriched/sitesmetadata.Sync v2 — chunked, resumable, cancellable (plan 19)
transfer.rs: 8 MiB chunk planning, resume-set subtraction, a hashing writer, self-deleting staged/temp files, and a per-site cancel registry (Arc<AtomicBool>checked between chunks); 28 unit tests.serverkit::push_chunked/download_resumable;sync.rsselects the protocol viasupports_v2with v1 kept as one isolated function per operation; a client-generated?session=pins one server-side export soRange-resumed downloads never splice two different exports.snapshot::write_wp_content_tgz,docker::compose_run_reader) removes the build-it-all-in-RAM ceiling in both directions.cancelledterminal stage;isTerminalStageinstores/sites.tsis now the single list two components previously hardcoded asdone | error, plus{bytes_done, bytes_total}byte progress onsite-event.Clone + blueprints (plan 20)
site::clone_site: snapshot the source → new record with fresh ports, DB passwords, and WP salts → restore into the target → search-replace → rewrite the login MU plugin and carry overadmin_user; secrets are never copied.blueprint.rs:blueprints/<slug>/(blueprint.json+ snapshot artifacts, hardlink-or-copy),save/list/delete/create_site_from_blueprint, and portable.lkbpexport/import; plugin/theme lists captured viawp plugin list --format=jsonas display metadata; safe-extract unit tests on the import path.CLI ServerKit surface (plan 21)
lk/src/main.rs:connection add|list|test|remove(validated on add via the GUI's health→key→pair probe, hidden-TTY key prompt viarpassword, API key redacted in output),sites --remote,push --code|--db,pull --db(target defaults to the site's linked remote,--connection/--remote-sitefill in otherwise, exit 2 on server rejection),completionsviaclap_complete, and per-connectiondoctorprobes.Multi-stack core (plan 22)
site.rscapability matrix (const per kind, exposed on everySitepayload andapp_info); the WP assumptions are de-hardcoded behindSiteConfig—terminal.rsexecsconfig.service,sync.rstarsconfig.sync_path,router.rsupstream readsconfig.upstream_port, each defaulting to the old WordPress literal.dockerapp.rs: import an existing compose project by copy (excludes.git/node_modules/vendor), choose the app service + port; WordPress-only flows (clone, blueprints, ServerKit sync, wp-cli) reject adockersite with a clean "not supported for this site kind" error rather than half-running.KindBadge, capability-gated SiteDetail sections and per-site palette commands, and a Docker import dialog (typed folder path for now).Status reconciliation & crash recovery (plan 23)
reconcile.rs: aclassify/decidedecision table over a single batcheddocker::project_container_statespass, a sharedInFlight(DashSet) guard honored by every lifecycle path (GUI,lk, tray), a 60 sspawn_loopplus a startup pass, and a newdegradedstatus (amber, distinct from running/stopped)..localkit-install-completemarker + startup backfill;site::resume(+resume_sitecommand andlk resume) re-enters the install wait/wp core installtail;docker::check_cached(30 s) suspends the reconciler and drives a "Docker unavailable" sidebar pill when the daemon drops.degraded/incompleterendered acrossStatusBadge, dashboard, SiteDetail, palette, tray, andlk list, with a "Setup incomplete" → Resume / Clean up choice on the dashboard.Site tools (plan 24)
adminer:4-standalonesidecar ondb_port + 1000with adb-<slug>.testroute;open_site_databaserewrites pre-existing compose files first, logs in as thewordpressDB user (root's password is randomized and unknowable), and copies that password to the clipboard.wordpress::search_replaceruns dry-run first and parses wp-cli's tab-separated report (it drops the ASCII grid on a pipe), snapshottingpre_search_replacebefore Apply;set_debugwriteswp-config.phpvia a root wpcli runner and tails the bind-mounteddebug.log; the config editor editswp-config.php(viadocker compose cp, since it's root-owned) and.env(offering a restart on change).Release polish (plan 25)
update.rs: checks the latest GitHub release tag againstCARGO_PKG_VERSION, surfaces a Settings row + launch toast +lk doctorline, snoozes inapp_settings; a drop-in for the real Tauri updater once releases are signed.keystore.rs:keyringcrate keyedlocalkit/connection/<id>; read path is keyring → SQLite fallback → migrate-on-read (write keyring, null the column), degrading cleanly to SQLite on headless/locked systems. The plaintextapi_keycolumn stays nullable for downgrade compatibility but is no longer written.tauri-plugin-notificationfires on long-op completion only when the window is unfocused or closed-to-tray, gated on anosNotificationssetting.vitest.config.ts) with tests forlib/errors,lib/fuzzy,lib/keybindings,lib/shortcuts, andstores/settings;cargo test --workspaceper-module unit tests; both wired into CI.PHP/Laravel stack (plan 26)
php.rswith a kind-awarerender_compose:appbuilt from a generateddocker/Dockerfile(php:<ver>-fpm+pdo_mysql+ Composer — the two extensions a bundled mariadb is pointless without),web(nginx static + fastcgi),db(mariadb), and a profile-gatedadminer; empty Laravel-ready skeleton or import an existing folder.dbsync.rs: engine-native dump/restore dispatch (mariadb-dump/mysqldump + mysql, pg_dump + psql) wired intosnapshot::create/restoreandsync.rspush/pull, with a best-effortAPP_URL.envpatch instead of WP search-replace.serverkit.rsgains akindsadvertisement gating per-kind push/pull/import; the client speaks the php protocol now, while the extension advertises['wordpress']only until a server-side php backend exists.Windows hardening (standalone commit
f6cf7de)site.rsgains an asyncremove_site_dirthat retries directory deletion to ride out transient Windows file locks afterdocker compose down, used in both the cleanup and delete paths.wordpress.rsDB import stages a transient SQL file underwp-contentand runswp db import <file>(streaming gzip decompression straight to that file) instead of piping over stdin-, avoiding platform-specific stdin-EOF hangs, and cleans the staged file up afterward.Mock mode, verification, and docs
mock/core.ts/mock/data.tsextended for every new flow (plusmock/notification.ts); new headless checksscripts/verify-{snapshots,import,blueprints,cli-serverkit,multistack,site-tools,sync-progress,router-conflict}.mjs.examples/docker_smoke.rsandsnapshot_smoke.rs;m4_smoke.rsextended with import, interrupted-and-resumed chunked push, and a php sync cycle;smoke.rsgrowsclone/blueprint/reconcile/recover/phpsubcommands;mock_localkit_ext.cjsserves sync-v2, health/account probes, and a fake php remote.docs/plans/16–26) with as-built deviations, expandedROADMAP.mdtracks, AGENTS.md convention updates, README troubleshooting/feature refresh, thecreate-prskill, and CLAUDE.md.