Skip to content

Turn LocalKit into a multi-stack dev platform: snapshots, cloning, resumable sync, site tools, and release polish - #4

Merged
jhd3197 merged 67 commits into
mainfrom
dev
Jul 21, 2026
Merged

Turn LocalKit into a multi-stack dev platform: snapshots, cloning, resumable sync, site tools, and release polish#4
jhd3197 merged 67 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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 lk CLI grows in lockstep, so none of it is GUI-only.

Highlights

  • Local domains now survive alongside LocalWP and anything else fighting over ports 80/443 — LocalKit names the process holding the port and offers one-click fallback ports (8080/8443).
  • Any site can be snapshotted and restored in one click, and a snapshot is taken automatically before every destructive operation (push, pull, delete, restore).
  • Clone a site in one click, or save any site as a reusable blueprint — with portable .lkbp export/import to share starter stacks across a team.
  • Import a remote ServerKit site as a brand-new local copy — code and database — with URLs rewritten automatically.
  • Push and pull large sites without hitting the old 100 MB wall: transfers are chunked, resume after a dropped connection, show real byte progress, and can be cancelled.
  • LocalKit is no longer WordPress-only — import an existing Docker Compose project or generate a PHP/Laravel stack, each exposing only the features its stack actually supports.
  • Site status stops lying: a background reconciler settles the app's view against Docker reality, flags degraded and half-finished sites, and offers Resume or Clean up for interrupted setups.
  • A new Tools tab brings the daily inner loop in-app — an Adminer database browser, serialization-safe search-replace, a WP_DEBUG toggle with a live log viewer, and a wp-config.php / .env editor.
  • The lk CLI gains the full ServerKit surface (connections, push, pull, remote listing, import, resume) plus shell completions for bash/zsh/fish/PowerShell.
  • The installed app now notices new releases, keeps ServerKit API keys in the OS keyring instead of plaintext, and fires an OS notification when a long operation finishes in the background.
Technical changes

Data model (db.rs, forward-only migrations)

  • Migration 5 (plan 18): nullable connection_id + remote_site_id on sites, recording an imported site's origin so future pulls default to the right remote.
  • Migration 6 (plan 22): kind (wordpress | docker | php, default wordpress) + config_json; '{}' deserializes to the WordPress SiteConfig defaults, so every existing row migrates to the stack it already is.
  • Migration 7 (plan 23): status_updated_at backing 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_ports treat the OS listener table (Get-NetTCPConnection / lsof) as the primary conflict signal — a bare TcpListener::bind false-negatives against Docker's SO_REUSEADDR publisher on Windows (a bound-and-published port reads as free).
  • set_enabled runs the probe before touching the hosts file and short-circuits with a named PortConflict { port, process }, surfaced through RouterStatus.conflicts; status() re-probes while enabled && !running so the diagnosis persists across restarts.
  • Configurable router_http_port / router_https_port (app_settings KV); render_compose maps <host>:80 / <host>:443 (container ports unchanged), and site_url is port-aware — clean https://slug.test at defaults, http://slug.test:8080 in fallback mode.
  • sync.rs push and pull now use the port-aware site_public_url instead of a hardcoded http://localhost:<port>, so domains round-trip through search-replace in both directions.

Snapshots (plan 17)

  • New snapshot.rs: per-site snapshots/<id>/ holding db.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, manual never pruned) implemented as a pure, unit-tested function over the manifest list.
  • sync.rs and site.rs take pre_push / pre_pull / pre_delete snapshots; 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_port now consults router::listening_ports and 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 new pull/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 skips wp core install — the imported DB is the site.
  • wordpress::wait_for_config fixes a readiness race where Docker published the host port before the entrypoint had written wp-config.php; optional post-import steps (permalink/cache flush, admin lookup) are bounded by optional() so a hung docker compose run can't discard finished work.
  • GET /pair features capability contract gates the Import button on extension support instead of failing mid-flow; serverkit.rs gains the pull/code client and enriched /sites metadata.

