`: best-effort process name for
+ the conflict message. Windows: PowerShell
+ `Get-NetTCPConnection -LocalPort -State Listen` → `OwningProcess` →
+ `Get-Process -Id` (spawn via `docker::no_window`); macOS/Linux:
+ `lsof -nP -iTCP:
-sTCP:LISTEN`. Failure to identify is fine — the message
+ falls back to the generic hint list.
+- `PortConflict { port, process: Option }` — serializable, surfaced in
+ `RouterStatus` as `conflicts: Vec`.
+- `set_enabled` runs the probe *before* touching the hosts file; a conflict
+ short-circuits with a named error ("port 80 is held by `httpd.exe`
+ (LocalWP's router) — stop it or switch LocalKit to fallback ports") instead
+ of writing hosts entries that would point at a foreign router.
+- `status()` probes whenever `enabled && !running` so reopening the app while
+ the conflict persists shows the same diagnosis instead of a bare "not
+ running".
+
+### Phase 2 — Configurable router ports (fallback mode)
+
+- New `app_settings` keys (KV, no migration): `router_http_port` (default 80),
+ `router_https_port` (default 443). Settings → Domains gets two validated
+ number fields; changing them regenerates compose + Caddyfile, restarts the
+ router, and calls `rewrite_site_urls` (the same path the enable toggle
+ already uses).
+- `render_compose` binds `:80` / `:443` (container ports stay
+ 80/443 — only the host mapping moves). `render_caddyfile` is unchanged.
+- `site_url(slug, ca_trusted)` becomes port-aware: default ports → clean
+ `https://slug.test`; fallback ports → `http://slug.test:8080`. All consumers
+ already funnel through `site_url` / `site_public_url` (frontend `siteUrl`
+ mirror, one-click login, `site.rs` install-time URL) — extend the mirror in
+ `src/lib/types.ts` and the settings store accessors.
+- The hosts block is port-blind (`127.0.0.1 slug.test`), so no hosts changes;
+ the browser appends the port from the URL.
+- HTTPS in fallback mode still works (`https://slug.test:8443`, `tls
+ internal`), but the UI should default fallback URLs to http to avoid a
+ second cert prompt on a non-standard port.
+
+### Phase 3 — Conflict UX
+
+- Settings → Domains: when `conflicts` is non-empty, show an amber callout
+ naming the process + port, with two actions: "Use fallback ports"
+ (one-click sets 8080/8443 and retries enable) and "Retry" (after the user
+ quit the other app). No silent failures.
+- SiteDetail: if the site's public URL is a domain URL and the router is in
+ conflict, show a dismissible banner with the same callout (the toast alone
+ is too easy to miss — the user is staring at a foreign 404 page).
+- `lk doctor` reports port 80/443 ownership and the active router mode
+ (default/fallback/disabled), so support questions have a copy-paste answer.
+
+### Phase 4 — Docs
+
+- README troubleshooting entry: "LocalWP / Local by Flywheel is installed" →
+ expected behavior, fallback ports, why both apps can't share 80.
+- AGENTS.md router convention block: note the port settings keys and that
+ `site_url` is port-aware.
+
+## Risks
+
+- WP absolute URLs: in fallback mode `home`/`siteurl` contain `:8080`.
+ `sync.rs` search-replace on pull uses the *current* `site_public_url`, which
+ is port-aware after Phase 2, so local→remote and remote→local both
+ round-trip; verify with a port-bearing URL in the m4 smoke.
+- Another tool may bind 8080 too — the fallback enable path runs the same
+ pre-flight probe and reports the new conflict by name instead of looping.
+- Docker Desktop's own port forwarding on Windows can hold 80 briefly after a
+ failed `compose up`; the probe runs before any compose mutation, so it can't
+ race our own containers.
+
+## Verification
+
+- `cargo test --lib router`: new unit tests — `render_compose` with custom
+ ports, `site_url` port formatting, probe returning empty when ports free.
+- Manual matrix: (1) LocalWP running → enable domains → named conflict,
+ one-click fallback → `http://test.test:8080` serves the LocalKit site;
+ (2) quit LocalWP → "Retry" → clean `http://test.test`; (3) nothing
+ conflicting → unchanged default behavior.
+- `lk doctor` output in both modes.
diff --git a/docs/plans/17_snapshots.md b/docs/plans/17_snapshots.md
new file mode 100644
index 0000000..1a8e24a
--- /dev/null
+++ b/docs/plans/17_snapshots.md
@@ -0,0 +1,122 @@
+# 17 — Local site snapshots & one-click restore
+
+Status: ✅ shipped
+
+Point-in-time copies of a site (DB dump + `wp-content` archive) with
+one-click restore, taken automatically before every destructive operation
+(push, pull, delete) and manually from the UI/CLI. This is the safety net
+that makes plan 18 (import) and plan 19 (sync v2) safe to build on.
+
+## Motivation
+
+Every mutating operation in LocalKit is currently one-way: pull DB overwrites
+the local database, push DB overwrites the *remote* database, delete is
+forever. `sync_history` records that something happened but cannot undo it.
+A bad search-replace or a pull against the wrong connection means data loss.
+Snapshots turn all of these into reversible operations and give users a
+cheap "checkpoint before I try something" habit.
+
+## Design
+
+### Phase 1 — Snapshot engine (`src-tauri/src/snapshot.rs`)
+
+- Layout: `/snapshots///` containing `db.sql.gz`
+ (`wp db export -` via `docker::compose_run`, gzipped with flate2 — same
+ stack as `sync.rs`), `wp-content.tar.gz` (in-memory tar of the
+ bind-mounted `wp-content/`, same code path as push code), and
+ `manifest.json`.
+- Manifest: `{site_id, created_at, kind, note, db_bytes, code_bytes,
+ wp_version}` where `kind` is `manual | pre_push | pre_pull | pre_delete |
+ pre_restore`. Yes, snapshot before restore too — restoring is destructive.
+- Commands: `list_snapshots(site_id)`, `create_snapshot(site_id, kind,
+ note?)`, `restore_snapshot(snapshot_id)`, `delete_snapshot(snapshot_id)`.
+ Restore = `wp db import` via `compose_run_stdin` + extract the tar into the
+ site's `wp-content` dir (plain fs write, bind-mounted) + `wp cache flush`.
+ Site must be running to import the DB; restore auto-starts a stopped site
+ and reports that it did.
+- Long operations emit `site-event` stages (`snapshot` / `restore`) so the
+ pinned progress toast pattern keeps working.
+- Retention: auto-kinds are pruned to the newest 5 per site per kind after
+ each create; `manual` snapshots are never auto-pruned. Deleting a site
+ keeps its snapshots directory unless the delete dialog's new "also delete
+ snapshots" checkbox is checked.
+
+### Phase 2 — Wiring into destructive flows
+
+- `sync.rs`: `push_db` and `pull_db` take a local pre-sync snapshot first
+ (kind `pre_push` / `pre_pull`, note = connection name + remote URL).
+ Failure to snapshot aborts the sync with a clear error — never mutate
+ without a net.
+- `site.rs::delete_site`: kind `pre_delete` snapshot, blocking, before any
+ container teardown. Delete dialog copy: "A restorable snapshot will be
+ kept."
+- Snapshot-before-delete is also the foundation for a future "restore deleted
+ site" flow (out of scope here, but the manifest keeps enough metadata).
+
+### Phase 3 — UI + CLI
+
+- SiteDetail → new "Snapshots" tab: table (created, kind badge, note, sizes,
+ DB/code presence), actions Restore / Delete / (manual) Create with an
+ optional note field. Restore confirms with a dialog that names the
+ snapshot time and mentions the pre-restore snapshot.
+- `lk snapshot list|create|restore|delete ` following CLI conventions
+ (stdout data only, `--json`, restore prompts with default No, `--yes` on
+ non-TTY).
+- Command palette: "Create snapshot" per-site command via the existing
+ `buildCommands()` per-site command block.
+
+## Risks
+
+- Disk usage: `wp-content` archives can be large (uploads). Mitigate with
+ the retention cap + sizes visible in the UI + the delete-site checkbox.
+ A future plan can add exclude-paths for `uploads/cache`-style dirs.
+- Restore while the user has the terminal open mid-write is racy in theory;
+ in practice `wp db import` is atomic enough and the site stays up. Not
+ worth a maintenance-mode flag for v1.
+- Snapshot of a site whose containers are stopped: DB export needs the db
+ container — auto-start just the db service (`docker compose up -d db`),
+ wait healthy, export. Reuse the create flow's wait loop.
+
+## Verification
+
+- New `cargo run --example snapshot_smoke` — create site → snapshot → break
+ the DB (`wp post delete 1 --force`) → restore → assert the post is back;
+ pre-delete snapshot survives site deletion.
+- Unit tests: retention pruning (pure function over manifest list), manifest
+ serde round-trip.
+- `npm run dev:mock`: mock snapshots in `src/mock/data.ts` + `core.ts` so the
+ tab renders without Docker.
+
+## As built — deviations from the plan above
+
+- **Commands take `(site_id, snapshot_id)`**, not a bare `snapshot_id`. The
+ snapshot id is a timestamp scoped to its site directory, and both frontends
+ always act inside a site context, so threading the site id beats inventing a
+ globally unique id or scanning every site's directory to find one.
+- **SiteDetail gets a Snapshots *section*, not a tab** — the page has no tab
+ bar, it is a column of sections (Site, Credentials, Database, wp-cli,
+ ServerKit sync, Logs). Adding one for a single feature would have been a
+ bigger change than the feature.
+- **The pre-delete snapshot is best effort, not blocking.** Blocking is right
+ for push/pull (the plan's "never mutate without a net" — those abort), but a
+ site whose Docker stack is broken must still be deletable; otherwise the
+ snapshot feature strands the user with a site they cannot remove. The
+ failure is reported through the event stream and the delete continues.
+- **The manifest also carries `site_name` / `site_slug`.** Deleting a site
+ keeps its snapshots, so the manifest is the only remaining record of what
+ the site was called — and it is what lets `lk snapshot list ` still
+ answer after the delete.
+- **`wp_version` comes from the site row**, not `wp core version`: it needs no
+ running container, so snapshotting a stopped site stays cheap.
+- **Retention keys on the manifest kind only.** The plan's "newest 5 per site
+ per kind" is implemented as a pure function over the manifest list, so it is
+ unit-tested without touching the disk.
+
+### Fixed along the way
+
+Port allocation bind-probed `127.0.0.1` only, so a port already published by
+a running container (Docker's publisher uses SO_REUSEADDR) read as free and
+site creation died at `compose up`, *after* the image pull, with a raw Docker
+error. This is the same trap plan 16 documented for the router. `free_port`
+now consults the OS listener table via the new `router::listening_ports` and
+checks the DB port as well — only the site port was ever checked.
diff --git a/docs/plans/18_import-remote-site.md b/docs/plans/18_import-remote-site.md
new file mode 100644
index 0000000..c9637c8
--- /dev/null
+++ b/docs/plans/18_import-remote-site.md
@@ -0,0 +1,144 @@
+# 18 — Import a ServerKit site as a new local site
+
+Status: ✅ shipped
+
+One-click "clone to local" for any site on a connected ServerKit server:
+provision a fresh local site, pull down the remote `wp-content` and database,
+rewrite URLs, and land the user on a working local copy. Closes the last
+open item in Track B (today pull only targets an *existing* local site).
+
+## Motivation
+
+The most common real workflow — "client's site is on the server, I need to
+work on it locally" — currently requires: create a local site by hand,
+delete its stock content, pull the DB, and somehow get the remote
+`wp-content` (which LocalKit cannot fetch at all today: the extension has
+push endpoints only). Each step is manual and the URL/plugin/theme mismatch
+failure modes are unforgiving. This plan adds the missing download direction
+for code and orchestrates the whole flow behind one button.
+
+## Design
+
+### Phase 1 — Server side: `GET /api/v1/localkit/pull/code` (ServerKit repo)
+
+- New endpoint in the `serverkit-localkit` extension mirroring `pull/db`:
+ `site_id` param → tar.gz of the remote site's `wp-content/` (streamed,
+ `after_this_request` temp cleanup, same admin RBAC decorators).
+- Reuse the extension's existing `_resolve_wp_content_dir` knowledge of the
+ container layout; create the archive with `tar czf - -C wp-content` via `docker exec` so symlinks/perms survive, stream
+ the file back.
+- Extend `GET /pair` with a `features` array (`["sites", "push", "pull-db",
+ "pull-code"]`) so LocalKit can gate the Import button on extension
+ capability instead of failing mid-flow. Older extension = feature absent =
+ button disabled with a tooltip.
+- Also extend `GET /sites` payloads with `wp_version`, `php_version`, and
+ `site_url` if not already present — the import flow needs them to pick
+ local versions and run search-replace.
+
+### Phase 2 — Orchestration (`src-tauri/src/sync.rs`)
+
+- `pull_new_site(connection_id, remote_site_id, local_name?) ->
+ Result`:
+ 1. Read remote metadata (name, wp/php version, URL).
+ 2. `site::create_site_files` equivalent: new record, unique slug from the
+ remote name (or `local_name`), fresh ports, compose + `.env` with the
+ closest matching PHP version from `PHP_VERSIONS` (record the mismatch
+ in a warning event if not exact).
+ 3. Pre-pull images (existing `pulling` stage path).
+ 4. Download `pull/code` → extract into the site's `wp-content` bind mount
+ (safe-extract policy: reject absolute paths, `..`, symlinks escaping
+ the target — the client-side mirror of the server's tar policy).
+ 5. Start containers, wait healthy (existing stages).
+ 6. Download `pull/db` → `wp db import` via `compose_run_stdin` →
+ `wp search-replace --all-tables` (serialization-
+ safe; `local_url` from port-aware `site_public_url`) → `rewrite flush`.
+ 7. Skip `wp core install` entirely — the imported DB *is* the site. The
+ local `admin_user` record comes from the first administrator in the
+ imported users table (`site_wp_users` logic), falling back to the
+ remote admin email; the one-click login MU plugin is written by the
+ existing `ensure_login_plugin` on first login.
+ 8. Record a `SyncRecord` (`kind: "import"`) and emit per-stage
+ `site-event`s throughout, ending in `done`.
+- A `pre_import` guard: refuse if a local site with the same slug exists and
+ was itself created by an import linked to the same remote site — offer
+ "pull into existing" instead. Store `remote_site_id` + `connection_id` on
+ the local site (new columns, **migration 5**) so future pulls default to
+ the right remote.
+
+### Phase 3 — UI + CLI
+
+- Connection detail (ServerKit page): each remote site row gets an "Import"
+ button next to the existing push/pull targets → dialog with local name
+ override, PHP/WP version readout (with mismatch warning), and the progress
+ toast doing the rest.
+- Dashboard: imported sites show a subtle link icon with the connection
+ name (from the migration-5 columns).
+- `lk import [--name ]` — same site/connection
+ resolution rules as the rest of the CLI, `--json` prints the created site.
+
+## Risks
+
+- Large sites: archive is streamed but still monolithic — the 100 MB
+ `MAX_CONTENT_LENGTH` on the server bounds downloads too. Plan 19 (chunked
+ sync) generalizes this; the Import button should warn when the remote
+ reports a huge `wp-content`.
+- PHP/WP version drift: importing a PHP 8.3 site onto an 8.1 image usually
+ works but not always — the mismatch warning event + sync-history note is
+ enough for v1; don't attempt image-matrix matching.
+- Multisite and custom `WP_CONTENT_DIR` remotes: detect and refuse with a
+ clear error rather than producing a half-broken copy.
+
+## Verification
+
+- Extend `examples/m4_smoke.rs` (and `mock_localkit_ext.cjs`) with an
+ `import` path: seed the mock server with a fake site → run import →
+ assert local site runs, URLs rewritten, sync_history row written.
+- `cargo test --lib sync`: safe-extract unit tests (traversal, absolute
+ paths, symlink escapes) against fixture archives.
+- Manual E2E against a real ServerKit box: import → one-click login works →
+ edit a theme file locally → push code back.
+
+## What shipped
+
+All three phases, plus `scripts/verify-import.mjs` (headless UI check against
+the mock server, mirroring the other plans' `verify-*.mjs`).
+
+Deviations from the plan above, and why:
+
+- **Feature names.** `GET /pair` reports `["sites", "push-code", "push-db",
+ "pull-db", "pull-code"]` — hyphenated and split per direction, rather than
+ the sketch's `["sites", "push", "pull-db", "pull-code"]`. A single `push`
+ could not express a server that gained one direction but not the other.
+- **`/sites` enrichment.** `url` and `wp_version` were already in the hub
+ payload; only `php_version` (regexed off the compose image tag, not a
+ per-site container shell) and an explicit `site_url` alias were added.
+ `multisite` was already there and is what the refusal reads.
+- **Version matching returns a warning, not just an event.** `match_version`
+ is a pure, unit-tested function shared by the backend, and mirrored in the
+ Import dialog so the user sees the mismatch *before* committing.
+- **`pre_import` is stricter than sketched.** It refuses a second import from
+ the same remote outright rather than offering "pull into existing" inline —
+ the error names the local site to pull into, which is the same guidance
+ without a second flow to build.
+
+Two things found by running it that the plan did not anticipate:
+
+- **`wait_for_port` is not a readiness signal.** Docker publishes the host
+ port when the container is *created*, so the first wp-cli call raced the
+ image entrypoint still writing wp-config.php and died with "'wp-config.php'
+ not found". `site::create` never noticed because its install step retries
+ for a minute. Fixed with `wordpress::wait_for_config`.
+- **A hung `docker compose run` can discard a finished import.** Observed a
+ container Docker reported as "Up" with no processes inside it. The optional
+ post-import steps (permalink/cache flush, admin lookup) are now bounded by
+ `optional()`, since they run after the data has already landed.
+
+Deferred, deliberately:
+
+- **Large-site warning.** The plan wanted the Import button to warn when the
+ remote reports a huge `wp-content`; the extension does not report a size,
+ and adding one belongs with plan 19's chunked transfer work.
+- **A killed import leaves a `creating` row with live containers.** In-process
+ failures clean up, but a SIGKILL cannot. That is plan 23's (reconciliation)
+ job, not a second half-measure here.
diff --git a/docs/plans/19_sync-v2-chunked.md b/docs/plans/19_sync-v2-chunked.md
new file mode 100644
index 0000000..993a8e7
--- /dev/null
+++ b/docs/plans/19_sync-v2-chunked.md
@@ -0,0 +1,146 @@
+# 19 — Sync v2: chunked transfers, byte progress, resume, cancel
+
+Status: ✅ shipped (one deferred item — see *What shipped* below)
+
+Replace the monolithic in-memory push/pull with a chunked, resumable
+transfer protocol between LocalKit and the `serverkit-localkit` extension,
+with real byte-level progress and cancellable operations.
+
+## Motivation
+
+Sync v1 (plan 4) builds the whole `wp-content` tar.gz in memory, POSTs it in
+one request, and hopes: bounded by the server's 100 MB body limit, no
+progress beyond coarse stages, a dropped connection at 99% means starting
+over, and the UI can offer no cancel button because the operation is one
+giant `await`. Any site with a real `uploads/` directory hits these walls.
+The extension's own docstring already flags "sync runs inline, no job queue"
+as its known v1 limitation.
+
+## Design
+
+### Phase 1 — Chunked upload protocol (both sides)
+
+- Server (`serverkit-localkit` extension, ServerKit repo):
+ - `POST /push/{code,db}/init` → `{transfer_id}`; body describes the
+ transfer: `site_id`, `total_bytes`, `chunk_size`, `sha256` of the whole
+ archive, plus operation metadata (`local_url` for DB pushes).
+ - `PUT /push/{code,db}/chunk` — `{transfer_id, offset, sha256}` + raw
+ body; server writes the range into a temp file, records the chunk hash,
+ returns the set of offsets already confirmed (idempotent: re-sending a
+ confirmed chunk is a no-op 200).
+ - `POST /push/{code,db}/finish` — verifies whole-file sha256, then runs
+ the *existing* v1 processing path (safe-extract → `docker cp` / DB
+ import → search-replace) on the assembled temp file, streams the result.
+ - Stale transfers (no chunk for 30 min) are reaped by a lazy sweep on
+ `init`.
+- Client (`src-tauri/src/sync.rs`): stream the archive through a
+ hasher+chunker (8 MiB chunks) instead of a `Vec` — for push code this
+ also means tarring straight to the socket pipeline instead of memory,
+ which fixes the RAM blowup on big sites as a side effect.
+- Resume: before uploading, `init` returns any previously confirmed offsets
+ for the same `(site_id, sha256)`; the client skips those chunks. Retrying
+ a failed push therefore re-sends only what was lost.
+
+### Phase 2 — Progress, cancel, versioning
+
+- `site-event` payload extended with `{bytes_done, bytes_total}` during
+ transfer stages; `sites.ts handleEvent` renders "Pushing code —
+ 148 / 312 MB" in the pinned progress toast (keep stage messages for the
+ non-transfer phases).
+- Cancel: new `cancel_sync(site_id)` command drops an `Arc`
+ checked between chunks; server-side, an unfinished transfer is simply
+ abandoned and reaped — no half-applied state is possible because
+ processing only happens in `finish` after hash verification.
+- Capability negotiation: `GET /pair` `features` array (plan 18) gains
+ `"sync-v2"`. Older extension → LocalKit silently uses the v1 monolithic
+ path with the v1 progress granularity. One client, both servers.
+
+### Phase 3 — Pull direction + job handoff
+
+- Downloads: server sends `Content-Length` and supports `Range`; client
+ downloads in ranges to a temp file with the same resume/verify/cancel
+ mechanics, then imports. No protocol invention needed — HTTP already is
+ the chunked protocol here.
+- Server-side long processing (the import/extract in `finish`) moves onto
+ the extension's job queue with a `GET /jobs/` poll, so a client
+ disconnect during *processing* (not transfer) can re-attach and learn the
+ outcome instead of guessing. `SyncRecord` is written from the poll result.
+- Timeouts: reqwest per-chunk timeout only (no whole-request cap); the
+ whole operation is bounded by liveness, not duration.
+
+## Risks
+
+- Two code paths (v1/v2) in `sync.rs` — keep v1 as a single isolated
+ function and route through a `match features` at the top; do not sprinkle
+ conditionals through the flow.
+- Hash-verified `finish` means the server holds a trusted-but-unprocessed
+ archive; the safe-extract policy stays mandatory exactly as in v1.
+- Chunk size tradeoff: 8 MiB is a round number that keeps request counts
+ low on LAN-ish links without making progress bars jumpy; make it a const,
+ not a setting.
+
+## Verification
+
+- `examples/mock_localkit_ext.cjs` implements v2 (in-memory chunk store) →
+ `m4_smoke` runs the full v2 flow against it, including: kill the client
+ mid-upload, re-run, assert only missing chunks were re-sent (mock counts
+ requests) and the final hashes match.
+- Synthetic >100 MB `wp-content` fixture (sparse files) proves the v1 limit
+ is gone and memory stays flat (`docker stats`-level eyeball is fine).
+- Unit tests: chunker/hasher pipeline (offset math, hash continuity),
+ resume-set subtraction.
+
+## What shipped
+
+Phases 1 and 2 in full; phase 3 except the job-queue handoff.
+
+- **Client** — `src-tauri/src/transfer.rs` (chunk planning, resume
+ subtraction, hashing writer, self-deleting staged/temp files, per-site
+ cancel registry; 28 unit tests), `serverkit::push_chunked` /
+ `download_resumable`, protocol selection in `sync.rs` via `supports_v2`
+ with v1 preserved as one isolated function per operation.
+- **Server** — `POST /push//init`, `PUT /push//chunk`,
+ `POST /push//finish` in the ServerKit extension, plus `?session=` +
+ `conditional=True` on both pulls. v1 and v2 both end in the shared
+ `_install_code` / `_import_db`, so there is exactly one processing path.
+ `FEATURES` gained `sync-v2`.
+- **Memory** — the plan's "tar straight to the pipeline" turned out to matter
+ more than the chunking: `snapshot::write_wp_content_tgz` stages the archive
+ to a file, `docker::compose_run_reader` streams a dump into
+ `wp db import`, and the import untars off disk. Nothing large is buffered
+ in either direction anymore.
+- **UI** — byte counters on `site-event`, "Pushing wp-content — 148 MB /
+ 312 MB" in the pinned toast, a Cancel button while bytes move, and a
+ `cancelled` terminal stage/history status that reads neutral rather than
+ as a failure.
+- **Verification** — `m4_smoke` writes a 110 MB incompressible fixture, has
+ the mock refuse chunks after two land, and asserts the retry re-sends only
+ the missing 14 of 16; the same 123 MB archive is refused over v1 with the
+ 100 MB error, and v1 still works when `/pair` withholds `sync-v2`.
+ `scripts/verify-sync-progress.mjs` covers the UI headlessly.
+
+### Deferred: the server-side job queue
+
+Phase 3's "move `finish`'s processing onto the extension's job queue with a
+`GET /jobs/` poll" is **not** implemented. `finish` still processes
+inline. The gap it leaves is narrow — a client that disconnects *during
+server-side processing* (not during transfer) cannot re-attach to learn the
+outcome — and it is partly mitigated: a transfer whose processing fails is
+kept rather than discarded, so a retry resumes straight to `finish` instead
+of re-uploading. Closing it properly needs job infrastructure the extension
+does not have today (ServerKit's `deployment_job_service` is
+deployment-specific), which is a larger piece of work than the rest of this
+plan combined and belongs in its own slice.
+
+### Notes for whoever picks this up
+
+- Resume needed one thing the plan did not anticipate: `pull/db` and
+ `pull/code` *materialize* their payload per request, so plain `Range`
+ against them would splice bytes from two different exports. Hence the
+ client-generated `?session=` that pins one export server-side. It is a
+ small addition to "HTTP already is the chunked protocol here", not a
+ replacement for it.
+- Adding a third terminal stage (`cancelled`) broke two frontend components
+ that hardcoded `done | error` — they stopped clearing `busy` and left the
+ push buttons disabled forever. `isTerminalStage` in `stores/sites.ts` is
+ now the single list; use it.
diff --git a/docs/plans/20_clone-and-blueprints.md b/docs/plans/20_clone-and-blueprints.md
new file mode 100644
index 0000000..c5bfde9
--- /dev/null
+++ b/docs/plans/20_clone-and-blueprints.md
@@ -0,0 +1,88 @@
+# 20 — Site clone + reusable blueprints
+
+Status: ✅ shipped
+
+Two related creation flows: **clone** an existing local site in one click,
+and save any site as a named **blueprint** (content + config recipe) that
+new sites can be created from. Builds directly on the plan-17 snapshot
+engine.
+
+## Motivation
+
+Track A's open item ("site duplication / clone") covers the daily case:
+"I need a throwaway copy of this site to test a plugin update." The wider
+case is just as common: developers who spin up client sites keep
+re-installing the same starter stack — same theme, same five plugins, same
+settings. Today that muscle memory lives outside the app. Blueprints make a
+configured site a first-class, reusable template, and both flows share 90%
+of their machinery with snapshots, so the marginal cost is low.
+
+## Design
+
+### Phase 1 — Clone (`src-tauri/src/site.rs`)
+
+- `clone_site(id, new_name) -> Result`:
+ 1. Snapshot the source via the plan-17 engine (`kind: "clone_source"`,
+ pruned aggressively — it's an implementation detail, not a user
+ snapshot).
+ 2. Create the target record: `unique_slug(new_name)`, fresh ports, fresh
+ DB passwords + WP salts in `.env` / `wp-config` — never copy secrets.
+ 3. Restore the snapshot into the target's dirs/containers.
+ 4. `wp search-replace --all-tables`
+ (both URLs from `site_public_url`, port-aware per plan 16) +
+ `rewrite flush` + `cache flush`.
+ 5. Rewrite the login MU plugin (`ensure_login_plugin`) and default the
+ clone's `admin_user` from the source.
+- Emits `site-event` stages (`snapshot → files → containers → import →
+ done`) so the progress toast works unchanged.
+- UI: Clone button in SiteDetail header + dashboard card context area
+ (name dialog, then progress). CLI: `lk clone `.
+
+### Phase 2 — Blueprints
+
+- Storage: `/blueprints//` = `blueprint.json` + `db.sql.gz`
+ + `wp-content.tar.gz` (the snapshot artifacts, copied) —
+ `blueprint.json` adds `{name, description, wp_version, php_version,
+ plugins: [...], theme, created_at, source_site_name}`. Plugin/theme lists
+ are captured via `wp plugin list --format=json` at save time — display
+ metadata only, v1 does not re-resolve them.
+- Commands: `save_blueprint(site_id, name, description?)`,
+ `list_blueprints()`, `delete_blueprint(id)`,
+ `create_site_from_blueprint(blueprint_id, name, ...)` — the create flow
+ with steps 3–5 of clone (fresh creds/salts, restore, search-replace from
+ the recorded source URL to the new site URL).
+- NewSiteDialog gains a "From blueprint" section (list with plugin/theme
+ chips + a description line); Dashboard empty-state suggests saving a
+ blueprint once a site exists. CLI: `lk blueprint list|save|delete`,
+ `lk create --blueprint `.
+- Portability: `lk blueprint export -o site.lkbp` (single tar.gz of
+ the blueprint dir) and `import` — enough to share blueprints in a team
+ without building a registry.
+
+### Phase 3 — Polish
+
+- Blueprint thumbnails: optional; capture the dashboard screenshot pipeline
+ (`scripts/capture-screenshots.mjs`) is dev-only, so v1 = a generated
+ initial-letter tile, not site screenshots.
+- Router integration: clones/blueprint-sites are ordinary sites — Caddyfile
+ regen + hosts sync already hook site create/delete.
+
+## Risks
+
+- Cloning a running site: the snapshot reads a live DB — `wp db export` is
+ consistent enough for dev; document that cloning quiesces nothing.
+- Blueprint staleness: a blueprint's WP core version is whatever the image
+ provides (content only stores `wp-content` + DB) — the create dialog shows
+ the recorded `wp_version` and warns if the local allowlist no longer has
+ it (falls back to nearest).
+- Disk: blueprints duplicate snapshot bytes. `save_blueprint` hardlinks the
+ snapshot artifacts when the fs allows, copies otherwise.
+
+## Verification
+
+- Extend `examples/smoke.rs` with a `clone` subcommand: create → add a post
+ → clone → assert the post exists at the clone's URL and passwords differ.
+- Unit tests: blueprint manifest serde, hardlink-or-copy fallback, slug
+ uniqueness against existing sites.
+- Mock mode: sample blueprints in `src/mock/data.ts` so the NewSiteDialog
+ section is reviewable without Docker.
diff --git a/docs/plans/21_cli-serverkit.md b/docs/plans/21_cli-serverkit.md
new file mode 100644
index 0000000..98dc6e2
--- /dev/null
+++ b/docs/plans/21_cli-serverkit.md
@@ -0,0 +1,88 @@
+# 21 — `lk` CLI: ServerKit connections, push/pull, shell completions
+
+Status: ✅ shipped
+
+Shipped as designed, with two deliberate calls where the plan was open:
+
+- **`connection list` is local-only** (name, url, added) rather than probing
+ every server for its extension version / last-used. A list that hangs on N
+ network round-trips (and can't run offline) is the wrong default for a CLI;
+ `lk connection test ` does the live probe, and `lk doctor` probes every
+ connection at once. Both `list` outputs redact the API key.
+- **push/pull gained `--remote-site `.** The plan only named
+ `--connection`, but a push needs *both* a connection and a remote site id.
+ Imported sites carry both (plan-18 migration-5 columns) so the common case is
+ zero-flag; `--connection`/`--remote-site` fill in for a site with no link.
+ Exit codes: 0 / 1 / 2 (server rejected, via a heuristic over the library's
+ error strings, since `sync::*` returns a bare `String`).
+
+Close out Track D: give the `lk` CLI full access to the ServerKit side of
+the app — manage connections, list remote sites, push, pull — plus shell
+completions. Everything is a thin wrapper over `localkit_lib` calls that
+already exist; this plan is mostly CLI ergonomics and conventions.
+
+## Motivation
+
+The GUI can do everything ServerKit-related; the CLI can do none of it.
+That blocks scripting the exact workflows the CLI exists for ("nightly
+`lk pull db` before I start work", CI-flavored local refreshes) and leaves
+Track D's checkboxes open. Because `sync.rs` and `serverkit.rs` already do
+the heavy lifting and emit progress to stderr when there's no Tauri handle,
+this is a high-value, low-risk surface expansion.
+
+## Design
+
+### Phase 1 — Connections (`src-tauri/lk/src/main.rs`)
+
+- `lk connection add ` — prompts for the API key with a hidden
+ TTY prompt (`rpassword`; `--key` flag / `LOCALKIT_API_KEY` env for
+ non-TTY), then immediately runs the same `test_connection` validation as
+ the GUI (health → key → extension probe) and refuses to store a key that
+ doesn't validate.
+- `lk connection list` — table or `--json`: name, url, extension version /
+ features, last used. `lk connection test ` re-runs validation.
+ `lk connection remove ` — prompts (default No), `--yes` on non-TTY.
+- Connections resolve by exact id or case-insensitive name, same rule as
+ sites; ambiguity is an error listing the matches.
+
+### Phase 2 — Sync commands
+
+- `lk sites --remote ` — remote site listing via the extension
+ (new read-only wrapper over `serverkit.rs`).
+- `lk push --code|--db [--connection ]` and `lk pull
+ --db [--connection ]`. `--connection` is required only when the
+ site has no linked remote (plan 18's migration-5 columns) and more than
+ one connection exists.
+- Progress: the library's `site::emit` already prints `[stage] message` to
+ stderr with no app handle; v2 byte progress (plan 19) prints a
+ `\r`-redrawn single-line percentage on TTY, plain lines when piped.
+- Exit codes: 0 success, 1 error, 2 = remote rejected the operation
+ (distinguishable in scripts). Errors keep the `error: ` stderr
+ convention.
+- `--json` on push/pull prints the resulting `SyncRecord`.
+
+### Phase 3 — Completions + doctor
+
+- `lk completions ` via `clap_complete` — static
+ subcommand/flag completion (dynamic site-name completion is a later
+ stretch; the generator hooks make it cheap to add).
+- `lk doctor` gains connection checks: for each stored connection, DNS +
+ TLS + `/pair` reachability, printed as pass/fail lines — one command to
+ answer "is it me or the server".
+
+## Conventions (binding, per AGENTS.md)
+
+- stdout carries data only; all chrome/progress/✓ to stderr.
+- `--json` per command, always pretty.
+- No logic in the CLI crate — anything reusable goes into `localkit_lib`
+ (e.g. the "resolve connection by name" helper lives next to the site
+ resolver).
+
+## Verification
+
+- Against `examples/mock_localkit_ext.cjs` (extended in plans 18/19):
+ scripted run — `connection add` (env key) → `sites --remote` → `push
+ --code` → `pull --db` → assert exit codes and `--json` shapes.
+- `clap_complete` output smoke: generate all four shells, assert non-empty
+ and stable (snapshot test).
+- Manual: `lk doctor` with the mock server up/down.
diff --git a/docs/plans/22_multi-stack-core.md b/docs/plans/22_multi-stack-core.md
new file mode 100644
index 0000000..6363447
--- /dev/null
+++ b/docs/plans/22_multi-stack-core.md
@@ -0,0 +1,135 @@
+# 22 — Multi-stack core: kind/capability model + generic Docker apps
+
+Status: ✅ shipped
+
+**Shipped as migration 6** (the plan said "migration 7" before the actual
+numbering settled; 6 was the next free `user_version`). Docker apps ship
+**code-only**: `config.db_engine`/`db_service` are detected and stored, but
+`db_sync` stays off until engine-native dumps land — a kind must not claim a
+capability it can't deliver (see Risks). Clone, blueprints and ServerKit
+push/pull stay WordPress-only (per-kind support is plan 26) and reject a docker
+site with a clean error. The import dialog takes a **typed folder path** rather
+than a native picker (a `tauri-plugin-dialog` folder picker is a follow-up).
+
+Generalize LocalKit's core from "WordPress site manager" to "local project
+manager" in two steps: a `kind` + capability model that makes every feature
+stack-aware, then the first non-WP kind — bring-your-own-compose **Docker
+apps**. Deliberately placed before plans 23–25 so everything built after
+this is capability-aware from day one instead of retrofitted. The
+PHP/Laravel stack and per-kind ServerKit sync are plan 26.
+
+## Motivation
+
+LocalKit assumes WordPress everywhere it matters: the terminal shells into
+a hardcoded `wordpress` service, sync tars a hardcoded `wp-content/`, DB
+ops go through wp-cli, one-click login uses a WP MU plugin, and the UI
+shows WP affordances unconditionally. Yet most of the machinery — per-site
+Compose projects, the shared router, terminals, logs, snapshots, tray — is
+stack-agnostic in principle. A developer with a Laravel API or a stray
+dockerized tool alongside their WP sites gets zero value today. Meanwhile
+every plan we ship before this one adds more WP-shaped code to unwind
+later. The goal: one capability system that every feature checks, with
+WordPress as the polished reference implementation — not an `if` branch.
+
+## Design
+
+### Phase 1 — Kind + capability core (migration 7)
+
+- `sites.kind` column: `"wordpress" | "docker"` (default `"wordpress"` —
+ existing rows migrate cleanly; `"php"` arrives with plan 26). Sites also
+ gain `config_json` (per-kind settings: service names, sync path, app
+ port).
+- Capability table in `src-tauri/src/site.rs` (const per kind, exposed via
+ `app_info` and on each `Site` payload):
+ `domains, terminal, logs, snapshots, db_gui, db_sync, code_sync,
+ one_click_login, wp_tools, search_replace`. WordPress = all true;
+ docker = `domains, terminal, logs, snapshots, code_sync`.
+- De-hardcode the WP assumptions:
+ - `terminal.rs` execs into `config.service` (default `wordpress`);
+ - `sync.rs` code archives tar `config.sync_path` (default `wp-content/`);
+ - `router.rs` upstream reads `config.app_port` (default = site port);
+ - one-click login, Tools tab, WP Admin button, `lk wp` gate on
+ capability in both frontends — Tauri commands return a clean
+ "not supported for this site kind" error; the UI hides rather than
+ errors.
+- **Grep-audit gate:** checklist of every `wordpress` / `wp-content` /
+ `wpcli` literal in `src-tauri/src` with a verdict (capability-gated,
+ config-driven, or legitimately WP-only). `cargo check` + the full WP
+ smoke example must pass unmodified before Phase 2 starts — WordPress is
+ the zero-change path by construction.
+
+### Phase 2 — Generic Docker app kind
+
+- Creation flow: "Import a Docker project" in NewSiteDialog — pick a
+ directory containing a compose file; LocalKit **copies** it into the
+ managed site dir (owned, not referenced — external dirs are a
+ backup/locking nightmare), asks which service is the app + its port,
+ writes `.env` and the record. Copy excludes `.git`, `node_modules`,
+ `vendor` via a default ignore list with an opt-out.
+- Gets for free: start/stop/restart/delete, logs viewer, terminal (exec
+ into the chosen service), local domain (`.test` → app port, all
+ plan-16 conflict/fallback behavior included), tray actions, `lk`
+ lifecycle commands.
+- Snapshots (plan 17): code-only by default; if a recognized db image
+ (`mysql`/`mariadb`/`postgres`) is among the services, `db_sync`
+ capability flips on and DB snapshots/dumps use the engine's native dump
+ tool.
+- No WP tooling, no ServerKit sync (plan 26), no admin login — the value
+ is "all my local projects in one place, with domains and a tray".
+
+### Phase 3 — Frontend capability gating
+
+- `Site` type in `src/lib/types.ts` gains `kind` + `capabilities`;
+ SiteDetail renders tabs/sections from the capability list (Tools tab and
+ WP Admin button hidden for `docker`), Dashboard cards get a small kind
+ badge (WP / Docker), `buildCommands()` skips capability-less per-site
+ commands.
+- Mock mode: one fake site per kind so gated UI is reviewable in
+ `npm run dev:mock`.
+
+## Risks
+
+- Scope creep — the guardrail: a kind ships only when every capability it
+ claims works; partial kinds are worse than no kinds. WordPress
+ regressions block merge, full stop.
+- The de-hardcoding touches `terminal.rs`, `sync.rs`, `router.rs`,
+ `wordpress.rs` — hence the Phase 1 grep-audit gate; no "we'll catch it
+ later".
+- Users importing huge compose projects: the ignore list covers the common
+ cases; the import dialog shows the copied size before confirming.
+
+## Verification
+
+- WP regression: existing `smoke` / `m4_smoke` examples pass unmodified.
+- New `docker_smoke` example: import a trivial two-service compose fixture
+ → start → domain resolves → terminal opens in the right service →
+ stop → delete.
+- `cargo test --lib site`: capability matrix tests (every kind × every
+ capability is an explicit, tested decision), compose-copy ignore list,
+ `config_json` serde defaults.
+
+## Phase 1 grep-audit gate (verdicts)
+
+Every `wordpress` / `wp-content` / `wpcli` literal in `src-tauri/src`, with a
+verdict. `cargo check` + the full WP smoke (`create`/`verify`/`info`/`clone`)
+and `snapshot_smoke` all pass unmodified — WordPress is the zero-change path.
+
+- **config-driven** (now read from `SiteConfig`, WP default = the old literal):
+ - `terminal.rs` shell service — `config.service` (default `wordpress`).
+ - `site.rs`/`snapshot.rs`/`sync.rs` archive + restore path — `config.sync_path`
+ (default `wp-content`), threaded through `build/write_wp_content_tgz` and
+ `restore_wp_content`.
+ - `router.rs` upstream port — `config.upstream_port(site.port)`.
+ - every "is the app running" `c.service == "wordpress"` check —
+ `c.service == site.app_service()` (site.rs `list`, lib.rs `login`/`terminal`,
+ snapshot.rs `is_running`).
+- **capability-gated** (WP-only; a non-WP site gets a clean refusal, UI hides):
+ `wp_cli_info`, `login`/`site_wp_users`, `lk wp`, `lk login` (via `require`),
+ the router WP-URL rewrite (`search_replace`), clone / blueprint save /
+ ServerKit push+pull (kind guard), and the snapshot DB dump (`db_sync`).
+- **legitimately WP-only** (left as literals — these ARE WordPress by nature):
+ `site.rs render_compose`/`render_env` (the generated WP compose + `.env`);
+ the whole of `wordpress.rs` (wp-cli, the MU login plugin, `wp db`/
+ `search-replace`); `sync.rs` `safe_entry_path`/`extract_wp_content` (the WP
+ ServerKit import archive contract, plan 26 for other kinds); `blueprint.rs`
+ wp-cli steps; the `snapshot.rs` `wp cache flush` (gated on `wp_tools`).
diff --git a/docs/plans/23_reconciliation.md b/docs/plans/23_reconciliation.md
new file mode 100644
index 0000000..58a4726
--- /dev/null
+++ b/docs/plans/23_reconciliation.md
@@ -0,0 +1,101 @@
+# 23 — Status reconciliation & crash recovery
+
+Status: ✅ shipped
+
+> Shipped: migration 7 (`status_updated_at`) with a forward-only
+> `settle_status` compare-and-swap; `reconcile.rs` (`classify`/`decide`
+> decision table, batched `docker::project_container_states`, `InFlight`
+> guard shared across every lifecycle path, 60 s `spawn_loop` + startup pass);
+> the new `degraded` status across StatusBadge / dashboard / SiteDetail /
+> palette / tray / `lk list` / mock; a 30 s-cached `docker::check_cached` behind
+> a sidebar "Docker unavailable" pill (`useDocker`); and half-created recovery
+> via the `.localkit-install-complete` marker + startup backfill, `incomplete`
+> on `SiteWithStatus`/`SiteDetail`, `site::resume` (+ `resume_site` command,
+> `lk resume`), and the dashboard's "Setup incomplete" → Resume / Clean up.
+> Verified: `cargo test --lib` (decision table, settle CAS, ps grouping), the
+> smoke `reconcile` + `recover` subcommands against real Docker, and the mock
+> UI (degraded badge, Docker pill, incomplete → Resume).
+
+Keep the app's view of the world honest: a reconciler that continuously
+settles the DB's site statuses against Docker's ground truth, plus a
+recovery path for sites left half-created by a crash or kill mid-install.
+
+## Motivation
+
+Site status today is write-path only: commands set `running`/`stopped` in
+SQLite when they succeed. Reality disagrees often — Docker Desktop
+restarts, the user kills the app mid-create, containers get `docker stop`ed
+from outside, a push dies halfway. The UI then shows "running" sites that
+are dead, "stopped" sites that are up, and (worst) a site whose directory
+and containers exist but whose WP install never finished, with no offered
+path except manual cleanup. The tray menu reads the same DB status, so the
+lie propagates everywhere. Rule: **inspect ground truth, settle forward,
+never guess.**
+
+## Design
+
+### Phase 1 — Reconciler (`src-tauri/src/reconcile.rs`)
+
+- `reconcile_once(state) -> Vec`: for each site, compare
+ the DB status with `docker::compose_ps` ground truth:
+ - DB `running` but no containers → settle to `stopped` (external stop) —
+ unless the site's own event stream marked it running in the last 60 s
+ (grace window for slow starts).
+ - DB `stopped` but wordpress container `Up` → settle to `running`
+ (external start).
+ - Containers up but wordpress service restarting/unhealthy → `degraded`
+ (new status, amber badge — distinct from both running and stopped).
+- **Forward-only semantics:** reconciliation may never downgrade a status
+ that a *newer* explicit command/event set. Each status write carries a
+ `status_updated_at` (new column, **migration 6**); the reconciler only
+ wins when its observation is newer. A late success can never be clobbered
+ by a stale inspect, and vice versa.
+- Scheduling: once at app start (before the window's first data load, so
+ the dashboard opens honest), then every 60 s while running, debounced
+ against any in-flight lifecycle command per site (a site with an active
+ command is skipped — its events own the truth right now).
+- After any settle: `tray::refresh(&app)` + a lightweight
+ `sites-changed` event so the frontend re-fetches; settles are logged
+ (`reconciled: site X running→stopped (external stop)`) at info level.
+
+### Phase 2 — Half-created site recovery
+
+- Marker: `site.rs` writes `.localkit-install-complete` (empty file) in the
+ site dir as the last create step. On reconcile, a site record whose dir
+ lacks the marker is flagged `incomplete`.
+- UI: incomplete sites render with an amber "Setup incomplete" badge and a
+ choice dialog — **Resume setup** (re-run from the install stage:
+ containers exist, images pulled, so it's the wait + `wp core install`
+ tail of the create flow) or **Clean up** (delete path, which already
+ tolerates partial state).
+- Same guard covers killed installs behind the "waiting for database"
+ stage: resume re-enters the existing wait loop rather than assuming
+ health.
+
+### Phase 3 — Docker daemon health
+
+- `docker::check` result cached for 30 s and exposed via `app_info`; when
+ the daemon drops, the sidebar shows a global "Docker unavailable" pill,
+ lifecycle commands short-circuit with the existing `friendly_error`, and
+ the reconciler suspends itself (no ground truth = no settles, definitely
+ no mass "stopped" flapping when Docker Desktop restarts).
+
+## Risks
+
+- `compose_ps` per site every 60 s is N subprocesses; batch by running one
+ `docker ps` filter pass and matching compose project names locally. Keep
+ `no_window` discipline.
+- Race with in-flight creates: the per-site in-flight set must be shared
+ with *all* command paths (GUI commands, `lk`, tray spawns) — a
+ `DashSet` on `AppState` checked by the reconciler.
+- `degraded` is a new status value — touchpoints: `StatusBadge`, dashboard
+ filters, tray menu labels, `lk list` output, mock data.
+
+## Verification
+
+- `cargo test --lib reconcile`: decision-table unit tests over a stubbed
+ compose-ps (every DB-status × container-state × recency combination,
+ including the forward-only guard).
+- Manual: `docker stop` a site's container externally → within 60 s the UI
+ and tray show stopped; kill the app mid-create → relaunch → "Setup
+ incomplete" → Resume finishes the install.
diff --git a/docs/plans/24_site-tools.md b/docs/plans/24_site-tools.md
new file mode 100644
index 0000000..759763c
--- /dev/null
+++ b/docs/plans/24_site-tools.md
@@ -0,0 +1,110 @@
+# 24 — Site tools: database GUI, search-replace, debug mode, config editor
+
+Status: ✅ shipped
+
+All four phases landed. Notes where the implementation reconciled the plan
+against real behaviour:
+
+- **Adminer login** uses the site's `wordpress` DB user, not `root`: the compose
+ template sets `MYSQL_RANDOM_ROOT_PASSWORD`, so root's password is unknowable.
+ The "Open database" button opens `?server=db&username=wordpress&db=wordpress`
+ and copies the `wordpress` user's password to the clipboard.
+- **Adminer port** is `db_port + 1000` (`Site::adminer_port`), mapped in the
+ deterministic compose template; `open_site_database` rewrites the compose file
+ first so sites created before the feature pick up the service.
+- **`db-.test`** is carried in `render_caddyfile` (+ matching hosts
+ entries) for `db_gui` sites; the button opens the domain when local domains are
+ on, else `localhost:`.
+- **Search-replace** parses wp-cli's *tab-separated* report (it drops the ASCII
+ grid when stdout is a pipe, which is what LocalKit captures).
+- **Debug** writes `wp-config.php` via a root wpcli runner (`--user root` +
+ `--allow-root`) — the file is root-owned in the wp-data volume.
+- **Config editor** reads/writes `wp-config.php` with `docker compose cp`
+ (runs as the daemon, so it overwrites the root-owned file; requires the site
+ running); `.env` is a plain host file whose save offers a restart
+ (`compose up -d`, which recreates services whose env changed).
+
+A "Tools" tab on SiteDetail covering the four things every WP developer
+reaches for an external app to do today: browse the database, run a
+search-replace, toggle WP_DEBUG and read the debug log, and edit
+`wp-config.php` / `.env` without leaving the app.
+
+## Motivation
+
+LocalKit covers the site lifecycle well, but the *inner loop* of WordPress
+development still pushes users elsewhere: they install TablePlus/phpMyAdmin
+for the database, open a terminal for `wp search-replace` (or worse, run a
+serialization-unsafe SQL replace by hand), edit `wp-config.php` in an
+editor to turn on debugging, and tail `debug.log` in another window. Each
+is a small, well-understood feature that the existing infrastructure
+(profile-gated compose services, the wpcli runner, the router, the file
+system) already supports. Together they make SiteDetail the single place
+the daily work happens.
+
+## Design
+
+### Phase 1 — Database GUI (Adminer sidecar)
+
+- Adminer (single-file PHP, ~0.5 MB — not phpMyAdmin's 50 MB image) as a
+ profile-gated `adminer` service in the site compose template
+ (`adminer:4-standalone`), off by default, toggled from Tools → Database.
+ Gated on the `db_gui` capability (plan 22), so non-WP kinds with a
+ database get it too. Port: `db_port + 1000` mapped at create time (deterministic, no
+ allocator changes), plus a router host `db-.test` when domains are
+ enabled — `render_caddyfile` gains one conditional block; when the router
+ is in fallback-port mode (plan 16) the same port-awareness applies.
+- "Open database" button starts the profile service on first use
+ (`docker compose --profile tools up -d adminer`), then opens the URL with
+ `?server=db&username=root` prefilled (password copied to clipboard with a
+ toast — Adminer can't take it in the URL).
+- Mock mode: the button opens a fake disabled state with the same copy.
+
+### Phase 2 — Search-replace (`src-tauri/src/wordpress.rs`)
+
+- `search_replace(site_id, from, to, dry_run) -> SearchReplaceResult`
+ wrapping `wp search-replace --all-tables --precise
+ --report-changed-only [--dry-run]` — the serialization-safe path, never
+ raw SQL.
+- UI: Tools → Search & Replace: from/to fields, always runs dry-run first
+ and shows the per-table change counts, then an explicit Apply. Result
+ notes the pre-replace snapshot (plan 17 auto-snapshot, kind
+ `pre_search_replace`) with a restore shortcut.
+- `lk wp` already covers the CLI case — no new subcommand needed.
+
+### Phase 3 — Debug mode + log viewer
+
+- `set_debug(site_id, enabled)`: `wp config set WP_DEBUG --raw` +
+ `WP_DEBUG_LOG` + `WP_DEBUG_DISPLAY false` (log to file, never to screen)
+ via the wpcli runner; status read via `wp config get`.
+- Tools → Debug: toggle + an auto-refreshing tail of
+ `wp-content/debug.log` rendered in the same mono/log styling as the
+ container logs viewer (plain fs read — the file is bind-mounted), with a
+ "clear log" button.
+
+### Phase 4 — Config file editor
+
+- Tools → Config: a lightweight editor (existing JetBrains Mono textarea
+ styling, no Monaco dependency) for `wp-config.php` and the site `.env`,
+ with save → offers to restart the site when `.env` changed (required for
+ compose to pick it up; `wp-config` needs nothing). Danger styling and a
+ one-line "editing this can break the site" note; no diff/backup machinery
+ — snapshots (plan 17) are the safety net.
+
+## Risks
+
+- Adminer on the router adds an attack surface on a dev machine: bound to
+ localhost anyway (same trust level as the sites), and off by default.
+- Search-replace on big databases can take minutes — run via the wpcli
+ runner with the standard site-event progress; the dry-run-first flow
+ means the user sees cost before committing.
+- The config editor must not fight the compose/env templates: `.env` keys
+ LocalKit manages (ports, passwords) get inline "managed by LocalKit"
+ markers in the template so the editor can warn on those lines only.
+
+## Verification
+
+- Manual: enable Adminer → log in → browse; dry-run a replace → apply →
+ assert serialized widget survives; toggle debug → fatal in a must-use
+ test plugin appears in the log viewer; edit `.env` port → prompted
+ restart → site answers on the new port.
+- Mock mode renders all four tool sections with fake data.
diff --git a/docs/plans/25_release-polish-completion.md b/docs/plans/25_release-polish-completion.md
new file mode 100644
index 0000000..4f95580
--- /dev/null
+++ b/docs/plans/25_release-polish-completion.md
@@ -0,0 +1,91 @@
+# 25 — Release polish completion: updater, keyring, notifications, test suite
+
+Status: ✅ implemented (on `dev`) — all four phases: update checker,
+OS keyring for API keys, OS notifications, and the automated test suite
+(Rust `cargo test --workspace` + frontend `vitest`, both wired into CI).
+
+Finish the genuinely remaining M5 work (plan 5 predates the CI/release
+workflows, which shipped separately): in-app update awareness, OS-keyring
+storage for ServerKit API keys, OS desktop notifications for long
+operations, and a real automated test suite.
+
+## Motivation
+
+Releases already build and publish for all platforms via
+`.github/workflows/release.yml`, but the *installed* app has no idea newer
+versions exist — users only update by re-downloading manually. ServerKit
+API keys sit in plaintext SQLite (accepted for v1, now the largest security
+debt in the app). Long operations (create, push, pull) complete silently
+when the window is unfocused or closed-to-tray. And the test surface is
+still `cargo check` + a handful of unit tests + manual smoke examples,
+which every plan above (16–24) will strain. These four items are the
+difference between "works on my machine" and a distributable product.
+
+## Design
+
+### Phase 1 — Update awareness
+
+- `tauri-plugin-updater` requires signed releases; our releases are
+ unsigned. So: a lightweight checker instead — on launch (and daily),
+ GET the latest GitHub release tag via the API; if newer than
+ `env!("CARGO_PKG_VERSION")`, show a dismissible toast + a Settings →
+ General "Update available" row linking to the release page
+ (opener plugin). Snooze state + last-checked in `app_settings` (KV).
+- Same check in `lk` (`lk doctor` prints "update available: vX.Y.Z"; never
+ auto-downloads).
+- If releases become signed later, swapping the checker for the real
+ updater is a drop-in replacement behind the same Settings row.
+
+### Phase 2 — OS keyring for ServerKit API keys
+
+- `keyring` crate (Windows Credential Manager / macOS Keychain / Secret
+ Service) keyed `localkit/connection/`.
+- `serverkit.rs` gains a `KeyStore` abstraction with two backends; read
+ path = keyring → SQLite fallback (legacy) → migrate-on-read (write to
+ keyring, null the column). New/changed keys only ever touch the keyring.
+- Graceful degradation: keyring unavailable (headless Linux, locked
+ keychain) → fall back to SQLite with a one-time warning logged, never a
+ hard failure. `lk` on servers keeps working.
+- `serverkit_connections.api_key` column stays (nullable) for downgrade
+ compat — no migration needed, just stop writing it.
+
+### Phase 3 — OS desktop notifications
+
+- `tauri-plugin-notification`: fire on completion of long operations
+ (site created, push/pull done or failed, restore done) **only when the
+ window is unfocused or closed-to-tray** — the toast system already owns
+ in-focus feedback, and double-notifying is worse than either alone.
+- Settings → General toggle `osNotifications` (default on), per the
+ settings-store conventions. Clicking a notification focuses the window
+ (single-instance plugin already handles focus).
+
+### Phase 4 — Test suite
+
+- Rust (`cargo test --workspace`, already wired in CI): unit tests per
+ pure module — `site::slugify`/`unique_slug`/port allocation, `db`
+ migration forward-only invariants (apply 1→N twice, assert
+ `user_version`), `sync` archive builders, plus whatever plans 16–24 add
+ (probe parsing, chunker, reconcile decision table, retention pruning).
+- Frontend (`vitest`, new dev-dep, added to the CI build job):
+ `lib/shortcuts.ts` canonicalizer, `lib/fuzzy.ts`, `lib/keybindings.ts`
+ resolver, settings store parsing (`"true"`→bool, migrations), toast
+ dedupe logic in `lib/errors.ts`.
+- Keep the smoke examples as the E2E layer; the unit suites exist so most
+ regressions are caught without Docker.
+
+## Risks
+
+- Keyring prompts: macOS may show a keychain permission dialog on first
+ access — acceptable one-time cost; documented in Settings copy.
+- Notification permission on macOS must be requested at runtime; treat
+ denial as "toggle off", don't nag.
+- Vitest + jsdom for store tests: keep them DOM-free where possible (pure
+ logic), mock `window.__LOCALKIT_SETTINGS__` explicitly.
+
+## Verification
+
+- `cargo test --workspace` + `npm run test` green in CI (new step).
+- Manual: install previous release → launch → update toast appears → link
+ opens release page. Add a connection → key visible in Windows Credential
+ Manager, absent from SQLite. Close to tray → run a push from `lk` →
+ completion notification appears.
diff --git a/docs/plans/26_php-laravel-stack.md b/docs/plans/26_php-laravel-stack.md
new file mode 100644
index 0000000..fa5badf
--- /dev/null
+++ b/docs/plans/26_php-laravel-stack.md
@@ -0,0 +1,107 @@
+# 26 — PHP/Laravel stack + per-kind ServerKit sync parity
+
+Status: ✅ shipped (LocalKit side); server-side php *hosting* awaits a php backend
+
+**Implementation notes (what shipped vs the design below):**
+- Phase 1: `php.rs` generates the stack. The `app` service is **built** from a
+ tiny `docker/Dockerfile` (`FROM php:-fpm` + `pdo_mysql` + Composer) rather
+ than the bare php-fpm image — the plan's "keep the default extension set" left
+ a Laravel app unable to reach the bundled mariadb, so the two extensions a
+ bundled DB makes pointless without are added. Exotic extensions stay the
+ documented "edit the Dockerfile" path. `render_compose` is now kind-aware.
+- Phase 2: `dbsync.rs` is the engine-native dispatch (mariadb-dump/mysqldump/
+ pg_dump + clients), wired into `snapshot::create`/`restore`. Verified via
+ `smoke -- php` (snapshot DB round-trip).
+- Phase 3: client-side per-kind push/pull/import (`sync.rs`), `kinds`
+ advertisement + gating (`serverkit.rs`), mock php remote + `m4_smoke` step 8.
+ The **server extension** gained the contract (`kinds` in `/pair`, `kind` in
+ `/sites`) but advertises `['wordpress']` only — ServerKit has no php site
+ backend yet, so php hosting there is a follow-up. The client already speaks
+ the php protocol, so flipping `KINDS` on lands with that backend.
+- Not done (out of the plan's Phase 1–3 scope): per-kind clone/blueprints stay
+ WordPress-only.
+
+The second multi-stack kind: a generated **PHP/Laravel** site template with
+database sync that doesn't depend on wp-cli — plus the ServerKit extension
+changes that make push/pull/import work per site kind. Depends on plan 22
+(kind/capability core), plan 17 (snapshots), plan 18 (import flow), and
+plan 19 (sync v2).
+
+## Motivation
+
+Plan 22 makes LocalKit stack-aware and covers ad-hoc Docker projects, but
+the most common non-WP case on the server side deserves a first-class
+template: plain PHP/Laravel apps. Today syncing one means hand-running
+`mysqldump` and rsync. With the capability core in place, a `php` kind is
+an additive increment: a compose template, engine-native DB sync, and
+per-kind dispatch on both ends of the sync protocol — no new architecture.
+Node/Python kinds remain deliberately out of scope; the capability model
+makes them a follow-up plan of the same shape when there's demand.
+
+## Design
+
+### Phase 1 — PHP/Laravel stack template (`src-tauri/src/site.rs`)
+
+- New `kind: "php"` in the capability matrix: everything except the
+ WP-specific trio (`one_click_login`, `wp_tools`, `search_replace`);
+ `db_gui` true (plan 24's Adminer tooling applies), `db_sync` and
+ `code_sync` true.
+- Generated compose, mirroring the WP template's conventions: `app`
+ (php-fpm, version from the existing `PHP_VERSIONS` allowlist), `web`
+ (nginx with a static + fastcgi config template), `db` (mariadb,
+ `db_port` allocation unchanged), profile-gated `adminer`.
+- Creation dialog: empty docroot skeleton (Laravel-ready `public/` webroot)
+ or import existing code into the site dir (same ignore-list copy as
+ plan 22's Docker import). No framework installer inside the app — the
+ terminal is right there for `composer create-project`.
+
+### Phase 2 — Engine-native DB sync (`src-tauri/src/sync.rs`)
+
+- DB export/import per kind, dispatched on capability instead of wp-cli:
+ `php`/`docker`-with-db → `mysqldump` in-container for export, `mysql <
+ dump` via `compose_run_stdin` for import (postgres services: `pg_dump` /
+ `psql` — same dispatch table).
+- No search-replace for `php`: URL config is the app's own concern. The
+ import/pull flow offers a best-effort `APP_URL` patch in the project's
+ `.env` (Laravel convention), off by default, clearly labeled
+ best-effort.
+- Push/pull orchestration, snapshots (plan 17 kinds `pre_push`/`pre_pull`),
+ sync_history records, and site-event stages are kind-agnostic already
+ after plan 22 — this phase is dispatch + templates, not new flow.
+
+### Phase 3 — ServerKit parity (both repos)
+
+- Extension (`serverkit-localkit`, ServerKit repo):
+ - `/sites` payload gains `kind`; push/pull endpoints accept non-WP site
+ ids and dispatch per kind: code = tar of the app's project dir (not
+ `wp-content`), db = engine dump/restore instead of the WP container
+ assumptions.
+ - `/pair` `features` advertises supported kinds (e.g. `"kinds":
+ ["wordpress", "php"]`); LocalKit disables sync/import UI for kinds the
+ server's extension version doesn't know — never fails mid-flow.
+- Import flow (plan 18) extends to `php`/`docker` kinds with the same
+ orchestration minus WP install steps; `lk import` gains the kinds
+ transparently.
+- Sync v2 (plan 19) chunked protocol is kind-agnostic by design — only the
+ server-side processing step in `finish` dispatches per kind.
+
+## Risks
+
+- PHP matrix drift (8.1/8.2/8.3 extensions): keep the image's default
+ extension set; document that exotic extensions mean customizing the
+ imported compose (which plan 22 makes a supported path).
+- `docker` kind + ServerKit sync: arbitrary compose projects can't be
+ matched to server apps reliably — v1 sync parity covers `php` only;
+ `docker` sites keep local-only sync (snapshots).
+- Two repo lockstep again: the `kinds` advertisement keeps old client ↔
+ new server and new client ↔ old server combinations safe in both
+ directions.
+
+## Verification
+
+- Extend `mock_localkit_ext.cjs` with a fake `php` site: full import →
+ push db → pull db cycle through `m4_smoke`, asserting engine-native dump
+ commands were used (mock logs them).
+- `cargo test --lib sync`: per-kind dispatch table tests (every kind ×
+ operation has an explicit, tested handler or a clean unsupported error).
+- WP regression: all existing smoke examples pass unmodified.
diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md
index 1b27240..3a8af48 100644
--- a/docs/plans/ROADMAP.md
+++ b/docs/plans/ROADMAP.md
@@ -23,6 +23,17 @@ The file numbers ARE the build order — each plan leans on the ones before it.
| 13 | `13_settings-store` | ✅ | Unified settings store on `app_settings` KV + pre-paint injection; substrate for terminal settings and themes. |
| 14 | `14_terminal-quick-wins` | ✅ shipped | Web-links, copy-on-select, ghost-text history, terminal font/scrollback settings (needs 13). |
| 15 | `15_command-palette-shortcuts` | ✅ shipped | Command registry + palette (mod+K), global shortcuts, remappable bindings in Settings (needs 13). |
+| 16 | `16_router-coexistence` | ✅ shipped | Port-80/443 conflict pre-flight + configurable router ports so domains survive alongside LocalWP & co. |
+| 17 | `17_snapshots` | ✅ shipped | DB + wp-content snapshots with one-click restore; automatic before push/pull/delete. Safety net for 18–20. |
+| 18 | `18_import-remote-site` | ✅ shipped | Clone a ServerKit site down as a *new* local site; adds the extension's `pull/code` endpoint + a `features` capability contract. |
+| 19 | `19_sync-v2-chunked` | ✅ shipped | Chunked resumable push/pull with byte progress + cancel (breaks the 100 MB / in-memory limits). Server-side job-queue handoff deferred — see the plan. |
+| 20 | `20_clone-and-blueprints` | ✅ shipped | One-click site clone + save-site-as-blueprint creation flows, portable `.lkbp` export/import (needs 17). |
+| 21 | `21_cli-serverkit` | ✅ shipped | `lk connection/push/pull` + remote listing + shell completions (Track D). |
+| 22 | `22_multi-stack-core` | ✅ shipped | Kind/capability site model + bring-your-own-compose Docker apps — before 23–25 so new features are capability-aware from day one. |
+| 23 | `23_reconciliation` | ✅ shipped | Settle DB site status against Docker ground truth (forward-only, 60s reconciler); `degraded` status; recover half-created sites (Resume/Clean up); Docker-health pill. |
+| 24 | `24_site-tools` | ✅ shipped | Tools tab: Adminer sidecar, serialization-safe search-replace, WP_DEBUG + log viewer, config editor. |
+| 25 | `25_release-polish-completion` | ✅ | M5 remainder: update checker, OS keyring for API keys, OS notifications, real test suite. |
+| 26 | `26_php-laravel-stack` | ✅ shipped | Generated PHP/Laravel stack + engine-native DB sync + per-kind ServerKit sync/import parity (needs 22, 17–19). Server-side php *hosting* awaits a php backend; `serverkit-localkit` advertises `kinds: ['wordpress']` until then. |
Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferred
@@ -38,7 +49,22 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre
(plan 10)
- ✅ Windows polish: hide subprocess console windows, visible first-run
install progress (plan 9)
-- ⬜ Site duplication / clone (nice-to-have, unplanned)
+- ✅ Snapshots + one-click restore (plan 17): DB dump + wp-content archive per
+ snapshot, taken automatically before every push, pull, delete and restore;
+ retention capped per kind; Snapshots panel, `lk snapshot`, palette command
+- ✅ Site duplication / clone + reusable blueprints (plan 20): one-click clone
+ (fresh ports/secrets, admin login carried over), save-a-site-as-blueprint,
+ create-from-blueprint in the New Site dialog, and a portable `.lkbp`
+ export/import — all on the plan-17 snapshot engine
+- ✅ Status reconciliation + crash recovery (plan 23): a 60 s reconciler settles
+ DB status against Docker ground truth (forward-only, one batched `docker ps`),
+ a new `degraded` status, half-created-site recovery (Resume / Clean up via a
+ completion marker), and a Docker-unavailable pill — status never lies again
+- ✅ Site tools (plan 24): a Tools tab on SiteDetail with the inner-loop tools
+ WP devs reach for an external app to do — an Adminer database GUI (profile-
+ gated sidecar on db_port + 1000, `db-.test` route), a serialization-safe
+ search-replace (dry-run first, snapshot before Apply), a WP_DEBUG toggle +
+ debug-log viewer, and a wp-config.php / .env editor
## Track B — ServerKit (M3–M4)
@@ -48,19 +74,35 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre
- ✅ Push code (in-memory tar.gz of `wp-content/`), push DB (`wp db export`),
pull DB (download → `wp db import` → `wp search-replace`)
- ✅ Sync history per site (migration 3)
-- ⬜ Pull a remote site down as a *new* local site (today pull targets an
- existing local site)
+- ✅ Pull a remote site down as a *new* local site (plan 18): the extension's
+ new `pull/code` endpoint, safe-extract policy, no-`core install` import,
+ migration-5 origin columns, Import UI + `lk import`
+- ✅ Extension capability contract (`GET /pair` → `features`), so the UI
+ disables what an older server cannot do instead of failing mid-operation
+- ✅ Sync v2 (plan 19): chunked resumable push (8 MiB chunks, hash-verified
+ `finish`), `Range`-resumed downloads, byte-level progress and cancel —
+ the 100 MB request limit and the build-it-all-in-RAM ceiling are both gone,
+ with v1 kept as the fallback for servers without `sync-v2`
+- ⬜ Server-side job queue for the post-upload import/extract (plan 19 phase 3
+ remainder): today `finish` processes inline, so a client that disconnects
+ *during processing* — not transfer — cannot re-attach to learn the outcome
## Track C — Product (M5–M6)
-- ⬜ `npm run tauri build` installers per platform
-- ⬜ Auto-update (Tauri updater)
-- ⬜ OS keyring for ServerKit API keys (plaintext SQLite accepted for v1)
-- ⬜ Real test suite (today: `cargo check` + router hosts-block unit tests +
- the `smoke` / `m4_smoke` / `m6_smoke` examples)
+- ✅ `npm run tauri build` installers per platform (release.yml, all platforms + lk)
+- ✅ Update awareness (plan 25): GitHub-release checker → Settings row + launch
+ toast + `lk doctor` line; Tauri updater is a drop-in if releases get signed
+- ✅ OS keyring for ServerKit API keys (plan 25; `keystore.rs`, degrades to SQLite)
+- ✅ Real test suite (plan 25): `cargo test --workspace` (per-module unit tests) +
+ `npm run test` (vitest), both in CI; the `smoke`/`m4_smoke`/`m6_smoke` examples
+ stay as the E2E layer
- ✅ Local domains: `http(s)://.test` via a shared Caddy router +
managed hosts block + local CA trust (plan 6), layered on top of the
always-working `localhost:` URLs
+- ✅ Router coexistence (plan 16): port pre-flight that names the process
+ holding 80/443, configurable router ports with one-click fallback to
+ 8080/8443, port-aware `site_public_url`, conflict UX in Settings +
+ SiteDetail + `lk doctor`
- ✅ System tray + background mode: close-to-tray, tray menu with quick site
actions, single-instance focus (plan 8)
@@ -72,9 +114,35 @@ Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferre
/ `info` / `logs`
- ✅ `lk wp ` wp-cli passthrough, `lk env` (eval-able
exports), `lk doctor`, `-o json` / `--quiet` / `--data-dir` global flags
-- ⬜ ServerKit from the CLI: `lk connection add/list`, `lk push`, `lk pull`
- (library calls already exist; future)
-- ⬜ Shell completions, self-update (future)
+- ✅ `lk import ` (plan 18) — the first ServerKit
+ command in the CLI; the rest lands with plan 21
+- ✅ `lk clone `, `lk blueprint list|save|delete|export|import`
+ and `lk create --blueprint ` (plan 20)
+- ✅ ServerKit from the CLI (plan 21): `lk connection add/list/test/remove`,
+ `lk sites --remote `, `lk push --code|--db`, `lk pull
+ --db` — validated `connection add`, target defaults to the site's linked
+ remote, exit 2 on a server rejection, `doctor` connection probes
+- ✅ Shell completions (plan 21): `lk completions `
+ via `clap_complete`; self-update (future)
+
+## Track F — Multi-stack (M9)
+
+- ✅ Kind/capability site model (`wordpress` | `docker`, `config_json` via
+ migration 6, capability-gated features in both frontends) — plan 22, placed
+ before the remaining feature plans so they're capability-aware from day one
+- ✅ Generic Docker apps (plan 22): import an existing compose project (copied,
+ not referenced; `.git`/`node_modules`/`vendor` excluded) → lifecycle, logs,
+ terminal, local domain (`.test` → the app's published port), tray,
+ code-only snapshots. Code-only for now — engine-native DB dumps (which would
+ flip `db_sync` on) are a follow-up
+- ✅ PHP/Laravel generated stack (plan 26): a generated php-fpm + nginx + mariadb
+ stack (built with pdo_mysql + Composer), empty Laravel-ready skeleton or import
+ an existing folder; engine-native DB sync (`dbsync`: mysqldump/mysql,
+ pg_dump/psql) wired into snapshots; per-kind ServerKit push/pull/import parity
+ gated on a `kinds` advertisement (`lk create --kind php`, New Site "PHP /
+ Laravel" tab). Server-side php *hosting* awaits a php backend (the extension
+ advertises `kinds: ['wordpress']`); per-kind clone/blueprints remain WP-only.
+- 🅿️ Node/Python kinds (unplanned; same capability shape when there's demand)
## Track E — UX ports from Faro (M12–M14)
@@ -93,7 +161,8 @@ Faro paths referenced in each plan):
fuzzy palette (mod+K), global shortcuts with editable-target guards,
remappable bindings in Settings → Keyboard, cheat-sheet, shared
`useDialog` for modals
-- ⬜ Later candidates from the survey (unplanned): OS desktop
- notifications, auto-updater (Track C), context menus, structured
+- ✅ OS desktop notifications (plan 25): fired on long-op completion only when
+ the window is unfocused/closed-to-tray, `osNotifications` toggle
+- ⬜ Later candidates from the survey (unplanned): context menus, structured
`{kind, message}` IPC errors, snippets, light theme (needs a CSS-var
token layer first)
diff --git a/package-lock.json b/package-lock.json
index 862eae0..73974ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
+ "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.0.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
@@ -25,11 +26,13 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
+ "jsdom": "^25.0.1",
"postcss": "^8.4.0",
"puppeteer-core": "^24.43.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
- "vite": "^5.4.0"
+ "vite": "^5.4.0",
+ "vitest": "^2.1.9"
}
},
"node_modules/@alloc/quick-lru": {
@@ -45,6 +48,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -327,6 +351,121 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1445,6 +1584,15 @@
"node": ">= 10"
}
},
+ "node_modules/@tauri-apps/plugin-notification": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+ "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
@@ -1584,6 +1732,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@@ -1669,6 +1930,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
@@ -1682,6 +1953,13 @@
"node": ">=4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
@@ -1912,6 +2190,30 @@
"node": "*"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1943,6 +2245,33 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2030,6 +2359,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -2060,6 +2402,27 @@
"node": ">=4"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2077,6 +2440,20 @@
"node": ">= 14"
}
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2095,6 +2472,23 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/degenerator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
@@ -2110,6 +2504,16 @@
"node": ">= 14"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/devtools-protocol": {
"version": "0.0.1608973",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",
@@ -2131,6 +2535,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.393",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
@@ -2155,18 +2574,77 @@
"once": "^1.4.0"
}
},
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
- "license": "MIT",
+ "license": "BSD-2-Clause",
"engines": {
- "node": ">= 0.4"
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
}
},
- "node_modules/esbuild": {
- "version": "0.21.5",
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
@@ -2260,6 +2738,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2280,6 +2768,16 @@
"bare-events": "^2.7.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -2371,6 +2869,23 @@
"node": ">=8"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2430,6 +2945,45 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -2474,6 +3028,48 @@
"node": ">=10.13.0"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2487,6 +3083,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2515,6 +3124,19 @@
"node": ">= 14"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@@ -2597,6 +3219,13 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -2613,6 +3242,47 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "25.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.1.0",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2671,6 +3341,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2681,6 +3358,26 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2705,6 +3402,29 @@
"node": ">=8.6"
}
},
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -2780,6 +3500,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2844,6 +3571,19 @@
"node": ">= 14"
}
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2851,6 +3591,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -3119,6 +3876,16 @@
"once": "^1.3.1"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/puppeteer-core": {
"version": "24.43.1",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz",
@@ -3305,6 +4072,13 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3329,6 +4103,26 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -3348,6 +4142,13 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -3410,6 +4211,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/streamx": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz",
@@ -3486,6 +4301,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3625,6 +4447,20 @@
"node": ">=0.8"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3673,6 +4509,56 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3686,6 +4572,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -3827,6 +4739,108 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
@@ -3834,6 +4848,71 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -3881,6 +4960,23 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index bcd240d..12e4333 100644
--- a/package.json
+++ b/package.json
@@ -8,11 +8,13 @@
"dev:mock": "vite --mode mock --port 1426 --strictPort",
"shots": "node scripts/capture-screenshots.mjs",
"build": "tsc && vite build",
+ "test": "vitest run",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
+ "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.0.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
@@ -29,10 +31,12 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
+ "jsdom": "^25.0.1",
"postcss": "^8.4.0",
"puppeteer-core": "^24.43.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
- "vite": "^5.4.0"
+ "vite": "^5.4.0",
+ "vitest": "^2.1.9"
}
}
diff --git a/scripts/verify-blueprints.mjs b/scripts/verify-blueprints.mjs
new file mode 100644
index 0000000..89de7c7
--- /dev/null
+++ b/scripts/verify-blueprints.mjs
@@ -0,0 +1,291 @@
+// Headless runtime verification for plan 20 (site clone + reusable blueprints).
+//
+// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the two
+// creation flows: the New Site dialog's "From blueprint" section lists the
+// sample blueprints with plugin/theme chips, selecting one switches the dialog
+// into create-from mode and stamps a new site out of it; a site's Clone button
+// opens the copy under a new name; and "Save as blueprint" from a site records
+// a new template that then shows up in the dialog.
+//
+// node scripts/verify-blueprints.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ } else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ page.on("dialog", (d) => d.accept());
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ // Focus a field and type into it for real (one React onChange per key), so
+ // a subsequent submit reliably reads the new value — the direct value-setter
+ // trick races the controlled-input re-render.
+ const typeInto = async (predicateSrc, value) => {
+ const focused = await page.evaluate((src) => {
+ // eslint-disable-next-line no-new-func
+ const match = new Function("i", `return (${src})(i)`);
+ const input = [...document.querySelectorAll("input, textarea")].find(match);
+ if (!input) return false;
+ input.focus();
+ return true;
+ }, predicateSrc);
+ if (!focused) return false;
+ await page.keyboard.down("Control");
+ await page.keyboard.press("KeyA");
+ await page.keyboard.up("Control");
+ await page.keyboard.press("Backspace");
+ await page.keyboard.type(value, { delay: 5 });
+ return true;
+ };
+
+ // Open a specific site's detail page by clicking the Details button inside
+ // that site's card (matching by name on the nearest card, not the first
+ // Details on the page).
+ const openSite = (siteName) =>
+ page.evaluate((name) => {
+ const btn = [...document.querySelectorAll("button")]
+ .filter((b) => b.textContent.trim() === "Details")
+ .find((b) => {
+ const card = b.closest("div.rounded-xl");
+ return card && card.textContent.includes(name);
+ });
+ if (!btn) return false;
+ btn.click();
+ return true;
+ }, siteName);
+
+ // Click the "Use" button inside a specific blueprint row (matching by name
+ // on the nearest row, not the first Use on the page).
+ const useBlueprint = (bpName) =>
+ page.evaluate((name) => {
+ const btn = [...document.querySelectorAll("button")]
+ .filter((b) => b.textContent.trim() === "Use")
+ .find((b) => {
+ const row = b.closest("div.rounded-lg");
+ return row && row.textContent.includes(name);
+ });
+ if (!btn) return false;
+ btn.click();
+ return true;
+ }, bpName);
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ // --- 1) New Site dialog: the "From blueprint" section ------------------
+ await clickByText("button", "New Site");
+ await sleep(500);
+ let text = await bodyText();
+ ok("New Site dialog opens", text.includes("New WordPress site"));
+ ok("blueprint section is present", /or start from a blueprint/i.test(text));
+ ok("sample blueprints are listed", text.includes("Starter Shop") && text.includes("Agency Base"));
+ ok("plugin/theme chips render", text.includes("woocommerce") && text.includes("storefront"));
+ ok(
+ "blueprint names its source site",
+ text.includes("from Pixel Bakery") || text.includes("from Acme Corporate")
+ );
+
+ // Select the Starter Shop blueprint (the Use button inside its row).
+ ok("a blueprint can be selected", await useBlueprint("Starter Shop"));
+ await sleep(300);
+ text = await bodyText();
+ ok("selection shows the based-on summary", text.includes("Based on") && text.includes("Starter Shop"));
+ ok(
+ "the create button switches to blueprint mode",
+ await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some(
+ (b) => b.textContent.trim() === "Create from blueprint"
+ )
+ )
+ );
+ ok(
+ "the name is prefilled from the blueprint",
+ await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find((i) => i.value === "Starter Shop");
+ return !!input;
+ })
+ );
+
+ // Back to a blank site, then forward again — the mode toggles cleanly.
+ await clickByText("button", "Use a blank site");
+ await sleep(200);
+ ok(
+ "can return to a blank site",
+ (await bodyText()).match(/or start from a blueprint/i) &&
+ (await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some((b) => b.textContent.trim() === "Create site")
+ ))
+ );
+
+ // --- 2) Create a site from a blueprint end to end ----------------------
+ ok("selected the Agency Base blueprint", await useBlueprint("Agency Base"));
+ await sleep(200);
+ await typeInto("(i) => i.value === 'Agency Base'", "Agency Copy");
+ await sleep(150);
+ await clickByText("button", "Create from blueprint");
+ // The staged progress toast should appear as the create runs.
+ const sawProgress = await page
+ .waitForFunction(
+ () =>
+ /writing project files|downloading wordpress|starting docker|waiting for wordpress|laying down|rewriting urls|created from blueprint/i.test(
+ document.body.innerText
+ ),
+ { timeout: 4000 }
+ )
+ .then(() => true)
+ .catch(() => false);
+ ok("a progress toast tracks the blueprint create", sawProgress);
+ await sleep(600);
+ text = await bodyText();
+ ok("creating from a blueprint navigates to the new site", text.includes("Back to sites"));
+ ok("the new site carries the given name", text.includes("Agency Copy"));
+
+ // --- 3) Clone a site under a new name ----------------------------------
+ await clickByText("button", "Back to sites");
+ await sleep(500);
+ // Open Pixel Bakery detail and clone it.
+ ok("opened Pixel Bakery", await openSite("Pixel Bakery"));
+ await sleep(700);
+ await clickByText("button", "Clone");
+ await sleep(400);
+ text = await bodyText();
+ ok("Clone dialog opens", /clone .+pixel bakery/i.test(text));
+ ok(
+ "clone name defaults to a copy",
+ await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find((i) =>
+ i.value.toLowerCase().includes("copy")
+ );
+ return !!input;
+ })
+ );
+ await typeInto("(i) => i.value.toLowerCase().includes('copy')", "Bakery Clone");
+ await sleep(150);
+ await clickByText("button", "Clone site");
+ await sleep(900);
+ text = await bodyText();
+ ok("cloning navigates to the new site", text.includes("Back to sites") && text.includes("Bakery Clone"));
+
+ // --- 4) Save an existing site as a blueprint ---------------------------
+ await clickByText("button", "Back to sites");
+ await sleep(500);
+ ok("opened Hiking Blog", await openSite("Hiking Blog"));
+ await sleep(700);
+ await clickByText("button", "Save as blueprint");
+ await sleep(400);
+ text = await bodyText();
+ ok(
+ "Save-as-blueprint dialog opens",
+ text.includes("Hiking Blog") && /as a blueprint/i.test(text)
+ );
+ await typeInto("(i) => i.value === 'Hiking Blog blueprint'", "Hiking Starter");
+ await sleep(150);
+ await clickByText("button", "Save blueprint");
+ await sleep(1200);
+ ok("saving a blueprint is toasted", (await bodyText()).includes("Saved"));
+
+ // The new blueprint shows up in the New Site dialog.
+ await clickByText("button", "Back to sites");
+ await sleep(400);
+ await clickByText("button", "New Site");
+ await sleep(500);
+ ok("the saved blueprint appears in the dialog", (await bodyText()).includes("Hiking Starter"));
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-cli-serverkit.mjs b/scripts/verify-cli-serverkit.mjs
new file mode 100644
index 0000000..5217f08
--- /dev/null
+++ b/scripts/verify-cli-serverkit.mjs
@@ -0,0 +1,158 @@
+// Headless runtime check of the plan-21 `lk` ServerKit surface against the mock
+// serverkit-localkit extension (examples/mock_localkit_ext.cjs). It shells out
+// to the compiled `lk` binary with a throwaway --data-dir, so it exercises the
+// real CLI (arg parsing, resolution, exit codes, JSON shapes) — not a stand-in.
+//
+// Covered: connection add (env key) → list --json (key redacted) → test →
+// sites --remote --json → add-with-bad-key refusal → push/pull argument errors
+// → completions for all shells → remove. The Docker-backed push/pull path is
+// exercised by `cargo run --example m4_smoke` and the arg resolution by the
+// `lk` unit tests; this script covers everything that talks to a live server
+// without needing Docker.
+//
+// Prereq: build the binary first — `cd src-tauri && cargo build -p lk`.
+// Run: node scripts/verify-cli-serverkit.mjs
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const isWin = process.platform === "win32";
+const binName = isWin ? "lk.exe" : "lk";
+const LK = join(root, "src-tauri", "target", "debug", binName);
+const MOCK = join(root, "src-tauri", "examples", "mock_localkit_ext.cjs");
+const MOCK_URL = "http://127.0.0.1:9872";
+const API_KEY = "good-key";
+
+if (!existsSync(LK)) {
+ console.error(`lk binary not found at ${LK}\n build it first: cd src-tauri && cargo build -p lk`);
+ process.exit(1);
+}
+
+const dataDir = mkdtempSync(join(tmpdir(), "lk-cli-verify-"));
+let failures = 0;
+let mock;
+
+/** Run `lk` with the scratch data dir; returns {code, stdout, stderr}. */
+function lk(args, { key } = {}) {
+ const env = { ...process.env };
+ if (key !== undefined) env.LOCALKIT_API_KEY = key;
+ else delete env.LOCALKIT_API_KEY;
+ const r = spawnSync(LK, ["--no-color", "--data-dir", dataDir, ...args], {
+ encoding: "utf8",
+ env,
+ });
+ return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
+}
+
+function check(label, cond, detail = "") {
+ if (cond) {
+ console.log(` ✓ ${label}`);
+ } else {
+ failures += 1;
+ console.error(` ✗ ${label}${detail ? ` — ${detail}` : ""}`);
+ }
+}
+
+function waitForMock(timeoutMs = 8000) {
+ const started = Date.now();
+ return new Promise((resolve, reject) => {
+ const tick = async () => {
+ try {
+ const res = await fetch(`${MOCK_URL}/api/v1/system/health`);
+ if (res.ok) return resolve();
+ } catch {
+ /* not up yet */
+ }
+ if (Date.now() - started > timeoutMs) return reject(new Error("mock did not start"));
+ setTimeout(tick, 150);
+ };
+ tick();
+ });
+}
+
+async function main() {
+ mock = spawn("node", [MOCK], { stdio: "ignore" });
+ await waitForMock();
+
+ console.log("connection add (env key):");
+ let r = lk(["connection", "add", "mock", MOCK_URL], { key: API_KEY });
+ check("exit 0", r.code === 0, `code=${r.code} ${r.stderr.trim()}`);
+ check("id on stdout", r.stdout.trim().length > 0);
+ check("extension features on stderr", /features:/.test(r.stderr));
+
+ console.log("connection list --json:");
+ r = lk(["connection", "list", "--json"]);
+ let list;
+ try {
+ list = JSON.parse(r.stdout);
+ } catch {
+ list = null;
+ }
+ check("valid JSON array of 1", Array.isArray(list) && list.length === 1, r.stdout.trim());
+ check("api key redacted", r.stdout.includes("mock") && !/api_key|good-key/.test(r.stdout));
+
+ console.log("connection test:");
+ r = lk(["connection", "test", "mock"]);
+ check("exit 0", r.code === 0, r.stderr.trim());
+ check("reports extension installed", /extension: installed/.test(r.stdout));
+
+ console.log("sites --remote mock --json:");
+ r = lk(["sites", "--remote", "mock", "--json"]);
+ let sites;
+ try {
+ sites = JSON.parse(r.stdout);
+ } catch {
+ sites = null;
+ }
+ check("valid JSON array of 3", Array.isArray(sites) && sites.length === 3, r.stdout.trim());
+ check("multisite flag present", Array.isArray(sites) && sites.some((s) => s.multisite === true));
+
+ console.log("connection add with a bad key is refused:");
+ r = lk(["connection", "add", "badconn", MOCK_URL, "--key", "wrong-key"]);
+ check("exit 1", r.code === 1, `code=${r.code}`);
+ check("not stored", (() => {
+ const l = lk(["connection", "list", "--json"]);
+ try {
+ return JSON.parse(l.stdout).length === 1;
+ } catch {
+ return false;
+ }
+ })());
+
+ console.log("push/pull argument errors:");
+ r = lk(["push", "nope", "--code", "--connection", "mock", "--remote-site", "1"]);
+ check("push on missing site → exit 1", r.code === 1, `code=${r.code}`);
+ r = lk(["pull", "nope"]);
+ check("pull without --db → exit 1", r.code === 1, `code=${r.code}`);
+ check("pull guidance points at lk import", /lk import/.test(r.stderr));
+
+ console.log("completions for every shell:");
+ for (const shell of ["bash", "zsh", "fish", "powershell"]) {
+ const c = lk(["completions", shell]);
+ check(`${shell} non-empty & mentions connection`, c.code === 0 && c.stdout.includes("connection"));
+ }
+
+ console.log("connection remove:");
+ r = lk(["connection", "remove", "mock", "--yes"]);
+ check("exit 0", r.code === 0, r.stderr.trim());
+ r = lk(["connection", "list", "--json"]);
+ check("list now empty", r.stdout.trim() === "[]", r.stdout.trim());
+}
+
+main()
+ .catch((e) => {
+ console.error(`fatal: ${e.message}`);
+ failures += 1;
+ })
+ .finally(() => {
+ if (mock) mock.kill();
+ rmSync(dataDir, { recursive: true, force: true });
+ if (failures > 0) {
+ console.error(`\n${failures} check(s) failed`);
+ process.exit(1);
+ }
+ console.log("\nlk ServerKit CLI verified OK");
+ });
diff --git a/scripts/verify-import.mjs b/scripts/verify-import.mjs
new file mode 100644
index 0000000..e476b81
--- /dev/null
+++ b/scripts/verify-import.mjs
@@ -0,0 +1,323 @@
+// Headless runtime verification for plan 18 (import a remote site as a new
+// local site).
+//
+// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the
+// import UX: Settings → ServerKit lists the remote sites with per-row Import
+// buttons, multisite rows are refused up front, the dialog reports the version
+// match (and warns when there is no exact image), importing streams the same
+// progress stages the backend emits, and the new site lands on the dashboard
+// carrying its origin badge.
+//
+// node scripts/verify-import.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+/**
+ * Open Settings (sidebar gear) and select the ServerKit section from the
+ * left rail — the modal opens on whatever section nav last deep-linked to.
+ */
+async function openServerKitSettings(page) {
+ await page.evaluate(() => {
+ const gear = [...document.querySelectorAll("button")].find(
+ (b) => b.getAttribute("aria-label") === "Settings"
+ );
+ gear?.click();
+ });
+ await sleep(600);
+ await page.evaluate(() => {
+ const rail = document.querySelector('[aria-label="Settings"] nav');
+ const btn = [...(rail?.querySelectorAll("button") ?? [])].find((b) =>
+ b.textContent.trim().toLowerCase().includes("serverkit")
+ );
+ btn?.click();
+ });
+ await sleep(600);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ } else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ page.on("dialog", (d) => d.accept());
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ /** The remote-site table as {name, wp, importable, tooltip} records. */
+ const remoteRows = () =>
+ page.evaluate(() => {
+ const table = [...document.querySelectorAll("table")].find((t) =>
+ t.textContent.includes("acme-corporate")
+ );
+ if (!table) return [];
+ return [...table.querySelectorAll("tbody tr")].map((tr) => {
+ const cells = [...tr.querySelectorAll("td")].map((td) => td.innerText.trim());
+ const btn = tr.querySelector("button");
+ return {
+ name: cells[0],
+ wp: cells[3],
+ importable: btn ? !btn.disabled : false,
+ tooltip: btn?.title ?? "",
+ };
+ });
+ });
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ // 0) The dashboard already marks the seeded imported site.
+ let text = await bodyText();
+ ok("imported site shows its origin connection", text.includes("Production"));
+
+ // 1) Settings → ServerKit → expand the connection.
+ await openServerKitSettings(page);
+ // Panel headings are CSS-uppercased, and innerText applies text-transform.
+ ok("settings opened on the ServerKit section", /serverkit connections/i.test(await bodyText()));
+
+ await clickByText("button", "View WP sites");
+ await page.waitForFunction(
+ () => document.body.innerText.includes("agency-network"),
+ { timeout: 15_000 }
+ );
+ await sleep(600);
+ console.log("› remote sites listed");
+
+ // 2) Import buttons: present per row, refused for multisite.
+ const rows = await remoteRows();
+ ok("every remote site row has an Import control", rows.length === 5);
+ const network = rows.find((r) => r.name === "agency-network");
+ const bakery = rows.find((r) => r.name === "pixel-bakery");
+ ok("importable sites offer Import", bakery?.importable === true);
+ ok("multisite rows are refused", network?.importable === false);
+ ok(
+ "the refusal explains itself in the tooltip",
+ /multisite/i.test(network?.tooltip ?? "")
+ );
+
+ // 3) Version mismatch warning — legacy-shop is WP 6.2 / PHP 7.4, neither
+ // of which LocalKit has an image for.
+ await page.evaluate(() => {
+ const table = [...document.querySelectorAll("table")].find((t) =>
+ t.textContent.includes("legacy-shop")
+ );
+ const row = [...table.querySelectorAll("tbody tr")].find((tr) =>
+ tr.textContent.includes("legacy-shop")
+ );
+ row.querySelector("button").click();
+ });
+ await sleep(600);
+
+ text = await bodyText();
+ ok("the import dialog opens", text.includes("Import “legacy-shop”"));
+ ok("it reports the WordPress version match", text.includes("6.2 → 6.7"));
+ ok("it reports the PHP version match", text.includes("7.4 → 8.3"));
+ ok(
+ "it warns when there is no exact image",
+ text.includes("does not have an exact image match")
+ );
+ ok("it promises not to touch the remote", text.includes("remote site is not modified"));
+
+ // Cancel — then import a site that matches exactly, so the warning's
+ // absence is also verified.
+ await clickByText("button", "Cancel");
+ await sleep(400);
+ ok("cancel closes the dialog", !(await bodyText()).includes("Import “legacy-shop”"));
+
+ // 4) Import pixel-bakery (WP 6.7 / PHP 8.3 — both exact) under a new name.
+ await page.evaluate(() => {
+ const table = [...document.querySelectorAll("table")].find((t) =>
+ t.textContent.includes("pixel-bakery")
+ );
+ const row = [...table.querySelectorAll("tbody tr")].find((tr) =>
+ tr.textContent.includes("pixel-bakery")
+ );
+ row.querySelector("button").click();
+ });
+ await sleep(600);
+
+ text = await bodyText();
+ ok("exact version matches raise no warning", !text.includes("does not have an exact image match"));
+ ok("the name defaults to the remote site's", await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find(
+ (i) => i.value === "pixel-bakery"
+ );
+ return Boolean(input);
+ }));
+
+ await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find((i) => i.value === "pixel-bakery");
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value"
+ ).set;
+ setter.call(input, "Bakery Copy");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await sleep(300);
+ await clickByText("button", "Import site");
+
+ // 5) Progress streams the backend's stages, then lands on the new site.
+ await page.waitForFunction(
+ () => document.body.innerText.includes("Downloading remote wp-content"),
+ { timeout: 15_000 }
+ );
+ ok("progress reports the code download stage", true);
+ await page.waitForFunction(
+ () => document.body.innerText.includes("Rewriting URLs remote -> local"),
+ { timeout: 20_000 }
+ );
+ ok("progress reports the URL rewrite stage", true);
+ await page.waitForFunction(
+ () => document.body.innerText.includes("Bakery Copy imported from Production"),
+ { timeout: 20_000 }
+ );
+ ok("the import resolves with a success message", true);
+
+ // 6) Back on the dashboard, the new site carries its origin.
+ await clickByText("button", "Back to sites").catch(() => {});
+ await page.evaluate(() => {
+ const link = [...document.querySelectorAll("button, a")].find(
+ (b) => b.textContent.trim() === "Sites"
+ );
+ link?.click();
+ });
+ await sleep(900);
+
+ const dash = await page.evaluate(() => {
+ const cards = [...document.querySelectorAll("div")].filter((d) =>
+ d.textContent.includes("Bakery Copy")
+ );
+ const card = cards[cards.length - 1];
+ return {
+ present: Boolean(card),
+ badge: Boolean(
+ [...document.querySelectorAll("span")].find(
+ (s) =>
+ s.title?.includes("Imported from Production") &&
+ s.textContent.includes("Production")
+ )
+ ),
+ };
+ });
+ ok("the imported site appears on the dashboard", dash.present);
+ ok("it carries the imported-from badge", dash.badge);
+
+ // 7) Re-importing the same remote site is refused.
+ await openServerKitSettings(page);
+ await clickByText("button", "View WP sites");
+ await page.waitForFunction(() => document.body.innerText.includes("pixel-bakery"), {
+ timeout: 15_000,
+ });
+ await sleep(500);
+ await page.evaluate(() => {
+ const table = [...document.querySelectorAll("table")].find((t) =>
+ t.textContent.includes("pixel-bakery")
+ );
+ const row = [...table.querySelectorAll("tbody tr")].find((tr) =>
+ tr.textContent.includes("pixel-bakery")
+ );
+ row.querySelector("button").click();
+ });
+ await sleep(500);
+ await clickByText("button", "Import site");
+ await sleep(1200);
+ ok(
+ "a second import of the same remote site is refused",
+ (await bodyText()).includes("already imported")
+ );
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-multistack.mjs b/scripts/verify-multistack.mjs
new file mode 100644
index 0000000..a54161e
--- /dev/null
+++ b/scripts/verify-multistack.mjs
@@ -0,0 +1,242 @@
+// Headless runtime verification for plan 22 (multi-stack: kind + capability
+// gating). Spins up the mock Vite build (no Tauri, no Docker) and checks that
+// a docker-kind site is gated correctly against a WordPress one:
+// - both dashboard cards carry a kind badge (WP / Docker);
+// - the docker card offers no Clone; the WP card does;
+// - the docker SiteDetail hides WP Admin, the credentials + database panels,
+// clone/blueprint and ServerKit push, but still shows Snapshots + Logs;
+// - the WordPress SiteDetail still shows all of those;
+// - the New Site dialog's "Docker project" tab drives an inspect → import
+// flow (path + Inspect → app service/port fields appear).
+//
+// node scripts/verify-multistack.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ page.on("dialog", (d) => d.accept());
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ // The specific card for `siteName`: climb from its exact-match title button
+ // to the nearest ancestor that owns a Details button (the card itself, not
+ // the whole grid — which would match every card's buttons at once).
+ const findCard = `(n) => {
+ const title = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === n);
+ if (!title) return null;
+ let card = title.parentElement;
+ while (card && ![...card.querySelectorAll(':scope button')].some((b) => b.textContent.trim() === 'Details')) {
+ card = card.parentElement;
+ }
+ return card;
+ }`;
+ const cardButtons = (siteName) =>
+ page.evaluate(
+ (n, find) => {
+ const card = new Function('return ' + find)()(n);
+ return card ? [...card.querySelectorAll("button")].map((b) => b.textContent.trim()) : null;
+ },
+ siteName,
+ findCard
+ );
+ const openDetail = (siteName) =>
+ page.evaluate(
+ (n, find) => {
+ const card = new Function('return ' + find)()(n);
+ [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click();
+ },
+ siteName,
+ findCard
+ );
+
+ await page.waitForFunction(() => document.body.innerText.includes("Analytics API"));
+ console.log("› dashboard loaded");
+
+ // 1) Dashboard: both kinds carry a kind badge; docker offers no Clone.
+ const dockerBtns = await cardButtons("Analytics API");
+ const wpBtns = await cardButtons("Pixel Bakery");
+ ok("docker card renders", dockerBtns !== null);
+ ok("wordpress card renders", wpBtns !== null);
+ ok("docker card has no Clone", dockerBtns && !dockerBtns.includes("Clone"));
+ ok("wordpress card has a Clone", wpBtns && wpBtns.includes("Clone"));
+ const badges = await page.evaluate(() =>
+ [...document.querySelectorAll("span")]
+ .map((s) => s.textContent.trim())
+ .filter((t) => t === "WP" || t === "Docker")
+ );
+ ok("a Docker kind badge is shown", badges.includes("Docker"));
+ ok("a WP kind badge is shown", badges.includes("WP"));
+
+ // 2) Docker SiteDetail: WP-only sections are gone, generic ones remain.
+ await openDetail("Analytics API");
+ await sleep(900);
+ let text = await bodyText();
+ ok("navigated to the docker site", text.includes("Back to sites"));
+ ok("docker detail hides WP Admin", !/WP Admin/i.test(text));
+ ok("docker detail hides the credentials panel", !/WP Admin credentials/i.test(text));
+ ok("docker detail hides the database panel", !/Database \(MariaDB\)/i.test(text));
+ ok("docker detail hides wp-cli info", !/WordPress info/i.test(text));
+ ok("docker detail keeps the Snapshots panel", /snapshots/i.test(text));
+ ok("docker detail keeps Container logs", /Container logs/i.test(text));
+ ok("docker detail shows the app service", /app service/i.test(text));
+ const detailButtons = await page.evaluate(() =>
+ [...document.querySelectorAll("button")].map((b) => b.textContent.trim())
+ );
+ ok("docker detail hides Clone", !detailButtons.includes("Clone"));
+ ok("docker detail hides Save as blueprint", !detailButtons.includes("Save as blueprint"));
+ ok("docker detail keeps Terminal", detailButtons.includes("Terminal"));
+
+ // Back to the dashboard.
+ await clickByText("button", "Back to sites");
+ await sleep(600);
+
+ // 3) WordPress SiteDetail still shows everything.
+ await openDetail("Pixel Bakery");
+ await sleep(900);
+ text = await bodyText();
+ ok("wordpress detail shows WP Admin", /WP Admin/i.test(text));
+ ok("wordpress detail shows the database panel", /Database \(MariaDB\)/i.test(text));
+ ok("wordpress detail shows wp-cli info", /WordPress info/i.test(text));
+ const wpDetailButtons = await page.evaluate(() =>
+ [...document.querySelectorAll("button")].map((b) => b.textContent.trim())
+ );
+ ok("wordpress detail shows Clone", wpDetailButtons.includes("Clone"));
+ ok("wordpress detail shows Save as blueprint", wpDetailButtons.includes("Save as blueprint"));
+
+ await clickByText("button", "Back to sites");
+ await sleep(600);
+
+ // 4) New Site dialog → Docker project tab → inspect → import fields.
+ await clickByText("button", "New Site");
+ await sleep(500);
+ ok("dialog opens on the WordPress tab", /install WordPress automatically/i.test(await bodyText()));
+ await clickByText("button", "Docker project");
+ await sleep(400);
+ text = await bodyText();
+ ok("docker tab explains the copy", /copies an existing Docker Compose project/i.test(text));
+ const hasPathInput = await page.evaluate(() =>
+ [...document.querySelectorAll("input")].some(
+ (i) => i.placeholder && i.placeholder.includes("docker-compose.yml")
+ )
+ );
+ ok("docker tab shows a project-folder input", hasPathInput);
+
+ // Type a path and inspect (the mock returns a fictional two-service project).
+ await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find(
+ (i) => i.placeholder && i.placeholder.includes("docker-compose.yml")
+ );
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
+ setter.call(input, "C:/dev/analytics-api");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await clickByText("button", "Inspect");
+ await sleep(600);
+ text = await bodyText();
+ ok("inspect reveals the app service/port fields", /App service/i.test(text) && /App port/i.test(text));
+ ok("inspect reports the detected database", /database/i.test(text));
+ ok("inspect names the default excludes", /node_modules/i.test(text));
+ const canImport = await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some(
+ (b) => b.textContent.trim() === "Import project" && !b.disabled
+ )
+ );
+ ok("Import project is enabled once inspected", canImport);
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-router-conflict.mjs b/scripts/verify-router-conflict.mjs
new file mode 100644
index 0000000..4867d26
--- /dev/null
+++ b/scripts/verify-router-conflict.mjs
@@ -0,0 +1,210 @@
+// Headless runtime verification for plan 16 (router coexistence).
+//
+// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the
+// port-conflict matrix from the plan: a fictional LocalWP holds 80/443, so
+// enabling local domains must surface a NAMED conflict (not a silent
+// failure), "Use fallback ports" must recover to 8080/8443, site URLs must
+// gain the port, and the SiteDetail banner must appear while blocked.
+//
+// node scripts/verify-router-conflict.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ } else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1000 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ /** Click the first element whose trimmed text matches `text`. */
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ // Open Settings → Local domains.
+ await page.evaluate(() => {
+ const gear = [...document.querySelectorAll("button")].find(
+ (b) => (b.getAttribute("aria-label") || "").toLowerCase().includes("setting")
+ );
+ gear?.click();
+ });
+ await sleep(400);
+ await clickByText("button", "Local domains");
+ await sleep(500);
+ ok("Domains settings shows the default ports", (await bodyText()).includes("80/443"));
+
+ // 1) Toggle domains OFF then ON — the mock LocalWP owns 80/443, so
+ // re-enabling must hit the pre-flight and report a NAMED conflict.
+ const toggle = 'button[aria-label="Enable local domains"]';
+ await page.click(toggle);
+ await sleep(500);
+ await page.click(toggle);
+ await sleep(800);
+
+ let text = await bodyText();
+ ok("conflict names the holding process", text.includes("httpd.exe"));
+ ok("conflict names both ports", text.includes("port 80") && text.includes("port 443"));
+ ok("status reads as blocked, not a bare failure", text.includes("blocked by another program"));
+ ok("offers the fallback-ports action", text.includes("Use fallback ports"));
+ ok("offers Retry", /\bRetry\b/.test(text));
+
+ // 2) SiteDetail banner — the *persistent* hazard, which is a different
+ // state from the failed enable above: domains are ON (hosts entries
+ // written, WordPress URLs already rewritten to .test) and the
+ // router later lost its ports. That's when the user is actually
+ // staring at the other program's 404, so that's when the banner fires.
+ // A failed enable changes nothing, so it deliberately has no banner.
+ await page.evaluate(() => {
+ const mock = window.__LOCALKIT_MOCK__;
+ mock.routerStatus.enabled = true;
+ mock.routerStatus.running = false;
+ });
+ await page.evaluate(() => {
+ document.querySelector('button[aria-label="Close settings"]')?.click();
+ });
+ await sleep(400);
+ // Click the "Details" button inside the Pixel Bakery card — matching on
+ // card text alone hits a wrapping div and silently stays on the dashboard.
+ await page.evaluate(() => {
+ const card = [...document.querySelectorAll("div")].find(
+ (d) =>
+ d.textContent.includes("Pixel Bakery") &&
+ [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details")
+ );
+ [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click();
+ });
+ await sleep(900);
+ text = await bodyText();
+ ok("navigated to SiteDetail", text.includes("Back to sites"));
+ ok("SiteDetail warns local domains are blocked", text.includes("Local domains are blocked"));
+ ok("SiteDetail names the holder", text.includes("httpd.exe"));
+ ok("SiteDetail still offers the working localhost URL", /localhost:\d+/.test(text));
+
+ // Dismiss is sticky for that conflict.
+ await clickByText("button", "Dismiss");
+ await sleep(400);
+ ok("banner dismisses", !(await bodyText()).includes("Local domains are blocked"));
+
+ // 3) One-click recovery: fallback ports resolve the conflict.
+ await page.evaluate(() => {
+ const gear = [...document.querySelectorAll("button")].find(
+ (b) => (b.getAttribute("aria-label") || "").toLowerCase().includes("setting")
+ );
+ gear?.click();
+ });
+ await sleep(400);
+ await clickByText("button", "Local domains");
+ await sleep(400);
+ await clickByText("button", "Use fallback ports");
+ await sleep(1000);
+
+ text = await bodyText();
+ ok("router recovers onto the fallback ports", text.includes("8080/8443"));
+ ok("fallback mode is labelled", text.toLowerCase().includes("fallback"));
+ ok("conflict callout is gone", !text.includes("Another program is using"));
+
+ // 4) Site URLs must now carry the port (the whole point of phase 2).
+ await page.evaluate(() => {
+ document.querySelector('button[aria-label="Close settings"]')?.click();
+ });
+ await sleep(500);
+ text = await bodyText();
+ ok("dashboard site URLs carry the fallback port", /\.test:8080/.test(text));
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-site-tools.mjs b/scripts/verify-site-tools.mjs
new file mode 100644
index 0000000..dd14b58
--- /dev/null
+++ b/scripts/verify-site-tools.mjs
@@ -0,0 +1,250 @@
+// Headless runtime verification for plan 24 (site tools). Spins up the mock
+// Vite build (no Tauri, no Docker) and checks the Tools tab on SiteDetail:
+// - a WordPress site has a Tools tab; switching to it shows the tool sections;
+// - Search & Replace previews per-column change counts, then Apply appears;
+// - a code-only docker site has no Tools tab at all.
+//
+// node scripts/verify-site-tools.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ page.on("dialog", (d) => d.accept());
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ const clickExact = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim() === t);
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ const setInputByPlaceholder = (needle, value) =>
+ page.evaluate(
+ (n, v) => {
+ const input = [...document.querySelectorAll("input")].find(
+ (i) => i.placeholder && i.placeholder.includes(n)
+ );
+ if (!input) return false;
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
+ setter.call(input, v);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ return true;
+ },
+ needle,
+ value
+ );
+ const findCard = `(n) => {
+ const title = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === n);
+ if (!title) return null;
+ let card = title.parentElement;
+ while (card && ![...card.querySelectorAll(':scope button')].some((b) => b.textContent.trim() === 'Details')) {
+ card = card.parentElement;
+ }
+ return card;
+ }`;
+ const openDetail = (siteName) =>
+ page.evaluate(
+ (n, find) => {
+ const card = new Function("return " + find)()(n);
+ [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click();
+ },
+ siteName,
+ findCard
+ );
+ const buttonLabels = () =>
+ page.evaluate(() => [...document.querySelectorAll("button")].map((b) => b.textContent.trim()));
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ // 1) WordPress site → Tools tab exists and switches.
+ await openDetail("Pixel Bakery");
+ await sleep(900);
+ ok("WP detail shows a Tools tab", (await buttonLabels()).includes("tools"));
+ ok("WP detail defaults to the overview (logs visible)", /Container logs/i.test(await bodyText()));
+
+ await clickExact("button", "tools");
+ await sleep(400);
+ let text = await bodyText();
+ ok("Tools tab shows the Database GUI", /Browse and edit the database in Adminer/i.test(text));
+ ok("Tools tab shows Search & Replace", /Search & Replace/i.test(text));
+ ok("Tools tab hides the overview logs panel", !/Container logs/i.test(text));
+
+ // Database: "Open database" fires (opener is a no-op in mock) and toasts.
+ const canOpenDb = await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some(
+ (b) => b.textContent.trim() === "Open database" && !b.disabled
+ )
+ );
+ ok("Open database is enabled on a running site", canOpenDb);
+ await clickByText("button", "Open database");
+ await sleep(500);
+ ok("opening the database toasts the login", /Log in as/i.test(await bodyText()));
+
+ // 2) Search & Replace: preview shows per-column counts + Apply appears.
+ ok("filled the 'replace this' field", await setInputByPlaceholder("old.test", "https://old.test"));
+ ok("filled the 'with this' field", await setInputByPlaceholder("new.test", "https://new.test"));
+ await clickByText("button", "Preview changes");
+ await sleep(500);
+ text = await bodyText();
+ ok("preview reports a total", /19 occurrences in 3 columns would change/i.test(text));
+ ok("preview lists a table/column row", /wp_options/i.test(text) && /option_value/i.test(text));
+ const canApply = await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some(
+ (b) => /^Apply — 19 changes$/.test(b.textContent.trim()) && !b.disabled
+ )
+ );
+ ok("Apply button appears with the change count", canApply);
+
+ // Apply → snapshot-first replace → success line + snapshot link.
+ await clickByText("button", "Apply — 19");
+ await sleep(1600);
+ text = await bodyText();
+ ok("apply reports success", /Replaced 19 occurrences/i.test(text));
+ ok("apply offers the snapshot shortcut", /view snapshots/i.test(text));
+
+ // 3) Debug: the section shows, and toggling on seeds the log viewer.
+ ok("Tools tab shows Debug", /Debug/i.test(await bodyText()));
+ const debugSwitch = () =>
+ page.evaluate(() => {
+ const btn = [...document.querySelectorAll('button[role="switch"]')][0];
+ return btn ? btn.getAttribute("aria-checked") : null;
+ });
+ ok("debug starts off", (await debugSwitch()) === "false");
+ await page.evaluate(() => {
+ [...document.querySelectorAll('button[role="switch"]')][0].click();
+ });
+ await sleep(500);
+ ok("debug toggles on", (await debugSwitch()) === "true");
+ text = await bodyText();
+ ok("debug log viewer shows seeded output", /PHP Fatal error/i.test(text));
+ // Clear log empties the viewer.
+ await clickByText("button", "Clear log");
+ await sleep(400);
+ ok("clear empties the log viewer", /No debug output yet/i.test(await bodyText()));
+
+ // 4) Config editor: wp-config.php loads; switching to .env loads it.
+ text = await bodyText();
+ ok("Tools tab shows the Config editor", /Config/i.test(text) && /Editing this can break the site/i.test(text));
+ const textareaValue = () =>
+ page.evaluate(() => {
+ const ta = document.querySelector("textarea");
+ return ta ? ta.value : null;
+ });
+ ok("config editor loads wp-config.php", /<\?php/.test((await textareaValue()) ?? ""));
+ // Switch to .env (a mono button labelled ".env").
+ await clickExact("button", ".env");
+ await sleep(400);
+ ok("config editor loads the .env", /WP_PORT=/.test((await textareaValue()) ?? ""));
+
+ await clickByText("button", "Back to sites");
+ await sleep(600);
+
+ // 3) Docker site → no Tools tab.
+ await openDetail("Analytics API");
+ await sleep(900);
+ ok("docker detail has no Tools tab", !(await buttonLabels()).includes("tools"));
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-snapshots.mjs b/scripts/verify-snapshots.mjs
new file mode 100644
index 0000000..60a5c0f
--- /dev/null
+++ b/scripts/verify-snapshots.mjs
@@ -0,0 +1,311 @@
+// Headless runtime verification for plan 17 (snapshots & one-click restore).
+//
+// Spins up the mock Vite build (no Tauri runtime, no Docker) and walks the
+// snapshot UX: the panel lists existing snapshots with their kind badges,
+// taking one with a note prepends it, restoring confirms and reports back,
+// deleting removes it, a DB pull leaves a `pre_pull` snapshot behind, and the
+// delete-site dialog leads with the kept snapshot while offering the opt-out.
+//
+// node scripts/verify-snapshots.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ } else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ // window.confirm blocks headless; auto-accept so Restore/Delete proceed.
+ page.on("dialog", (d) => d.accept());
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return false;
+ el.click();
+ return true;
+ },
+ selector,
+ text
+ );
+ /** Snapshot rows as [when, kind, size, note] tuples. */
+ const rows = () =>
+ page.evaluate(() => {
+ const heading = [...document.querySelectorAll("h2")].find(
+ (h) => h.textContent.trim() === "Snapshots"
+ );
+ const table = heading?.closest("section")?.querySelector("table");
+ if (!table) return [];
+ return [...table.querySelectorAll("tbody tr")].map((tr) =>
+ [...tr.querySelectorAll("td")].map((td) => td.innerText.trim())
+ );
+ });
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ // Open Pixel Bakery's detail page (it has seeded snapshots).
+ await page.evaluate(() => {
+ const card = [...document.querySelectorAll("div")].find(
+ (d) =>
+ d.textContent.includes("Pixel Bakery") &&
+ [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details")
+ );
+ [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click();
+ });
+ await sleep(900);
+
+ let text = await bodyText();
+ ok("navigated to SiteDetail", text.includes("Back to sites"));
+ // Panel headings are CSS-uppercased, and innerText applies text-transform.
+ ok("Snapshots panel is present", /snapshots/i.test(text));
+
+ // 1) Existing snapshots list with human labels, not raw kinds.
+ let table = await rows();
+ ok("seeded snapshots are listed", table.length === 3);
+ ok(
+ "kinds render as readable badges",
+ table.some((r) => r[1] === "Manual") &&
+ table.some((r) => r[1] === "Before pull") &&
+ table.some((r) => r[1] === "Before push")
+ );
+ ok(
+ "sizes are human-readable",
+ table.every((r) => /^\d+(\.\d+)? (B|KB|MB|GB)$/.test(r[2]))
+ );
+ ok("notes are shown", table.some((r) => r[3].includes("before the checkout rewrite")));
+ ok("newest is first", table[0][1] === "Before pull");
+
+ // 2) Take a snapshot with a note.
+ await page.evaluate(() => {
+ const input = [...document.querySelectorAll("input")].find(
+ (i) => i.placeholder && i.placeholder.startsWith("Note")
+ );
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value"
+ ).set;
+ setter.call(input, "verification run");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await clickByText("button", "Take snapshot");
+ await sleep(900);
+
+ table = await rows();
+ ok("taking a snapshot prepends a row", table.length === 4);
+ ok("the new snapshot is Manual", table[0][1] === "Manual");
+ ok("the note is stored", table[0][3] === "verification run");
+ ok("success is toasted", (await bodyText()).includes("Snapshot of Pixel Bakery taken"));
+
+ // 2b) The palette's per-site "Create snapshot" command takes one too.
+ // Every site contributes one, so pick the row under the Pixel Bakery
+ // group header rather than trusting fuzzy ranking — pressing Enter
+ // would snapshot whichever site happens to rank first.
+ const countBeforePalette = (await rows()).length;
+ await page.keyboard.down("Control");
+ await page.keyboard.press("KeyK");
+ await page.keyboard.up("Control");
+ await sleep(400);
+ await page.keyboard.type("Create snapshot");
+ await sleep(500);
+ const paletteHit = await page.evaluate(() => {
+ const panel = document.querySelector('[aria-label="Command palette"]');
+ if (!panel) return false;
+ // Each row is a wrapper div holding an optional group header + button.
+ const wrapper = [...panel.querySelectorAll("div")].find(
+ (d) =>
+ d.querySelector("button[data-idx]") &&
+ d.textContent.trim() === "Pixel BakeryCreate snapshot"
+ );
+ if (!wrapper) return false;
+ wrapper.querySelector("button[data-idx]").click();
+ return true;
+ });
+ ok("palette offers Create snapshot per site", paletteHit);
+ await sleep(1200);
+ ok("palette command takes a snapshot", (await rows()).length === countBeforePalette + 1);
+
+ // 3) Restore the newest snapshot — confirms, then snapshots first.
+ await page.evaluate(() => {
+ const heading = [...document.querySelectorAll("h2")].find(
+ (h) => h.textContent.trim() === "Snapshots"
+ );
+ const row = heading.closest("section").querySelector("tbody tr");
+ [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "Restore").click();
+ });
+ await sleep(3200);
+
+ text = await bodyText();
+ table = await rows();
+ ok("restore reports back", text.includes("restored to the snapshot from"));
+ ok("restore snapshots the current state first", table.length === 6);
+ ok("that snapshot is labelled Before restore", table[0][1] === "Before restore");
+
+ // 4) Delete a snapshot.
+ const before = table.length;
+ await page.evaluate(() => {
+ const heading = [...document.querySelectorAll("h2")].find(
+ (h) => h.textContent.trim() === "Snapshots"
+ );
+ const row = heading.closest("section").querySelector("tbody tr");
+ [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "Delete").click();
+ });
+ await sleep(900);
+ ok("deleting a snapshot removes its row", (await rows()).length === before - 1);
+ ok("deletion is toasted", (await bodyText()).includes("Snapshot deleted"));
+
+ // 5) A DB pull must leave a pre_pull snapshot behind (plan 17 phase 2).
+ const countBeforePull = (await rows()).length;
+ await page.select("select", "conn-prod").catch(() => {});
+ await page.evaluate(() => {
+ const selects = [...document.querySelectorAll("select")];
+ const set = (el, value) => {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLSelectElement.prototype,
+ "value"
+ ).set;
+ setter.call(el, value);
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ };
+ const conn = selects.find((s) => s.innerHTML.includes("Production"));
+ if (conn) set(conn, "conn-prod");
+ });
+ await sleep(700);
+ await page.evaluate(() => {
+ const selects = [...document.querySelectorAll("select")];
+ const remote = selects.find((s) => s.innerHTML.includes("pixel-bakery"));
+ if (remote) {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLSelectElement.prototype,
+ "value"
+ ).set;
+ setter.call(remote, "27");
+ remote.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+ await sleep(400);
+ await clickByText("button", "Pull DB");
+ await sleep(2200);
+
+ table = await rows();
+ ok("a DB pull leaves a snapshot behind", table.length === countBeforePull + 1);
+ ok("it is labelled Before pull", table[0][1] === "Before pull");
+ ok("it names the connection it pulled from", table[0][3].includes("Production"));
+
+ // 6) Delete-site dialog: leads with the kept snapshot, offers the opt-out.
+ await clickByText("button", "Delete");
+ await sleep(500);
+ text = await bodyText();
+ ok("delete dialog promises a snapshot", text.includes("A restorable snapshot will be kept"));
+ ok("delete dialog offers the opt-out", text.includes("Also delete this site's snapshots"));
+ ok(
+ "default action keeps the snapshots",
+ await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some((b) => b.textContent.trim() === "Delete site")
+ )
+ );
+ // Ticking the box escalates the button copy — the destructive path reads
+ // differently from the safe one.
+ await page.evaluate(() => {
+ const box = [...document.querySelectorAll('input[type="checkbox"]')].find((c) =>
+ c.closest("label")?.textContent.includes("Also delete")
+ );
+ box.click();
+ });
+ await sleep(300);
+ ok(
+ "opting out escalates the confirm button",
+ await page.evaluate(() =>
+ [...document.querySelectorAll("button")].some(
+ (b) => b.textContent.trim() === "Delete everything"
+ )
+ )
+ );
+
+ console.log(failures === 0 ? "\n✓ all checks passed" : `\n✗ ${failures} check(s) failed`);
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/verify-sync-progress.mjs b/scripts/verify-sync-progress.mjs
new file mode 100644
index 0000000..10a80f1
--- /dev/null
+++ b/scripts/verify-sync-progress.mjs
@@ -0,0 +1,270 @@
+// Headless runtime verification for plan 19 (chunked sync: byte progress + cancel).
+//
+// Spins up the mock Vite build (no Tauri runtime, no Docker, no ServerKit) and
+// walks the transfer UX a chunked sync is supposed to produce: the progress
+// toast counts real bytes instead of sitting on one static line, it offers a
+// Cancel button only while bytes are actually moving, cancelling stops the
+// transfer and resolves neutrally rather than as a red failure, and the sync
+// history records it as `cancelled` rather than `error`.
+//
+// node scripts/verify-sync-progress.mjs
+//
+import { spawn } from "node:child_process";
+import { setTimeout as sleep } from "node:timers/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const PORT = 1426;
+const URL = `http://localhost:${PORT}/`;
+
+const CHROME_CANDIDATES = [
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
+];
+const chrome = CHROME_CANDIDATES.find((p) => existsSync(p));
+if (!chrome) {
+ console.error("No Chrome/Edge found.");
+ process.exit(1);
+}
+
+const isWin = process.platform === "win32";
+let failures = 0;
+
+function ok(name, cond) {
+ if (cond) console.log(" ✓", name);
+ else {
+ failures++;
+ console.error(" ✗ FAIL:", name);
+ }
+}
+
+async function waitForServer(url, ms = 60_000) {
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(url);
+ if (r.ok) return;
+ } catch {}
+ await sleep(500);
+ }
+ throw new Error(`Vite mock server never came up at ${url}`);
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
+ } else {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {}
+ }
+}
+
+async function main() {
+ console.log("› starting mock Vite server…");
+ const server = spawn("npm", ["run", "dev:mock"], {
+ cwd: ROOT,
+ shell: true,
+ stdio: "ignore",
+ detached: !isWin,
+ });
+
+ let browser;
+ try {
+ await waitForServer(URL);
+ browser = await puppeteer.launch({
+ executablePath: chrome,
+ headless: true,
+ defaultViewport: { width: 1440, height: 1200 },
+ });
+ const page = await browser.newPage();
+ page.on("pageerror", (e) => console.warn(" page error:", e.message));
+ await page.goto(URL, { waitUntil: "networkidle0" });
+
+ const bodyText = () => page.evaluate(() => document.body.innerText);
+ // Clicking a *disabled* button silently does nothing, which would turn a
+ // real regression into a confusing timeout further down. Report it.
+ const clickByText = (selector, text) =>
+ page.evaluate(
+ (sel, t) => {
+ const el = [...document.querySelectorAll(sel)].find((e) =>
+ e.textContent.trim().toLowerCase().includes(t.toLowerCase())
+ );
+ if (!el) return "missing";
+ if (el.disabled) return "disabled";
+ el.click();
+ return "clicked";
+ },
+ selector,
+ text
+ );
+ const click = async (selector, text) => (await clickByText(selector, text)) === "clicked";
+ /** Text of the pinned progress toast, or "" when none is up. */
+ const toastText = () =>
+ page.evaluate(() => {
+ const el = document.querySelector(".fixed.bottom-4.right-4 > div");
+ return el ? el.innerText.trim() : "";
+ });
+ /** Sync-history rows as [when, op, result, message] tuples. */
+ const historyRows = () =>
+ page.evaluate(() => {
+ const heading = [...document.querySelectorAll("h3")].find((h) =>
+ h.textContent.trim().toLowerCase().startsWith("sync history")
+ );
+ const table = heading?.parentElement?.querySelector("table");
+ if (!table) return [];
+ return [...table.querySelectorAll("tbody tr")].map((tr) =>
+ [...tr.querySelectorAll("td")].map((td) => td.innerText.trim())
+ );
+ });
+ /** Tailwind classes on the result cell of the newest history row. */
+ const newestResultClass = () =>
+ page.evaluate(() => {
+ const heading = [...document.querySelectorAll("h3")].find((h) =>
+ h.textContent.trim().toLowerCase().startsWith("sync history")
+ );
+ const tr = heading?.parentElement?.querySelector("table tbody tr");
+ return tr ? tr.querySelectorAll("td")[2].className : "";
+ });
+
+ await page.waitForFunction(() => document.body.innerText.includes("Pixel Bakery"));
+ console.log("› dashboard loaded");
+
+ await page.evaluate(() => {
+ const card = [...document.querySelectorAll("div")].find(
+ (d) =>
+ d.textContent.includes("Pixel Bakery") &&
+ [...d.querySelectorAll("button")].some((b) => b.textContent.trim() === "Details")
+ );
+ [...card.querySelectorAll("button")].find((b) => b.textContent.trim() === "Details").click();
+ });
+ await sleep(900);
+ ok("navigated to SiteDetail", (await bodyText()).includes("Back to sites"));
+
+ // Pick a connection + remote site so the push buttons enable.
+ await page.evaluate(() => {
+ const setValue = (el, value) => {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLSelectElement.prototype,
+ "value"
+ ).set;
+ setter.call(el, value);
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ };
+ const selects = [...document.querySelectorAll("select")];
+ for (const sel of selects) {
+ const real = [...sel.options].find((o) => o.value && o.value !== "");
+ if (real) setValue(sel, real.value);
+ }
+ });
+ await sleep(800);
+ // The remote-site select only populates after the connection is chosen.
+ await page.evaluate(() => {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLSelectElement.prototype,
+ "value"
+ ).set;
+ for (const sel of document.querySelectorAll("select")) {
+ const real = [...sel.options].find((o) => o.value && o.value !== "");
+ if (real && !sel.value) {
+ setter.call(sel, real.value);
+ sel.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ }
+ });
+ await sleep(400);
+
+ // --- 1. byte progress -------------------------------------------------
+ console.log("› push code (byte progress)");
+ ok("Push code is clickable", await click("button", "Push code"));
+ await sleep(700);
+
+ let first = await toastText();
+ ok("a progress toast appeared", first.length > 0);
+ ok(
+ "the transfer reports bytes, not just a stage",
+ /\d+(\.\d+)?\s?(B|KB|MB|GB)\s*\/\s*\d+(\.\d+)?\s?(B|KB|MB|GB)/.test(first)
+ );
+ ok("the byte readout names the payload", /wp-content/i.test(first));
+ ok("a running transfer offers Cancel", first.includes("Cancel"));
+
+ await sleep(600);
+ const second = await toastText();
+ ok("the byte count actually advances", second !== first);
+
+ const bytesOf = (t) => {
+ const m = /([\d.]+)\s?(B|KB|MB|GB)\s*\/\s*([\d.]+)\s?(B|KB|MB|GB)/.exec(t);
+ if (!m) return null;
+ const scale = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
+ return [parseFloat(m[1]) * scale[m[2]], parseFloat(m[3]) * scale[m[4]]];
+ };
+ const a = bytesOf(first);
+ const b = bytesOf(second);
+ ok("progress moves forward, never backward", a && b && b[0] > a[0]);
+ ok("the total stays fixed across updates", a && b && a[1] === b[1]);
+ ok("done never exceeds total", b && b[0] <= b[1]);
+
+ // --- 2. cancel --------------------------------------------------------
+ console.log("› cancel mid-transfer");
+ ok("clicked Cancel", await click("button", "Cancel"));
+ await sleep(900);
+
+ const resolved = await toastText();
+ ok("the toast resolves on cancel", /cancelled/i.test(resolved));
+ ok("the spinner is gone once cancelled", !resolved.includes("Cancel\n"));
+ ok(
+ "a cancel is not styled as an error",
+ await page.evaluate(() => {
+ const el = document.querySelector(".fixed.bottom-4.right-4 > div");
+ return el ? !el.className.includes("red") : false;
+ })
+ );
+
+ // The transfer really stopped: no "Pushed code" success follows.
+ await sleep(1500);
+ ok(
+ "a cancelled transfer never completes",
+ !/pushed code/i.test(await bodyText())
+ );
+
+ // --- 3. history -------------------------------------------------------
+ console.log("› sync history");
+ const rows = await historyRows();
+ ok("the cancel is recorded in sync history", rows.length > 0 && rows[0][2] === "cancelled");
+ ok("it is recorded as a push code op", rows.length > 0 && /push\s+code/i.test(rows[0][1]));
+ const cls = await newestResultClass();
+ ok("cancelled renders neutral, not red", cls.includes("zinc") && !cls.includes("red"));
+
+ // --- 4. a completed transfer still resolves green ---------------------
+ console.log("› push db to completion");
+ ok("Push DB is clickable", await click("button", "Push DB"));
+ // 312 MB in 8 MiB steps at ~80ms/step ≈ 3.2s.
+ await page.waitForFunction(
+ () => /Pushed db/i.test(document.body.innerText),
+ { timeout: 20_000 }
+ );
+ ok("an uninterrupted transfer completes", /Pushed db/i.test(await bodyText()));
+ const after = await historyRows();
+ ok("success is recorded", after.some((r) => r[2] === "success"));
+ const okCls = await newestResultClass();
+ ok("success stays emerald", okCls.includes("emerald"));
+ } finally {
+ if (browser) await browser.close();
+ killTree(server);
+ }
+
+ console.log(failures ? `\n${failures} failure(s)` : "\nAll sync-progress checks passed");
+ process.exit(failures ? 1 : 0);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index a3bbf96..d4668b2 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -326,6 +337,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -478,6 +498,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.3.0"
@@ -550,6 +579,16 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clap"
version = "4.6.2"
@@ -572,6 +611,15 @@ dependencies = [
"strsim",
]
+[[package]]
+name = "clap_complete"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
+dependencies = [
+ "clap",
+]
+
[[package]]
name = "clap_derive"
version = "4.6.1"
@@ -625,6 +673,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -648,7 +706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -661,7 +719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"libc",
]
@@ -801,6 +859,24 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "dbus-secret-service"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
+dependencies = [
+ "aes",
+ "block-padding",
+ "cbc",
+ "dbus",
+ "fastrand",
+ "hkdf",
+ "num",
+ "once_cell",
+ "sha2",
+ "zeroize",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -839,6 +915,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
+ "subtle",
]
[[package]]
@@ -1652,6 +1729,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1948,6 +2043,16 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
[[package]]
name = "ioctl-rs"
version = "0.1.6"
@@ -2105,6 +2210,22 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "dbus-secret-service",
+ "log",
+ "secret-service",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2194,33 +2315,40 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lk"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
+ "chrono",
"clap",
+ "clap_complete",
"dirs 5.0.1",
"localkit",
"open",
+ "rpassword",
"serde",
"serde_json",
"tokio",
+ "uuid",
]
[[package]]
name = "localkit"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"chrono",
"dirs 5.0.1",
"flate2",
+ "keyring",
"portable-pty",
"rand 0.8.7",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
+ "sha2",
"tar",
"tauri",
"tauri-build",
+ "tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio",
@@ -2248,6 +2376,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2395,12 +2537,102 @@ dependencies = [
"pin-utils",
]
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset 0.9.1",
+]
+
+[[package]]
+name = "notify-rust"
+version = "4.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus 5.18.0",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2555,6 +2787,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
+ "libc",
"objc2",
"objc2-core-foundation",
]
@@ -2874,7 +3107,7 @@ dependencies = [
"lazy_static",
"libc",
"log",
- "nix",
+ "nix 0.25.1",
"serial",
"shared_library",
"shell-words",
@@ -3073,10 +3306,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -3098,6 +3341,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3107,6 +3360,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -3296,6 +3558,27 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rpassword"
+version = "7.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
+dependencies = [
+ "libc",
+ "rtoolbox",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rtoolbox"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -3451,6 +3734,61 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "secret-service"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
+dependencies = [
+ "aes",
+ "cbc",
+ "futures-util",
+ "generic-array",
+ "hkdf",
+ "num",
+ "once_cell",
+ "rand 0.8.7",
+ "serde",
+ "sha2",
+ "zbus 4.4.0",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3692,6 +4030,17 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3823,6 +4172,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -3943,7 +4298,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.1",
"block2",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -4133,6 +4488,25 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.9.5",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.19",
+ "time",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -4152,7 +4526,7 @@ dependencies = [
"thiserror 2.0.19",
"url",
"windows",
- "zbus",
+ "zbus 5.18.0",
]
[[package]]
@@ -4168,7 +4542,7 @@ dependencies = [
"tokio",
"tracing",
"windows-sys 0.60.2",
- "zbus",
+ "zbus 5.18.0",
]
[[package]]
@@ -4271,6 +4645,17 @@ dependencies = [
"toml 1.1.3+spec-1.1.0",
]
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
+dependencies = [
+ "thiserror 2.0.19",
+ "windows",
+ "windows-version",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5669,6 +6054,16 @@ dependencies = [
"rustix",
]
+[[package]]
+name = "xdg-home"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5692,6 +6087,38 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
+dependencies = [
+ "async-broadcast",
+ "async-process",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "hex",
+ "nix 0.29.0",
+ "ordered-stream",
+ "rand 0.8.7",
+ "serde",
+ "serde_repr",
+ "sha1",
+ "static_assertions",
+ "tracing",
+ "uds_windows",
+ "windows-sys 0.52.0",
+ "xdg-home",
+ "zbus_macros 4.4.0",
+ "zbus_names 3.0.0",
+ "zvariant 4.2.0",
+]
+
[[package]]
name = "zbus"
version = "5.18.0"
@@ -5722,9 +6149,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
- "zbus_macros",
- "zbus_names",
- "zvariant",
+ "zbus_macros 5.18.0",
+ "zbus_names 4.3.4",
+ "zvariant 5.13.1",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -5737,9 +6177,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
- "zbus_names",
- "zvariant",
- "zvariant_utils",
+ "zbus_names 4.3.4",
+ "zvariant 5.13.1",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zbus_names"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
+dependencies = [
+ "serde",
+ "static_assertions",
+ "zvariant 4.2.0",
]
[[package]]
@@ -5750,7 +6201,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
- "zvariant",
+ "zvariant 5.13.1",
]
[[package]]
@@ -5799,6 +6250,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
[[package]]
name = "zerotrie"
@@ -5839,6 +6304,19 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zvariant"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zvariant_derive 4.2.0",
+]
+
[[package]]
name = "zvariant"
version = "5.13.1"
@@ -5849,8 +6327,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 1.0.4",
- "zvariant_derive",
- "zvariant_utils",
+ "zvariant_derive 5.13.1",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -5863,7 +6354,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
- "zvariant_utils",
+ "zvariant_utils 3.5.0",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index ff2c4bd..c7538ed 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["image-ico", "tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
+tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -32,4 +33,9 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
tar = "0.4"
flate2 = "1"
+sha2 = "0.10"
portable-pty = "0.8"
+# OS keyring for ServerKit API keys (plan 25). Per-platform native backends;
+# Linux uses the pure-Rust secret-service + crypto so no system libs are needed
+# to build (a headless box with no Secret Service just degrades to SQLite).
+keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index d7b5a26..91f2d6b 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -3,5 +3,5 @@
"identifier": "default",
"description": "Default capability set for the main window",
"windows": ["main"],
- "permissions": ["core:default", "opener:default"]
+ "permissions": ["core:default", "opener:default", "notification:default"]
}
diff --git a/src-tauri/examples/docker_smoke.rs b/src-tauri/examples/docker_smoke.rs
new file mode 100644
index 0000000..dc04579
--- /dev/null
+++ b/src-tauri/examples/docker_smoke.rs
@@ -0,0 +1,223 @@
+//! End-to-end smoke test for the generic Docker app kind (plan 22 phase 2).
+//! Runs outside the Tauri runtime (no AppHandle; events print to stderr).
+//!
+//! Usage: cargo run --example docker_smoke [-- run|clean]
+//!
+//! Imports a trivial two-service compose fixture (an nginx web server + a
+//! mariadb) as a `docker` kind site, then asserts the whole generic lifecycle:
+//! the app answers HTTP on its published port, the chosen app service is
+//! exec-able (what the terminal shells into), a code-only snapshot is taken
+//! (no database dump), and stop/start/delete all work.
+
+use std::path::PathBuf;
+use std::sync::Mutex;
+
+use localkit_lib::{db::Db, docker, dockerapp, site, snapshot, AppState};
+
+const NAME: &str = "Docker Smoke";
+const SLUG: &str = "docker-smoke";
+
+fn data_dir() -> PathBuf {
+ std::env::temp_dir().join("localkit-docker-smoke")
+}
+
+fn source_dir() -> PathBuf {
+ std::env::temp_dir().join("localkit-docker-smoke-src")
+}
+
+fn make_state() -> AppState {
+ let dir = data_dir();
+ std::fs::create_dir_all(&dir).expect("create data dir");
+ let db = Db::open(&dir.join("localkit.db")).expect("open db");
+ AppState {
+ db: Mutex::new(db),
+ data_dir: dir,
+ terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
+ }
+}
+
+/// A free host port for the fixture to publish on.
+fn free_port() -> u16 {
+ let l = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let port = l.local_addr().unwrap().port();
+ drop(l);
+ port
+}
+
+fn http_code(url: &str) -> String {
+ std::process::Command::new("curl")
+ .args(["-s", "-o", "NUL", "-w", "%{http_code}", "--max-time", "20", url])
+ .output()
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_else(|e| format!("curl failed: {e}"))
+}
+
+/// Write the fixture compose project into a fresh source directory.
+fn write_fixture(port: u16) -> PathBuf {
+ let src = source_dir();
+ let _ = std::fs::remove_dir_all(&src);
+ std::fs::create_dir_all(&src).unwrap();
+ // A .git dir the copy must exclude, to prove the ignore list works.
+ std::fs::create_dir_all(src.join(".git")).unwrap();
+ std::fs::write(src.join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
+ std::fs::write(
+ src.join("docker-compose.yml"),
+ format!(
+ "services:\n \
+ web:\n image: nginx:latest\n ports:\n - \"{port}:80\"\n \
+ db:\n image: mariadb:11\n environment:\n MARIADB_ROOT_PASSWORD: example\n"
+ ),
+ )
+ .unwrap();
+ src
+}
+
+async fn find_site(state: &AppState) -> Result {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.list_sites()?
+ .into_iter()
+ .find(|s| s.slug == SLUG || s.slug.starts_with(&format!("{SLUG}-")))
+ .ok_or_else(|| "docker smoke site not found".to_string())
+}
+
+async fn run(state: &AppState) -> Result<(), String> {
+ clean(state).await;
+ let port = free_port();
+ let src = write_fixture(port);
+
+ // 1. Inspect — the dialog's read-only pass.
+ let inspection = dockerapp::inspect(&src).await?;
+ println!(
+ "INSPECT compose={} services={:?} app={:?}:{:?} db_engine={:?} copy={} B",
+ inspection.compose_file,
+ inspection.services.iter().map(|s| s.name.clone()).collect::>(),
+ inspection.suggested_service,
+ inspection.suggested_port,
+ inspection.db_engine,
+ inspection.copy_bytes,
+ );
+ assert_eq!(inspection.suggested_service.as_deref(), Some("web"));
+ assert_eq!(inspection.suggested_port, Some(port));
+ assert_eq!(inspection.db_engine.as_deref(), Some("mariadb"));
+ assert!(inspection.copy_bytes > 0, "copy size should be non-zero");
+
+ // 2. Import — copy + record + up.
+ let s = dockerapp::import_project(
+ None,
+ state,
+ NAME.to_string(),
+ src.clone(),
+ "web".to_string(),
+ port,
+ false,
+ )
+ .await?;
+ println!(
+ "IMPORTED id={} slug={} kind={} app_port={:?}",
+ s.id, s.slug, s.kind, s.config.app_port
+ );
+ assert_eq!(s.kind, site::KIND_DOCKER);
+ assert!(s.capabilities.code_sync && s.capabilities.terminal && s.capabilities.domains);
+ assert!(
+ !s.capabilities.wp_tools && !s.capabilities.one_click_login && !s.capabilities.db_sync,
+ "a docker app must not claim WordPress/db capabilities"
+ );
+ assert_eq!(s.config.service, "web");
+ assert_eq!(s.config.app_port, Some(port));
+
+ // 3. The copy is owned, and the ignore list dropped .git; the .env carries
+ // a deterministic compose project name.
+ let dir = s.dir();
+ assert!(dir.join("docker-compose.yml").is_file(), "compose file copied");
+ assert!(!dir.join(".git").exists(), ".git must be excluded from the copy");
+ let env = std::fs::read_to_string(dir.join(".env")).unwrap_or_default();
+ assert!(
+ env.contains(&format!("COMPOSE_PROJECT_NAME=localkit-{SLUG}")),
+ ".env should set COMPOSE_PROJECT_NAME, got: {env:?}"
+ );
+
+ // 4. The app answers HTTP on its published port.
+ let url = format!("http://localhost:{port}");
+ let code = http_code(&format!("{url}/"));
+ println!("HTTP {url}/ -> {code}");
+ assert!(
+ ["200", "301", "302", "304"].contains(&code.as_str()),
+ "app did not answer on its port: {code}"
+ );
+
+ // 5. The chosen app service is exec-able — this is exactly what the terminal
+ // shells into (`docker compose exec web bash`).
+ let echoed = docker::compose_exec(&dir, "web", &["echo", "localkit-ok"]).await?;
+ assert!(echoed.contains("localkit-ok"), "exec into `web` failed: {echoed:?}");
+ println!("terminal target `web` is exec-able");
+
+ // 6. A code-only snapshot: no database dump (db_sync is off for docker).
+ let snap = snapshot::create(None, state, &s.id, snapshot::KIND_MANUAL, Some("smoke".into())).await?;
+ println!("SNAPSHOT id={} db_bytes={} code_bytes={}", snap.id, snap.db_bytes, snap.code_bytes);
+ assert_eq!(snap.db_bytes, 0, "a docker snapshot must be code-only");
+ assert!(snap.code_bytes > 0, "the code archive should be non-empty");
+ assert!(
+ snapshot::list(state, &s.id)?.iter().any(|x| x.id == snap.id),
+ "the snapshot should be listed"
+ );
+
+ // 7. Lifecycle: stop then start.
+ let stopped = site::stop(state, &s.id).await?;
+ assert_eq!(stopped.status, "stopped");
+ let started = site::start(state, &s.id).await?;
+ assert_eq!(started.status, "running");
+ println!("stop/start OK");
+
+ // 8. Delete removes everything.
+ site::delete(None, state, &s.id, true).await?;
+ assert!(!dir.exists(), "site dir should be gone after delete");
+ assert!(find_site(state).await.is_err(), "db row should be gone after delete");
+ println!("DOCKER SMOKE OK on {url}");
+
+ let _ = std::fs::remove_dir_all(&src);
+ Ok(())
+}
+
+async fn clean(state: &AppState) {
+ let sites = {
+ let db = state.db.lock().expect("lock db");
+ db.list_sites().unwrap_or_default()
+ };
+ for s in sites {
+ if s.slug == SLUG || s.slug.starts_with(&format!("{SLUG}-")) {
+ let _ = site::delete(None, state, &s.id, true).await;
+ println!("cleaned {}", s.slug);
+ }
+ }
+ let orphan = state.data_dir.join("sites").join(SLUG);
+ if orphan.exists() {
+ let _ = docker::compose_down(&orphan, true).await;
+ let _ = std::fs::remove_dir_all(&orphan);
+ }
+ let _ = std::fs::remove_dir_all(source_dir());
+}
+
+#[tokio::main]
+async fn main() {
+ let cmd = std::env::args().nth(1).unwrap_or_else(|| "run".to_string());
+ let status = docker::check().await;
+ if !status.available {
+ eprintln!("docker unavailable: {:?}", status.error);
+ std::process::exit(2);
+ }
+ let state = make_state();
+ let result = match cmd.as_str() {
+ "run" => run(&state).await,
+ "clean" => {
+ clean(&state).await;
+ Ok(())
+ }
+ other => Err(format!("unknown command: {other}")),
+ };
+ if let Err(e) = result {
+ eprintln!("DOCKER SMOKE {cmd} FAILED: {e}");
+ std::process::exit(1);
+ }
+}
diff --git a/src-tauri/examples/m4_smoke.rs b/src-tauri/examples/m4_smoke.rs
index 9a76d79..2488108 100644
--- a/src-tauri/examples/m4_smoke.rs
+++ b/src-tauri/examples/m4_smoke.rs
@@ -1,13 +1,19 @@
//! M4 end-to-end smoke: real local Docker site <-> mock serverkit-localkit ext.
//! Prereq: the `smoke` example's site exists (`cargo run --example smoke -- create`).
//! Usage: cargo run --example m4_smoke
+//!
+//! Covers push code / push DB / pull DB (M4) and, since plan 18, importing a
+//! remote site as a brand-new local site — which provisions real containers
+//! and tears them down again at the end.
use std::sync::Mutex;
-use localkit_lib::{db::Db, docker, serverkit::ServerKitConnection, sync, AppState};
+use localkit_lib::{db::Db, docker, php, serverkit::ServerKitConnection, site, sync, AppState};
const MOCK_URL: &str = "http://127.0.0.1:9872";
const REMOTE_URL: &str = "https://blog.example.com";
+/// Canary file the mock extension puts in the wp-content it serves.
+const CANARY: &str = "wp-content/themes/remote-theme/style.css";
fn make_state() -> AppState {
let data_dir = std::env::temp_dir().join("localkit-smoke");
@@ -16,6 +22,8 @@ fn make_state() -> AppState {
db: Mutex::new(db),
data_dir,
terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
}
}
@@ -85,5 +93,350 @@ async fn main() {
}
assert!(history.iter().filter(|h| h.status == "success").count() >= 3);
+ // 6) Import remote site #1 as a brand-new local site (plan 18).
+ import_smoke(&state).await;
+
+ // 7) Sync v2: chunked upload, interrupted and resumed (plan 19).
+ chunked_smoke(&state, &site).await;
+
+ // 8) PHP/Laravel stack: engine-native push/pull DB over ServerKit (plan 26).
+ php_sync_smoke(&state).await;
+
println!("M4 SMOKE OK");
}
+
+// ---------------------------------------------------------------------------
+// Plan 26 — php stack ServerKit parity (engine-native db sync, no wp-cli)
+// ---------------------------------------------------------------------------
+
+/// Create a real local php site, push its database to the mock's php remote
+/// (id 4), wipe a marker row, pull it back, and assert the marker returns —
+/// proving the whole push/pull DB path runs engine-native (a php site has no
+/// wpcli service, so any wp-cli fallback would simply fail). Also asserts the
+/// per-kind gate: a server that stops advertising php refuses the push.
+async fn php_sync_smoke(state: &AppState) {
+ println!("--- php ServerKit cycle (plan 26) ---");
+ const PHP_REMOTE: i64 = 4;
+
+ // Drop any leftover php-sync site from a prior run.
+ let prior: Vec = {
+ let db = state.db.lock().unwrap();
+ db.list_sites().unwrap().into_iter().filter(|s| s.slug == "php-sync").collect()
+ };
+ for s in prior {
+ let _ = site::delete(None, state, &s.id, true).await;
+ }
+
+ let s = php::create_php_site(None, state, "PHP Sync".into(), "8.3".into(), None, false)
+ .await
+ .expect("create php site");
+ let dir = s.dir();
+ let pw = site::db_password(&dir);
+
+ php_sql(
+ &dir,
+ &pw,
+ "CREATE TABLE lk_sync (id INT PRIMARY KEY, note VARCHAR(64)); \
+ INSERT INTO lk_sync VALUES (1, 'pushed');",
+ )
+ .await;
+
+ // Gate: a server that drops php from /pair must refuse the push before dumping.
+ control(serde_json::json!({ "kinds": ["wordpress"] })).await;
+ let refused = sync::push_db(None, state, "mock-conn", &s.id, PHP_REMOTE).await;
+ assert!(refused.is_err(), "a server without php support must refuse the push");
+ println!("PHP GATE OK ({})", refused.unwrap_err());
+ control(serde_json::json!({ "kinds": null })).await; // restore php support
+
+ // Engine-native push (mysqldump) — no wp-cli anywhere.
+ sync::push_db(None, state, "mock-conn", &s.id, PHP_REMOTE).await.expect("php push_db");
+ println!("PHP PUSH DB OK");
+ sync::push_code(None, state, "mock-conn", &s.id, PHP_REMOTE).await.expect("php push_code");
+ println!("PHP PUSH CODE OK");
+
+ // Wipe the row, then pull it back from the mock (which serves the pushed dump).
+ php_sql(&dir, &pw, "DELETE FROM lk_sync;").await;
+ let gone = php_query(&dir, &pw, "SELECT COUNT(*) FROM lk_sync;").await;
+ assert_eq!(gone.trim(), "0", "marker not wiped before pull");
+
+ sync::pull_db(None, state, "mock-conn", &s.id, PHP_REMOTE, None).await.expect("php pull_db");
+ let restored = php_query(&dir, &pw, "SELECT note FROM lk_sync WHERE id=1;").await;
+ assert_eq!(
+ restored.trim(),
+ "pushed",
+ "engine-native pull did not restore the marker row"
+ );
+ println!("PHP PULL DB OK (engine-native round-trip)");
+
+ site::delete(None, state, &s.id, true).await.expect("delete php site");
+ println!("PHP SYNC SMOKE OK");
+}
+
+async fn php_sql(dir: &std::path::Path, pw: &str, sql: &str) {
+ docker::compose_exec_env(dir, "db", &[("MYSQL_PWD", pw)], &["mariadb", "-u", "laravel", "laravel", "-e", sql])
+ .await
+ .expect("php sql failed");
+}
+
+async fn php_query(dir: &std::path::Path, pw: &str, sql: &str) -> String {
+ docker::compose_exec_env(
+ dir,
+ "db",
+ &[("MYSQL_PWD", pw)],
+ &["mariadb", "-N", "-B", "-u", "laravel", "laravel", "-e", sql],
+ )
+ .await
+ .expect("php query failed")
+}
+
+// ---------------------------------------------------------------------------
+// Plan 19 — chunked transfers
+// ---------------------------------------------------------------------------
+
+/// Mock-only control/stats endpoints (see `mock_localkit_ext.cjs`).
+async fn control(cfg: serde_json::Value) {
+ reqwest::Client::new()
+ .post(format!("{MOCK_URL}/api/v1/localkit/__control"))
+ .header("X-API-Key", "good-key")
+ .json(&cfg)
+ .send()
+ .await
+ .expect("mock __control failed");
+}
+
+async fn stats() -> serde_json::Value {
+ reqwest::Client::new()
+ .get(format!("{MOCK_URL}/api/v1/localkit/__stats"))
+ .header("X-API-Key", "good-key")
+ .send()
+ .await
+ .expect("mock __stats failed")
+ .json()
+ .await
+ .expect("mock __stats returned no JSON")
+}
+
+fn count(s: &serde_json::Value, key: &str) -> u64 {
+ s.get(key).and_then(|v| v.as_u64()).unwrap_or_default()
+}
+
+/// A push big enough to need several 8 MiB chunks.
+///
+/// The smoke site's real `wp-content` is a few hundred KB, which is one chunk —
+/// and a one-chunk transfer cannot demonstrate resume. The filler is
+/// incompressible (an LCG, not zeroes) so gzip cannot collapse it back into a
+/// single chunk and quietly make the test prove nothing.
+fn write_filler(site: &site::Site, bytes: usize) -> std::path::PathBuf {
+ let path = site.dir().join("wp-content").join("localkit-chunk-filler.bin");
+ let mut buf = vec![0u8; bytes];
+ let mut x: u32 = 0x1234_5678;
+ for b in buf.iter_mut() {
+ x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ *b = (x >> 24) as u8;
+ }
+ std::fs::write(&path, &buf).expect("failed to write the chunk filler");
+ path
+}
+
+/// Kill the client mid-upload, re-run, and prove only the missing chunks were
+/// re-sent — the whole point of plan 19.
+async fn chunked_smoke(state: &AppState, site: &site::Site) {
+ // Deliberately over the server's 100 MB body limit, so the same fixture
+ // proves both halves of the plan: resume re-sends only what was lost, and
+ // a payload v1 physically cannot deliver goes up fine in 8 MiB chunks.
+ let filler = write_filler(site, 110 * 1024 * 1024);
+ let result = run_chunked_assertions(state, site, &filler).await;
+ let _ = std::fs::remove_file(&filler);
+ result.expect("chunked sync verification failed");
+ println!("CHUNKED SYNC OK");
+}
+
+async fn run_chunked_assertions(
+ state: &AppState,
+ site: &site::Site,
+ filler: &std::path::Path,
+) -> Result<(), String> {
+ const STOP_AFTER: u64 = 2;
+
+ // --- an interrupted upload -------------------------------------------
+ control(serde_json::json!({
+ "resetStats": true, "forgetTransfers": true, "failChunksAfter": STOP_AFTER
+ }))
+ .await;
+
+ let err = sync::push_code(None, state, "mock-conn", &site.id, 1)
+ .await
+ .expect_err("the injected chunk failure did not fail the push");
+ println!("push interrupted as designed: {err}");
+
+ let s = stats().await;
+ let landed = count(&s, "chunkPuts");
+ if landed != STOP_AFTER {
+ return Err(format!("expected {STOP_AFTER} chunks before the failure, got {landed}"));
+ }
+ if count(&s, "finishes") != 0 {
+ return Err("an interrupted upload reached finish — nothing should have been applied".into());
+ }
+ println!("INTERRUPT LEFT {landed} CHUNKS ON THE SERVER OK");
+
+ // --- the retry resumes -------------------------------------------------
+ control(serde_json::json!({ "resetStats": true, "failChunksAfter": null })).await;
+ sync::push_code(None, state, "mock-conn", &site.id, 1)
+ .await
+ .map_err(|e| format!("the resumed push failed: {e}"))?;
+
+ let s = stats().await;
+ let resent = count(&s, "chunkPuts");
+ let total = count(&s, "lastTotalChunks");
+ if count(&s, "resumedInits") != 1 {
+ return Err("the server did not recognise the retry as a resume".into());
+ }
+ if total < 3 {
+ return Err(format!(
+ "the payload was only {total} chunk(s) — too small to prove anything about resume"
+ ));
+ }
+ // The assertion the plan asks for: only what was lost went back up.
+ if resent != total - STOP_AFTER {
+ return Err(format!(
+ "resume re-sent {resent} chunks; expected {} of {total} (the {STOP_AFTER} already confirmed should have been skipped)",
+ total - STOP_AFTER
+ ));
+ }
+ // A successful `finish` IS the whole-file hash check: the server refuses
+ // to process a payload whose assembled sha256 does not match `init`.
+ if count(&s, "finishes") != 1 {
+ return Err("the resumed upload never completed a verified finish".into());
+ }
+ println!("RESUME RE-SENT ONLY {resent} OF {total} CHUNKS, HASH VERIFIED OK");
+
+ // The archive that just went up is bigger than the server would accept in
+ // one request — which is the whole point of the plan. Prove the wall is
+ // real by making the same client talk v1 to the same payload.
+ let sent_bytes = count(&s, "chunkBytes") + STOP_AFTER * 8 * 1024 * 1024;
+ if sent_bytes <= 100 * 1024 * 1024 {
+ return Err(format!(
+ "the fixture is only {sent_bytes} bytes — too small to prove the 100 MB limit is gone"
+ ));
+ }
+ control(serde_json::json!({ "resetStats": true, "forgetTransfers": true, "syncV2": false })).await;
+ let err = sync::push_code(None, state, "mock-conn", &site.id, 1)
+ .await
+ .expect_err("v1 accepted a payload over the server's 100 MB limit");
+ if !err.contains("too large") {
+ return Err(format!("expected a size refusal from v1, got: {err}"));
+ }
+ println!("SAME PAYLOAD OVER V1: REFUSED ({err}) — LIMIT LIFTED BY V2 OK");
+
+ // --- one client, both servers -----------------------------------------
+ // With sync-v2 withdrawn from /pair, a payload that *does* fit must still
+ // go up the v1 monolithic path rather than failing.
+ std::fs::remove_file(filler).map_err(|e| format!("failed to remove the filler: {e}"))?;
+ control(serde_json::json!({ "resetStats": true, "forgetTransfers": true, "syncV2": false })).await;
+ sync::push_code(None, state, "mock-conn", &site.id, 1)
+ .await
+ .map_err(|e| format!("the v1 fallback push failed: {e}"))?;
+
+ let s = stats().await;
+ if count(&s, "v1Pushes") != 1 || count(&s, "chunkPuts") != 0 {
+ return Err(format!(
+ "expected exactly one v1 multipart push and no chunks, got v1={} chunks={}",
+ count(&s, "v1Pushes"),
+ count(&s, "chunkPuts")
+ ));
+ }
+ println!("V1 FALLBACK ON AN OLD SERVER OK");
+
+ control(serde_json::json!({ "syncV2": true, "resetStats": true })).await;
+ Ok(())
+}
+
+/// Plan 18: clone remote site #1 down as a new local site, assert the remote
+/// wp-content and database actually landed, then delete it again.
+///
+/// The imported site is always removed at the end (including on assertion
+/// failure paths that run after creation) so repeat runs start clean — a
+/// leftover would collide on the slug and mask a real regression.
+async fn import_smoke(state: &AppState) {
+ // A stale import from a previous run would trip the "already imported"
+ // guard, so clear it first.
+ let stale: Vec = {
+ let db = state.db.lock().unwrap();
+ db.sites_from_remote("mock-conn", 1)
+ .unwrap()
+ .into_iter()
+ .map(|s| s.id)
+ .collect()
+ };
+ for id in stale {
+ println!("removing stale imported site {id}");
+ site::delete(None, state, &id, true).await.expect("cleanup stale import");
+ }
+
+ // Multisite must be refused *before* anything is provisioned.
+ let before = { state.db.lock().unwrap().list_sites().unwrap().len() };
+ let err = sync::import_site(None, state, "mock-conn", 3, None)
+ .await
+ .expect_err("importing a multisite must fail");
+ assert!(err.contains("multisite"), "unexpected error: {err}");
+ let after = { state.db.lock().unwrap().list_sites().unwrap().len() };
+ assert_eq!(before, after, "a refused import left a site row behind");
+ println!("IMPORT REFUSES MULTISITE OK");
+
+ let imported = sync::import_site(None, state, "mock-conn", 1, Some("Imported Blog".into()))
+ .await
+ .expect("import_site failed");
+ println!("imported: {} on port {}", imported.slug, imported.port);
+
+ let result = verify_import(state, &imported).await;
+
+ // Always tear the imported site down, then report.
+ site::delete(None, state, &imported.id, true)
+ .await
+ .expect("failed to delete the imported site");
+ println!("imported site deleted");
+ result.expect("import verification failed");
+ println!("IMPORT OK");
+}
+
+async fn verify_import(state: &AppState, imported: &site::Site) -> Result<(), String> {
+ // Origin recorded, so a future pull knows which remote to default to.
+ if imported.connection_id.as_deref() != Some("mock-conn") || imported.remote_site_id != Some(1) {
+ return Err(format!(
+ "origin not recorded: {:?} / {:?}",
+ imported.connection_id, imported.remote_site_id
+ ));
+ }
+
+ // The remote wp-content actually landed on disk.
+ let canary = imported.dir().join(CANARY);
+ let body = std::fs::read_to_string(&canary)
+ .map_err(|e| format!("remote wp-content missing at {}: {e}", canary.display()))?;
+ if !body.contains("pulled from the remote site") {
+ return Err(format!("canary file has unexpected content: {body}"));
+ }
+ println!("remote wp-content extracted OK");
+
+ // The one-click login plugin survived the archive landing on top of it.
+ if !imported.dir().join("wp-content/mu-plugins/localkit-login.php").exists() {
+ return Err("the login MU plugin did not survive the import".into());
+ }
+
+ // The imported database is live and rewritten to the local URL.
+ let siteurl = docker::compose_run(&imported.dir(), "wpcli", &["wp", "option", "get", "siteurl"])
+ .await
+ .map_err(|e| format!("wp option get siteurl failed: {e}"))?;
+ let siteurl = siteurl.trim();
+ let expected = format!("http://localhost:{}", imported.port);
+ if siteurl != expected {
+ return Err(format!("siteurl is {siteurl}, expected {expected}"));
+ }
+ println!("imported siteurl: {siteurl}");
+
+ // The import is recorded in the new site's sync history.
+ let history = sync::history(state, &imported.id)?;
+ if !history.iter().any(|h| h.kind == "import" && h.status == "success") {
+ return Err("no successful import row in sync history".into());
+ }
+ Ok(())
+}
diff --git a/src-tauri/examples/m6_smoke.rs b/src-tauri/examples/m6_smoke.rs
index 30a96ae..9c94cf4 100644
--- a/src-tauri/examples/m6_smoke.rs
+++ b/src-tauri/examples/m6_smoke.rs
@@ -23,6 +23,8 @@ fn make_state() -> AppState {
db: Mutex::new(db),
data_dir,
terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
}
}
diff --git a/src-tauri/examples/mock_localkit_ext.cjs b/src-tauri/examples/mock_localkit_ext.cjs
index 553052b..98a8535 100644
--- a/src-tauri/examples/mock_localkit_ext.cjs
+++ b/src-tauri/examples/mock_localkit_ext.cjs
@@ -1,17 +1,110 @@
// Mock of the serverkit-localkit extension for LocalKit M4 E2E testing.
// Mimics builtin-extensions/serverkit-localkit/backend/localkit.py contract.
-// - Validates X-API-Key (good-key); invalid key -> 401 {'error': ...} on ALL routes.
+// - Serves the ServerKit core probes `test_connection` needs (plan 21): public
+// GET /api/v1/system/health (service: serverkit-api, no key) and the
+// key-gated GET /api/v1/setup-health/account, so `lk connection add/test`
+// and `lk doctor` can validate against this mock.
+// - Validates X-API-Key (good-key); invalid key -> 401 {'error': ...} on ALL
+// routes except the public health check above.
// - Stores the SQL uploaded via POST /push/db; GET /pull/db returns it gzipped
// with the local URL rewritten to the remote URL (simulating a remote DB).
+// - Implements sync v2 (plan 19): chunked resumable push with an in-memory
+// chunk store, and Range/session downloads on the pull side.
+//
+// Two mock-only routes exist so m4_smoke can make assertions the real
+// extension has no reason to expose:
+// GET /__stats — request counters (how many chunks actually got sent)
+// POST /__control — fault injection: refuse chunk PUTs after N succeed,
+// which is how the smoke simulates a client dying
+// mid-upload deterministically instead of racing a kill.
const http = require("http");
const zlib = require("zlib");
+const crypto = require("crypto");
const GOOD_KEY = "good-key";
const LOCAL_URL = "http://localhost:8081";
const REMOTE_URL = "https://blog.example.com";
+// Capabilities of the real extension; LocalKit gates Import on pull-code and
+// the chunked transfer path on sync-v2.
+const FEATURES = ["sites", "push-code", "push-db", "pull-db", "pull-code", "sync-v2"];
+// Canary file the import E2E looks for after extracting the remote wp-content.
+const CANARY_PATH = "wp-content/themes/remote-theme/style.css";
+const CANARY_BODY = "/* pulled from the remote site */\n";
let storedSql = null;
let receivedTgz = null;
+// --- v2 transfer state -----------------------------------------------------
+/** transfer_id -> {siteId, kind, total, chunkSize, sha256, localUrl, buf, received:Map} */
+const transfers = new Map();
+/** `${session}:${kind}:${siteId}` -> Buffer — a download pinned for resuming. */
+const downloadSessions = new Map();
+
+const stats = newStats();
+function newStats() {
+ return {
+ inits: 0,
+ resumedInits: 0,
+ chunkPuts: 0,
+ chunkBytes: 0,
+ duplicates: 0,
+ finishes: 0,
+ rangeGets: 0,
+ // Chunks the most recently finished transfer needed in total. The resume
+ // assertion is `chunkPuts === totalChunks - `,
+ // and that needs the total from the server's own arithmetic.
+ lastTotalChunks: 0,
+ v1Pushes: 0,
+ };
+}
+function resetStats() {
+ for (const k of Object.keys(stats)) stats[k] = 0;
+}
+/**
+ * Test knobs:
+ * - failChunksAfter: once N chunks have landed, refuse the rest (stands in for
+ * the client's connection dying mid-upload).
+ * - syncV2: drop "sync-v2" from /pair, so a v2-capable client is forced down
+ * the v1 path — that is how the fallback gets exercised.
+ */
+const control = { failChunksAfter: null, chunksSinceControl: 0, syncV2: true, kinds: null };
+
+const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
+
+// --- minimal tar writer ----------------------------------------------------
+// Node ships zlib but no tar, and the archive shape is the contract under
+// test, so the 512-byte ustar blocks are written out by hand.
+function tarEntry(name, body) {
+ const header = Buffer.alloc(512);
+ const write = (text, offset, len) => header.write(text.slice(0, len), offset, "ascii");
+ const octal = (n, offset, len) => write(n.toString(8).padStart(len - 1, "0") + "\0", offset, len);
+ write(name, 0, 100);
+ octal(0o644, 100, 8); // mode
+ octal(0, 108, 8); // uid
+ octal(0, 116, 8); // gid
+ octal(body.length, 124, 12);
+ octal(0, 136, 12); // mtime
+ header.write(" ", 148, 8, "ascii"); // checksum placeholder (spaces)
+ write("0", 156, 1); // typeflag: regular file
+ write("ustar\0", 257, 6);
+ write("00", 263, 2);
+ let sum = 0;
+ for (const b of header) sum += b;
+ // Checksum is the odd one out: 6 octal digits then NUL then space, not the
+ // (len-1)-digits-then-NUL every other numeric field uses.
+ header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "ascii");
+ const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
+ return Buffer.concat([header, body, pad]);
+}
+
+function remoteWpContentTgz() {
+ const tar = Buffer.concat([
+ tarEntry(CANARY_PATH, Buffer.from(CANARY_BODY)),
+ tarEntry("wp-content/plugins/remote-plugin/remote-plugin.php", Buffer.from(" {
+function readBody(req) {
+ return new Promise((resolve) => {
+ const chunks = [];
+ req.on("data", (c) => chunks.push(c));
+ req.on("end", () => resolve(Buffer.concat(chunks)));
+ });
+}
+
+// The panel's MAX_CONTENT_LENGTH. Mirrored here because it is the wall sync
+// v2 exists to get over: a v1 multipart push of a real site hits it, while a
+// v2 chunk request is 8 MiB no matter how large the payload is.
+const MAX_BODY = 100 * 1024 * 1024;
+
+/** Apply the v1 processing rules to an assembled code payload. */
+function acceptCodeArchive(buf) {
+ if (buf[0] !== 0x1f || buf[1] !== 0x8b) return { error: "not gzip" };
+ let tar;
+ try {
+ tar = zlib.gunzipSync(buf);
+ } catch (e) {
+ return { error: `not gzip: ${e.message}` };
+ }
+ // A WordPress push is prefixed wp-content/; a php push (plan 26) is prefixed
+ // with the app sync_path (app/). Accept either — the wire is kind-agnostic.
+ if (!tar.includes(Buffer.from("wp-content")) && !tar.includes(Buffer.from("app/"))) {
+ return { error: "No wp-content or app/ found in the archive" };
+ }
+ receivedTgz = buf.length;
+ return null;
+}
+
+/** Send binary with ETag + Range support, mirroring Flask's conditional=True. */
+function sendBinary(req, res, buf) {
+ const etag = `"${sha256(buf).slice(0, 32)}"`;
+ const range = req.headers.range;
+ const ifRange = req.headers["if-range"];
+ const base = { "Content-Type": "application/gzip", ETag: etag, "Accept-Ranges": "bytes" };
+
+ // If-Range that no longer matches means the body changed under the client;
+ // per RFC 9110 the correct answer is the whole thing, not a partial one.
+ if (range && (!ifRange || ifRange === etag)) {
+ const m = /^bytes=(\d+)-(\d*)$/.exec(range);
+ if (m) {
+ const start = parseInt(m[1], 10);
+ const end = m[2] ? Math.min(parseInt(m[2], 10), buf.length - 1) : buf.length - 1;
+ if (start >= buf.length || start > end) {
+ res.writeHead(416, { ...base, "Content-Range": `bytes */${buf.length}` });
+ return res.end();
+ }
+ stats.rangeGets += 1;
+ const slice = buf.subarray(start, end + 1);
+ res.writeHead(206, {
+ ...base,
+ "Content-Length": slice.length,
+ "Content-Range": `bytes ${start}-${end}/${buf.length}`,
+ });
+ return res.end(slice);
+ }
+ }
+ res.writeHead(200, { ...base, "Content-Length": buf.length });
+ res.end(buf);
+}
+
+/** The bytes a pull should serve, pinned per session so ranges stay coherent. */
+function pinnedExport(session, kind, siteId, build) {
+ if (!session) return build();
+ const key = `${session}:${kind}:${siteId}`;
+ if (!downloadSessions.has(key)) downloadSessions.set(key, build());
+ return downloadSessions.get(key);
+}
+
+const server = http.createServer(async (req, res) => {
const json = (code, obj) => {
res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(obj));
};
+
+ // Public health check — no key required (the real ServerKit serves this
+ // unauthenticated, and `test_connection` deliberately sends no key so an
+ // invalid key can't mask an unreachable/wrong server). Must come before the
+ // key gate so `lk connection add`/`test`/`doctor` can validate against us.
+ if (req.url.split("?")[0] === "/api/v1/system/health") {
+ return json(200, {
+ status: "ok",
+ service: "serverkit-api",
+ canonical_domain: "panel.example.com",
+ canonical_origin: "https://panel.example.com",
+ staging: false,
+ });
+ }
+
if (req.headers["x-api-key"] !== GOOD_KEY) {
return json(401, { error: "Invalid or expired API key" });
}
const url = new URL(req.url, "http://x");
+ // API-key-validation endpoint (`@auth_required` upstream) — any 200 for a
+ // good key proves the key works. `test_connection` hits this after health.
+ if (url.pathname === "/api/v1/setup-health/account" && req.method === "GET") {
+ return json(200, { account: { email: "admin@example.com", plan: "pro" } });
+ }
+
+ // --- mock-only test hooks ------------------------------------------------
+ if (url.pathname === "/api/v1/localkit/__stats") {
+ return json(200, { ...stats, transfers: transfers.size });
+ }
+ if (url.pathname === "/api/v1/localkit/__control" && req.method === "POST") {
+ const body = await readBody(req);
+ const cfg = body.length ? JSON.parse(body.toString()) : {};
+ control.failChunksAfter = cfg.failChunksAfter ?? null;
+ control.chunksSinceControl = 0;
+ if (cfg.syncV2 !== undefined) control.syncV2 = cfg.syncV2;
+ if (cfg.kinds !== undefined) control.kinds = cfg.kinds; // null = default (wordpress+php)
+ if (cfg.resetStats) resetStats();
+ if (cfg.forgetTransfers) transfers.clear();
+ return json(200, { ok: true, ...control });
+ }
+
if (url.pathname === "/api/v1/localkit/pair") {
- return json(200, { status: "ok", service: "serverkit-localkit", panel: "ServerKit", version: "1.7.0", user: "admin", canonical_domain: "panel.example.com", canonical_origin: "https://panel.example.com" });
+ const features = control.syncV2 ? FEATURES : FEATURES.filter((f) => f !== "sync-v2");
+ // Kinds this extension can sync (plan 26). `control.kinds` lets a test drop
+ // php to exercise the old-server ↔ new-client gate.
+ const kinds = control.kinds || ["wordpress", "php"];
+ return json(200, { status: "ok", service: "serverkit-localkit", panel: "ServerKit", version: "1.7.0", user: "admin", canonical_domain: "panel.example.com", canonical_origin: "https://panel.example.com", features, kinds });
}
if (url.pathname === "/api/v1/localkit/sites" && req.method === "GET") {
return json(200, { sites: [
- { id: 1, name: "client-blog", url: REMOTE_URL, status: "running", wp_version: "6.7.2", environment_count: 0 },
- { id: 2, name: "woo-store", url: null, status: "stopped", wp_version: "6.6.4", environment_count: 1 },
+ { id: 1, name: "client-blog", url: REMOTE_URL, site_url: REMOTE_URL, status: "running", wp_version: "6.7.2", php_version: "8.3", kind: "wordpress", multisite: false, environment_count: 0 },
+ { id: 2, name: "woo-store", url: null, site_url: null, status: "stopped", wp_version: "6.6.4", php_version: "8.1", kind: "wordpress", multisite: false, environment_count: 1 },
+ // Refused by the import flow — one compose project cannot be a network.
+ { id: 3, name: "network-hq", url: "https://network.example.com", status: "running", wp_version: "6.7.2", php_version: "8.2", kind: "wordpress", multisite: true, environment_count: 0 },
+ // A PHP/Laravel remote (plan 26) — engine-native db sync, no wp-cli.
+ { id: 4, name: "checkout-service", url: null, site_url: null, status: "running", wp_version: null, php_version: "8.3", kind: "php", multisite: false, environment_count: 0 },
]});
}
if (url.pathname === "/api/v1/localkit/sites" && req.method === "POST") {
- let body = "";
- req.on("data", (c) => (body += c));
- req.on("end", () => json(201, { success: true, site: { id: 3, name: JSON.parse(body).name.toLowerCase().replace(/ /g, "-") }, http_port: 8090 }));
- return;
+ const body = await readBody(req);
+ return json(201, { success: true, site: { id: 3, name: JSON.parse(body.toString()).name.toLowerCase().replace(/ /g, "-") }, http_port: 8090 });
}
- if (url.pathname === "/api/v1/localkit/push/code" && req.method === "POST") {
- const chunks = [];
- req.on("data", (c) => chunks.push(c));
- req.on("end", () => {
- const body = Buffer.concat(chunks);
- const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1];
- const { fields, file } = parseMultipart(body, boundary);
- if (!fields.site_id || !file) return json(400, { error: "site_id and file required" });
- if (file.data[0] !== 0x1f || file.data[1] !== 0x8b) return json(400, { error: "not gzip" });
- const tar = zlib.gunzipSync(file.data);
- if (!tar.includes(Buffer.from("wp-content"))) return json(400, { error: "No wp-content found in the archive" });
- receivedTgz = file.data.length;
- json(200, { success: true, message: "wp-content pushed to the site" });
+ // --- sync v2: chunked push ----------------------------------------------
+
+ const initMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/init$/.exec(url.pathname);
+ if (initMatch && req.method === "POST") {
+ const kind = initMatch[1];
+ const body = await readBody(req);
+ const data = body.length ? JSON.parse(body.toString()) : {};
+ if (!data.site_id) return json(400, { error: "site_id is required" });
+ if (!/^[0-9a-f]{64}$/.test(data.sha256 || "")) {
+ return json(400, { error: "sha256 must be a hex-encoded SHA-256 digest" });
+ }
+ stats.inits += 1;
+
+ // Resume: an existing transfer of the identical payload keeps its chunks.
+ for (const [id, t] of transfers) {
+ if (t.kind === kind && t.siteId === data.site_id && t.sha256 === data.sha256
+ && t.total === data.total_bytes && t.chunkSize === data.chunk_size) {
+ stats.resumedInits += 1;
+ return json(200, {
+ transfer_id: id,
+ chunk_size: t.chunkSize,
+ received: [...t.received.keys()].sort((a, b) => a - b),
+ resumed: true,
+ });
+ }
+ }
+
+ const id = crypto.randomBytes(16).toString("hex");
+ transfers.set(id, {
+ kind,
+ siteId: data.site_id,
+ total: data.total_bytes,
+ chunkSize: data.chunk_size,
+ sha256: data.sha256,
+ localUrl: data.local_url || "",
+ buf: Buffer.alloc(data.total_bytes),
+ received: new Map(),
});
- return;
+ return json(201, { transfer_id: id, chunk_size: data.chunk_size, received: [], resumed: false });
+ }
+
+ const chunkMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/chunk$/.exec(url.pathname);
+ if (chunkMatch && req.method === "PUT") {
+ const kind = chunkMatch[1];
+ const t = transfers.get(url.searchParams.get("transfer_id"));
+ if (!t || t.kind !== kind) return json(404, { error: "Unknown or expired transfer" });
+
+ const offset = Number(url.searchParams.get("offset"));
+ const chunkSha = url.searchParams.get("sha256");
+ const expected = Math.min(t.chunkSize, t.total - offset);
+ if (!Number.isInteger(offset) || offset < 0 || offset >= t.total || offset % t.chunkSize !== 0) {
+ return json(400, { error: `offset ${offset} is not a chunk boundary of this transfer` });
+ }
+
+ if (t.received.get(offset) === chunkSha) {
+ stats.duplicates += 1;
+ return json(200, { received: [...t.received.keys()].sort((a, b) => a - b), duplicate: true });
+ }
+
+ const body = await readBody(req);
+
+ // Fault injection stands in for "the client's connection died here".
+ if (control.failChunksAfter != null && control.chunksSinceControl >= control.failChunksAfter) {
+ return json(503, { error: "mock: injected chunk failure" });
+ }
+
+ if (body.length !== expected) {
+ return json(400, { error: `chunk at offset ${offset} must be ${expected} bytes, got ${body.length}` });
+ }
+ if (sha256(body) !== chunkSha) {
+ return json(400, { error: `chunk at offset ${offset} failed its checksum` });
+ }
+ body.copy(t.buf, offset);
+ t.received.set(offset, chunkSha);
+ stats.chunkPuts += 1;
+ stats.chunkBytes += body.length;
+ control.chunksSinceControl += 1;
+ return json(200, { received: [...t.received.keys()].sort((a, b) => a - b), duplicate: false });
+ }
+
+ const finishMatch = /^\/api\/v1\/localkit\/push\/(code|db)\/finish$/.exec(url.pathname);
+ if (finishMatch && req.method === "POST") {
+ const kind = finishMatch[1];
+ const body = await readBody(req);
+ const data = body.length ? JSON.parse(body.toString()) : {};
+ const id = data.transfer_id;
+ const t = transfers.get(id);
+ if (!t || t.kind !== kind) return json(404, { error: "Unknown or expired transfer" });
+
+ const missing = [];
+ for (let o = 0; o < t.total; o += t.chunkSize) if (!t.received.has(o)) missing.push(o);
+ if (missing.length) {
+ return json(409, {
+ error: `${missing.length} chunk(s) are still missing`,
+ received: [...t.received.keys()].sort((a, b) => a - b),
+ missing,
+ });
+ }
+ if (sha256(t.buf) !== t.sha256) {
+ transfers.delete(id);
+ return json(400, { error: "The assembled upload failed its checksum — nothing was applied." });
+ }
+
+ stats.finishes += 1;
+ stats.lastTotalChunks = Math.ceil(t.total / t.chunkSize);
+ if (kind === "code") {
+ const bad = acceptCodeArchive(t.buf);
+ if (bad) return json(400, bad);
+ transfers.delete(id);
+ return json(200, { success: true, message: "wp-content pushed to the site" });
+ }
+ storedSql = t.buf.toString();
+ transfers.delete(id);
+ return json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true });
+ }
+
+ // --- v1 push (still exercised: it is the fallback for old servers) -------
+
+ if (url.pathname === "/api/v1/localkit/push/code" && req.method === "POST") {
+ const body = await readBody(req);
+ if (body.length > MAX_BODY) return json(413, { error: "Request Entity Too Large" });
+ const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1];
+ const { fields, file } = parseMultipart(body, boundary);
+ if (!fields.site_id || !file) return json(400, { error: "site_id and file required" });
+ stats.v1Pushes += 1;
+ const bad = acceptCodeArchive(file.data);
+ if (bad) return json(400, bad);
+ return json(200, { success: true, message: "wp-content pushed to the site" });
}
if (url.pathname === "/api/v1/localkit/push/db" && req.method === "POST") {
- const chunks = [];
- req.on("data", (c) => chunks.push(c));
- req.on("end", () => {
- const body = Buffer.concat(chunks);
- const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1];
- const { fields, file } = parseMultipart(body, boundary);
- if (!fields.site_id || !file) return json(400, { error: "site_id and file required" });
- storedSql = file.data.toString();
- json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true });
- });
- return;
+ const body = await readBody(req);
+ if (body.length > MAX_BODY) return json(413, { error: "Request Entity Too Large" });
+ const boundary = /boundary=(.+)$/.exec(req.headers["content-type"])[1];
+ const { fields, file } = parseMultipart(body, boundary);
+ if (!fields.site_id || !file) return json(400, { error: "site_id and file required" });
+ stats.v1Pushes += 1;
+ storedSql = file.data.toString();
+ return json(200, { success: true, message: "Database imported", remote_url: REMOTE_URL, search_replace: true });
+ }
+
+ // --- pull (Range + session, plan 19 phase 3) -----------------------------
+
+ if (url.pathname === "/api/v1/localkit/pull/code" && req.method === "GET") {
+ const siteId = url.searchParams.get("site_id");
+ if (!siteId) return json(400, { error: "site_id is required" });
+ return sendBinary(req, res, pinnedExport(url.searchParams.get("session"), "code", siteId, remoteWpContentTgz));
}
if (url.pathname === "/api/v1/localkit/pull/db" && req.method === "GET") {
if (!storedSql) return json(404, { error: "Site not found" });
- const remoteSql = storedSql.split(LOCAL_URL).join(REMOTE_URL);
- const gz = zlib.gzipSync(Buffer.from(remoteSql));
- res.writeHead(200, { "Content-Type": "application/gzip" });
- res.end(gz);
- return;
+ const siteId = url.searchParams.get("site_id");
+ return sendBinary(req, res, pinnedExport(url.searchParams.get("session"), "db", siteId, () =>
+ zlib.gzipSync(Buffer.from(storedSql.split(LOCAL_URL).join(REMOTE_URL)))
+ ));
}
json(404, { error: "Not found" });
diff --git a/src-tauri/examples/smoke.rs b/src-tauri/examples/smoke.rs
index b87613d..393ae01 100644
--- a/src-tauri/examples/smoke.rs
+++ b/src-tauri/examples/smoke.rs
@@ -1,17 +1,28 @@
//! End-to-end smoke test driver for the real LocalKit site lifecycle.
//! Runs outside the Tauri runtime (no AppHandle; events are skipped).
//!
-//! Usage: cargo run --example smoke --
+//! Usage: cargo run --example smoke --
//!
//! Uses a fixed smoke data dir + site name so subcommands can run as separate
//! invocations (each one reconstructs the same AppState).
+use std::path::Path;
use std::sync::Mutex;
-use localkit_lib::{db::Db, docker, site, wordpress, AppState};
+use localkit_lib::{blueprint, db::Db, docker, php, reconcile, site, snapshot, wordpress, AppState};
const SMOKE_NAME: &str = "Smoke Test";
const SMOKE_SLUG: &str = "smoke-test";
+/// Plan 26 php-stack verification: a self-contained PHP/Laravel smoke site.
+const PHP_NAME: &str = "PHP Smoke";
+const PHP_SLUG: &str = "php-smoke";
+/// Plan 20 clone verification: a throwaway copy of the smoke site.
+const CLONE_NAME: &str = "Smoke Clone";
+const CLONE_SLUG: &str = "smoke-clone";
+/// Plan 20 blueprint verification: a template + a site stamped from it.
+const BP_NAME: &str = "Smoke Blueprint";
+const BP_FROM_NAME: &str = "Smoke From BP";
+const BP_FROM_SLUG: &str = "smoke-from-bp";
fn make_state() -> AppState {
let data_dir = std::env::temp_dir().join("localkit-smoke");
@@ -21,6 +32,8 @@ fn make_state() -> AppState {
db: Mutex::new(db),
data_dir,
terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
}
}
@@ -40,6 +53,15 @@ fn http_code(url: &str) -> String {
.unwrap_or_else(|e| format!("curl failed: {e}"))
}
+/// The response body (for asserting on a page's rendered content).
+fn http_body(url: &str) -> String {
+ std::process::Command::new("curl")
+ .args(["-s", "--max-time", "20", url])
+ .output()
+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
+ .unwrap_or_else(|e| format!("curl failed: {e}"))
+}
+
async fn create(state: &AppState) -> Result<(), String> {
// Idempotent: remove any stale smoke site from a previous (killed) run.
let _ = cleanup(state).await;
@@ -126,10 +148,619 @@ async fn start(state: &AppState) -> Result<(), String> {
Ok(())
}
+/// Backdate a site's status write via a second connection to the smoke DB, so
+/// the reconciler's 60 s grace window does not shield an "external stop". This
+/// is the one thing the public `Db::set_status` (which always stamps `now`)
+/// deliberately won't do — hence the raw UPDATE, kept here in the dev tool.
+fn force_status(state: &AppState, id: &str, status: &str, ts: &str) -> Result<(), String> {
+ let conn = rusqlite::Connection::open(state.data_dir.join("localkit.db"))
+ .map_err(|e| e.to_string())?;
+ conn.execute(
+ "UPDATE sites SET status = ?1, status_updated_at = ?2 WHERE id = ?3",
+ rusqlite::params![status, ts, id],
+ )
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+fn db_status(state: &AppState, id: &str) -> Result {
+ Ok(state.db.lock().map_err(|e| e.to_string())?.get_site(id)?.status)
+}
+
+/// Stop a site's containers *without* removing them (`docker compose stop`),
+/// simulating an external `docker stop` — LocalKit's own stop uses `down`.
+fn compose_stop(dir: &Path) -> Result<(), String> {
+ let out = std::process::Command::new("docker")
+ .args(["compose", "stop"])
+ .current_dir(dir)
+ .output()
+ .map_err(|e| format!("docker compose stop failed to run: {e}"))?;
+ if out.status.success() {
+ Ok(())
+ } else {
+ Err(String::from_utf8_lossy(&out.stderr).to_string())
+ }
+}
+
+/// Reconciler verification (plan 23) against real Docker drift: stop the
+/// site's containers behind LocalKit's back and confirm the reconciler settles
+/// running→stopped, then bring them back and confirm it settles stopped→
+/// running. The DB is manipulated directly to create the drift a crash / an
+/// external `docker stop` would leave.
+async fn reconcile_smoke(state: &AppState) -> Result<(), String> {
+ let s = find_site(state)?;
+ // Start from a known-up state.
+ docker::compose_up(&s.dir()).await?;
+ site::start(state, &s.id).await?;
+
+ // --- External stop: containers down, DB still says running (backdated past
+ // the grace window so the reconciler is allowed to downgrade). ---
+ println!("stopping containers externally (docker compose stop)...");
+ compose_stop(&s.dir())?;
+ force_status(state, &s.id, "running", "2000-01-01T00:00:00+00:00")?;
+ let events = reconcile::reconcile_once(state).await;
+ println!("after external stop -> {} settle(s): {events:?}", events.len());
+ assert_eq!(db_status(state, &s.id)?, "stopped", "external stop must settle to stopped");
+ assert!(
+ events.iter().any(|e| e.to == "stopped" && e.reason == "external stop"),
+ "expected an external-stop settle event"
+ );
+
+ // --- External start: containers up, DB still says stopped. ---
+ println!("starting containers externally (docker compose up -d)...");
+ docker::compose_up(&s.dir()).await?;
+ // Give the container a moment to report `running` to `docker ps`.
+ tokio::time::sleep(std::time::Duration::from_secs(3)).await;
+ force_status(state, &s.id, "stopped", "2000-01-01T00:00:00+00:00")?;
+ let events = reconcile::reconcile_once(state).await;
+ println!("after external start -> {} settle(s): {events:?}", events.len());
+ assert_eq!(db_status(state, &s.id)?, "running", "external start must settle to running");
+
+ // --- Forward-only: a fresh command write must NOT be clobbered by a stale
+ // reconcile observation. Stop the containers but keep a *now* running
+ // write; the reconciler must leave it alone (grace window). ---
+ compose_stop(&s.dir())?;
+ state.db.lock().map_err(|e| e.to_string())?.set_status(&s.id, "running")?;
+ let events = reconcile::reconcile_once(state).await;
+ assert_eq!(db_status(state, &s.id)?, "running", "a fresh running write must survive the grace window");
+ assert!(events.is_empty(), "grace window should suppress the downgrade");
+ println!("forward-only grace window held: fresh running write survived");
+
+ // Leave the smoke site genuinely running for the next subcommand.
+ site::start(state, &s.id).await?;
+ println!("RECONCILE OK");
+ Ok(())
+}
+
+/// Half-created recovery verification (plan 23): simulate a create killed
+/// mid-flight (remove the completion marker, force `status = creating`), confirm
+/// the site reports as `incomplete`, then resume it and confirm it comes back
+/// running, complete, and no longer flagged.
+async fn recover(state: &AppState) -> Result<(), String> {
+ let s = find_site(state)?;
+ let dir = s.dir();
+
+ // Arrange: a killed create leaves no marker and a stuck `creating` status.
+ let marker = dir.join(site::INSTALL_MARKER);
+ let _ = std::fs::remove_file(&marker);
+ force_status(state, &s.id, "creating", "2000-01-01T00:00:00+00:00")?;
+ assert!(!site::is_complete(&dir), "marker should be gone");
+
+ // Assert: the list flags it incomplete.
+ let listed = site::list(state).await?;
+ let entry = listed
+ .iter()
+ .find(|e| e.site.id == s.id)
+ .ok_or("smoke site missing from list")?;
+ assert!(entry.incomplete, "a marker-less creating site must read as incomplete");
+ println!("flagged incomplete: slug={} status={}", entry.site.slug, entry.site.status);
+
+ // Act: resume.
+ let resumed = site::resume(None, state, &s.id).await?;
+ println!("RESUMED status={}", resumed.status);
+
+ // Assert: running, complete, no longer flagged.
+ assert_eq!(resumed.status, "running", "resume should leave the site running");
+ assert!(site::is_complete(&dir), "resume must re-write the completion marker");
+ let after = site::list(state).await?;
+ let entry = after.iter().find(|e| e.site.id == s.id).unwrap();
+ assert!(!entry.incomplete, "resumed site must no longer read as incomplete");
+
+ // It actually serves HTTP.
+ let home = http_code(&format!("http://localhost:{}/", resumed.port));
+ assert!(["200", "301", "302"].contains(&home.as_str()), "resumed site not serving: {home}");
+ println!("RECOVER OK");
+ Ok(())
+}
+
+async fn wp(s: &site::Site, args: &[&str]) -> Result {
+ let mut full: Vec<&str> = vec!["wp"];
+ full.extend_from_slice(args);
+ docker::compose_run(&s.dir(), "wpcli", &full).await
+}
+
+fn read_db_password(dir: &Path) -> Option {
+ let content = std::fs::read_to_string(dir.join(".env")).ok()?;
+ for line in content.lines() {
+ if let Some((k, v)) = line.split_once('=') {
+ if k.trim() == "DB_PASSWORD" {
+ return Some(v.trim().to_string());
+ }
+ }
+ }
+ None
+}
+
+/// Clone verification (plan 20): create a marker post on the source, clone it,
+/// and 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.
+async fn clone(state: &AppState) -> Result<(), String> {
+ let source = find_site(state)?;
+ // Idempotent: drop a clone left by a previous (killed) run.
+ remove_clone(state).await;
+
+ if source.status != "running" {
+ site::start(state, &source.id).await?;
+ }
+
+ // Arrange: a uniquely-titled published post on the source.
+ const MARKER: &str = "LocalKit clone smoke marker";
+ let titles = wp(
+ &source,
+ &["post", "list", "--post_status=publish", "--field=post_title", "--format=csv"],
+ )
+ .await
+ .unwrap_or_default();
+ if !titles.contains(MARKER) {
+ wp(
+ &source,
+ &["post", "create", &format!("--post_title={MARKER}"), "--post_status=publish"],
+ )
+ .await?;
+ }
+
+ // Act.
+ let clone = site::clone_site(None, state, &source.id, CLONE_NAME.to_string()).await?;
+ println!(
+ "CLONED id={} slug={} port={} admin={}",
+ clone.id, clone.slug, clone.port, clone.admin_user
+ );
+
+ // Assert: the clone serves HTTP.
+ let url = format!("http://localhost:{}", clone.port);
+ let home = http_code(&format!("{url}/"));
+ assert!(
+ ["200", "301", "302"].contains(&home.as_str()),
+ "clone home returned unexpected status: {home}"
+ );
+
+ // Assert: the marker post rode along in the copied database.
+ let clone_titles = wp(
+ &clone,
+ &["post", "list", "--post_status=publish", "--field=post_title", "--format=csv"],
+ )
+ .await?;
+ assert!(
+ clone_titles.contains(MARKER),
+ "marker post missing from the clone: {clone_titles:?}"
+ );
+ println!("marker post present in the clone");
+
+ // Assert: secrets are fresh (never copied), port is distinct.
+ let src_pw = read_db_password(&source.dir()).ok_or("source .env missing DB_PASSWORD")?;
+ let clone_pw = read_db_password(&clone.dir()).ok_or("clone .env missing DB_PASSWORD")?;
+ assert_ne!(src_pw, clone_pw, "clone reused the source's DB password");
+ assert_ne!(source.port, clone.port, "clone reused the source's port");
+ println!("fresh DB password + distinct port confirmed");
+
+ // Assert: the admin login carries over (the copied DB holds it).
+ assert_eq!(clone.admin_user, source.admin_user, "admin user should carry over");
+ assert_eq!(clone.admin_pass, source.admin_pass, "admin password should carry over");
+
+ // Assert: the transient clone_source snapshot was pruned from the source.
+ let snaps = snapshot::list(state, &source.id)?;
+ assert!(
+ snaps.iter().all(|s| s.kind != snapshot::KIND_CLONE_SOURCE),
+ "a clone_source snapshot was left behind on the source"
+ );
+ println!("CLONE OK on {url}");
+
+ // Tidy up so re-runs stay idempotent.
+ remove_clone(state).await;
+ Ok(())
+}
+
+/// Blueprint verification (plan 20): save the smoke site as a blueprint, assert
+/// its artifacts landed and the transient snapshot was pruned, then stamp a new
+/// site out of it and assert the source's content rode along.
+async fn blueprint_smoke(state: &AppState) -> Result<(), String> {
+ let source = find_site(state)?;
+ // Idempotent: drop leftovers from a previous run.
+ remove_from_bp(state).await;
+ for bp in blueprint::list(state)?.iter().filter(|b| b.manifest.name == BP_NAME) {
+ let _ = blueprint::delete(state, &bp.id);
+ }
+
+ if source.status != "running" {
+ site::start(state, &source.id).await?;
+ }
+
+ // Arrange: a uniquely-titled published post on the source.
+ const MARKER: &str = "LocalKit blueprint smoke marker";
+ let titles = wp(
+ &source,
+ &["post", "list", "--post_status=publish", "--field=post_title", "--format=csv"],
+ )
+ .await
+ .unwrap_or_default();
+ if !titles.contains(MARKER) {
+ wp(
+ &source,
+ &["post", "create", &format!("--post_title={MARKER}"), "--post_status=publish"],
+ )
+ .await?;
+ }
+
+ // Save.
+ let bp = blueprint::save(
+ None,
+ state,
+ &source.id,
+ BP_NAME.to_string(),
+ Some("smoke blueprint".into()),
+ )
+ .await?;
+ println!(
+ "BLUEPRINT id={} plugins={} theme={} db={} B code={} B",
+ bp.id,
+ bp.manifest.plugins.len(),
+ bp.manifest.theme,
+ bp.db_bytes,
+ bp.code_bytes
+ );
+
+ // Assert: artifacts landed.
+ let dir = blueprint::blueprints_root(&state.data_dir).join(&bp.id);
+ for f in ["blueprint.json", "db.sql.gz", "wp-content.tar.gz"] {
+ assert!(dir.join(f).exists(), "blueprint missing {f}");
+ }
+ assert!(bp.db_bytes > 0, "empty blueprint database dump");
+ assert!(bp.code_bytes > 0, "empty blueprint wp-content archive");
+
+ // Assert: the transient blueprint_source snapshot was pruned.
+ let snaps = snapshot::list(state, &source.id)?;
+ assert!(
+ snaps.iter().all(|s| s.kind != snapshot::KIND_BLUEPRINT_SOURCE),
+ "a blueprint_source snapshot was left behind"
+ );
+
+ // Act: stamp a new site out of the blueprint.
+ let created = blueprint::create_site(None, state, &bp.id, Some(BP_FROM_NAME.to_string())).await?;
+ println!(
+ "CREATED FROM BLUEPRINT id={} slug={} port={} admin={}",
+ created.id, created.slug, created.port, created.admin_user
+ );
+
+ // Assert: it serves HTTP and carries the source's content.
+ let url = format!("http://localhost:{}", created.port);
+ let home = http_code(&format!("{url}/"));
+ assert!(
+ ["200", "301", "302"].contains(&home.as_str()),
+ "blueprint site home returned unexpected status: {home}"
+ );
+ let created_titles = wp(
+ &created,
+ &["post", "list", "--post_status=publish", "--field=post_title", "--format=csv"],
+ )
+ .await?;
+ assert!(
+ created_titles.contains(MARKER),
+ "marker post missing from the blueprint site: {created_titles:?}"
+ );
+ println!("BLUEPRINT SMOKE OK on {url}");
+
+ // Tidy up.
+ remove_from_bp(state).await;
+ let _ = blueprint::delete(state, &bp.id);
+ Ok(())
+}
+
+async fn remove_from_bp(state: &AppState) {
+ let sites = {
+ let db = state.db.lock().expect("lock db");
+ db.list_sites().unwrap_or_default()
+ };
+ for s in sites {
+ if s.slug == BP_FROM_SLUG || s.slug.starts_with(&format!("{BP_FROM_SLUG}-")) {
+ let _ = site::delete(None, state, &s.id, true).await;
+ println!("cleaned blueprint site {}", s.slug);
+ }
+ }
+ let orphan = state.data_dir.join("sites").join(BP_FROM_SLUG);
+ if orphan.exists() {
+ let _ = docker::compose_down(&orphan, true).await;
+ let _ = std::fs::remove_dir_all(&orphan);
+ }
+}
+
+/// Force-remove any clone leftovers (compose project + dir + db rows + snapshots).
+async fn remove_clone(state: &AppState) {
+ let sites = {
+ let db = state.db.lock().expect("lock db");
+ db.list_sites().unwrap_or_default()
+ };
+ for s in sites {
+ if s.slug == CLONE_SLUG || s.slug.starts_with(&format!("{CLONE_SLUG}-")) {
+ let _ = site::delete(None, state, &s.id, true).await;
+ println!("cleaned clone {}", s.slug);
+ }
+ }
+ let orphan = state.data_dir.join("sites").join(CLONE_SLUG);
+ if orphan.exists() {
+ let _ = docker::compose_down(&orphan, true).await;
+ let _ = std::fs::remove_dir_all(&orphan);
+ }
+}
+
+/// Site-tools verification (plan 24) against real Docker. Exercises the
+/// wp-cli-backed tools on the smoke site and asserts the real wp-cli output
+/// parses the way the pure unit tests assume:
+/// - search-replace dry-run finds the baked-in home/siteurl without writing;
+/// - Apply (with a pre_search_replace snapshot) actually rewrites them;
+/// - the URL is restored so later subcommands keep working.
+async fn tools_smoke(state: &AppState) -> Result<(), String> {
+ let s = find_site(state)?;
+ if s.status != "running" {
+ site::start(state, &s.id).await?;
+ }
+ let dir = s.dir();
+ let from = format!("http://localhost:{}", s.port);
+ let to = "http://smoke-sr.test".to_string();
+
+ // --- Search & replace: dry run must find home/siteurl and write nothing. ---
+ let dry = wordpress::search_replace_report(&dir, &from, &to, true).await?;
+ println!("DRY total={} changes={}", dry.total, dry.changes.len());
+ assert!(dry.total > 0, "dry-run found nothing to replace (expected home/siteurl)");
+ assert!(!dry.changes.is_empty(), "dry-run parsed no per-column rows from real wp-cli output");
+ let home_before = wp(&s, &["option", "get", "home"]).await?;
+ assert_eq!(home_before.trim(), from, "dry-run must not write: home changed");
+
+ // --- Apply, with the pre_search_replace snapshot the command takes. ---
+ let snap = snapshot::create(
+ None,
+ state,
+ &s.id,
+ snapshot::KIND_PRE_SEARCH_REPLACE,
+ Some("smoke search-replace".into()),
+ )
+ .await?;
+ println!("pre_search_replace snapshot {} taken", snap.id);
+ let applied = wordpress::search_replace_report(&dir, &from, &to, false).await?;
+ println!("APPLIED total={} changes={}", applied.total, applied.changes.len());
+ assert!(applied.total > 0, "apply reported no changes");
+ let home_after = wp(&s, &["option", "get", "home"]).await?;
+ assert_eq!(home_after.trim(), to, "apply did not rewrite home");
+
+ let snaps = snapshot::list(state, &s.id)?;
+ assert!(
+ snaps.iter().any(|x| x.kind == snapshot::KIND_PRE_SEARCH_REPLACE),
+ "pre_search_replace snapshot not listed after apply"
+ );
+ println!("pre_search_replace snapshot listed OK");
+
+ // Restore the original URL so the smoke site stays usable for later runs.
+ wordpress::search_replace_report(&dir, &to, &from, false).await?;
+ let home_restored = wp(&s, &["option", "get", "home"]).await?;
+ assert_eq!(home_restored.trim(), from, "failed to restore the original home URL");
+ println!("search-replace OK");
+
+ // --- Debug mode: toggle round-trips through wp-config.php (root writer). ---
+ let before = wordpress::debug_status(&dir).await?;
+ let on = wordpress::set_debug(&dir, true).await?;
+ println!("DEBUG on -> enabled={} log_bytes={}", on.enabled, on.log_bytes);
+ assert!(on.enabled, "set_debug(true) did not enable WP_DEBUG");
+ let off = wordpress::set_debug(&dir, false).await?;
+ assert!(!off.enabled, "set_debug(false) did not disable WP_DEBUG");
+ // Restore whatever the site started with.
+ wordpress::set_debug(&dir, before.enabled).await?;
+ // The log helpers never error even when the file is absent.
+ let _ = wordpress::read_debug_log(&dir);
+ wordpress::clear_debug_log(&dir)?;
+ println!("debug toggle OK");
+
+ println!("TOOLS OK (search-replace + debug)");
+ Ok(())
+}
+
+/// Config-editor verification (plan 24), split from `tools` so it runs fast
+/// (a couple of `compose cp` calls, not a chain of wpcli spin-ups):
+/// - wp-config.php reads out of the running container and a write round-trips
+/// without breaking the site;
+/// - the `.env` reads/writes as a plain host file.
+async fn config_smoke(state: &AppState) -> Result<(), String> {
+ let s = find_site(state)?;
+ if s.status != "running" {
+ site::start(state, &s.id).await?;
+ }
+ let dir = s.dir();
+ let svc = s.app_service();
+
+ let wpconfig = wordpress::read_wp_config(&dir, svc).await?;
+ assert!(wpconfig.contains(" Result<(), String> {
+ let s = find_site(state)?;
+ if s.status != "running" {
+ site::start(state, &s.id).await?;
+ }
+ let dir = s.dir();
+ // Ensure the compose file carries the adminer service (deterministic render).
+ std::fs::write(dir.join("docker-compose.yml"), site::render_compose(&s))
+ .map_err(|e| format!("failed to rewrite docker-compose.yml: {e}"))?;
+ docker::compose_up_profile_service(&dir, "tools", "adminer").await?;
+
+ let port = s.adminer_port();
+ println!("adminer starting on port {port} (db_port {} + 1000)...", s.db_port());
+ let mut code = String::new();
+ for _ in 0..15 {
+ code = http_code(&format!("http://localhost:{port}/"));
+ if code == "200" {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+ }
+ assert_eq!(code, "200", "Adminer did not serve its login page on port {port}");
+ println!("adminer serving HTTP 200 on {port}");
+
+ // Tidy: stop just the Adminer service (leave wordpress/db running).
+ let _ = std::process::Command::new("docker")
+ .args(["compose", "--profile", "tools", "stop", "adminer"])
+ .current_dir(&dir)
+ .output();
+ println!("ADMINER OK on db-{}.test-equivalent port {port}", s.slug);
+ Ok(())
+}
+
+/// Plan 26: create a PHP/Laravel stack site, prove it serves and (via the
+/// skeleton page's PDO probe) that php-fpm can reach the bundled mariadb, then
+/// delete it. Self-contained — its own site, cleaned up on the way out.
+async fn php_smoke(state: &AppState) -> Result<(), String> {
+ // Idempotent: drop any php-smoke leftovers from a prior run first.
+ let existing: Vec = {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.list_sites()?.into_iter().filter(|s| s.slug == PHP_SLUG).collect()
+ };
+ for s in existing {
+ let _ = site::delete(None, state, &s.id, true).await;
+ }
+
+ let s = php::create_php_site(None, state, PHP_NAME.to_string(), "8.3".to_string(), None, false)
+ .await?;
+ println!(
+ "CREATED php id={} slug={} port={} db_port={} kind={}",
+ s.id, s.slug, s.port, s.db_port(), s.kind
+ );
+ assert_eq!(s.kind, site::KIND_PHP, "kind should be php");
+ assert!(s.capabilities.db_sync, "php claims db_sync");
+ assert!(!s.capabilities.wp_tools, "php has no WP tools");
+
+ let url = format!("http://localhost:{}", s.port);
+ let home = http_code(&format!("{url}/"));
+ println!("HTTP / -> {home}");
+ assert_eq!(home, "200", "the skeleton webroot should serve 200");
+
+ // The skeleton page runs a PDO connectivity check against the bundled db —
+ // "connected" proves php-fpm has pdo_mysql AND mariadb is reachable.
+ let body = http_body(&format!("{url}/"));
+ assert!(body.contains("Your PHP stack is running"), "unexpected body:\n{body}");
+ assert!(
+ body.contains("connected"),
+ "php-fpm could not reach the database (pdo_mysql/mariadb):\n{body}"
+ );
+ println!("skeleton page rendered + database reachable");
+
+ // The app code is bind-mounted from ./app on the host.
+ assert!(
+ s.dir().join("app").join("public").join("index.php").exists(),
+ "app/public/index.php missing on host"
+ );
+ assert_eq!(s.status, "running", "db status should be running");
+
+ // Engine-native DB snapshot round-trip (plan 26 phase 2): mysqldump export +
+ // mysql import, no wp-cli. Write a marker row, snapshot, wipe it, restore,
+ // and assert it is back — proving the mariadb dump/restore path works.
+ let dir = s.dir();
+ let pw = localkit_lib::site::db_password(&dir);
+ php_sql(
+ &dir,
+ &pw,
+ "CREATE TABLE lk_marker (id INT PRIMARY KEY, note VARCHAR(64)); \
+ INSERT INTO lk_marker VALUES (1, 'before-snapshot');",
+ )
+ .await?;
+ let snap = snapshot::create(None, state, &s.id, snapshot::KIND_MANUAL, Some("php smoke".into()))
+ .await?;
+ assert!(snap.db_bytes > 0, "php snapshot captured no database (empty dump)");
+ println!("snapshot took an engine-native dump ({} db bytes)", snap.db_bytes);
+
+ php_sql(&dir, &pw, "DELETE FROM lk_marker;").await?;
+ let gone = php_query(&dir, &pw, "SELECT COUNT(*) FROM lk_marker;").await?;
+ assert_eq!(gone.trim(), "0", "marker row was not deleted before restore");
+
+ snapshot::restore(None, state, &s.id, &snap.id).await?;
+ let restored = php_query(&dir, &pw, "SELECT note FROM lk_marker WHERE id=1;").await?;
+ assert_eq!(
+ restored.trim(),
+ "before-snapshot",
+ "engine-native restore did not bring the marker row back"
+ );
+ println!("engine-native snapshot restore round-trip OK");
+
+ // Clean up wholesale (drop snapshots too — this is a throwaway).
+ site::delete(None, state, &s.id, true).await?;
+ assert!(!s.dir().exists(), "php site dir survived delete");
+ println!("PHP SMOKE OK on {url}");
+ Ok(())
+}
+
+/// Run a SQL statement against a php site's mariadb via its own client.
+async fn php_sql(dir: &Path, pw: &str, sql: &str) -> Result {
+ docker::compose_exec_env(
+ dir,
+ "db",
+ &[("MYSQL_PWD", pw)],
+ &["mariadb", "-u", "laravel", "laravel", "-e", sql],
+ )
+ .await
+}
+
+/// Run a scalar query (no column headers) against a php site's mariadb.
+async fn php_query(dir: &Path, pw: &str, sql: &str) -> Result {
+ docker::compose_exec_env(
+ dir,
+ "db",
+ &[("MYSQL_PWD", pw)],
+ &["mariadb", "-N", "-B", "-u", "laravel", "laravel", "-e", sql],
+ )
+ .await
+}
+
async fn delete(state: &AppState) -> Result<(), String> {
let s = find_site(state)?;
let dir = s.dir();
- site::delete(state, &s.id).await?;
+ // Keep the snapshots so `snapshot_smoke` can assert they survive the site.
+ site::delete(None, state, &s.id, false).await?;
assert!(!dir.exists(), "site dir still exists after delete");
let db = state.db.lock().map_err(|e| e.to_string())?;
assert!(db.list_sites()?.is_empty(), "db rows left after delete");
@@ -139,6 +770,9 @@ async fn delete(state: &AppState) -> Result<(), String> {
/// Force-remove any smoke-test leftovers (compose project + dir + db rows).
async fn cleanup(state: &AppState) -> Result<(), String> {
+ // Sites the `clone` / `blueprint` subcommands leave behind are leftovers too.
+ remove_clone(state).await;
+ remove_from_bp(state).await;
let sites = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.list_sites()?
@@ -180,6 +814,14 @@ async fn main() {
"info" => info(&state).await,
"stop" => stop(&state).await,
"start" => start(&state).await,
+ "reconcile" => reconcile_smoke(&state).await,
+ "recover" => recover(&state).await,
+ "clone" => clone(&state).await,
+ "blueprint" => blueprint_smoke(&state).await,
+ "tools" => tools_smoke(&state).await,
+ "config" => config_smoke(&state).await,
+ "adminer" => adminer_smoke(&state).await,
+ "php" => php_smoke(&state).await,
"delete" => delete(&state).await,
"cleanup" => cleanup(&state).await,
other => Err(format!("unknown command: {other}")),
diff --git a/src-tauri/examples/snapshot_smoke.rs b/src-tauri/examples/snapshot_smoke.rs
new file mode 100644
index 0000000..cd28035
--- /dev/null
+++ b/src-tauri/examples/snapshot_smoke.rs
@@ -0,0 +1,166 @@
+//! End-to-end smoke test for snapshots + restore (plan 17).
+//! Runs outside the Tauri runtime (no AppHandle; progress prints to stderr).
+//!
+//! Usage:
+//! cargo run --example smoke -- create # once, to have a site
+//! cargo run --example snapshot_smoke # or `-- run`
+//! cargo run --example snapshot_smoke -- clean
+//!
+//! Shares the `smoke` example's data dir and site, so it exercises the same
+//! WordPress install the lifecycle smoke test builds.
+
+use std::sync::Mutex;
+
+use localkit_lib::{db::Db, docker, site, snapshot, AppState};
+
+const SMOKE_SLUG: &str = "smoke-test";
+/// Dropped into wp-content to prove the code archive round-trips, not just the DB.
+const CANARY: &str = "localkit-snapshot-canary.txt";
+
+fn make_state() -> AppState {
+ let data_dir = std::env::temp_dir().join("localkit-smoke");
+ std::fs::create_dir_all(&data_dir).expect("create smoke data dir");
+ let db = Db::open(&data_dir.join("localkit.db")).expect("open smoke db");
+ AppState {
+ db: Mutex::new(db),
+ data_dir,
+ terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
+ }
+}
+
+fn find_site(state: &AppState) -> Result {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.list_sites()?
+ .into_iter()
+ .find(|s| s.slug == SMOKE_SLUG || s.slug.starts_with(&format!("{SMOKE_SLUG}-")))
+ .ok_or_else(|| "smoke site not found — run `cargo run --example smoke -- create` first".into())
+}
+
+async fn wp(s: &site::Site, args: &[&str]) -> Result {
+ let mut full: Vec<&str> = vec!["wp"];
+ full.extend_from_slice(args);
+ docker::compose_run(&s.dir(), "wpcli", &full).await
+}
+
+/// Does post 1 still exist? (`wp post get` fails once it is really gone.)
+async fn post_exists(s: &site::Site) -> bool {
+ wp(s, &["post", "get", "1", "--field=ID"])
+ .await
+ .map(|out| out.trim() == "1")
+ .unwrap_or(false)
+}
+
+async fn run(state: &AppState) -> Result<(), String> {
+ let s = find_site(state)?;
+ println!("site: {} ({})", s.name, s.slug);
+
+ // The DB import needs the stack up.
+ if s.status != "running" {
+ println!("starting the site...");
+ site::start(state, &s.id).await?;
+ }
+
+ // --- arrange: a known post + a known file in wp-content -----------------
+ if !post_exists(&s).await {
+ wp(&s, &["post", "create", "--post_title=Hello world!", "--post_status=publish"]).await?;
+ }
+ let canary = s.dir().join("wp-content").join(CANARY);
+ std::fs::write(&canary, b"present at snapshot time\n")
+ .map_err(|e| format!("failed to write canary: {e}"))?;
+ assert!(post_exists(&s).await, "post 1 should exist before the snapshot");
+
+ // --- snapshot -----------------------------------------------------------
+ let snap = snapshot::create(
+ None,
+ state,
+ &s.id,
+ snapshot::KIND_MANUAL,
+ Some("snapshot smoke test".into()),
+ )
+ .await?;
+ println!(
+ "SNAPSHOT id={} kind={} db={} B code={} B",
+ snap.id, snap.kind, snap.db_bytes, snap.code_bytes
+ );
+ assert!(snap.db_bytes > 0, "empty database dump");
+ assert!(snap.code_bytes > 0, "empty wp-content archive");
+
+ // --- break it -----------------------------------------------------------
+ wp(&s, &["post", "delete", "1", "--force"]).await?;
+ std::fs::remove_file(&canary).map_err(|e| format!("failed to remove canary: {e}"))?;
+ assert!(!post_exists(&s).await, "post 1 should be gone after the delete");
+ assert!(!canary.exists(), "canary should be gone after the delete");
+ println!("broke the site: post 1 deleted, {CANARY} removed");
+
+ // --- restore ------------------------------------------------------------
+ let message = snapshot::restore(None, state, &s.id, &snap.id).await?;
+ println!("RESTORE {message}");
+ assert!(post_exists(&s).await, "post 1 should be back after the restore");
+ assert!(canary.exists(), "{CANARY} should be back after the restore");
+
+ // Restoring is destructive too, so it snapshots first.
+ let all = snapshot::list(state, &s.id)?;
+ assert!(
+ all.iter().any(|x| x.kind == snapshot::KIND_PRE_RESTORE),
+ "restore should have taken a pre_restore snapshot"
+ );
+ // Newest first.
+ assert_eq!(all[0].kind, snapshot::KIND_PRE_RESTORE);
+ println!(
+ "snapshots on disk: {}",
+ all.iter()
+ .map(|x| format!("{}({})", x.kind, x.id))
+ .collect::>()
+ .join(", ")
+ );
+
+ // --- delete one ---------------------------------------------------------
+ snapshot::delete(state, &s.id, &snap.id)?;
+ let after = snapshot::list(state, &s.id)?;
+ assert!(
+ !after.iter().any(|x| x.id == snap.id),
+ "deleted snapshot still listed"
+ );
+ assert!(
+ !snapshot::site_snapshots_dir(&state.data_dir, &s.id)
+ .join(&snap.id)
+ .exists(),
+ "deleted snapshot directory still on disk"
+ );
+
+ let _ = std::fs::remove_file(&canary);
+ println!("SNAPSHOT SMOKE OK");
+ Ok(())
+}
+
+/// Drop every snapshot the smoke run left behind.
+fn clean(state: &AppState) -> Result<(), String> {
+ let root = snapshot::snapshots_root(&state.data_dir);
+ if root.exists() {
+ std::fs::remove_dir_all(&root).map_err(|e| format!("failed to clean snapshots: {e}"))?;
+ println!("cleaned {}", root.display());
+ }
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() {
+ let cmd = std::env::args().nth(1).unwrap_or_else(|| "run".to_string());
+ let status = docker::check().await;
+ if !status.available {
+ eprintln!("docker unavailable: {:?}", status.error);
+ std::process::exit(2);
+ }
+ let state = make_state();
+ let result = match cmd.as_str() {
+ "run" => run(&state).await,
+ "clean" => clean(&state),
+ other => Err(format!("unknown command: {other}")),
+ };
+ if let Err(e) = result {
+ eprintln!("SNAPSHOT SMOKE {cmd} FAILED: {e}");
+ std::process::exit(1);
+ }
+}
diff --git a/src-tauri/lk/Cargo.toml b/src-tauri/lk/Cargo.toml
index 2a761dc..bf20346 100644
--- a/src-tauri/lk/Cargo.toml
+++ b/src-tauri/lk/Cargo.toml
@@ -8,8 +8,12 @@ rust-version = "1.77.2"
[dependencies]
localkit_lib = { path = "..", package = "localkit" }
clap = { version = "4", features = ["derive", "env"] }
+clap_complete = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
open = "5"
+rpassword = "7"
+uuid = { version = "1", features = ["v4"] }
+chrono = { version = "0.4", default-features = false, features = ["clock"] }
diff --git a/src-tauri/lk/src/main.rs b/src-tauri/lk/src/main.rs
index b006d1e..a4763be 100644
--- a/src-tauri/lk/src/main.rs
+++ b/src-tauri/lk/src/main.rs
@@ -15,11 +15,14 @@
//! is required when not on a TTY.
use std::io::IsTerminal;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::Mutex;
-use clap::{Parser, Subcommand, ValueEnum};
-use localkit_lib::{db::Db, docker, router, site, wordpress, AppState};
+use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
+use clap_complete::Shell as CompletionShell;
+use localkit_lib::serverkit::{self, ServerKitConnection};
+use localkit_lib::sync::{self, SyncRecord};
+use localkit_lib::{blueprint, db::Db, docker, php, router, site, snapshot, wordpress, AppState};
// ---------------------------------------------------------------------------
// clap surface
@@ -59,17 +62,97 @@ enum Cmd {
json: bool,
},
+ /// List the WordPress sites on a ServerKit server (read-only).
+ Sites {
+ /// ServerKit connection to query (exact id, or case-insensitive name)
+ #[arg(long)]
+ remote: String,
+ /// Output machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Manage ServerKit connections (add, list, test, remove)
+ #[command(subcommand)]
+ Connection(ConnectionCmd),
+
+ /// Push a local site's code and/or database to its ServerKit remote.
+ /// `--connection`/`--remote-site` are only needed when the site has no
+ /// linked remote (imported sites carry one). Exit 2 = the server rejected it.
+ Push {
+ /// Local site (exact id, or case-insensitive slug or name)
+ site: String,
+ /// Push wp-content
+ #[arg(long)]
+ code: bool,
+ /// Push the database (site must be running)
+ #[arg(long)]
+ db: bool,
+ /// ServerKit connection (defaults to the site's linked remote)
+ #[arg(long)]
+ connection: Option,
+ /// Remote site to target (numeric id or name; defaults to the link)
+ #[arg(long)]
+ remote_site: Option,
+ /// Print the resulting sync record(s) as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Pull a database from a local site's ServerKit remote into it (destructive
+ /// — a pre-pull snapshot is taken first). Exit 2 = the server rejected it.
+ /// To bring a remote site down as a NEW local site, use `lk import`.
+ Pull {
+ /// Local site (exact id, or case-insensitive slug or name)
+ site: String,
+ /// Pull the database (the only pull; the site must be running)
+ #[arg(long)]
+ db: bool,
+ /// ServerKit connection (defaults to the site's linked remote)
+ #[arg(long)]
+ connection: Option,
+ /// Remote site to target (numeric id or name; defaults to the link)
+ #[arg(long)]
+ remote_site: Option,
+ /// Print the resulting sync record as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
/// Create a new site (pulls Docker images on first run).
/// Prints the site URL on stdout; progress goes to stderr.
Create {
- /// Site name, e.g. "My Blog"
- name: String,
- /// WordPress version (allowlist lives in the app)
+ /// Site name, e.g. "My Blog" (defaults to the blueprint name with --blueprint)
+ name: Option,
+ /// Stack kind: `wordpress` (default) or `php` (a PHP/Laravel stack)
+ #[arg(long, default_value = "wordpress")]
+ kind: String,
+ /// WordPress version (allowlist lives in the app; ignored with --blueprint)
#[arg(long)]
wp_version: Option,
- /// PHP version (allowlist lives in the app)
+ /// PHP version (allowlist lives in the app; ignored with --blueprint)
#[arg(long)]
php_version: Option,
+ /// For --kind php: import an existing PHP project folder instead of an
+ /// empty Laravel-ready skeleton
+ #[arg(long)]
+ from: Option,
+ /// Create from a saved blueprint (its id or name) instead of a blank install
+ #[arg(long)]
+ blueprint: Option,
+ /// Output the created site as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Clone an existing local site into a NEW one (copies its database and
+ /// wp-content, with fresh ports and DB credentials). Prints the new site's
+ /// URL on stdout; progress goes to stderr.
+ Clone {
+ /// Source site (exact id, or case-insensitive slug or name)
+ site: String,
+ /// Name for the new cloned site
+ new_name: String,
/// Output the created site as machine-readable JSON
#[arg(long)]
json: bool,
@@ -84,13 +167,44 @@ enum Cmd {
/// Restart a site
Restart { site: String },
+ /// Finish a half-created site (a create killed mid-install)
+ Resume { site: String },
+
/// Delete a site (removes containers, volumes, and files).
+ /// A restorable snapshot is kept unless --delete-snapshots is passed.
/// Prompts for confirmation unless --yes; --yes is required non-interactively.
Delete {
site: String,
/// Skip the confirmation prompt
#[arg(long)]
yes: bool,
+ /// Also delete this site's snapshots (they are kept by default)
+ #[arg(long)]
+ delete_snapshots: bool,
+ },
+
+ /// Manage point-in-time snapshots (database + wp-content) of a site
+ #[command(subcommand)]
+ Snapshot(SnapshotCmd),
+
+ /// Manage reusable site blueprints (save one, list, delete, share)
+ #[command(subcommand)]
+ Blueprint(BlueprintCmd),
+
+ /// Clone a site from a ServerKit server down as a NEW local site.
+ /// Downloads its wp-content and database, rewrites URLs to the local one,
+ /// and leaves the site running. Prints the new site's URL on stdout.
+ Import {
+ /// ServerKit connection (exact id, or case-insensitive label)
+ connection: String,
+ /// Remote site (numeric id from the server, or its case-insensitive name)
+ site: String,
+ /// Name for the new local site (defaults to the remote site's name)
+ #[arg(long)]
+ name: Option,
+ /// Output the created site as machine-readable JSON
+ #[arg(long)]
+ json: bool,
},
/// Show site details, including DB credentials
@@ -140,9 +254,161 @@ enum Cmd {
open: bool,
},
- /// Diagnose the local environment (Docker, compose, data dir).
- /// Exits non-zero while any check fails, so it can gate scripts.
+ /// Diagnose the local environment (Docker, compose, data dir) plus every
+ /// stored ServerKit connection. Exits non-zero while any local check fails,
+ /// so it can gate scripts; a connection being down is reported but does not
+ /// flip the exit code (a remote outage is not a local misconfiguration).
Doctor,
+
+ /// Print a shell completion script for `lk` to stdout.
+ /// e.g. `lk completions bash > /etc/bash_completion.d/lk`.
+ Completions {
+ /// Target shell
+ #[arg(value_enum)]
+ shell: CompletionShell,
+ },
+}
+
+/// ServerKit connection management (Track D, plan 21). Connections live in the
+/// same SQLite table the GUI uses, so `lk connection add` and the app's
+/// Settings → ServerKit panel share one list.
+#[derive(Subcommand)]
+enum ConnectionCmd {
+ /// Add a connection. Validates it (health + API key + extension probe) the
+ /// same way the app does and refuses to store a key that doesn't work.
+ /// The key is read from a hidden prompt, `--key`, or LOCALKIT_API_KEY.
+ Add {
+ /// Connection name (label), e.g. "prod"
+ name: String,
+ /// ServerKit base URL, e.g. https://panel.example.com
+ url: String,
+ /// API key (skips the hidden prompt; required when not on a TTY)
+ #[arg(long, env = "LOCALKIT_API_KEY", hide_env_values = true)]
+ key: Option,
+ /// Output the stored connection as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// List stored connections (local only — no network). Use `test` to probe.
+ List {
+ /// Output machine-readable JSON (never includes the API key)
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Re-run the connection test: health, API key, and extension features.
+ Test {
+ /// Connection (exact id, or case-insensitive name)
+ connection: String,
+ /// Output the test result as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Remove a connection. Prompts unless --yes; --yes required on non-TTY.
+ Remove {
+ /// Connection (exact id, or case-insensitive name)
+ connection: String,
+ /// Skip the confirmation prompt
+ #[arg(long)]
+ yes: bool,
+ },
+}
+
+#[derive(Subcommand)]
+enum SnapshotCmd {
+ /// List a site's snapshots, newest first
+ List {
+ site: String,
+ /// Output machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Take a snapshot now. Prints the new snapshot id on stdout.
+ Create {
+ site: String,
+ /// Optional note stored in the snapshot's manifest
+ #[arg(long)]
+ note: Option,
+ /// Output the created snapshot as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Restore a site to a snapshot (destructive — snapshots first, then
+ /// replaces the database and wp-content). Prompts unless --yes.
+ Restore {
+ site: String,
+ /// Snapshot id from `lk snapshot list`
+ snapshot: String,
+ /// Skip the confirmation prompt
+ #[arg(long)]
+ yes: bool,
+ },
+
+ /// Delete one snapshot. Prompts unless --yes.
+ Delete {
+ site: String,
+ /// Snapshot id from `lk snapshot list`
+ snapshot: String,
+ /// Skip the confirmation prompt
+ #[arg(long)]
+ yes: bool,
+ },
+}
+
+#[derive(Subcommand)]
+enum BlueprintCmd {
+ /// List saved blueprints
+ List {
+ /// Output machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Save an existing site as a reusable blueprint.
+ /// Prints the new blueprint's id on stdout; progress goes to stderr.
+ Save {
+ /// Source site (exact id, or case-insensitive slug or name)
+ site: String,
+ /// Blueprint name
+ name: String,
+ /// Optional description stored in the blueprint
+ #[arg(long)]
+ description: Option,
+ /// Output the created blueprint as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
+
+ /// Delete a blueprint (id or name). Prompts unless --yes.
+ Delete {
+ /// Blueprint (exact id, or case-insensitive name)
+ blueprint: String,
+ /// Skip the confirmation prompt
+ #[arg(long)]
+ yes: bool,
+ },
+
+ /// Export a blueprint to a single portable `.lkbp` file for sharing
+ Export {
+ /// Blueprint (exact id, or case-insensitive name)
+ blueprint: String,
+ /// Output file (defaults to .lkbp in the current directory)
+ #[arg(short, long)]
+ output: Option,
+ },
+
+ /// Import a blueprint from a `.lkbp` file
+ Import {
+ /// Path to the `.lkbp` file
+ file: PathBuf,
+ /// Output the imported blueprint as machine-readable JSON
+ #[arg(long)]
+ json: bool,
+ },
}
// ---------------------------------------------------------------------------
@@ -154,27 +420,114 @@ async fn main() {
let cli = Cli::parse();
NO_COLOR_FLAG.store(cli.no_color, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = run(&cli).await {
- eprintln!("{} {e}", red("error:"));
- std::process::exit(1);
+ eprintln!("{} {}", red("error:"), e.message);
+ std::process::exit(e.code);
}
}
-async fn run(cli: &Cli) -> Result<(), String> {
- // `doctor` works without opening the DB.
- if let Cmd::Doctor = cli.command {
- return cmd_doctor(cli.data_dir.clone()).await;
+/// A CLI failure plus the process exit code it carries. Almost everything is
+/// code 1; a sync operation the *server* rejects surfaces as code 2 so scripts
+/// can tell "the server said no" apart from "something local broke".
+struct CliError {
+ message: String,
+ code: i32,
+}
+
+impl CliError {
+ fn new(message: impl Into) -> Self {
+ Self { message: message.into(), code: 1 }
+ }
+ /// Exit code 2 — the remote rejected the operation (see `sync_err`).
+ fn rejected(message: impl Into) -> Self {
+ Self { message: message.into(), code: 2 }
}
+}
- let state = make_state(cli)?;
+impl From for CliError {
+ fn from(message: String) -> Self {
+ CliError::new(message)
+ }
+}
+async fn run(cli: &Cli) -> Result<(), CliError> {
+ // These two never touch the DB.
match &cli.command {
+ Cmd::Doctor => return cmd_doctor(cli.data_dir.clone()).await.map_err(CliError::from),
+ Cmd::Completions { shell } => return cmd_completions(*shell).map_err(CliError::from),
+ _ => {}
+ }
+
+ let state = make_state(cli)?;
+
+ // Push/pull own their exit code (2 on a server rejection), so they `return`
+ // a `CliError` directly; every other command's `String` error collapses to
+ // a plain code-1 `CliError` at the end.
+ let out: Result<(), String> = match &cli.command {
+ Cmd::Push {
+ site,
+ code,
+ db,
+ connection,
+ remote_site,
+ json,
+ } => {
+ return cmd_push(
+ &state,
+ site,
+ *code,
+ *db,
+ connection.as_deref(),
+ remote_site.as_deref(),
+ *json,
+ )
+ .await
+ }
+ Cmd::Pull {
+ site,
+ db,
+ connection,
+ remote_site,
+ json,
+ } => {
+ return cmd_pull(
+ &state,
+ site,
+ *db,
+ connection.as_deref(),
+ remote_site.as_deref(),
+ *json,
+ )
+ .await
+ }
Cmd::List { json } => cmd_list(&state, *json).await,
+ Cmd::Sites { remote, json } => cmd_remote_sites(&state, remote, *json).await,
+ Cmd::Connection(sub) => cmd_connection(&state, sub).await,
Cmd::Create {
name,
+ kind,
wp_version,
php_version,
+ from,
+ blueprint,
+ json,
+ } => {
+ cmd_create(
+ &state,
+ name.as_deref(),
+ kind,
+ wp_version,
+ php_version,
+ from.as_deref(),
+ blueprint.as_deref(),
+ *json,
+ )
+ .await
+ }
+ Cmd::Clone {
+ site: q,
+ new_name,
json,
- } => cmd_create(&state, name, wp_version, php_version, *json).await,
+ } => cmd_clone(&state, q, new_name, *json).await,
Cmd::Start { site: q } => {
let s = resolve(&state, q)?;
let s = site::start(&state, &s.id).await?;
@@ -197,7 +550,26 @@ async fn run(cli: &Cli) -> Result<(), String> {
println!("{}", site_url(&s));
Ok(())
}
- Cmd::Delete { site: q, yes } => cmd_delete(&state, q, *yes).await,
+ Cmd::Resume { site: q } => {
+ let s = resolve(&state, q)?;
+ let s = site::resume(None, &state, &s.id).await?;
+ eprintln!("{} {} setup finished", ok("✓"), bold(&s.name));
+ println!("{}", site_url(&s));
+ Ok(())
+ }
+ Cmd::Delete {
+ site: q,
+ yes,
+ delete_snapshots,
+ } => cmd_delete(&state, q, *yes, *delete_snapshots).await,
+ Cmd::Snapshot(sub) => cmd_snapshot(&state, sub).await,
+ Cmd::Blueprint(sub) => cmd_blueprint(&state, sub).await,
+ Cmd::Import {
+ connection,
+ site: remote,
+ name,
+ json,
+ } => cmd_import(&state, connection, remote, name.clone(), *json).await,
Cmd::Info { site: q, json } => cmd_info(&state, q, *json),
Cmd::Logs { site: q, tail } => {
let s = resolve(&state, q)?;
@@ -207,6 +579,7 @@ async fn run(cli: &Cli) -> Result<(), String> {
}
Cmd::Wp { site: q, args } => {
let s = resolve(&state, q)?;
+ s.require(s.capabilities.wp_tools, "`lk wp`")?;
let mut full: Vec<&str> = vec!["wp"];
full.extend(args.iter().map(String::as_str));
let out = docker::compose_run(&s.dir(), "wpcli", &full).await?;
@@ -215,118 +588,958 @@ async fn run(cli: &Cli) -> Result<(), String> {
}
Cmd::Env { site: q, shell, json } => cmd_env(&state, q, *shell, *json),
Cmd::Login { site: q, user, open } => cmd_login(&state, q, user.as_deref(), *open).await,
- Cmd::Doctor => unreachable!("handled above"),
+ Cmd::Doctor | Cmd::Completions { .. } => unreachable!("handled before make_state"),
+ };
+ out.map_err(CliError::from)
+}
+
+// ---------------------------------------------------------------------------
+// Subcommands
+// ---------------------------------------------------------------------------
+
+async fn cmd_list(state: &AppState, json: bool) -> Result<(), String> {
+ let sites = site::list(state).await?;
+ if json {
+ return print_json(&sites);
+ }
+ if sites.is_empty() {
+ eprintln!("{} no sites yet. create one with `lk create `.", info("→"));
+ return Ok(());
+ }
+ let rows: Vec<[String; 4]> = sites
+ .iter()
+ .map(|s| {
+ [
+ s.site.slug.clone(),
+ // A half-created site (plan 23) reads as `incomplete` — run
+ // `lk resume ` to finish it.
+ if s.incomplete { "incomplete".to_string() } else { s.live_status.clone() },
+ site_url(&s.site),
+ format!("WP {} / PHP {}", s.site.wp_version, s.site.php_version),
+ ]
+ })
+ .collect();
+ let headers = ["SLUG", "STATUS", "URL", "VERSION"];
+ let mut w = [0usize; 4];
+ for (i, h) in headers.iter().enumerate() {
+ w[i] = h.len();
+ }
+ for r in &rows {
+ for (i, c) in r.iter().enumerate() {
+ w[i] = w[i].max(c.len());
+ }
+ }
+ for (i, h) in headers.iter().enumerate() {
+ print!("{: ok(&padded),
+ // Degraded (up but unhealthy) and incomplete (a killed create)
+ // both warrant attention — amber, not dim (plan 23).
+ (1, "degraded") | (1, "incomplete") => warn(&padded),
+ (1, _) => dim(&padded),
+ _ => padded,
+ };
+ print!("{cell} ");
+ }
+ println!();
+ }
+ Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn cmd_create(
+ state: &AppState,
+ name: Option<&str>,
+ kind: &str,
+ wp_version: &Option,
+ php_version: &Option,
+ from: Option<&Path>,
+ blueprint: Option<&str>,
+ json: bool,
+) -> Result<(), String> {
+ // From a blueprint: versions come from the recipe, the name defaults to it.
+ if let Some(query) = blueprint {
+ let bp = blueprint::find(state, query)?;
+ let site = blueprint::create_site(None, state, &bp.id, name.map(str::to_string)).await?;
+ if json {
+ print_json(&site)?;
+ } else {
+ println!("{}", site_url(&site));
+ }
+ eprintln!(
+ "{} {} created from blueprint {} and running",
+ ok("✓"),
+ bold(&site.name),
+ bold(&bp.manifest.name)
+ );
+ eprintln!(
+ "{} log in with `lk login {}` — the blueprint's database keeps its accounts",
+ info("→"),
+ site.slug
+ );
+ return Ok(());
+ }
+
+ let name = name.ok_or("a site name is required (or pass --blueprint )")?;
+
+ // A PHP/Laravel stack: empty skeleton, or import an existing folder (--from).
+ if kind == site::KIND_PHP {
+ let php = php_version
+ .clone()
+ .unwrap_or_else(|| site::PHP_VERSIONS[0].into());
+ let source = from.map(|p| p.to_path_buf());
+ let site = php::create_php_site(None, state, name.to_string(), php, source, false).await?;
+ if json {
+ print_json(&site)?;
+ } else {
+ println!("{}", site_url(&site));
+ }
+ eprintln!("{} {} is running", ok("✓"), bold(&site.name));
+ eprintln!(
+ "{} open a terminal (`lk` has none — use the app) or edit ./{}/ to add your code",
+ info("→"),
+ php::APP_DIR
+ );
+ return Ok(());
+ }
+ if kind != site::KIND_WORDPRESS {
+ return Err(format!(
+ "unknown kind `{kind}` — use `wordpress` (default) or `php`"
+ ));
+ }
+ if from.is_some() {
+ return Err("--from is only valid with --kind php".into());
+ }
+
+ let wp = wp_version
+ .clone()
+ .unwrap_or_else(|| site::WP_VERSIONS[0].into());
+ let php = php_version
+ .clone()
+ .unwrap_or_else(|| site::PHP_VERSIONS[0].into());
+ let site = site::create(None, state, name.to_string(), wp, php).await?;
+ if json {
+ print_json(&site)?;
+ } else {
+ // stdout carries the URL (scriptable); chrome stays on stderr.
+ println!("{}", site_url(&site));
+ }
+ eprintln!("{} {} is running", ok("✓"), bold(&site.name));
+ eprintln!(
+ "{} admin credentials: {} / {}",
+ info("→"),
+ site.admin_user,
+ site.admin_pass
+ );
+ Ok(())
+}
+
+/// `lk clone` — thin wrapper over `site::clone_site`; all orchestration lives
+/// in the library. Progress reaches the terminal on its own: with no Tauri app
+/// handle `site::emit` prints each stage to stderr.
+async fn cmd_clone(
+ state: &AppState,
+ query: &str,
+ new_name: &str,
+ json: bool,
+) -> Result<(), String> {
+ let source = resolve(state, query)?;
+ let clone = site::clone_site(None, state, &source.id, new_name.to_string()).await?;
+ if json {
+ print_json(&clone)?;
+ } else {
+ // stdout carries the URL (scriptable); chrome stays on stderr.
+ println!("{}", site_url(&clone));
+ }
+ eprintln!(
+ "{} {} cloned from {} and running",
+ ok("✓"),
+ bold(&clone.name),
+ bold(&source.name)
+ );
+ eprintln!(
+ "{} admin login carries over from the source: {} / {}",
+ info("→"),
+ clone.admin_user,
+ clone.admin_pass
+ );
+ Ok(())
+}
+
+/// Does `query` name a deleted site whose snapshots are still on disk?
+/// Only an exact site id can match — there is no sites row left to map a
+/// slug through.
+fn orphan_snapshots_exist(state: &AppState, query: &str) -> bool {
+ snapshot::site_snapshots_dir(&state.data_dir, query).is_dir()
+}
+
+/// Destructive-command gate: prompt with a No default unless `--yes`, and
+/// require `--yes` when there is no TTY to prompt on.
+fn confirm(yes: bool, question: &str, non_tty_hint: &str) -> Result<(), String> {
+ if yes {
+ return Ok(());
+ }
+ if !std::io::stdout().is_terminal() {
+ return Err(non_tty_hint.to_string());
+ }
+ eprint!("{} {question} [y/N] ", warn("!"));
+ let mut line = String::new();
+ use std::io::BufRead;
+ // EOF/no-tty falls through to the No path.
+ let read = std::io::stdin().lock().read_line(&mut line);
+ if read.is_err() || !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
+ return Err("aborted".into());
+ }
+ Ok(())
+}
+
+async fn cmd_delete(
+ state: &AppState,
+ query: &str,
+ yes: bool,
+ delete_snapshots: bool,
+) -> Result<(), String> {
+ let s = resolve(state, query)?;
+ let tail = if delete_snapshots {
+ "this removes its containers, volumes, files AND snapshots."
+ } else {
+ "this removes its containers, volumes, and files (a snapshot is kept)."
+ };
+ confirm(
+ yes,
+ &format!("delete `{}`? {tail}", s.slug),
+ &format!(
+ "`lk delete` removes `{}` permanently. pass --yes to confirm.",
+ s.slug
+ ),
+ )?;
+ site::delete(None, state, &s.id, delete_snapshots).await?;
+ eprintln!("{} {} deleted", ok("✓"), bold(&s.name));
+ if !delete_snapshots {
+ eprintln!(
+ "{} snapshots kept — `lk snapshot list {}` still lists them",
+ info("→"),
+ s.id
+ );
+ }
+ Ok(())
+}
+
+async fn cmd_snapshot(state: &AppState, cmd: &SnapshotCmd) -> Result<(), String> {
+ match cmd {
+ SnapshotCmd::List { site: q, json } => {
+ // Listing tolerates a site that no longer exists: deleting a site
+ // keeps its snapshots, and their manifests carry the name/slug, so
+ // `lk snapshot list ` stays useful afterwards. (Restore
+ // and delete still require a live site — there is nothing to
+ // restore *into*.)
+ let (id, label) = match resolve(state, q) {
+ Ok(s) => (s.id, s.slug),
+ Err(_) if orphan_snapshots_exist(state, q) => (q.to_string(), q.to_string()),
+ Err(e) => return Err(e),
+ };
+ let snaps = snapshot::list(state, &id)?;
+ if *json {
+ return print_json(&snaps);
+ }
+ if snaps.is_empty() {
+ eprintln!(
+ "{} no snapshots for `{label}` yet. take one with `lk snapshot create {label}`.",
+ info("→"),
+ );
+ return Ok(());
+ }
+ let rows: Vec<[String; 5]> = snaps
+ .iter()
+ .map(|x| {
+ [
+ x.id.clone(),
+ short_time(&x.created_at),
+ x.kind.clone(),
+ format!("{} + {}", human_bytes(x.db_bytes), human_bytes(x.code_bytes)),
+ x.note.clone(),
+ ]
+ })
+ .collect();
+ print_table(&["ID", "CREATED", "KIND", "DB + CODE", "NOTE"], &rows);
+ Ok(())
+ }
+
+ SnapshotCmd::Create { site: q, note, json } => {
+ let s = resolve(state, q)?;
+ let snap = snapshot::create(
+ None,
+ state,
+ &s.id,
+ snapshot::KIND_MANUAL,
+ note.clone(),
+ )
+ .await?;
+ if *json {
+ print_json(&snap)?;
+ } else {
+ // stdout carries the id (scriptable); chrome stays on stderr.
+ println!("{}", snap.id);
+ }
+ eprintln!(
+ "{} snapshot of {} taken ({} database, {} wp-content)",
+ ok("✓"),
+ bold(&s.name),
+ human_bytes(snap.db_bytes),
+ human_bytes(snap.code_bytes)
+ );
+ Ok(())
+ }
+
+ SnapshotCmd::Restore {
+ site: q,
+ snapshot: id,
+ yes,
+ } => {
+ let s = resolve(state, q)?;
+ confirm(
+ *yes,
+ &format!(
+ "restore `{}` to snapshot {id}? this replaces its database and wp-content \
+ (a pre-restore snapshot is taken first).",
+ s.slug
+ ),
+ &format!(
+ "`lk snapshot restore` overwrites `{}`. pass --yes to confirm.",
+ s.slug
+ ),
+ )?;
+ let message = snapshot::restore(None, state, &s.id, id).await?;
+ eprintln!("{} {message}", ok("✓"));
+ Ok(())
+ }
+
+ SnapshotCmd::Delete {
+ site: q,
+ snapshot: id,
+ yes,
+ } => {
+ let s = resolve(state, q)?;
+ confirm(
+ *yes,
+ &format!("delete snapshot {id} of `{}`? this cannot be undone.", s.slug),
+ &format!("`lk snapshot delete` removes snapshot {id} permanently. pass --yes to confirm."),
+ )?;
+ snapshot::delete(state, &s.id, id)?;
+ eprintln!("{} snapshot {id} deleted", ok("✓"));
+ Ok(())
+ }
+ }
+}
+
+async fn cmd_blueprint(state: &AppState, cmd: &BlueprintCmd) -> Result<(), String> {
+ match cmd {
+ BlueprintCmd::List { json } => {
+ let bps = blueprint::list(state)?;
+ if *json {
+ return print_json(&bps);
+ }
+ if bps.is_empty() {
+ eprintln!(
+ "{} no blueprints yet. save one with `lk blueprint save `.",
+ info("→")
+ );
+ return Ok(());
+ }
+ let rows: Vec<[String; 5]> = bps
+ .iter()
+ .map(|b| {
+ let theme = if b.manifest.theme.is_empty() {
+ "—"
+ } else {
+ b.manifest.theme.as_str()
+ };
+ [
+ b.id.clone(),
+ b.manifest.name.clone(),
+ short_time(&b.manifest.created_at),
+ format!("{} + {}", human_bytes(b.db_bytes), human_bytes(b.code_bytes)),
+ format!("{} plugins · {theme}", b.manifest.plugins.len()),
+ ]
+ })
+ .collect();
+ print_table(&["ID", "NAME", "CREATED", "DB + CODE", "STACK"], &rows);
+ Ok(())
+ }
+
+ BlueprintCmd::Save {
+ site: q,
+ name,
+ description,
+ json,
+ } => {
+ let s = resolve(state, q)?;
+ let bp = blueprint::save(None, state, &s.id, name.clone(), description.clone()).await?;
+ if *json {
+ print_json(&bp)?;
+ } else {
+ // stdout carries the id (scriptable); chrome stays on stderr.
+ println!("{}", bp.id);
+ }
+ eprintln!(
+ "{} saved {} as the blueprint {} ({} plugins, {} theme)",
+ ok("✓"),
+ bold(&s.name),
+ bold(&bp.manifest.name),
+ bp.manifest.plugins.len(),
+ if bp.manifest.theme.is_empty() { "no" } else { bp.manifest.theme.as_str() }
+ );
+ Ok(())
+ }
+
+ BlueprintCmd::Delete { blueprint: q, yes } => {
+ let bp = blueprint::find(state, q)?;
+ confirm(
+ *yes,
+ &format!("delete blueprint `{}`? this cannot be undone.", bp.manifest.name),
+ &format!("`lk blueprint delete` removes `{}` permanently. pass --yes to confirm.", bp.id),
+ )?;
+ blueprint::delete(state, &bp.id)?;
+ eprintln!("{} blueprint {} deleted", ok("✓"), bold(&bp.manifest.name));
+ Ok(())
+ }
+
+ BlueprintCmd::Export { blueprint: q, output } => {
+ let bp = blueprint::find(state, q)?;
+ let dest = output
+ .clone()
+ .unwrap_or_else(|| PathBuf::from(format!("{}.lkbp", bp.id)));
+ blueprint::export(state, &bp.id, &dest)?;
+ // stdout carries the path (scriptable); chrome stays on stderr.
+ println!("{}", dest.display());
+ eprintln!(
+ "{} exported blueprint {} to {}",
+ ok("✓"),
+ bold(&bp.manifest.name),
+ dest.display()
+ );
+ Ok(())
+ }
+
+ BlueprintCmd::Import { file, json } => {
+ let bp = blueprint::import(state, file)?;
+ if *json {
+ print_json(&bp)?;
+ } else {
+ println!("{}", bp.id);
+ }
+ eprintln!(
+ "{} imported blueprint {} ({} plugins)",
+ ok("✓"),
+ bold(&bp.manifest.name),
+ bp.manifest.plugins.len()
+ );
+ eprintln!(
+ "{} create a site from it with `lk create --blueprint {}`",
+ info("→"),
+ bp.id
+ );
+ Ok(())
+ }
+ }
+}
+
+/// `lk import` — thin wrapper over `sync::import_site`; all orchestration
+/// lives in the library. Progress reaches the terminal on its own: with no
+/// Tauri app handle `site::emit` prints each stage to stderr.
+async fn cmd_import(
+ state: &AppState,
+ connection: &str,
+ remote: &str,
+ name: Option,
+ json: bool,
+) -> Result<(), String> {
+ let conn = resolve_connection(state, connection)?;
+ let remote_id = resolve_remote_site(&conn, remote).await?;
+
+ let site = localkit_lib::sync::import_site(None, state, &conn.id, remote_id, name).await?;
+ if json {
+ print_json(&site)?;
+ } else {
+ // stdout carries the URL (scriptable); chrome stays on stderr.
+ println!("{}", site_url(&site));
+ }
+ eprintln!(
+ "{} {} imported from {} and running",
+ ok("✓"),
+ bold(&site.name),
+ conn.label
+ );
+ eprintln!(
+ "{} log in with `lk login {}` — the imported database keeps the remote's accounts",
+ info("→"),
+ site.slug
+ );
+ Ok(())
+}
+
+/// Exact connection id wins, then case-insensitive label — the same shape as
+/// site resolution, so the two feel identical from the terminal.
+fn resolve_connection(state: &AppState, query: &str) -> Result {
+ let conns = load_connections(state)?;
+ if conns.is_empty() {
+ return Err(NO_CONNECTIONS.into());
+ }
+ pick_connection(&conns, query)
+}
+
+const NO_CONNECTIONS: &str =
+ "no ServerKit connections yet — add one with `lk connection add `.";
+
+fn load_connections(state: &AppState) -> Result, String> {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.list_connections()
+}
+
+/// Pure connection resolver: exact id, then unique case-insensitive label.
+/// Kept separate from `resolve_connection` so it can be unit-tested without a DB
+/// (it mirrors `pick` for sites).
+fn pick_connection(conns: &[ServerKitConnection], query: &str) -> Result {
+ if let Some(c) = conns.iter().find(|c| c.id == query) {
+ return Ok(c.clone());
+ }
+ let q = query.to_lowercase();
+ let hits: Vec<_> = conns.iter().filter(|c| c.label.to_lowercase() == q).collect();
+ match hits.len() {
+ 1 => Ok(hits[0].clone()),
+ 0 => Err(format!(
+ "no ServerKit connection named `{query}`. available: {}",
+ conns.iter().map(|c| c.label.as_str()).collect::>().join(", ")
+ )),
+ _ => Err(format!(
+ "`{query}` matches more than one connection. pass the exact id."
+ )),
+ }
+}
+
+/// A remote site is addressed by its numeric server id, or by name — in which
+/// case the server is listed to look it up.
+async fn resolve_remote_site(conn: &ServerKitConnection, query: &str) -> Result {
+ if let Ok(id) = query.parse::() {
+ return Ok(id);
+ }
+ let sites = serverkit::list_wp_sites(&conn.url, &conn.api_key).await?;
+ let q = query.to_lowercase();
+ let hits: Vec<_> = sites.iter().filter(|s| s.name.to_lowercase() == q).collect();
+ match hits.len() {
+ 1 => Ok(hits[0].id),
+ 0 => Err(format!(
+ "no site named `{query}` on {}. available: {}",
+ conn.label,
+ sites
+ .iter()
+ .map(|s| format!("{} (#{})", s.name, s.id))
+ .collect::>()
+ .join(", ")
+ )),
+ _ => Err(format!(
+ "`{query}` matches more than one remote site. pass the numeric id."
+ )),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ServerKit — connections, remote listing, push/pull (plan 21)
+// ---------------------------------------------------------------------------
+
+/// Redacted view of a connection for `--json` output — deliberately omits the
+/// API key, which the full `ServerKitConnection` struct carries in plaintext.
+#[derive(serde::Serialize)]
+struct ConnectionView<'a> {
+ id: &'a str,
+ name: &'a str,
+ url: &'a str,
+ created_at: &'a str,
+}
+
+impl<'a> From<&'a ServerKitConnection> for ConnectionView<'a> {
+ fn from(c: &'a ServerKitConnection) -> Self {
+ Self { id: &c.id, name: &c.label, url: &c.url, created_at: &c.created_at }
+ }
+}
+
+async fn cmd_connection(state: &AppState, cmd: &ConnectionCmd) -> Result<(), String> {
+ match cmd {
+ ConnectionCmd::Add { name, url, key, json } => cmd_connection_add(state, name, url, key.as_deref(), *json).await,
+ ConnectionCmd::List { json } => cmd_connection_list(state, *json),
+ ConnectionCmd::Test { connection, json } => cmd_connection_test(state, connection, *json).await,
+ ConnectionCmd::Remove { connection, yes } => cmd_connection_remove(state, connection, *yes),
+ }
+}
+
+/// `lk connection add` — validate before storing (health + key + extension),
+/// mirroring the app's Settings → ServerKit flow, and refuse to persist a key
+/// that doesn't work rather than storing a dud that fails at push time.
+async fn cmd_connection_add(
+ state: &AppState,
+ name: &str,
+ url: &str,
+ key: Option<&str>,
+ json: bool,
+) -> Result<(), String> {
+ let name = name.trim();
+ if name.is_empty() {
+ return Err("a connection name is required".into());
+ }
+ let url = serverkit::normalize_base_url(url)?;
+ let api_key = read_api_key(key)?;
+
+ eprintln!("{} testing {url}...", info("→"));
+ let ext = serverkit::test_connection(&url, &api_key).await?;
+
+ let conn = ServerKitConnection {
+ id: uuid::Uuid::new_v4().to_string(),
+ label: name.to_string(),
+ url,
+ api_key,
+ created_at: chrono::Utc::now().to_rfc3339(),
+ };
+ {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.insert_connection(&conn)?;
+ }
+
+ if json {
+ print_json(&ConnectionView::from(&conn))?;
+ } else {
+ // stdout carries the new id (scriptable); chrome stays on stderr.
+ println!("{}", conn.id);
+ }
+ eprintln!("{} connection {} saved ({})", ok("✓"), bold(&conn.label), conn.url);
+ if ext.localkit_extension {
+ eprintln!(
+ "{} serverkit-localkit extension detected — features: {}",
+ info("→"),
+ if ext.features.is_empty() { "(none advertised)".to_string() } else { ext.features.join(", ") }
+ );
+ } else {
+ eprintln!(
+ "{} the serverkit-localkit extension is not installed — push/pull/import will not work until it is.",
+ warn("!")
+ );
+ }
+ Ok(())
+}
+
+/// Read an API key from `--key`/env, or a hidden TTY prompt. Refuses to hang on
+/// a non-TTY with no key supplied.
+fn read_api_key(flag: Option<&str>) -> Result {
+ if let Some(k) = flag {
+ let k = k.trim();
+ if k.is_empty() {
+ return Err("the API key is empty".into());
+ }
+ return Ok(k.to_string());
}
+ if !std::io::stdin().is_terminal() {
+ return Err(
+ "no API key and no TTY to prompt on — pass --key or set LOCALKIT_API_KEY.".into(),
+ );
+ }
+ let key = rpassword::prompt_password("ServerKit API key: ")
+ .map_err(|e| format!("failed to read the API key: {e}"))?;
+ let key = key.trim().to_string();
+ if key.is_empty() {
+ return Err("no API key entered".into());
+ }
+ Ok(key)
}
-// ---------------------------------------------------------------------------
-// Subcommands
-// ---------------------------------------------------------------------------
+fn cmd_connection_list(state: &AppState, json: bool) -> Result<(), String> {
+ let conns = load_connections(state)?;
+ if json {
+ let views: Vec = conns.iter().map(ConnectionView::from).collect();
+ return print_json(&views);
+ }
+ if conns.is_empty() {
+ eprintln!(
+ "{} no ServerKit connections yet. add one with `lk connection add `.",
+ info("→")
+ );
+ return Ok(());
+ }
+ let rows: Vec<[String; 3]> = conns
+ .iter()
+ .map(|c| [c.label.clone(), c.url.clone(), short_time(&c.created_at)])
+ .collect();
+ print_table(&["NAME", "URL", "ADDED"], &rows);
+ eprintln!("{} probe a server's extension with `lk connection test `", info("→"));
+ Ok(())
+}
-async fn cmd_list(state: &AppState, json: bool) -> Result<(), String> {
- let sites = site::list(state).await?;
+async fn cmd_connection_test(state: &AppState, query: &str, json: bool) -> Result<(), String> {
+ let conn = resolve_connection(state, query)?;
+ eprintln!("{} testing {}...", info("→"), conn.url);
+ let ext = serverkit::test_connection(&conn.url, &conn.api_key).await?;
+ if json {
+ return print_json(&ext);
+ }
+ eprintln!("{} {} reachable, API key valid", ok("✓"), bold(&conn.label));
+ if ext.localkit_extension {
+ println!(
+ "serverkit-localkit extension: installed (features: {})",
+ if ext.features.is_empty() { "none advertised".to_string() } else { ext.features.join(", ") }
+ );
+ } else {
+ println!("serverkit-localkit extension: NOT installed");
+ }
+ Ok(())
+}
+
+fn cmd_connection_remove(state: &AppState, query: &str, yes: bool) -> Result<(), String> {
+ let conn = resolve_connection(state, query)?;
+ confirm(
+ yes,
+ &format!("remove connection `{}` ({})?", conn.label, conn.url),
+ &format!("`lk connection remove` deletes `{}`. pass --yes to confirm.", conn.label),
+ )?;
+ {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.delete_connection(&conn.id)?;
+ }
+ eprintln!("{} connection {} removed", ok("✓"), bold(&conn.label));
+ Ok(())
+}
+
+/// `lk sites --remote ` — read-only remote site listing.
+async fn cmd_remote_sites(state: &AppState, remote: &str, json: bool) -> Result<(), String> {
+ let conn = resolve_connection(state, remote)?;
+ let sites = serverkit::list_wp_sites(&conn.url, &conn.api_key).await?;
if json {
return print_json(&sites);
}
if sites.is_empty() {
- eprintln!("{} no sites yet. create one with `lk create `.", info("→"));
+ eprintln!("{} no WordPress sites on {}.", info("→"), conn.label);
return Ok(());
}
- let rows: Vec<[String; 4]> = sites
+ let rows: Vec<[String; 5]> = sites
.iter()
.map(|s| {
[
- s.site.slug.clone(),
- s.live_status.clone(),
- site_url(&s.site),
- format!("WP {} / PHP {}", s.site.wp_version, s.site.php_version),
+ s.id.to_string(),
+ s.name.clone(),
+ s.status.clone(),
+ s.url.clone().unwrap_or_else(|| "—".into()),
+ if s.multisite {
+ "multisite".into()
+ } else {
+ format!(
+ "WP {} / PHP {}",
+ s.wp_version.as_deref().unwrap_or("?"),
+ s.php_version.as_deref().unwrap_or("?")
+ )
+ },
]
})
.collect();
- let headers = ["SLUG", "STATUS", "URL", "VERSION"];
- let mut w = [0usize; 4];
- for (i, h) in headers.iter().enumerate() {
- w[i] = h.len();
+ print_table(&["ID", "NAME", "STATUS", "URL", "STACK"], &rows);
+ Ok(())
+}
+
+/// Decide which connection a push/pull targets: an explicit `--connection`
+/// wins; otherwise the site's linked remote (plan 18 columns); otherwise the
+/// sole connection if there is exactly one.
+fn resolve_sync_connection(
+ conns: Vec,
+ site: &site::Site,
+ flag: Option<&str>,
+) -> Result {
+ if let Some(q) = flag {
+ if conns.is_empty() {
+ return Err(NO_CONNECTIONS.into());
+ }
+ return pick_connection(&conns, q);
}
- for r in &rows {
- for (i, c) in r.iter().enumerate() {
- w[i] = w[i].max(c.len());
+ // A site imported from a remote carries its origin connection.
+ if let Some(cid) = &site.connection_id {
+ if let Some(c) = conns.iter().find(|c| &c.id == cid) {
+ return Ok(c.clone());
}
+ // The linked connection was removed — fall through to the auto rules.
}
- for (i, h) in headers.iter().enumerate() {
- print!("{: Err(NO_CONNECTIONS.into()),
+ 1 => Ok(conns.into_iter().next().unwrap()),
+ _ => Err(format!(
+ "`{}` has no linked remote and there is more than one connection — pass --connection . available: {}",
+ site.slug,
+ conns.iter().map(|c| c.label.as_str()).collect::>().join(", ")
+ )),
}
- println!();
- for r in &rows {
- for (i, c) in r.iter().enumerate() {
- // Pad first, then colorize, so ANSI codes don't break alignment.
- let padded = format!("{: ok(&padded),
- (1, _) => dim(&padded),
- _ => padded,
- };
- print!("{cell} ");
+}
+
+/// Decide which remote site id a push/pull targets: `--remote-site` wins;
+/// otherwise the site's linked remote id, but only when the resolved connection
+/// is the one it was linked to (a remote id is meaningless on another server).
+async fn resolve_sync_remote_id(
+ conn: &ServerKitConnection,
+ site: &site::Site,
+ flag: Option<&str>,
+) -> Result {
+ if let Some(q) = flag {
+ return resolve_remote_site(conn, q).await;
+ }
+ if site.connection_id.as_deref() == Some(conn.id.as_str()) {
+ if let Some(id) = site.remote_site_id {
+ return Ok(id);
}
- println!();
}
- Ok(())
+ Err(format!(
+ "`{}` has no linked remote site on {} — pass --remote-site (see `lk sites --remote {}`).",
+ site.slug, conn.label, conn.label
+ ))
}
-async fn cmd_create(
+/// The remote site's public URL, best-effort, so pull can search-replace remote
+/// -> local. A listing failure just means the rewrite is skipped, not that the
+/// pull fails.
+async fn remote_site_url(conn: &ServerKitConnection, remote_id: i64) -> Option {
+ serverkit::list_wp_sites(&conn.url, &conn.api_key)
+ .await
+ .ok()?
+ .into_iter()
+ .find(|s| s.id == remote_id)
+ .and_then(|s| s.url)
+}
+
+/// Classify a sync failure into an exit code: 2 when the failure clearly
+/// originated on the server (rejected key, missing/old extension, size limit,
+/// an HTTP status), 1 for local failures (site not found, snapshot, Docker).
+///
+/// A heuristic over the library's error strings — the sync API returns a bare
+/// `String`. Worst case a server error is reported as 1 rather than 2; it never
+/// mislabels a local failure as a remote rejection in a way that matters.
+fn remote_rejected(msg: &str) -> bool {
+ const MARKERS: [&str; 6] = [
+ "API key was rejected",
+ "extension is not installed",
+ "too old to import",
+ "too large for the server",
+ "failed with HTTP",
+ "ServerKit limit",
+ ];
+ MARKERS.iter().any(|m| msg.contains(m))
+}
+
+fn sync_err(e: String) -> CliError {
+ if remote_rejected(&e) {
+ CliError::rejected(e)
+ } else {
+ CliError::new(e)
+ }
+}
+
+/// The freshly written sync-history row for an operation, so `--json` can print
+/// the resulting `SyncRecord` (the library's sync fns return `()`).
+fn latest_record(state: &AppState, site_id: &str, direction: &str, kind: &str) -> Result {
+ sync::history(state, site_id)?
+ .into_iter()
+ .find(|r| r.direction == direction && r.kind == kind)
+ .ok_or_else(|| "the sync succeeded but no history record was found".into())
+}
+
+async fn cmd_push(
state: &AppState,
- name: &str,
- wp_version: &Option,
- php_version: &Option,
+ query: &str,
+ code: bool,
+ db: bool,
+ connection: Option<&str>,
+ remote_site: Option<&str>,
json: bool,
-) -> Result<(), String> {
- let wp = wp_version
- .clone()
- .unwrap_or_else(|| site::WP_VERSIONS[0].into());
- let php = php_version
- .clone()
- .unwrap_or_else(|| site::PHP_VERSIONS[0].into());
- let site = site::create(None, state, name.to_string(), wp, php).await?;
+) -> Result<(), CliError> {
+ if !code && !db {
+ return Err(CliError::new("nothing to push — pass --code and/or --db"));
+ }
+ let site = resolve(state, query)?;
+ let conns = load_connections(state)?;
+ let conn = resolve_sync_connection(conns, &site, connection)?;
+ let remote_id = resolve_sync_remote_id(&conn, &site, remote_site).await?;
+
+ let mut records: Vec = Vec::new();
+ if code {
+ sync::push_code(None, state, &conn.id, &site.id, remote_id).await.map_err(sync_err)?;
+ records.push(latest_record(state, &site.id, "push", "code")?);
+ }
+ if db {
+ sync::push_db(None, state, &conn.id, &site.id, remote_id).await.map_err(sync_err)?;
+ records.push(latest_record(state, &site.id, "push", "db")?);
+ }
+
if json {
- print_json(&site)?;
- } else {
- // stdout carries the URL (scriptable); chrome stays on stderr.
- println!("{}", site_url(&site));
+ // One record → the object; both → the array, so the shape is predictable.
+ match records.as_slice() {
+ [only] => print_json(only)?,
+ many => print_json(&many)?,
+ }
}
- eprintln!("{} {} is running", ok("✓"), bold(&site.name));
eprintln!(
- "{} admin credentials: {} / {}",
- info("→"),
- site.admin_user,
- site.admin_pass
+ "{} pushed {} to remote site #{remote_id} on {}",
+ ok("✓"),
+ pushed_kinds(code, db),
+ conn.label
);
Ok(())
}
-async fn cmd_delete(state: &AppState, query: &str, yes: bool) -> Result<(), String> {
- let s = resolve(state, query)?;
- if !yes {
- if !std::io::stdout().is_terminal() {
- return Err(format!(
- "`lk delete` removes `{}` permanently. pass --yes to confirm.",
- s.slug
- ));
- }
- eprint!(
- "{} delete `{}`? this removes its containers, volumes, and files. [y/N] ",
- warn("!"),
- s.slug
- );
- let mut line = String::new();
- use std::io::BufRead;
- // EOF/no-tty falls through to the No path.
- let read = std::io::stdin().lock().read_line(&mut line);
- if read.is_err() || !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
- return Err("aborted".into());
- }
+fn pushed_kinds(code: bool, db: bool) -> &'static str {
+ match (code, db) {
+ (true, true) => "code + database",
+ (true, false) => "code",
+ _ => "database",
}
- site::delete(state, &s.id).await?;
- eprintln!("{} {} deleted", ok("✓"), bold(&s.name));
+}
+
+async fn cmd_pull(
+ state: &AppState,
+ query: &str,
+ db: bool,
+ connection: Option<&str>,
+ remote_site: Option<&str>,
+ json: bool,
+) -> Result<(), CliError> {
+ if !db {
+ return Err(CliError::new(
+ "pass --db — pulling a remote site's code creates a NEW local site, which is `lk import`.",
+ ));
+ }
+ let site = resolve(state, query)?;
+ let conns = load_connections(state)?;
+ let conn = resolve_sync_connection(conns, &site, connection)?;
+ let remote_id = resolve_sync_remote_id(&conn, &site, remote_site).await?;
+ let remote_url = remote_site_url(&conn, remote_id).await;
+
+ sync::pull_db(None, state, &conn.id, &site.id, remote_id, remote_url)
+ .await
+ .map_err(sync_err)?;
+ let record = latest_record(state, &site.id, "pull", "db")?;
+ if json {
+ print_json(&record)?;
+ }
+ eprintln!(
+ "{} pulled the database from remote site #{remote_id} on {} into {}",
+ ok("✓"),
+ conn.label,
+ bold(&site.name)
+ );
+ eprintln!("{} a pre-pull snapshot was taken — `lk snapshot list {}` to restore", info("→"), site.slug);
+ Ok(())
+}
+
+/// `lk completions ` — static completion script via clap_complete.
+fn cmd_completions(shell: CompletionShell) -> Result<(), String> {
+ let mut cmd = Cli::command();
+ clap_complete::generate(shell, &mut cmd, "lk", &mut std::io::stdout());
Ok(())
}
@@ -390,6 +1603,7 @@ fn cmd_env(state: &AppState, query: &str, shell: Shell, json: bool) -> Result<()
async fn cmd_login(state: &AppState, query: &str, user: Option<&str>, open: bool) -> Result<(), String> {
let s = resolve(state, query)?;
+ s.require(s.capabilities.one_click_login, "`lk login`")?;
let base = router::site_public_url(state, &s);
// Thin wrapper: all logic lives in localkit_lib::wordpress.
let url = wordpress::login_url(&s.dir(), &s, user, &base).await?;
@@ -446,12 +1660,140 @@ async fn cmd_doctor(data_dir_override: Option) -> Result<(), String> {
);
ok &= writable;
+ ok &= doctor_router(&data_dir).await;
+
+ // Connection reachability is diagnostic only — a remote being down is not a
+ // local misconfiguration, so it prints pass/fail but never flips the exit
+ // code that scripts gate their local setup on.
+ doctor_connections(&data_dir).await;
+
+ // Same rule for the update check: an available update (or a GitHub outage)
+ // is informational, never a reason for `doctor` to exit non-zero.
+ doctor_update().await;
+
if !ok {
return Err("one or more checks failed".into());
}
Ok(())
}
+/// Update section of `doctor` (plan 25): report whether a newer LocalKit
+/// release exists. Never downloads and never flips the exit code — a GitHub
+/// outage is not a local misconfiguration.
+async fn doctor_update() {
+ match localkit_lib::update::check().await {
+ Ok(u) if u.update_available => {
+ check_line(true, &format!("update available: v{} (you have v{})", u.latest, u.current));
+ eprintln!(" {} download it from {}", info("→"), u.url);
+ }
+ Ok(u) => check_line(true, &format!("up to date (v{})", u.current)),
+ Err(e) => {
+ check_line(true, "update check skipped");
+ eprintln!(" {e}");
+ }
+ }
+}
+
+/// ServerKit section of `doctor` (plan 21): for each stored connection, run the
+/// same health + key + `/pair` probe the app does, so "is it me or the server"
+/// has a one-command answer. Best-effort and non-fatal — a missing DB or a
+/// down server does not fail `doctor`.
+async fn doctor_connections(data_dir: &Path) {
+ let Ok(db) = Db::open(&data_dir.join("localkit.db")) else {
+ return;
+ };
+ let conns = match db.list_connections() {
+ Ok(c) => c,
+ Err(_) => return,
+ };
+ // Drop the DB handle before the awaits below — nothing else needs it, and
+ // holding it across network calls buys nothing.
+ drop(db);
+
+ if conns.is_empty() {
+ check_line(true, "no ServerKit connections configured");
+ return;
+ }
+ for conn in &conns {
+ match serverkit::test_connection(&conn.url, &conn.api_key).await {
+ Ok(ext) => {
+ let extension = if ext.localkit_extension {
+ if ext.features.is_empty() {
+ "extension present".to_string()
+ } else {
+ format!("extension: {}", ext.features.join(", "))
+ }
+ } else {
+ "extension NOT installed".to_string()
+ };
+ check_line(true, &format!("connection {} → {} ({extension})", conn.label, conn.url));
+ }
+ Err(e) => {
+ check_line(false, &format!("connection {} → {}", conn.label, conn.url));
+ eprintln!(" {e}");
+ }
+ }
+ }
+}
+
+/// Local-domains section of `doctor` (plan 16): active router mode + who owns
+/// the router ports, so "my .test sites show someone else's 404" has a
+/// copy-paste answer. Best-effort — a missing DB just means "not configured".
+async fn doctor_router(data_dir: &Path) -> bool {
+ let Ok(db) = Db::open(&data_dir.join("localkit.db")) else {
+ check_line(true, "local domains not configured yet (no database)");
+ return true;
+ };
+ let state = AppState {
+ db: Mutex::new(db),
+ data_dir: data_dir.to_path_buf(),
+ terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
+ };
+
+ let ports = router::router_ports(&state);
+ let mode = if ports.is_default() { "default" } else { "fallback" };
+ let Ok(status) = router::status(&state).await else {
+ check_line(false, "local domains status unavailable");
+ return false;
+ };
+
+ if !status.enabled {
+ check_line(true, "local domains disabled — sites use localhost:");
+ return true;
+ }
+
+ check_line(
+ status.running,
+ &format!(
+ "local domains enabled — router on ports {}/{} ({mode}), {}",
+ ports.http,
+ ports.https,
+ if status.running { "running" } else { "NOT running" }
+ ),
+ );
+
+ if status.running {
+ // Our own Caddy owns the ports; say so rather than probing and
+ // reporting LocalKit as its own conflict.
+ eprintln!(" ports {}/{} held by LocalKit's router", ports.http, ports.https);
+ return true;
+ }
+
+ for c in router::probe_ports(ports.http, ports.https).await {
+ match c.process {
+ Some(p) => eprintln!(" port {} held by {p}", c.port),
+ None => eprintln!(" port {} in use by an unidentified process", c.port),
+ }
+ }
+ eprintln!(
+ " {} quit the other program, or set fallback ports in Settings → Local domains",
+ info("→")
+ );
+ false
+}
+
// ---------------------------------------------------------------------------
// State / data dir
// ---------------------------------------------------------------------------
@@ -464,6 +1806,8 @@ fn make_state(cli: &Cli) -> Result {
db: Mutex::new(db),
data_dir,
terminals: localkit_lib::terminal::PtyManager::new(),
+ transfers: Default::default(),
+ in_flight: Default::default(),
})
}
@@ -532,6 +1876,59 @@ fn site_url(s: &site::Site) -> String {
format!("http://localhost:{}", s.port)
}
+/// RFC3339 down to seconds for table display — the stored timestamps carry
+/// sub-second precision and an offset, which is noise in a column.
+/// `--json` keeps the full value.
+fn short_time(rfc3339: &str) -> String {
+ match rfc3339.split_once('T') {
+ Some((date, rest)) => {
+ let time: String = rest.chars().take(8).collect();
+ format!("{date} {time}")
+ }
+ None => rfc3339.to_string(),
+ }
+}
+
+/// Byte counts for humans — snapshot archives run from KB to GB.
+fn human_bytes(n: u64) -> String {
+ const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
+ let mut value = n as f64;
+ let mut unit = 0;
+ while value >= 1024.0 && unit < UNITS.len() - 1 {
+ value /= 1024.0;
+ unit += 1;
+ }
+ if unit == 0 {
+ format!("{n} B")
+ } else {
+ format!("{value:.1} {}", UNITS[unit])
+ }
+}
+
+/// Left-aligned column table with dimmed headers (stdout — it is data).
+fn print_table(headers: &[&str; N], rows: &[[String; N]]) {
+ let mut w = [0usize; N];
+ for (i, h) in headers.iter().enumerate() {
+ w[i] = h.len();
+ }
+ for r in rows {
+ for (i, c) in r.iter().enumerate() {
+ w[i] = w[i].max(c.len());
+ }
+ }
+ for (i, h) in headers.iter().enumerate() {
+ // Pad first, then colorize, so ANSI codes don't break alignment.
+ print!("{} ", dim(&format!("{: String {
let mut out = String::new();
@@ -611,7 +2008,7 @@ mod tests {
use super::*;
fn site(id: &str, slug: &str, name: &str) -> site::Site {
- site::Site {
+ let mut s = site::Site {
id: id.into(),
name: name.into(),
slug: slug.into(),
@@ -620,10 +2017,18 @@ mod tests {
wp_version: "6.7".into(),
php_version: "8.3".into(),
status: "running".into(),
+ status_updated_at: "2026-01-01T00:00:00Z".into(),
admin_user: "admin".into(),
admin_pass: "secret".into(),
created_at: "2026-01-01T00:00:00Z".into(),
- }
+ connection_id: None,
+ remote_site_id: None,
+ kind: site::KIND_WORDPRESS.into(),
+ config: site::SiteConfig::default(),
+ capabilities: site::Capabilities::default(),
+ };
+ s.refresh_capabilities();
+ s
}
fn sample_sites() -> Vec {
@@ -680,6 +2085,32 @@ mod tests {
assert_eq!(out, "export DB_HOST=\"127.0.0.1\"\n");
}
+ #[test]
+ fn short_time_drops_subseconds_and_offset() {
+ assert_eq!(
+ short_time("2026-07-20T18:23:53.160418100+00:00"),
+ "2026-07-20 18:23:53"
+ );
+ }
+
+ #[test]
+ fn short_time_passes_through_anything_unexpected() {
+ assert_eq!(short_time("not a timestamp"), "not a timestamp");
+ }
+
+ #[test]
+ fn human_bytes_stays_exact_under_a_kilobyte() {
+ assert_eq!(human_bytes(0), "0 B");
+ assert_eq!(human_bytes(1023), "1023 B");
+ }
+
+ #[test]
+ fn human_bytes_scales_up() {
+ assert_eq!(human_bytes(1024), "1.0 KB");
+ assert_eq!(human_bytes(1024 * 1024 * 3 / 2), "1.5 MB");
+ assert_eq!(human_bytes(5 * 1024 * 1024 * 1024), "5.0 GB");
+ }
+
#[test]
fn exports_powershell() {
let out = render_exports(
@@ -688,4 +2119,153 @@ mod tests {
);
assert_eq!(out, "$env:DB_PORT = \"18081\"\n");
}
+
+ // -- ServerKit CLI (plan 21) -------------------------------------------
+
+ fn conn(id: &str, label: &str) -> ServerKitConnection {
+ ServerKitConnection {
+ id: id.into(),
+ label: label.into(),
+ url: "https://x.example.com".into(),
+ api_key: "k".into(),
+ created_at: "2026-01-01T00:00:00Z".into(),
+ }
+ }
+
+ fn linked_site(conn_id: &str, remote_id: i64) -> site::Site {
+ let mut s = site("id-x", "linked", "Linked");
+ s.connection_id = Some(conn_id.into());
+ s.remote_site_id = Some(remote_id);
+ s
+ }
+
+ #[test]
+ fn connection_pick_exact_id_then_label() {
+ let conns = vec![conn("c1", "prod"), conn("c2", "staging")];
+ assert_eq!(pick_connection(&conns, "c2").unwrap().label, "staging");
+ assert_eq!(pick_connection(&conns, "PROD").unwrap().id, "c1");
+ }
+
+ #[test]
+ fn connection_pick_no_match_lists_available() {
+ let conns = vec![conn("c1", "prod")];
+ let err = pick_connection(&conns, "nope").unwrap_err();
+ assert!(err.contains("prod"), "unexpected: {err}");
+ }
+
+ #[test]
+ fn connection_pick_ambiguous_label_asks_for_id() {
+ let conns = vec![conn("c1", "dup"), conn("c2", "DUP")];
+ let err = pick_connection(&conns, "dup").unwrap_err();
+ assert!(err.contains("more than one"), "unexpected: {err}");
+ }
+
+ #[test]
+ fn sync_connection_flag_wins_over_link() {
+ let conns = vec![conn("c1", "prod"), conn("c2", "staging")];
+ let chosen = resolve_sync_connection(conns, &linked_site("c1", 5), Some("staging")).unwrap();
+ assert_eq!(chosen.id, "c2");
+ }
+
+ #[test]
+ fn sync_connection_defaults_to_link() {
+ let conns = vec![conn("c1", "prod"), conn("c2", "staging")];
+ let chosen = resolve_sync_connection(conns, &linked_site("c2", 5), None).unwrap();
+ assert_eq!(chosen.id, "c2");
+ }
+
+ #[test]
+ fn sync_connection_single_is_auto_selected() {
+ let conns = vec![conn("c1", "prod")];
+ let site = site("id-x", "unlinked", "Unlinked");
+ assert_eq!(resolve_sync_connection(conns, &site, None).unwrap().id, "c1");
+ }
+
+ #[test]
+ fn sync_connection_ambiguous_without_link_needs_flag() {
+ let conns = vec![conn("c1", "prod"), conn("c2", "staging")];
+ let site = site("id-x", "unlinked", "Unlinked");
+ let err = resolve_sync_connection(conns, &site, None).unwrap_err();
+ assert!(err.contains("--connection"), "unexpected: {err}");
+ }
+
+ #[test]
+ fn sync_connection_stale_link_falls_back_to_single() {
+ // Linked to a connection that no longer exists → the auto rules apply.
+ let conns = vec![conn("c1", "prod")];
+ let chosen = resolve_sync_connection(conns, &linked_site("gone", 5), None).unwrap();
+ assert_eq!(chosen.id, "c1");
+ }
+
+ #[tokio::test]
+ async fn sync_remote_id_defaults_to_link() {
+ let c = conn("c1", "prod");
+ assert_eq!(resolve_sync_remote_id(&c, &linked_site("c1", 42), None).await.unwrap(), 42);
+ }
+
+ #[tokio::test]
+ async fn sync_remote_id_unlinked_needs_flag() {
+ let c = conn("c1", "prod");
+ let site = site("id-x", "unlinked", "Unlinked");
+ let err = resolve_sync_remote_id(&c, &site, None).await.unwrap_err();
+ assert!(err.contains("--remote-site"), "unexpected: {err}");
+ }
+
+ #[tokio::test]
+ async fn sync_remote_id_link_ignored_for_other_connection() {
+ // The numeric remote id is meaningless on a different server.
+ let other = conn("c2", "staging");
+ let err = resolve_sync_remote_id(&other, &linked_site("c1", 42), None).await.unwrap_err();
+ assert!(err.contains("--remote-site"), "unexpected: {err}");
+ }
+
+ #[test]
+ fn remote_rejected_flags_server_errors() {
+ assert!(remote_rejected("The API key was rejected (or lacks admin rights). Check the key."));
+ assert!(remote_rejected("Push failed with HTTP 500."));
+ assert!(remote_rejected(
+ "The serverkit-localkit extension is not installed on this ServerKit server (404)."
+ ));
+ assert!(remote_rejected("The upload is too large for the server (ServerKit limit is 100MB)."));
+ }
+
+ #[test]
+ fn remote_rejected_ignores_local_errors() {
+ assert!(!remote_rejected("no site named `blog`"));
+ assert!(!remote_rejected("pre-sync snapshot failed, nothing was synced: disk full"));
+ assert!(!remote_rejected("Docker is not running"));
+ }
+
+ #[test]
+ fn pushed_kinds_labels() {
+ assert_eq!(pushed_kinds(true, true), "code + database");
+ assert_eq!(pushed_kinds(true, false), "code");
+ assert_eq!(pushed_kinds(false, true), "database");
+ }
+
+ #[test]
+ fn connection_view_omits_the_api_key() {
+ let json = serde_json::to_string(&ConnectionView::from(&conn("c1", "prod"))).unwrap();
+ assert!(!json.contains("api_key"), "the api key leaked into --json output: {json}");
+ assert!(!json.contains("\"k\""), "the api key value leaked: {json}");
+ assert!(json.contains("\"prod\""));
+ }
+
+ #[test]
+ fn completions_generate_for_every_shell() {
+ for shell in [
+ CompletionShell::Bash,
+ CompletionShell::Zsh,
+ CompletionShell::Fish,
+ CompletionShell::PowerShell,
+ ] {
+ let mut cmd = Cli::command();
+ let mut buf = Vec::new();
+ clap_complete::generate(shell, &mut cmd, "lk", &mut buf);
+ let out = String::from_utf8(buf).expect("completion script is valid UTF-8");
+ assert!(!out.is_empty(), "{shell:?} produced no completion script");
+ assert!(out.contains("connection"), "{shell:?} completion missing `connection`");
+ assert!(out.contains("completions"), "{shell:?} completion missing `completions`");
+ }
+ }
}
diff --git a/src-tauri/src/blueprint.rs b/src-tauri/src/blueprint.rs
new file mode 100644
index 0000000..74466f9
--- /dev/null
+++ b/src-tauri/src/blueprint.rs
@@ -0,0 +1,727 @@
+//! Reusable site blueprints (plan 20 phase 2).
+//!
+//! A blueprint is a *directory* on disk — no SQLite table, so no migration:
+//!
+//! ```text
+//! /blueprints//
+//! blueprint.json the Manifest below (the recipe + display metadata)
+//! db.sql.gz `wp db export -`, gzipped
+//! wp-content.tar.gz the site's wp-content dir
+//! ```
+//!
+//! The two archives are the same format the snapshot engine writes, so a
+//! blueprint is really "a snapshot you can stamp new sites out of". `save`
+//! captures a site's current state (snapshotting it, then hardlinking the
+//! snapshot's artifacts across so the bytes aren't duplicated) plus its plugin
+//! and theme list as display-only metadata; `create_site` provisions a fresh
+//! site and lays the recipe down, exactly like the clone flow.
+
+use serde::{Deserialize, Serialize};
+use std::path::{Path, PathBuf};
+use tauri::AppHandle;
+
+use crate::{docker, router, site, snapshot, wordpress, AppState};
+
+const MANIFEST_FILE: &str = "blueprint.json";
+const DB_FILE: &str = "db.sql.gz";
+const CODE_FILE: &str = "wp-content.tar.gz";
+
+/// A plugin captured at save time — display metadata only. v1 does not
+/// re-resolve or re-install these; they are shown so a blueprint's contents
+/// are legible before you create a site from it.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BlueprintPlugin {
+ pub name: String,
+ pub status: String,
+ pub version: String,
+}
+
+/// `blueprint.json` — the recipe. No id or byte sizes: the id is the directory
+/// name and the sizes are read off the files, so neither is duplicated here.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Manifest {
+ pub name: String,
+ pub description: String,
+ pub wp_version: String,
+ pub php_version: String,
+ pub plugins: Vec,
+ pub theme: String,
+ pub created_at: String,
+ pub source_site_name: String,
+}
+
+/// What the UI/CLI sees: the recipe plus the derived id and on-disk sizes.
+#[derive(Debug, Clone, Serialize)]
+pub struct Blueprint {
+ /// Directory slug — the stable id used to create-from / delete / export.
+ pub id: String,
+ #[serde(flatten)]
+ pub manifest: Manifest,
+ pub db_bytes: u64,
+ pub code_bytes: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Layout
+// ---------------------------------------------------------------------------
+
+pub fn blueprints_root(data_dir: &Path) -> PathBuf {
+ data_dir.join("blueprints")
+}
+
+fn blueprint_dir(data_dir: &Path, id: &str) -> PathBuf {
+ blueprints_root(data_dir).join(id)
+}
+
+/// First free ` `, ` -2`, ... for which `exists` is false. Pure so
+/// the uniqueness rule is unit-testable without touching the filesystem.
+fn pick_slug(base: &str, exists: impl Fn(&str) -> bool) -> String {
+ if !exists(base) {
+ return base.to_string();
+ }
+ for i in 2..1000 {
+ let candidate = format!("{base}-{i}");
+ if !exists(&candidate) {
+ return candidate;
+ }
+ }
+ format!("{base}-{}", 1000)
+}
+
+/// A blueprint slug unique among the blueprints already on disk.
+fn unique_slug(data_dir: &Path, name: &str) -> String {
+ let base = site::slugify(name);
+ pick_slug(&base, |slug| blueprint_dir(data_dir, slug).is_dir())
+}
+
+// ---------------------------------------------------------------------------
+// Hardlink-or-copy (pure enough to unit test)
+// ---------------------------------------------------------------------------
+
+/// Place `src` at `dst`, hardlinking when the filesystem allows (blueprints and
+/// snapshots both live under the LocalKit data dir, so this is the norm) and
+/// falling back to a byte copy otherwise. Hardlinking is what keeps a blueprint
+/// from duplicating the snapshot's bytes — a wp-content archive can be hundreds
+/// of megabytes.
+pub fn hardlink_or_copy(src: &Path, dst: &Path) -> Result<(), String> {
+ if dst.exists() {
+ let _ = std::fs::remove_file(dst);
+ }
+ if std::fs::hard_link(src, dst).is_ok() {
+ return Ok(());
+ }
+ copy_file(src, dst)
+}
+
+fn copy_file(src: &Path, dst: &Path) -> Result<(), String> {
+ std::fs::copy(src, dst)
+ .map(|_| ())
+ .map_err(|e| format!("failed to copy blueprint artifact: {e}"))
+}
+
+// ---------------------------------------------------------------------------
+// Read
+// ---------------------------------------------------------------------------
+
+fn file_len(path: &Path) -> u64 {
+ std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
+}
+
+fn read_blueprint(data_dir: &Path, id: &str) -> Result {
+ let dir = blueprint_dir(data_dir, id);
+ let text = std::fs::read_to_string(dir.join(MANIFEST_FILE))
+ .map_err(|_| format!("blueprint `{id}` not found"))?;
+ let manifest: Manifest = serde_json::from_str(&text)
+ .map_err(|e| format!("blueprint `{id}` has an unreadable manifest: {e}"))?;
+ Ok(Blueprint {
+ id: id.to_string(),
+ db_bytes: file_len(&dir.join(DB_FILE)),
+ code_bytes: file_len(&dir.join(CODE_FILE)),
+ manifest,
+ })
+}
+
+/// All blueprints, newest first. A directory whose manifest is missing or
+/// unreadable is skipped rather than failing the whole listing.
+pub fn list(state: &AppState) -> Result, String> {
+ let root = blueprints_root(&state.data_dir);
+ if !root.is_dir() {
+ return Ok(vec![]);
+ }
+ let entries =
+ std::fs::read_dir(&root).map_err(|e| format!("failed to read blueprints directory: {e}"))?;
+ let mut out = Vec::new();
+ for entry in entries.flatten() {
+ if !entry.path().is_dir() {
+ continue;
+ }
+ if let Some(name) = entry.file_name().to_str() {
+ if let Ok(bp) = read_blueprint(&state.data_dir, name) {
+ out.push(bp);
+ }
+ }
+ }
+ out.sort_by(|a, b| b.manifest.created_at.cmp(&a.manifest.created_at));
+ Ok(out)
+}
+
+/// Resolve a blueprint by exact id (slug), then case-insensitive name — the
+/// same shape as site resolution, for the CLI. Ambiguous names ask for the id.
+pub fn find(state: &AppState, query: &str) -> Result {
+ let all = list(state)?;
+ if let Some(bp) = all.iter().find(|b| b.id == query) {
+ return Ok(bp.clone());
+ }
+ let q = query.to_lowercase();
+ let hits: Vec<&Blueprint> = all.iter().filter(|b| b.manifest.name.to_lowercase() == q).collect();
+ match hits.len() {
+ 1 => Ok(hits[0].clone()),
+ 0 => {
+ let available = all.iter().map(|b| b.id.as_str()).collect::>().join(", ");
+ if available.is_empty() {
+ Err(format!("no blueprint named `{query}` — there are none yet. save one with `lk blueprint save `."))
+ } else {
+ Err(format!("no blueprint named `{query}`. available: {available}"))
+ }
+ }
+ _ => Err(format!("`{query}` matches more than one blueprint. pass the exact id.")),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Save
+// ---------------------------------------------------------------------------
+
+/// Save an existing site as a reusable blueprint.
+///
+/// Snapshots the site (transient `blueprint_source` kind), hardlinks the
+/// snapshot's artifacts into the blueprint dir so the bytes are shared, records
+/// the plugin/theme list as display metadata, then drops the snapshot. Emits
+/// only `snapshot`-stage progress plus its own terminal stage.
+pub async fn save(
+ app: Option<&AppHandle>,
+ state: &AppState,
+ site_id: &str,
+ name: String,
+ description: Option,
+) -> Result {
+ let s = site::get(state, site_id)?;
+ // Blueprints are WordPress recipes (per-kind blueprints arrive with plan
+ // 26); saving a docker app through this WP-shaped flow would produce a
+ // broken template.
+ s.require(s.kind == site::KIND_WORDPRESS, "Saving a blueprint")?;
+ let name = name.trim().to_string();
+ if name.is_empty() {
+ return Err("Blueprint name is required".into());
+ }
+
+ // Consistent point-in-time artifacts, via the retry-heavy snapshot engine.
+ let snap = snapshot::create(
+ app,
+ state,
+ site_id,
+ snapshot::KIND_BLUEPRINT_SOURCE,
+ Some(format!("blueprint \"{name}\"")),
+ )
+ .await
+ .map_err(|e| format!("could not snapshot the site: {e}"))?;
+
+ // Wrapped so a failure past this point still drops the transient snapshot.
+ let result = finish_save(state, &s, &name, description, &snap.id).await;
+ let _ = snapshot::delete(state, site_id, &snap.id);
+
+ match result {
+ Ok(bp) => {
+ site::emit(
+ app,
+ site_id,
+ "done",
+ &format!("Saved \"{}\" as the blueprint \"{}\"", s.name, bp.manifest.name),
+ );
+ Ok(bp)
+ }
+ Err(e) => {
+ site::emit(app, site_id, "error", &format!("Save as blueprint failed: {e}"));
+ Err(e)
+ }
+ }
+}
+
+async fn finish_save(
+ state: &AppState,
+ s: &site::Site,
+ name: &str,
+ description: Option,
+ snapshot_id: &str,
+) -> Result {
+ // The DB is up (the snapshot just exported it), so capture plugin/theme
+ // metadata now — best effort, it is display-only.
+ let plugins = capture_plugins(&s.dir()).await.unwrap_or_default();
+ let theme = capture_theme(&s.dir()).await.unwrap_or_default();
+
+ let id = unique_slug(&state.data_dir, name);
+ let dir = blueprint_dir(&state.data_dir, &id);
+ std::fs::create_dir_all(&dir)
+ .map_err(|e| format!("failed to create blueprint directory: {e}"))?;
+
+ let (snap_db, snap_code) = snapshot::artifact_paths(&state.data_dir, &s.id, snapshot_id);
+ hardlink_or_copy(&snap_db, &dir.join(DB_FILE))?;
+ hardlink_or_copy(&snap_code, &dir.join(CODE_FILE))?;
+
+ let manifest = Manifest {
+ name: name.to_string(),
+ description: description.unwrap_or_default().trim().to_string(),
+ wp_version: s.wp_version.clone(),
+ php_version: s.php_version.clone(),
+ plugins,
+ theme,
+ created_at: chrono::Utc::now().to_rfc3339(),
+ source_site_name: s.name.clone(),
+ };
+ // Manifest last: a half-written blueprint has no manifest, so `list` skips
+ // it instead of offering a broken create-from (same rule as snapshots).
+ let json = serde_json::to_string_pretty(&manifest)
+ .map_err(|e| format!("failed to serialize blueprint manifest: {e}"))?;
+ std::fs::write(dir.join(MANIFEST_FILE), json)
+ .map_err(|e| format!("failed to write blueprint manifest: {e}"))?;
+
+ read_blueprint(&state.data_dir, &id)
+}
+
+/// Active theme name, or `None` when wp-cli can't answer (best effort).
+async fn capture_theme(dir: &Path) -> Option {
+ let out = docker::compose_run(
+ dir,
+ "wpcli",
+ &["wp", "theme", "list", "--status=active", "--field=name"],
+ )
+ .await
+ .ok()?;
+ out.lines().map(str::trim).find(|l| !l.is_empty()).map(str::to_string)
+}
+
+/// Plugin list (name/status/version) as display metadata (best effort).
+async fn capture_plugins(dir: &Path) -> Result, String> {
+ let json = docker::compose_run(
+ dir,
+ "wpcli",
+ &["wp", "plugin", "list", "--format=json", "--fields=name,status,version"],
+ )
+ .await?;
+ serde_json::from_str(&json).map_err(|e| format!("failed to parse plugin list: {e}"))
+}
+
+// ---------------------------------------------------------------------------
+// Delete
+// ---------------------------------------------------------------------------
+
+pub fn delete(state: &AppState, id: &str) -> Result<(), String> {
+ let dir = blueprint_dir(&state.data_dir, id);
+ if !dir.is_dir() {
+ return Err(format!("blueprint `{id}` not found"));
+ }
+ std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to delete blueprint: {e}"))
+}
+
+// ---------------------------------------------------------------------------
+// Export / import — a single portable `.lkbp` file (plan 20)
+// ---------------------------------------------------------------------------
+
+/// The three files that make up a blueprint on disk; also the only entries an
+/// imported archive may contain, so a shared `.lkbp` can't write anything else.
+const ARTIFACTS: [&str; 3] = [MANIFEST_FILE, DB_FILE, CODE_FILE];
+
+/// Bundle a blueprint into a single `.lkbp` file (a tar.gz of its three
+/// artifacts at the archive root) so it can be shared without a registry.
+pub fn export(state: &AppState, id: &str, dest: &Path) -> Result<(), String> {
+ let dir = blueprint_dir(&state.data_dir, id);
+ if !dir.is_dir() {
+ return Err(format!("blueprint `{id}` not found"));
+ }
+ let file = std::fs::File::create(dest)
+ .map_err(|e| format!("failed to create {}: {e}", dest.display()))?;
+ let enc = flate2::write::GzEncoder::new(
+ std::io::BufWriter::new(file),
+ flate2::Compression::fast(),
+ );
+ let mut builder = tar::Builder::new(enc);
+ for name in ARTIFACTS {
+ let path = dir.join(name);
+ if !path.exists() {
+ return Err(format!("blueprint `{id}` is missing {name}; refusing to export a broken bundle"));
+ }
+ builder
+ .append_path_with_name(&path, name)
+ .map_err(|e| format!("failed to add {name} to the bundle: {e}"))?;
+ }
+ builder
+ .into_inner()
+ .map_err(|e| format!("failed to finalize the bundle: {e}"))?
+ .finish()
+ .map_err(|e| format!("failed to finalize the bundle: {e}"))?;
+ Ok(())
+}
+
+/// Install a blueprint from a `.lkbp` file under a fresh unique slug.
+///
+/// The archive is treated as semi-trusted (a teammate may have made it): only
+/// the three known filenames are accepted, each written through `io::copy` so a
+/// crafted symlink or path entry can never place a file outside the staging
+/// directory. Extraction lands in a temp dir first, so a bad bundle leaves no
+/// half-installed blueprint behind.
+pub fn import(state: &AppState, src: &Path) -> Result {
+ let file = std::fs::File::open(src)
+ .map_err(|e| format!("failed to open {}: {e}", src.display()))?;
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(std::io::BufReader::new(file)));
+
+ let root = blueprints_root(&state.data_dir);
+ std::fs::create_dir_all(&root)
+ .map_err(|e| format!("failed to create blueprints directory: {e}"))?;
+ let tmp = root.join(format!(".import-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&tmp).map_err(|e| format!("failed to stage the import: {e}"))?;
+
+ let staged = extract_artifacts(&mut archive, &tmp);
+ let bp = staged.and_then(|_| install_staged(state, &tmp));
+ if bp.is_err() {
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+ bp
+}
+
+fn extract_artifacts(
+ archive: &mut tar::Archive,
+ tmp: &Path,
+) -> Result<(), String> {
+ let entries = archive
+ .entries()
+ .map_err(|e| format!("the blueprint bundle is unreadable: {e}"))?;
+ for entry in entries {
+ let mut entry = entry.map_err(|e| format!("the blueprint bundle is unreadable: {e}"))?;
+ let path = entry
+ .path()
+ .map_err(|e| format!("bundle entry has an unreadable path: {e}"))?
+ .into_owned();
+ let name = path.to_str().ok_or("bundle entry has a non-UTF-8 name")?;
+ if !ARTIFACTS.contains(&name) {
+ return Err(format!("blueprint bundle contains an unexpected entry: {name}"));
+ }
+ // io::copy reads the entry's data stream and writes a plain file — it
+ // never follows a link header, so a symlink entry lands as a (harmless,
+ // empty) regular file instead of escaping the staging dir.
+ let mut out = std::fs::File::create(tmp.join(name))
+ .map_err(|e| format!("failed to write {name}: {e}"))?;
+ std::io::copy(&mut entry, &mut out).map_err(|e| format!("failed to write {name}: {e}"))?;
+ }
+ Ok(())
+}
+
+fn install_staged(state: &AppState, tmp: &Path) -> Result {
+ let text = std::fs::read_to_string(tmp.join(MANIFEST_FILE))
+ .map_err(|_| "the bundle has no blueprint.json".to_string())?;
+ let manifest: Manifest = serde_json::from_str(&text)
+ .map_err(|e| format!("the bundle's blueprint.json is unreadable: {e}"))?;
+ for name in [DB_FILE, CODE_FILE] {
+ if !tmp.join(name).exists() {
+ return Err(format!("the bundle is missing {name}"));
+ }
+ }
+ let id = unique_slug(&state.data_dir, &manifest.name);
+ let dest = blueprint_dir(&state.data_dir, &id);
+ std::fs::rename(tmp, &dest)
+ .map_err(|e| format!("failed to install the imported blueprint: {e}"))?;
+ read_blueprint(&state.data_dir, &id)
+}
+
+// ---------------------------------------------------------------------------
+// Create a site from a blueprint
+// ---------------------------------------------------------------------------
+
+/// Provision a brand-new site from a blueprint's recipe.
+///
+/// The create half of a clone, with the archives coming from the blueprint dir
+/// instead of a live source: reserve a fresh site (versions matched to the
+/// current allowlist, nearest when the recorded one has aged out), lay the
+/// database + wp-content down, and rewrite the baked-in URL — read back out of
+/// the imported database — to the new site's own. `wp core install` is never
+/// run: the blueprint's database *is* the site.
+pub async fn create_site(
+ app: Option<&AppHandle>,
+ state: &AppState,
+ blueprint_id: &str,
+ local_name: Option,
+) -> Result {
+ let bp = read_blueprint(&state.data_dir, blueprint_id)?;
+ let name = local_name
+ .map(|n| n.trim().to_string())
+ .filter(|n| !n.is_empty())
+ .unwrap_or_else(|| bp.manifest.name.clone());
+
+ let (wp_version, _) = crate::sync::match_version(site::WP_VERSIONS, Some(&bp.manifest.wp_version));
+ let (php_version, _) =
+ crate::sync::match_version(site::PHP_VERSIONS, Some(&bp.manifest.php_version));
+
+ // Blueprints are WordPress recipes today (per-kind blueprints arrive with
+ // plan 26), so the target reserves the WordPress stack.
+ let target = site::reserve(
+ state,
+ name,
+ site::KIND_WORDPRESS.to_string(),
+ wp_version,
+ php_version,
+ site::SiteConfig::default(),
+ None,
+ )
+ .await?;
+
+ // Own this site's status until it finishes provisioning (plan 23).
+ let _guard = state.in_flight.guard(&target.id);
+ match do_create(app, state, blueprint_id, &target).await {
+ Ok(site) => {
+ let url = router::site_public_url(state, &site);
+ site::emit(
+ app,
+ &site.id,
+ "done",
+ &format!(
+ "{} created from blueprint \"{}\" — now running at {url}",
+ site.name, bp.manifest.name
+ ),
+ );
+ site::get(state, &site.id)
+ }
+ Err(e) => {
+ site::emit(app, &target.id, "error", &format!("Create from blueprint failed: {e}"));
+ let _ = site::cleanup(state, &target).await;
+ Err(e)
+ }
+ }
+}
+
+async fn do_create(
+ app: Option<&AppHandle>,
+ state: &AppState,
+ blueprint_id: &str,
+ target: &site::Site,
+) -> Result {
+ let dir = target.dir();
+ let id = target.id.as_str();
+
+ site::emit(app, id, "files", "Writing project files...");
+ site::write_project_files(target)?;
+
+ site::emit(app, id, "pulling", "Downloading WordPress images (first run can take a few minutes)...");
+ docker::compose_pull(&dir, &["wordpress", "db", "wpcli"]).await?;
+
+ site::emit(app, id, "containers", "Starting Docker containers...");
+ docker::compose_up(&dir).await?;
+
+ site::emit(app, id, "waiting", "Waiting for WordPress to come online...");
+ site::wait_for_port(target.port, 180).await?;
+ wordpress::wait_for_config(&dir, 24).await?;
+
+ site::emit(app, id, "import", "Laying down the blueprint's content...");
+ let bp_dir = blueprint_dir(&state.data_dir, blueprint_id);
+ snapshot::restore_archives_into(&bp_dir.join(DB_FILE), &bp_dir.join(CODE_FILE), target).await?;
+ // The archive brought its own mu-plugins over the one just written; keep
+ // one-click login working.
+ wordpress::ensure_login_plugin(&dir)?;
+
+ // The blueprint's database has its source site's URL baked in; read it back
+ // and rewrite it to this site's own public URL.
+ let target_url = router::site_public_url(state, target);
+ let source_url = docker::compose_run(&dir, "wpcli", &["wp", "option", "get", "siteurl"])
+ .await
+ .map(|u| u.trim().to_string())
+ .unwrap_or_default();
+ site::emit(app, id, "import", "Rewriting URLs to the new site...");
+ wordpress::update_site_urls(&dir, &target_url).await?;
+ if !source_url.is_empty() && source_url != target_url {
+ wordpress::search_replace(&dir, &source_url, &target_url).await?;
+ }
+ let _ = docker::compose_run(&dir, "wpcli", &["wp", "rewrite", "flush"]).await;
+ let _ = docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"]).await;
+
+ // The admin login comes from the blueprint's database (its first
+ // administrator); no password is stored, exactly like an import.
+ let admin_user = first_admin(&dir)
+ .await
+ .unwrap_or_else(|| target.admin_user.clone());
+ {
+ let db = state.db.lock().map_err(|e| e.to_string())?;
+ db.set_status(id, "running")?;
+ db.update_credentials(id, &admin_user, "")?;
+ }
+ // Last step: the completion marker (plan 23) — its absence flags a killed
+ // blueprint provision.
+ site::mark_complete(&dir);
+ router::refresh_routes(state).await;
+ router::refresh_hosts(state).await;
+ site::get(state, id)
+}
+
+/// First administrator in the freshly imported database, for `admin_user`.
+async fn first_admin(dir: &Path) -> Option {
+ let out = docker::compose_run(
+ dir,
+ "wpcli",
+ &["wp", "user", "list", "--role=administrator", "--field=user_login"],
+ )
+ .await
+ .ok()?;
+ out.lines().map(str::trim).find(|l| !l.is_empty()).map(str::to_string)
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn manifest_round_trips() {
+ let manifest = Manifest {
+ name: "Starter Shop".into(),
+ description: "WooCommerce + our base theme".into(),
+ wp_version: "6.7".into(),
+ php_version: "8.3".into(),
+ plugins: vec![BlueprintPlugin {
+ name: "woocommerce".into(),
+ status: "active".into(),
+ version: "9.6.0".into(),
+ }],
+ theme: "storefront".into(),
+ created_at: "2026-07-20T10:00:00Z".into(),
+ source_site_name: "Pixel Bakery".into(),
+ };
+ let text = serde_json::to_string_pretty(&manifest).unwrap();
+ let back: Manifest = serde_json::from_str(&text).unwrap();
+ assert_eq!(back.name, "Starter Shop");
+ assert_eq!(back.theme, "storefront");
+ assert_eq!(back.plugins.len(), 1);
+ assert_eq!(back.plugins[0].name, "woocommerce");
+ assert_eq!(back.source_site_name, "Pixel Bakery");
+ }
+
+ #[test]
+ fn blueprint_flattens_manifest_into_a_flat_payload() {
+ // The frontend expects a flat object (id + recipe + sizes), not a
+ // nested `manifest`. Flatten is what delivers that.
+ let bp = Blueprint {
+ id: "starter-shop".into(),
+ manifest: Manifest {
+ name: "Starter Shop".into(),
+ description: String::new(),
+ wp_version: "6.7".into(),
+ php_version: "8.3".into(),
+ plugins: vec![],
+ theme: "twentytwentyfive".into(),
+ created_at: "2026-07-20T10:00:00Z".into(),
+ source_site_name: "Src".into(),
+ },
+ db_bytes: 2048,
+ code_bytes: 4096,
+ };
+ let v: serde_json::Value = serde_json::to_value(&bp).unwrap();
+ assert_eq!(v["id"], "starter-shop");
+ assert_eq!(v["name"], "Starter Shop"); // flattened, not v["manifest"]["name"]
+ assert_eq!(v["db_bytes"], 2048);
+ assert!(v.get("manifest").is_none());
+ }
+
+ #[test]
+ fn slug_is_unique_against_existing_blueprints() {
+ let taken = |s: &str| matches!(s, "shop" | "shop-2" | "shop-3");
+ assert_eq!(pick_slug("shop", taken), "shop-4");
+ // A free base is used verbatim.
+ assert_eq!(pick_slug("blog", |_| false), "blog");
+ }
+
+ #[test]
+ fn hardlink_or_copy_reproduces_the_bytes() {
+ let root = std::env::temp_dir().join(format!("localkit-bp-hlc-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let src = root.join("src.bin");
+ let dst = root.join("dst.bin");
+ std::fs::write(&src, b"blueprint payload").unwrap();
+
+ hardlink_or_copy(&src, &dst).unwrap();
+ assert_eq!(std::fs::read(&dst).unwrap(), b"blueprint payload");
+
+ // Idempotent: a second call over an existing dst still lands the bytes.
+ hardlink_or_copy(&src, &dst).unwrap();
+ assert_eq!(std::fs::read(&dst).unwrap(), b"blueprint payload");
+
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ fn make_tgz(entries: &[(&str, &[u8])]) -> Vec {
+ let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
+ {
+ let mut builder = tar::Builder::new(&mut enc);
+ for (name, data) in entries {
+ let mut header = tar::Header::new_gnu();
+ header.set_size(data.len() as u64);
+ header.set_mode(0o644);
+ builder.append_data(&mut header, name, *data).unwrap();
+ }
+ builder.finish().unwrap();
+ }
+ enc.finish().unwrap()
+ }
+
+ fn scratch(tag: &str) -> std::path::PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("localkit-bp-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ #[test]
+ fn import_extracts_the_three_known_artifacts() {
+ let tmp = scratch("extract-ok");
+ let tgz = make_tgz(&[
+ ("blueprint.json", b"{}"),
+ ("db.sql.gz", b"db"),
+ ("wp-content.tar.gz", b"code"),
+ ]);
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&tgz[..]));
+ extract_artifacts(&mut archive, &tmp).unwrap();
+ for f in ["blueprint.json", "db.sql.gz", "wp-content.tar.gz"] {
+ assert!(tmp.join(f).exists(), "missing {f}");
+ }
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn import_refuses_an_unexpected_entry() {
+ // A `.lkbp` may be shared by a teammate: anything but the three known
+ // filenames is refused rather than written.
+ let tmp = scratch("extract-evil");
+ let tgz = make_tgz(&[("blueprint.json", b"{}"), ("evil.txt", b"pwned")]);
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&tgz[..]));
+ let err = extract_artifacts(&mut archive, &tmp).unwrap_err();
+ assert!(err.contains("unexpected entry"), "unexpected error: {err}");
+ assert!(!tmp.join("evil.txt").exists(), "the rejected entry was written anyway");
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn copy_fallback_reproduces_the_bytes() {
+ // The branch hardlink_or_copy takes when the filesystem refuses a link.
+ let root = std::env::temp_dir().join(format!("localkit-bp-copy-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).unwrap();
+ let src = root.join("src.bin");
+ let dst = root.join("dst.bin");
+ std::fs::write(&src, b"copied bytes").unwrap();
+
+ copy_file(&src, &dst).unwrap();
+ assert_eq!(std::fs::read(&dst).unwrap(), b"copied bytes");
+
+ let _ = std::fs::remove_dir_all(&root);
+ }
+}
diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs
index b8e41e3..f317e8d 100644
--- a/src-tauri/src/db.rs
+++ b/src-tauri/src/db.rs
@@ -54,8 +54,12 @@ impl Db {
.map_err(|e| format!("migration 1 failed: {e}"))?;
}
if version < 2 {
- // NOTE: API keys are stored in plaintext in the local SQLite DB —
- // acceptable for v1 (documented); a keyring migration can come later.
+ // NOTE: the `api_key` column predates the OS keyring (plan 25). New
+ // connections keep their key in the keyring and store `''` here; a
+ // legacy plaintext key is migrated into the keyring the first time
+ // the connection is read (see `resolve_api_key`). The column stays
+ // as the fallback for keyring-less machines and for downgrades — so
+ // no migration is needed, we just stop writing real keys into it.
self.conn
.execute_batch(
"
@@ -105,6 +109,50 @@ impl Db {
)
.map_err(|e| format!("migration 4 failed: {e}"))?;
}
+ if version < 5 {
+ // Plan 18: where a site came from. Set on sites created by an
+ // import; NULL on every hand-made site, which is why both columns
+ // are nullable rather than defaulted.
+ self.conn
+ .execute_batch(
+ "
+ ALTER TABLE sites ADD COLUMN connection_id TEXT;
+ ALTER TABLE sites ADD COLUMN remote_site_id INTEGER;
+ PRAGMA user_version = 5;
+ ",
+ )
+ .map_err(|e| format!("migration 5 failed: {e}"))?;
+ }
+ if version < 6 {
+ // Plan 22: the stack kind + its per-kind settings. Constant defaults
+ // migrate every existing row to the WordPress stack it already is —
+ // `config_json = '{}'` deserializes to the WordPress `SiteConfig`
+ // defaults (service `wordpress`, sync path `wp-content`).
+ self.conn
+ .execute_batch(
+ "
+ ALTER TABLE sites ADD COLUMN kind TEXT NOT NULL DEFAULT 'wordpress';
+ ALTER TABLE sites ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
+ PRAGMA user_version = 6;
+ ",
+ )
+ .map_err(|e| format!("migration 6 failed: {e}"))?;
+ }
+ if version < 7 {
+ // Plan 23: when `status` was last written, for the reconciler's
+ // forward-only guard. Empty default = "long ago" (it sorts before
+ // any RFC3339 timestamp), so a legacy row is always safe for the
+ // reconciler to settle on its first pass, and no command write it
+ // races can ever be clobbered by a stale observation.
+ self.conn
+ .execute_batch(
+ "
+ ALTER TABLE sites ADD COLUMN status_updated_at TEXT NOT NULL DEFAULT '';
+ PRAGMA user_version = 7;
+ ",
+ )
+ .map_err(|e| format!("migration 7 failed: {e}"))?;
+ }
Ok(())
}
@@ -163,7 +211,13 @@ impl Db {
}
fn row_to_site(row: &Row) -> rusqlite::Result {
- Ok(Site {
+ // `config_json` parses to the WordPress defaults when empty/`{}` or
+ // unreadable, so a legacy row is always the fully-capable WP stack.
+ let config_json: String = row.get("config_json")?;
+ let config: crate::site::SiteConfig =
+ serde_json::from_str(&config_json).unwrap_or_default();
+ let kind: String = row.get("kind")?;
+ let mut site = Site {
id: row.get("id")?,
name: row.get("name")?,
slug: row.get("slug")?,
@@ -172,18 +226,31 @@ impl Db {
wp_version: row.get("wp_version")?,
php_version: row.get("php_version")?,
status: row.get("status")?,
+ status_updated_at: row.get("status_updated_at")?,
admin_user: row.get("admin_user")?,
admin_pass: row.get("admin_pass")?,
created_at: row.get("created_at")?,
- })
+ connection_id: row.get("connection_id")?,
+ remote_site_id: row.get("remote_site_id")?,
+ kind,
+ config,
+ capabilities: crate::site::Capabilities::default(),
+ };
+ site.refresh_capabilities();
+ Ok(site)
}
pub fn insert_site(&self, site: &Site) -> Result<(), String> {
+ // The derived `capabilities` field is never persisted — it is
+ // recomputed from `kind`/`config` on every read.
+ let config_json = serde_json::to_string(&site.config)
+ .map_err(|e| format!("failed to serialize site config: {e}"))?;
self.conn
.execute(
"INSERT INTO sites
- (id, name, slug, path, port, wp_version, php_version, status, admin_user, admin_pass, created_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
+ (id, name, slug, path, port, wp_version, php_version, status, status_updated_at,
+ admin_user, admin_pass, created_at, connection_id, remote_site_id, kind, config_json)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
site.id,
site.name,
@@ -193,22 +260,81 @@ impl Db {
site.wp_version,
site.php_version,
site.status,
+ site.status_updated_at,
site.admin_user,
site.admin_pass,
site.created_at,
+ site.connection_id,
+ site.remote_site_id,
+ site.kind,
+ config_json,
],
)
.map_err(|e| format!("failed to insert site: {e}"))?;
Ok(())
}
+ /// Sites imported from a given remote site (plan 18) — the `pre_import`
+ /// guard's "you already have a copy of this" check.
+ pub fn sites_from_remote(
+ &self,
+ connection_id: &str,
+ remote_site_id: i64,
+ ) -> Result, String> {
+ let mut stmt = self
+ .conn
+ .prepare(
+ "SELECT * FROM sites WHERE connection_id = ?1 AND remote_site_id = ?2
+ ORDER BY created_at ASC",
+ )
+ .map_err(|e| format!("failed to look up imported sites: {e}"))?;
+ let rows = stmt
+ .query_map(params![connection_id, remote_site_id], Self::row_to_site)
+ .map_err(|e| format!("failed to look up imported sites: {e}"))?;
+ let mut out = Vec::new();
+ for row in rows {
+ out.push(row.map_err(|e| format!("failed to read site row: {e}"))?);
+ }
+ Ok(out)
+ }
+
+ /// Write a site's status from an explicit command/event. Always stamps
+ /// `status_updated_at = now`, so a command write is dated "now" and can
+ /// never lose to a stale reconciler observation (plan 23 forward-only).
pub fn set_status(&self, id: &str, status: &str) -> Result<(), String> {
+ let now = chrono::Utc::now().to_rfc3339();
self.conn
- .execute("UPDATE sites SET status = ?1 WHERE id = ?2", params![status, id])
+ .execute(
+ "UPDATE sites SET status = ?1, status_updated_at = ?2 WHERE id = ?3",
+ params![status, now, id],
+ )
.map_err(|e| format!("failed to update site status: {e}"))?;
Ok(())
}
+ /// Settle a site's status from the reconciler (plan 23). This is a
+ /// compare-and-swap on `status_updated_at`: the write only lands if the
+ /// stored timestamp still equals `expected_prev` — i.e. no command/event
+ /// wrote a newer status between the reconciler reading the row and settling
+ /// it. Returns whether the settle was applied (false = a newer write won).
+ pub fn settle_status(
+ &self,
+ id: &str,
+ status: &str,
+ expected_prev: &str,
+ ) -> Result {
+ let now = chrono::Utc::now().to_rfc3339();
+ let changed = self
+ .conn
+ .execute(
+ "UPDATE sites SET status = ?1, status_updated_at = ?2
+ WHERE id = ?3 AND status_updated_at = ?4",
+ params![status, now, id, expected_prev],
+ )
+ .map_err(|e| format!("failed to settle site status: {e}"))?;
+ Ok(changed > 0)
+ }
+
pub fn update_credentials(&self, id: &str, user: &str, pass: &str) -> Result<(), String> {
self.conn
.execute(
@@ -289,37 +415,55 @@ impl Db {
}
pub fn insert_connection(&self, conn: &ServerKitConnection) -> Result<(), String> {
+ // Prefer the OS keyring; only when it is unavailable does the key fall
+ // back into the plaintext column (plan 25 graceful degradation).
+ let column_key = if crate::keystore::store(&conn.id, &conn.api_key) {
+ ""
+ } else {
+ conn.api_key.as_str()
+ };
self.conn
.execute(
"INSERT INTO serverkit_connections (id, label, url, api_key, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
- params![conn.id, conn.label, conn.url, conn.api_key, conn.created_at],
+ params![conn.id, conn.label, conn.url, column_key, conn.created_at],
)
.map_err(|e| format!("failed to insert connection: {e}"))?;
Ok(())
}
pub fn get_connection(&self, id: &str) -> Result {
- self.conn
+ let mut conn = self
+ .conn
.query_row(
"SELECT * FROM serverkit_connections WHERE id = ?1",
params![id],
Self::row_to_connection,
)
- .map_err(|_| "connection not found".to_string())
+ .map_err(|_| "connection not found".to_string())?;
+ self.resolve_api_key(&mut conn);
+ Ok(conn)
}
pub fn list_connections(&self) -> Result, String> {
- let mut stmt = self
- .conn
- .prepare("SELECT * FROM serverkit_connections ORDER BY created_at ASC")
- .map_err(|e| format!("failed to list connections: {e}"))?;
- let rows = stmt
- .query_map([], Self::row_to_connection)
- .map_err(|e| format!("failed to list connections: {e}"))?;
- let mut out = Vec::new();
- for row in rows {
- out.push(row.map_err(|e| format!("failed to read connection row: {e}"))?);
+ let mut out = {
+ let mut stmt = self
+ .conn
+ .prepare("SELECT * FROM serverkit_connections ORDER BY created_at ASC")
+ .map_err(|e| format!("failed to list connections: {e}"))?;
+ let rows = stmt
+ .query_map([], Self::row_to_connection)
+ .map_err(|e| format!("failed to list connections: {e}"))?;
+ let mut out = Vec::new();
+ for row in rows {
+ out.push(row.map_err(|e| format!("failed to read connection row: {e}"))?);
+ }
+ out
+ };
+ // The prepared statement is dropped above, so `resolve_api_key` is free
+ // to write back (blank the column) while migrating a legacy key.
+ for conn in &mut out {
+ self.resolve_api_key(conn);
}
Ok(out)
}
@@ -328,6 +472,34 @@ impl Db {
self.conn
.execute("DELETE FROM serverkit_connections WHERE id = ?1", params![id])
.map_err(|e| format!("failed to delete connection: {e}"))?;
+ // Best-effort — a keyring-less machine simply has nothing to remove.
+ crate::keystore::delete(id);
+ Ok(())
+ }
+
+ /// Fill in a connection's `api_key` from the keyring, migrating a legacy
+ /// plaintext key on the way. The keyring wins when present; otherwise a
+ /// non-empty column is a pre-plan-25 key that we move into the keyring and
+ /// then blank here, so the keyring becomes the only copy. If the keyring is
+ /// unavailable the column value is left untouched and used as-is.
+ fn resolve_api_key(&self, conn: &mut ServerKitConnection) {
+ if let Some(key) = crate::keystore::retrieve(&conn.id) {
+ conn.api_key = key;
+ return;
+ }
+ if !conn.api_key.is_empty() && crate::keystore::store(&conn.id, &conn.api_key) {
+ let _ = self.clear_connection_api_key(&conn.id);
+ }
+ }
+
+ /// Blank the plaintext column after a key has been migrated to the keyring.
+ fn clear_connection_api_key(&self, id: &str) -> Result<(), String> {
+ self.conn
+ .execute(
+ "UPDATE serverkit_connections SET api_key = '' WHERE id = ?1",
+ params![id],
+ )
+ .map_err(|e| format!("failed to clear stored api key: {e}"))?;
Ok(())
}
@@ -385,3 +557,229 @@ impl Db {
Ok(out)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_db_path(tag: &str) -> std::path::PathBuf {
+ std::env::temp_dir()
+ .join(format!("localkit-dbtest-{}-{tag}", std::process::id()))
+ .join("localkit.db")
+ }
+
+ fn site(id: &str, slug: &str) -> Site {
+ let mut s = Site {
+ id: id.into(),
+ name: slug.into(),
+ slug: slug.into(),
+ path: format!("/tmp/{slug}"),
+ port: 8081,
+ wp_version: "6.7".into(),
+ php_version: "8.3".into(),
+ status: "running".into(),
+ status_updated_at: "2026-07-20T00:00:00Z".into(),
+ admin_user: "admin".into(),
+ admin_pass: "secret".into(),
+ created_at: "2026-07-20T00:00:00Z".into(),
+ connection_id: None,
+ remote_site_id: None,
+ kind: crate::site::KIND_WORDPRESS.into(),
+ config: crate::site::SiteConfig::default(),
+ capabilities: crate::site::Capabilities::default(),
+ };
+ s.refresh_capabilities();
+ s
+ }
+
+ /// The pre-plan-18 schema, verbatim: a database created by the shipped
+ /// app before migration 5 existed. Migrating this is the upgrade path
+ /// every existing user takes, so it is what the test actually exercises —
+ /// a freshly created database would prove nothing about ALTER TABLE.
+ fn seed_v4(path: &std::path::Path) {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ let conn = Connection::open(path).unwrap();
+ conn.execute_batch(
+ "
+ CREATE TABLE sites (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ path TEXT NOT NULL,
+ port INTEGER NOT NULL,
+ wp_version TEXT NOT NULL,
+ php_version TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'creating',
+ admin_user TEXT NOT NULL DEFAULT '',
+ admin_pass TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL
+ );
+ CREATE TABLE serverkit_connections (
+ id TEXT PRIMARY KEY, label TEXT NOT NULL, url TEXT NOT NULL,
+ api_key TEXT NOT NULL, created_at TEXT NOT NULL
+ );
+ CREATE TABLE sync_history (
+ id TEXT PRIMARY KEY, site_id TEXT NOT NULL, connection_id TEXT NOT NULL,
+ direction TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL,
+ message TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
+ );
+ CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+ INSERT INTO sites (id, name, slug, path, port, wp_version, php_version, status,
+ admin_user, admin_pass, created_at)
+ VALUES ('old-1', 'Legacy', 'legacy', '/tmp/legacy', 8081, '6.7', '8.3', 'running',
+ 'admin', 'pw', '2026-01-01T00:00:00Z');
+ PRAGMA user_version = 4;
+ ",
+ )
+ .unwrap();
+ }
+
+ #[test]
+ fn migrations_upgrade_a_v4_database_without_touching_existing_rows() {
+ let path = temp_db_path("v4");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ seed_v4(&path);
+
+ let db = Db::open(&path).unwrap();
+ let version: i64 = db
+ .conn
+ .pragma_query_value(None, "user_version", |row| row.get(0))
+ .unwrap();
+ assert_eq!(version, 7);
+
+ // The pre-existing site survives, reads back with a NULL origin, and —
+ // crucially for plan 22 — migrates to the fully-capable WordPress stack:
+ // kind `wordpress`, the WordPress `SiteConfig` defaults, all caps true.
+ let legacy = db.get_site("old-1").unwrap();
+ assert_eq!(legacy.slug, "legacy");
+ assert_eq!(legacy.connection_id, None);
+ assert_eq!(legacy.remote_site_id, None);
+ assert_eq!(legacy.kind, crate::site::KIND_WORDPRESS);
+ assert_eq!(legacy.config, crate::site::SiteConfig::default());
+ assert_eq!(legacy.config.service, "wordpress");
+ assert_eq!(legacy.config.sync_path, "wp-content");
+ assert_eq!(legacy.capabilities, crate::site::Capabilities::WORDPRESS);
+ // Migration 7 back-fills an empty status timestamp, which sorts before
+ // any real one — so the reconciler may settle a legacy row on sight.
+ assert_eq!(legacy.status_updated_at, "");
+
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn settle_status_is_a_compare_and_swap_on_the_timestamp() {
+ let path = temp_db_path("settle");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let db = Db::open(&path).unwrap();
+
+ // A row whose status was written "long ago" (empty timestamp).
+ let mut s = site("s-1", "one");
+ s.status = "running".into();
+ s.status_updated_at = String::new();
+ db.insert_site(&s).unwrap();
+
+ // The reconciler observed `expected_prev = ""` and settles to stopped.
+ let applied = db.settle_status("s-1", "stopped", "").unwrap();
+ assert!(applied, "settle lands when the timestamp still matches");
+ let after = db.get_site("s-1").unwrap();
+ assert_eq!(after.status, "stopped");
+ assert_ne!(after.status_updated_at, "", "settle stamps a fresh timestamp");
+
+ // A second settle carrying the now-stale `""` must lose: a command (the
+ // first settle) advanced the timestamp, so the CAS matches no row.
+ let applied = db.settle_status("s-1", "running", "").unwrap();
+ assert!(!applied, "a settle carrying a stale timestamp is refused");
+ assert_eq!(db.get_site("s-1").unwrap().status, "stopped");
+
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn docker_kind_round_trips_config_and_derives_capabilities() {
+ let path = temp_db_path("docker-kind");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let db = Db::open(&path).unwrap();
+
+ let mut app = site("d-1", "my-api");
+ app.kind = crate::site::KIND_DOCKER.into();
+ app.config = crate::site::SiteConfig {
+ service: "app".into(),
+ sync_path: ".".into(),
+ app_port: Some(3000),
+ db_engine: Some("postgres".into()),
+ db_service: Some("db".into()),
+ };
+ app.refresh_capabilities();
+ db.insert_site(&app).unwrap();
+
+ let back = db.get_site("d-1").unwrap();
+ assert_eq!(back.kind, crate::site::KIND_DOCKER);
+ assert_eq!(back.config.service, "app");
+ assert_eq!(back.config.app_port, Some(3000));
+ assert_eq!(back.config.db_engine.as_deref(), Some("postgres"));
+ // The DB engine is captured, but a docker app stays code-only for now —
+ // db_sync waits on engine-native dumps.
+ assert!(back.capabilities.code_sync);
+ assert!(!back.capabilities.db_sync, "docker is code-only until native dumps land");
+ assert!(!back.capabilities.wp_tools);
+ assert!(!back.capabilities.one_click_login);
+
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn a_connection_round_trips_its_api_key_through_either_backend() {
+ let path = temp_db_path("conn-key");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let db = Db::open(&path).unwrap();
+
+ let conn = ServerKitConnection {
+ id: "conn-key-1".into(),
+ label: "prod".into(),
+ url: "https://panel.example.com".into(),
+ api_key: "sk-secret-123".into(),
+ created_at: "2026-07-20T00:00:00Z".into(),
+ };
+ db.insert_connection(&conn).unwrap();
+
+ // The key comes back whole regardless of where it landed — keyring on a
+ // desktop, the SQLite column on a headless box. The plaintext column is
+ // never the guaranteed source of truth anymore, only the resolved value.
+ assert_eq!(db.get_connection("conn-key-1").unwrap().api_key, "sk-secret-123");
+ let listed = db.list_connections().unwrap();
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].api_key, "sk-secret-123");
+
+ // Delete removes the row and (best-effort) the keyring entry, so a
+ // dev-box run leaves nothing behind in the real credential store.
+ db.delete_connection("conn-key-1").unwrap();
+ assert!(db.list_connections().unwrap().is_empty());
+
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn imported_sites_are_found_by_their_remote() {
+ let path = temp_db_path("origin");
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ let db = Db::open(&path).unwrap();
+
+ let mut imported = site("s-1", "client-blog");
+ imported.connection_id = Some("conn-a".into());
+ imported.remote_site_id = Some(7);
+ db.insert_site(&imported).unwrap();
+ db.insert_site(&site("s-2", "handmade")).unwrap();
+
+ let hits = db.sites_from_remote("conn-a", 7).unwrap();
+ assert_eq!(hits.len(), 1);
+ assert_eq!(hits[0].slug, "client-blog");
+ assert_eq!(hits[0].remote_site_id, Some(7));
+
+ // A different remote, and a different connection, are both misses —
+ // the guard must not confuse "site #7 on prod" with "site #7 on staging".
+ assert!(db.sites_from_remote("conn-a", 8).unwrap().is_empty());
+ assert!(db.sites_from_remote("conn-b", 7).unwrap().is_empty());
+
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+}
diff --git a/src-tauri/src/dbsync.rs b/src-tauri/src/dbsync.rs
new file mode 100644
index 0000000..0092248
--- /dev/null
+++ b/src-tauri/src/dbsync.rs
@@ -0,0 +1,247 @@
+//! Engine-native database export/import, dispatched on a site's kind + config
+//! (plan 26 phase 2).
+//!
+//! WordPress keeps its wp-cli path (`wp db export`/`import`). Every other kind
+//! that claims `db_sync` dumps via the database engine's own client, run inside
+//! the DB service container: `mariadb-dump`/`mariadb` for mariadb,
+//! `mysqldump`/`mysql` for mysql, `pg_dump`/`psql` for postgres. The client's
+//! password is handed over as an environment variable (`MYSQL_PWD`/`PGPASSWORD`)
+//! so it never lands on a command line.
+//!
+//! This is the single dispatch table Phase 2's "every kind × operation has an
+//! explicit handler or a clean unsupported error" guarantee is tested against.
+
+use std::io::BufReader;
+use std::path::Path;
+
+use crate::{docker, site, wordpress};
+use site::Site;
+
+/// The database engine's dump binary + the fixed flags a dump needs. The flags
+/// are chosen to work as the app DB user (not root): `--single-transaction`
+/// gives an InnoDB-consistent snapshot without a global lock, `--no-tablespaces`
+/// avoids the PROCESS privilege a non-root user lacks.
+fn dump_args(engine: &str, user: &str, db: &str) -> Result, String> {
+ let s = |v: &str| v.to_string();
+ Ok(match engine {
+ "mariadb" => vec![
+ s("mariadb-dump"),
+ s("--single-transaction"),
+ s("--no-tablespaces"),
+ s("-u"),
+ s(user),
+ s(db),
+ ],
+ "mysql" => vec![
+ s("mysqldump"),
+ s("--single-transaction"),
+ s("--no-tablespaces"),
+ s("-u"),
+ s(user),
+ s(db),
+ ],
+ "postgres" | "postgresql" => vec![
+ s("pg_dump"),
+ s("--clean"),
+ s("--if-exists"),
+ s("-U"),
+ s(user),
+ s(db),
+ ],
+ other => return Err(unsupported(other)),
+ })
+}
+
+/// The database engine's import client — reads a dump on stdin. A mysql/mariadb
+/// dump carries `DROP TABLE IF EXISTS`, and `pg_dump --clean --if-exists` does
+/// the same, so importing over an existing database is idempotent.
+fn import_args(engine: &str, user: &str, db: &str) -> Result, String> {
+ let s = |v: &str| v.to_string();
+ Ok(match engine {
+ "mariadb" => vec![s("mariadb"), s("-u"), s(user), s(db)],
+ "mysql" => vec![s("mysql"), s("-u"), s(user), s(db)],
+ "postgres" | "postgresql" => vec![s("psql"), s("-U"), s(user), s("-d"), s(db)],
+ other => return Err(unsupported(other)),
+ })
+}
+
+/// The environment variable the engine's clients read a password from.
+fn password_env(engine: &str) -> &'static str {
+ match engine {
+ "postgres" | "postgresql" => "PGPASSWORD",
+ _ => "MYSQL_PWD",
+ }
+}
+
+fn unsupported(engine: &str) -> String {
+ format!("no database dump support for engine `{engine}`")
+}
+
+/// The DB engine + service for an engine-native site, or a clean error if the
+/// site's config never recorded one (a code-only kind).
+fn engine_service(site: &Site) -> Result<(String, String), String> {
+ match (site.config.db_engine.as_deref(), site.config.db_service.as_deref()) {
+ (Some(engine), Some(service)) => Ok((engine.to_string(), service.to_string())),
+ _ => Err(format!(
+ "{} has no database engine to sync (code-only site)",
+ site.name
+ )),
+ }
+}
+
+/// The app DB user / database / password from the site's `.env`.
+fn creds(dir: &Path) -> (String, String, String) {
+ (site::db_user(dir), site::db_name(dir), site::db_password(dir))
+}
+
+/// Export the site's database as SQL text.
+///
+/// WordPress dumps through wp-cli (with a short retry for the stopped-site boot
+/// race); every other `db_sync` kind dumps engine-native. The dump is returned
+/// as a `String`, matching what the snapshot/sync layers already expected from
+/// the wp-cli path.
+pub async fn export_sql(site: &Site, dir: &Path) -> Result {
+ if site.kind == site::KIND_WORDPRESS {
+ return wp_export(dir).await;
+ }
+ let (engine, service) = engine_service(site)?;
+ let (user, db, password) = creds(dir);
+ let args = dump_args(&engine, &user, &db)?;
+ let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
+ // The DB must be up + healthy to dump; bring just it up (idempotent) so a
+ // snapshot of a stopped site works, mirroring what wp-cli got via depends_on.
+ docker::compose_up_wait_service(dir, &service).await?;
+ let out = docker::compose_exec_env(dir, &service, &[(password_env(&engine), &password)], &arg_refs)
+ .await?;
+ if out.trim().is_empty() {
+ return Err("the database export came back empty".into());
+ }
+ Ok(out)
+}
+
+/// Import a SQL dump over the site's database.
+///
+/// WordPress imports through wp-cli; every other kind pipes the dump into the
+/// engine's client running inside the DB container.
+pub async fn import_sql(site: &Site, dir: &Path, sql: &[u8]) -> Result<(), String> {
+ if site.kind == site::KIND_WORDPRESS {
+ return wordpress::import_db(dir, sql).await;
+ }
+ let (engine, service) = engine_service(site)?;
+ let (user, db, password) = creds(dir);
+ let args = import_args(&engine, &user, &db)?;
+ let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
+ docker::compose_up_wait_service(dir, &service).await?;
+ docker::compose_exec_env_stdin_reader(
+ dir,
+ &service,
+ &[(password_env(&engine), &password)],
+ &arg_refs,
+ &mut &sql[..],
+ )
+ .await
+ .map(|_| ())
+}
+
+/// Export the database to a file on the host — used by the push flow, which
+/// stages the dump for a chunked upload straight off disk (plan 19/26).
+pub async fn export_to_file(site: &Site, dir: &Path, dest: &Path) -> Result<(), String> {
+ let sql = export_sql(site, dir).await?;
+ std::fs::write(dest, sql).map_err(|e| format!("failed to write database dump: {e}"))
+}
+
+/// Import a gzipped dump straight off disk, decompressing into the client's
+/// stdin — the streaming counterpart of `import_sql` used by pull/import so a
+/// remote database never exists decompressed in memory (plan 19/26).
+pub async fn import_from_gz(site: &Site, dir: &Path, gz_path: &Path) -> Result<(), String> {
+ if site.kind == site::KIND_WORDPRESS {
+ return wordpress::import_db_from_gz(dir, gz_path).await;
+ }
+ let (engine, service) = engine_service(site)?;
+ let (user, db, password) = creds(dir);
+ let args = import_args(&engine, &user, &db)?;
+ let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
+ let file = std::fs::File::open(gz_path)
+ .map_err(|e| format!("failed to open the downloaded dump: {e}"))?;
+ let mut reader = flate2::read::GzDecoder::new(BufReader::new(file));
+ docker::compose_up_wait_service(dir, &service).await?;
+ docker::compose_exec_env_stdin_reader(
+ dir,
+ &service,
+ &[(password_env(&engine), &password)],
+ &arg_refs,
+ &mut reader,
+ )
+ .await
+ .map(|_| ())
+}
+
+/// `wp db export -` with a short retry loop: on a stopped site the first call
+/// races the database container's first boot (same reason `wordpress::install`
+/// retries). Kept here so `export_sql` is the one entry point for both paths.
+async fn wp_export(dir: &Path) -> Result {
+ const ATTEMPTS: u32 = 5;
+ let mut last = String::new();
+ for attempt in 1..=ATTEMPTS {
+ match docker::compose_run(dir, "wpcli", &["wp", "db", "export", "-"]).await {
+ Ok(sql) if !sql.trim().is_empty() => return Ok(sql),
+ Ok(_) => last = "the database export came back empty".into(),
+ Err(e) => last = e,
+ }
+ if attempt < ATTEMPTS {
+ tokio::time::sleep(std::time::Duration::from_secs(3)).await;
+ }
+ }
+ Err(format!("failed to export the database: {last}"))
+}
+
+// ---------------------------------------------------------------------------
+// Tests — the dispatch table
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Every engine LocalKit recognizes has an explicit dump + import handler,
+ /// and the password goes in the engine's own env var (never an argument).
+ #[test]
+ fn every_recognized_engine_has_dump_and_import_handlers() {
+ for engine in ["mariadb", "mysql", "postgres", "postgresql"] {
+ let dump = dump_args(engine, "app", "appdb").unwrap();
+ let imp = import_args(engine, "app", "appdb").unwrap();
+ assert!(dump.iter().all(|a| a != "app-pw"), "no password on the dump line");
+ assert!(imp.iter().all(|a| a != "app-pw"), "no password on the import line");
+ // The db name and user are always present.
+ assert!(dump.contains(&"appdb".to_string()) && dump.contains(&"app".to_string()));
+ assert!(imp.contains(&"appdb".to_string()) && imp.contains(&"app".to_string()));
+ }
+ }
+
+ #[test]
+ fn mariadb_and_mysql_use_their_named_clients() {
+ assert_eq!(dump_args("mariadb", "u", "d").unwrap()[0], "mariadb-dump");
+ assert_eq!(import_args("mariadb", "u", "d").unwrap()[0], "mariadb");
+ assert_eq!(dump_args("mysql", "u", "d").unwrap()[0], "mysqldump");
+ assert_eq!(import_args("mysql", "u", "d").unwrap()[0], "mysql");
+ assert_eq!(dump_args("postgres", "u", "d").unwrap()[0], "pg_dump");
+ assert_eq!(import_args("postgres", "u", "d").unwrap()[0], "psql");
+ }
+
+ #[test]
+ fn mysql_family_uses_mysql_pwd_and_postgres_uses_pgpassword() {
+ assert_eq!(password_env("mariadb"), "MYSQL_PWD");
+ assert_eq!(password_env("mysql"), "MYSQL_PWD");
+ assert_eq!(password_env("postgres"), "PGPASSWORD");
+ assert_eq!(password_env("postgresql"), "PGPASSWORD");
+ }
+
+ /// An unrecognized engine is a clean, user-displayable error — never a panic
+ /// or a silently-wrong command.
+ #[test]
+ fn an_unknown_engine_is_a_clean_error() {
+ let err = dump_args("cassandra", "u", "d").unwrap_err();
+ assert!(err.contains("cassandra"), "{err}");
+ assert!(import_args("mongodb", "u", "d").is_err());
+ }
+}
diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs
index 77b3a6d..50d46a6 100644
--- a/src-tauri/src/docker.rs
+++ b/src-tauri/src/docker.rs
@@ -4,9 +4,17 @@
//! fewer dependencies and it matches whatever Docker Desktop the user has.
use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
use std::path::Path;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant};
use tokio::process::Command;
+/// How long a `check()` result is cached (plan 23) — long enough that the
+/// sidebar can poll Docker health cheaply, short enough to notice a daemon
+/// going down within a tick.
+const CHECK_TTL: Duration = Duration::from_secs(30);
+
/// Hide the console window Windows would otherwise allocate for a
/// console-subsystem child of our GUI process. No-op on other OSes.
/// Every subprocess spawn in the app must go through this.
@@ -60,6 +68,32 @@ pub async fn check() -> DockerStatus {
}
}
+fn check_cache() -> &'static Mutex> {
+ static CACHE: OnceLock>> = OnceLock::new();
+ CACHE.get_or_init(|| Mutex::new(None))
+}
+
+/// `check()` behind a 30 s cache (plan 23). The sidebar polls this to show a
+/// global "Docker unavailable" pill without spawning a `docker info` subprocess
+/// every few seconds. `force` bypasses the cache — the Settings "refresh"
+/// button wants an immediate re-check. The lock is never held across the await.
+pub async fn check_cached(force: bool) -> DockerStatus {
+ if !force {
+ if let Ok(guard) = check_cache().lock() {
+ if let Some((at, status)) = guard.as_ref() {
+ if at.elapsed() < CHECK_TTL {
+ return status.clone();
+ }
+ }
+ }
+ }
+ let status = check().await;
+ if let Ok(mut guard) = check_cache().lock() {
+ *guard = Some((Instant::now(), status.clone()));
+ }
+ status
+}
+
/// Turn raw CLI stderr into something the UI can show directly.
fn friendly_error(stderr: &str) -> String {
let lower = stderr.to_lowercase();
@@ -117,11 +151,47 @@ pub async fn compose_pull(dir: &Path, services: &[&str]) -> Result<(), String> {
compose(dir, &args).await.map(|_| ())
}
+/// Build any services that declare a `build:` (plan 26 php stack builds its
+/// `app` image from a generated Dockerfile). `up -d` builds a missing image
+/// implicitly, but running it as its own step gives the create flow a labeled
+/// "building" stage instead of a silent multi-minute stall on first run.
+pub async fn compose_build(dir: &Path) -> Result<(), String> {
+ compose(dir, &["build"]).await.map(|_| ())
+}
+
pub async fn compose_down(dir: &Path, volumes: bool) -> Result<(), String> {
let args: &[&str] = if volumes { &["down", "-v"] } else { &["down"] };
compose(dir, args).await.map(|_| ())
}
+/// Start a single profile-gated service: `docker compose --profile up
+/// -d `. The `--profile` flag is required — without it a service with
+/// `profiles: [...]` is treated as nonexistent (plan 24 starts Adminer this way).
+pub async fn compose_up_profile_service(
+ dir: &Path,
+ profile: &str,
+ service: &str,
+) -> Result<(), String> {
+ compose(dir, &["--profile", profile, "up", "-d", service]).await.map(|_| ())
+}
+
+/// Pull every image referenced by the compose project (no service list — used
+/// for a bring-your-own-compose docker app, plan 22, where LocalKit does not
+/// know the services ahead of time). Best-effort: `up` pulls anything missing
+/// anyway, so this only exists to give the copy a labeled "pulling" stage.
+pub async fn compose_pull_all(dir: &Path) -> Result<(), String> {
+ compose(dir, &["pull"]).await.map(|_| ())
+}
+
+/// The normalized compose project as JSON (`docker compose config --format
+/// json`), so LocalKit can enumerate a bring-your-own project's services,
+/// images and published ports without shipping a YAML parser (plan 22). Docker
+/// itself does the parsing, so every compose quirk (extends, anchors, env
+/// interpolation) is already resolved.
+pub async fn compose_config(dir: &Path) -> Result {
+ compose(dir, &["config", "--format", "json"]).await
+}
+
/// Run a one-off command in a compose service, e.g. wp-cli:
/// `docker compose run --rm -T `
pub async fn compose_run(dir: &Path, service: &str, args: &[&str]) -> Result {
@@ -130,6 +200,18 @@ pub async fn compose_run(dir: &Path, service: &str, args: &[&str]) -> Result Result {
+ let mut full: Vec<&str> = vec!["run", "--rm", "-T", "--user", "root", service];
+ full.extend_from_slice(args);
+ compose(dir, &full).await
+}
+
/// Like `compose_run`, but pipes `input` to the command's stdin
/// (used for `wp db import -`).
pub async fn compose_run_stdin(
@@ -137,6 +219,27 @@ pub async fn compose_run_stdin(
service: &str,
args: &[&str],
input: &[u8],
+) -> Result {
+ compose_run_reader(dir, service, args, &mut &input[..]).await
+}
+
+/// Like `compose_run_stdin`, but pumps from a reader instead of a slice.
+///
+/// This is what keeps a pulled database off the heap (plan 19): the caller
+/// hands over a `GzDecoder` on the downloaded file, and the dump streams
+/// decompress -> pipe -> `wp db import` a megabyte at a time. Materializing a
+/// multi-GB dump as a `Vec` just to write it to a pipe was the other half
+/// of sync v1's memory problem.
+///
+/// The read side is blocking on purpose: it is a 1 MiB read off local disk
+/// between two awaits, which is how the rest of this codebase treats file IO.
+/// `Send` on the reader is not optional — it is held across an await, and every
+/// future in this crate has to stay `Send` to reach a Tauri command.
+pub async fn compose_run_reader(
+ dir: &Path,
+ service: &str,
+ args: &[&str],
+ input: &mut (dyn std::io::Read + Send),
) -> Result {
use tokio::io::AsyncWriteExt;
if !dir.exists() {
@@ -157,7 +260,20 @@ pub async fn compose_run_stdin(
.map_err(|e| format!("failed to run docker compose: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
- let _ = stdin.write_all(input).await;
+ let mut buf = vec![0u8; 1 << 20];
+ loop {
+ match input.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ // A closed pipe means the command died; stop pumping and
+ // let wait_with_output report why.
+ if stdin.write_all(&buf[..n]).await.is_err() {
+ break;
+ }
+ }
+ Err(e) => return Err(format!("failed to read the input stream: {e}")),
+ }
+ }
let _ = stdin.shutdown().await;
}
let output = child
@@ -184,6 +300,96 @@ pub async fn compose_exec(dir: &Path, service: &str, args: &[&str]) -> Result`. Used before an engine-native DB
+/// dump/import (plan 26) so the `db` container is up and past its healthcheck
+/// even when the site itself was stopped — the wp-cli path got this for free via
+/// `compose run`'s `depends_on`, the engine clients need it explicit.
+pub async fn compose_up_wait_service(dir: &Path, service: &str) -> Result<(), String> {
+ compose(dir, &["up", "-d", "--wait", service]).await.map(|_| ())
+}
+
+/// `compose exec -T` with environment variables (`-e K=V`) passed to the
+/// container — the injection-free way to hand a DB client its password
+/// (`MYSQL_PWD` / `PGPASSWORD`) so it never lands on a command line (plan 26).
+pub async fn compose_exec_env(
+ dir: &Path,
+ service: &str,
+ env: &[(&str, &str)],
+ args: &[&str],
+) -> Result {
+ let env_flags: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect();
+ let mut full: Vec<&str> = vec!["exec", "-T"];
+ for e in &env_flags {
+ full.push("-e");
+ full.push(e);
+ }
+ full.push(service);
+ full.extend_from_slice(args);
+ compose(dir, &full).await
+}
+
+/// Like `compose_exec_env`, but pumps `input` into the command's stdin — used to
+/// stream a SQL dump into `mysql`/`psql` running inside the DB container (plan
+/// 26). Mirrors `compose_run_reader`'s stdin pump, but `exec`s into the already
+/// running service rather than a throwaway `run --rm` container.
+pub async fn compose_exec_env_stdin_reader(
+ dir: &Path,
+ service: &str,
+ env: &[(&str, &str)],
+ args: &[&str],
+ input: &mut (dyn std::io::Read + Send),
+) -> Result {
+ use tokio::io::AsyncWriteExt;
+ if !dir.exists() {
+ return Err(format!("site directory not found: {}", dir.display()));
+ }
+ let env_flags: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect();
+ let mut full: Vec<&str> = vec!["exec", "-T"];
+ for e in &env_flags {
+ full.push("-e");
+ full.push(e);
+ }
+ full.push(service);
+ full.extend_from_slice(args);
+ let mut child = no_window(
+ Command::new("docker")
+ .arg("compose")
+ .args(&full)
+ .current_dir(dir)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped()),
+ )
+ .spawn()
+ .map_err(|e| format!("failed to run docker compose: {e}"))?;
+
+ if let Some(mut stdin) = child.stdin.take() {
+ let mut buf = vec![0u8; 1 << 20];
+ loop {
+ match input.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ if stdin.write_all(&buf[..n]).await.is_err() {
+ break;
+ }
+ }
+ Err(e) => return Err(format!("failed to read the input stream: {e}")),
+ }
+ }
+ let _ = stdin.shutdown().await;
+ }
+ let output = child
+ .wait_with_output()
+ .await
+ .map_err(|e| format!("failed to run docker compose: {e}"))?;
+ if output.status.success() {
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ } else {
+ Err(friendly_error(&String::from_utf8_lossy(&output.stderr)))
+ }
+}
+
/// Copy a file out of a service container:
/// `docker compose cp : `
pub async fn compose_cp(dir: &Path, service: &str, src: &str, dest: &Path) -> Result<(), String> {
@@ -192,11 +398,100 @@ pub async fn compose_cp(dir: &Path, service: &str, src: &str, dest: &Path) -> Re
compose(dir, &["cp", &from, &dest_arg]).await.map(|_| ())
}
+/// Copy a host file INTO a service container:
+/// `docker compose cp