Sync v2 — chunked, resumable, cancellable (plan 19)

  • New 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.rs selects the protocol via supports_v2 with v1 kept as one isolated function per operation; a client-generated ?session= pins one server-side export so Range-resumed downloads never splice two different exports.
  • Staged-to-disk archiving (snapshot::write_wp_content_tgz, docker::compose_run_reader) removes the build-it-all-in-RAM ceiling in both directions.
  • New cancelled terminal stage; isTerminalStage in stores/sites.ts is now the single list two components previously hardcoded as done | error, plus {bytes_done, bytes_total} byte progress on site-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 over admin_user; secrets are never copied.
  • New blueprint.rs: blueprints/<slug>/ (blueprint.json + snapshot artifacts, hardlink-or-copy), save / list / delete / create_site_from_blueprint, and portable .lkbp export/import; plugin/theme lists captured via wp plugin list --format=json as 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 via rpassword, API key redacted in output), sites --remote, push --code|--db, pull --db (target defaults to the site's linked remote, --connection/--remote-site fill in otherwise, exit 2 on server rejection), completions via clap_complete, and per-connection doctor probes.

Multi-stack core (plan 22)

  • site.rs capability matrix (const per kind, exposed on every Site payload and app_info); the WP assumptions are de-hardcoded behind SiteConfigterminal.rs execs config.service, sync.rs tars config.sync_path, router.rs upstream reads config.upstream_port, each defaulting to the old WordPress literal.
  • New 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 a docker site with a clean "not supported for this site kind" error rather than half-running.
  • Frontend 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)

  • New reconcile.rs: a classify / decide decision table over a single batched docker::project_container_states pass, a shared InFlight (DashSet) guard honored by every lifecycle path (GUI, lk, tray), a 60 s spawn_loop plus a startup pass, and a new degraded status (amber, distinct from running/stopped).
  • Half-created recovery via a .localkit-install-complete marker + startup backfill; site::resume (+ resume_site command and lk resume) re-enters the install wait/wp core install tail; docker::check_cached (30 s) suspends the reconciler and drives a "Docker unavailable" sidebar pill when the daemon drops.
  • degraded / incomplete rendered across StatusBadge, dashboard, SiteDetail, palette, tray, and lk list, with a "Setup incomplete" → Resume / Clean up choice on the dashboard.

Site tools (plan 24)

  • Profile-gated adminer:4-standalone sidecar on db_port + 1000 with a db-<slug>.test route; open_site_database rewrites pre-existing compose files first, logs in as the wordpress DB user (root's password is randomized and unknowable), and copies that password to the clipboard.
  • wordpress::search_replace runs dry-run first and parses wp-cli's tab-separated report (it drops the ASCII grid on a pipe), snapshotting pre_search_replace before Apply; set_debug writes wp-config.php via a root wpcli runner and tails the bind-mounted debug.log; the config editor edits wp-config.php (via docker compose cp, since it's root-owned) and .env (offering a restart on change).

Release polish (plan 25)

  • New update.rs: checks the latest GitHub release tag against CARGO_PKG_VERSION, surfaces a Settings row + launch toast + lk doctor line, snoozes in app_settings; a drop-in for the real Tauri updater once releases are signed.
  • New keystore.rs: keyring crate keyed localkit/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 plaintext api_key column stays nullable for downgrade compatibility but is no longer written.
  • tauri-plugin-notification fires on long-op completion only when the window is unfocused or closed-to-tray, gated on an osNotifications setting.
  • Vitest added (vitest.config.ts) with tests for lib/errors, lib/fuzzy, lib/keybindings, lib/shortcuts, and stores/settings; cargo test --workspace per-module unit tests; both wired into CI.

PHP/Laravel stack (plan 26)

  • New php.rs with a kind-aware render_compose: app built from a generated docker/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-gated adminer; empty Laravel-ready skeleton or import an existing folder.
  • New dbsync.rs: engine-native dump/restore dispatch (mariadb-dump/mysqldump + mysql, pg_dump + psql) wired into snapshot::create / restore and sync.rs push/pull, with a best-effort APP_URL .env patch instead of WP search-replace.
  • serverkit.rs gains a kinds advertisement 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.rs gains an async remove_site_dir that retries directory deletion to ride out transient Windows file locks after docker compose down, used in both the cleanup and delete paths.
  • wordpress.rs DB import stages a transient SQL file under wp-content and runs wp 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.ts extended for every new flow (plus mock/notification.ts); new headless checks scripts/verify-{snapshots,import,blueprints,cli-serverkit,multistack,site-tools,sync-progress,router-conflict}.mjs.
  • New examples/docker_smoke.rs and snapshot_smoke.rs; m4_smoke.rs extended with import, interrupted-and-resumed chunked push, and a php sync cycle; smoke.rs grows clone / blueprint / reconcile / recover / php subcommands; mock_localkit_ext.cjs serves sync-v2, health/account probes, and a fake php remote.
  • 11 new plan files (docs/plans/1626) with as-built deviations, expanded ROADMAP.md tracks, AGENTS.md convention updates, README troubleshooting/feature refresh, the create-pr skill, and CLAUDE.md.

jhd3197 and others added 30 commits July 20, 2026 10:56
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>
jhd3197 and others added 27 commits July 21, 2026 00:39
…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
Copilot AI review requested due to automatic review settings July 21, 2026 23:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@jhd3197
jhd3197 merged commit bb44e34 into main Jul 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants