diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md new file mode 100644 index 0000000..bec9f74 --- /dev/null +++ b/.claude/skills/create-pr/SKILL.md @@ -0,0 +1,110 @@ +--- +name: create-pr +description: Generate a pull request title and description from the current branch's commits. Produces a concise summary, optional feature highlights, and collapsible technical details. +--- + +# Create PR Description + +Generate a pull request title and description that's scannable, informative, and has just enough personality to feel human. + +## Instructions + +### 1. Gather context (do ALL of these) + +Run these commands to build a complete picture before writing anything: + +```bash +# Commit overview +git log main..HEAD --oneline --stat + +# Full diff stat for file-level scope +git diff main..HEAD --stat + +# Actual code changes — read the diff, don't just skim filenames +git diff main..HEAD +``` + +If the full diff is too large, diff individual areas (backend routes, frontend, storage, etc.) in batches. You must understand **what the code actually does**, not just which files were touched. + +### 2. Write the PR file + +Write the file to `.pr/YYYY-MM-DD.md` (using today's date). Create the `.pr/` directory if it doesn't exist. If a file for today's date already exists, append a counter: `YYYY-MM-DD-2.md`, `YYYY-MM-DD-3.md`, etc. + +The structure depends on whether the PR introduces user-facing features or is purely internal (refactors, bug fixes, infra). + +#### When the PR has user-facing features: + +~~~markdown +# + +<2-3 sentence summary> + +### Highlights + +- Highlight 1 +- Highlight 2 +- ... + +<details> +<summary>Technical changes</summary> + +- Detail 1 +- Detail 2 +- ... + +</details> +~~~ + +#### When the PR is purely internal (no user-facing features): + +~~~markdown +# <Title> + +<2-3 sentence summary> + +<details> +<summary>Technical changes</summary> + +- Detail 1 +- Detail 2 +- ... + +</details> +~~~ + +Omit the Highlights section entirely for internal-only PRs — don't force it. + +### Style Rules + +#### Title +- Imperative mood, start with a verb (Add, Fix, Refactor, etc.) +- Summarize the entire PR scope — not just one commit + +#### Summary +- **2-3 sentences max.** This is the elevator pitch, not the full story. +- **Open with a touch of personality.** One line that makes the reader smile — a wry observation, a lighthearted remark, a playful metaphor. Not forced, just human. Examples of the energy (don't copy these literally, invent your own each time): + - "This one's mostly about cleaning house." + - "Turns out the type checker was right to complain." + - A playful metaphor about what the code was doing wrong + - A wry observation about the state of things before this PR +- **Then say what the PR does at a high level.** Name the main change areas (new feature, refactor target, bug fixed) but don't enumerate every file. The personality is in *how* you describe the changes, not in being vague. +- **Do not repeat what Highlights or Technical changes already cover.** The summary is the "why" and the big picture; details live below. + +#### Highlights (only when applicable) +- One bullet per user-facing feature, behavior change, or notable improvement. +- Write from the user's perspective — what they'll notice, not internal implementation. +- Plain language, no code references. "Schedules now respect your configured timezone" not "`SchedulerService` gains a `timezone` attribute". +- 3-7 bullets is the sweet spot. If you can only think of 1-2, fold them into the summary and skip this section. + +#### Technical changes (inside the accordion) +- One bullet per discrete change. Be specific — name files, classes, functions, patterns. +- Format: `backtick code references` for identifiers, plain text for descriptions. +- Every meaningful change in the diff must have a bullet. If a change touches security (CORS, auth, SQL injection), error handling, accessibility, or concurrency, it gets its own bullet — do not bury these. +- Bullets should describe the mechanism, not just the intent. "Race condition in `get_or_create_chat` fixed by moving creation inside the lookup session" is good. "Fix database issues" is not. +- Group related changes together (all typing fixes, all security hardening, all API changes, etc.) + +#### General +- **No test plan section.** Do not include "Test plan" or "Testing". +- **No mention of tests.** Do not reference test files, test results, or testing. +- **No emoji.** +- **No "Generated by" footer.** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 248d66e..e2ce059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,8 @@ jobs: libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ - patchelf + patchelf \ + libdbus-1-dev - name: Install npm deps run: npm ci @@ -49,6 +50,9 @@ jobs: - name: TypeScript + Vite build run: npm run build + - name: Frontend unit tests (vitest) + run: npm run test + - name: cargo check (whole workspace — GUI lib/bin + lk CLI) working-directory: src-tauri run: cargo check --workspace --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6d30d1..75fb02d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,7 @@ jobs: librsvg2-dev \ patchelf \ libssl-dev \ + libdbus-1-dev \ build-essential \ curl \ wget \ @@ -223,6 +224,7 @@ jobs: libappindicator3-dev \ librsvg2-dev \ libssl-dev \ + libdbus-1-dev \ pkg-config # localkit_lib's `run()` calls `tauri::generate_context!`, which validates diff --git a/.gitignore b/.gitignore index 82eb25e..c7a4dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ Thumbs.db # Local-only plan executor prompt (per-machine, never commit) /docs/plans/prompt.md + +# PR descriptions generated by the create-pr skill +/.pr diff --git a/AGENTS.md b/AGENTS.md index ae0bcc0..51e6679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,9 @@ ## What this is Desktop app (Tauri 2) that manages local WordPress sites via per-site Docker -Compose projects. v1 = milestones M1–M4 (local sites + ServerKit push/pull). +Compose projects. Since plan 22 it also manages generic bring-your-own-compose +**Docker projects** via a capability-gated `kind` model (WordPress is the +reference kind). v1 = milestones M1–M4 (local sites + ServerKit push/pull). Push/pull talks to the `serverkit-localkit` extension on the server (`/api/v1/localkit`, in the ServerKit repo). @@ -30,7 +32,8 @@ src/ React 18 + TS + Vite frontend palette/new-site/cheat-sheet dialog flags, settings.ts = unified prefs over the app_settings KV — seeded pre-paint from window.__LOCALKIT_SETTINGS__, - sites.ts = data/actions, toast.ts = global toasts + + sites.ts = data/actions, blueprints.ts = plan-20 + template data/actions, toast.ts = global toasts + module-level toast.* helpers) pages/ Dashboard (grid/list site views), SiteDetail, Terminal (one tab per site, shell in the wordpress @@ -38,7 +41,9 @@ src/ React 18 + TS + Vite frontend not a page) components/ Sidebar, StatusBadge, CopyButton, NewSiteDialog, CommandPalette, KeyboardSettings, - KeyboardShortcutsDialog, + KeyboardShortcutsDialog, SnapshotsPanel, + DeleteSiteDialog, ImportSiteDialog, CloneSiteDialog, + SaveBlueprintDialog (plan 20), icons.tsx (inline SVGs, 1.75px rounded strokes) assets/logo.png Vite-bundled brand logo (sidebar); master at assets/logo.png mock/ in-browser mocks of @tauri-apps/* for `vite --mode mock` @@ -51,9 +56,22 @@ src-tauri/ Rust backend (also a cargo workspace root) thin clap wrapper over localkit_lib, shares the GUI's data dir + SQLite DB. Run with `cargo run -p lk -- <cmd>` src/db.rs rusqlite, forward-only migrations via PRAGMA user_version - src/docker.rs `docker compose` CLI wrapper (check/up/down/run/ps/logs) - src/site.rs Site model + lifecycle + compose/env templates + src/docker.rs `docker compose` CLI wrapper (check/up/down/run/ps/logs + + config-json inspect for plan-22 docker apps) + src/site.rs Site model + lifecycle + compose/env templates; the + plan-22 kind/capability model (SiteConfig, Capabilities) + src/dockerapp.rs plan-22 generic Docker app kind: inspect a compose + project (services/ports/DB engine) + import (copy the + folder, record app service/port, bring it up) + src/php.rs plan-26 PHP/Laravel stack kind: generated compose + (php-fpm built w/ pdo_mysql+Composer, nginx, mariadb, + profile-gated adminer) + create (empty skeleton or + import a code folder into app/) src/wordpress.rs wp-cli via `docker compose run --rm wpcli wp ...` + src/dbsync.rs plan-26 engine-native DB export/import dispatched on + kind+engine: wp-cli for WordPress, mariadb-dump/mariadb + (or mysqldump/mysql, pg_dump/psql) inside the db + container for php; password via MYSQL_PWD/PGPASSWORD src/router.rs M6 local domains: shared Caddy router (`*.test`), hosts-file block + elevated writer, CA trust, status src/tray.rs M8 system tray: close-to-tray, tray menu with quick @@ -62,7 +80,24 @@ src-tauri/ Rust backend (also a cargo workspace root) running `docker compose exec wordpress bash`; events `terminal://data` / `terminal://exit` src/serverkit.rs ServerKit API client (X-API-Key) + connection model - src/sync.rs push/pull orchestration + SyncRecord (sync_history) + src/keystore.rs plan-25 OS keyring for API keys (Credential Manager / + Keychain / Secret Service); degrades to SQLite + src/update.rs plan-25 update checker: latest GitHub release tag vs + CARGO_PKG_VERSION (pure `is_newer`); never downloads + src/sync.rs push/pull orchestration + SyncRecord (sync_history) + + plan 18 import (clone a remote site to a new local one) + src/transfer.rs plan 19 chunked transfers: chunk planning + resume + subtraction, sha256 hashing writer, self-deleting + staged/temp files, per-site cancel registry + src/snapshot.rs plan 17 snapshots: DB dump + wp-content archive on + disk (no DB table), restore, retention; also + restore_archives_into (seed a fresh site from a + snapshot's archives — the shared core of clone/blueprint) + src/blueprint.rs plan 20 blueprints: save a site as a reusable template + (<data>/blueprints/<slug>/ = blueprint.json + db.sql.gz + + wp-content.tar.gz, plugin/theme recipe), create new + sites from one, and export/import a portable .lkbp. + Clone itself lives in site.rs (clone_site) tauri.conf.json v2 schema; capabilities/default.json grants opener plugin; the main window is built in code (`lib.rs run()`), not from the config `windows` array, so the settings init @@ -72,7 +107,11 @@ src-tauri/ Rust backend (also a cargo workspace root) ## Build / test commands - `npm install && npm run build` — type-check (tsc) + Vite build -- `cd src-tauri && cargo check` — Rust compile check (no tests exist yet) +- `npm run test` — frontend unit tests (vitest + jsdom, plan 25): `lib/fuzzy`, + `lib/shortcuts`, `lib/keybindings`, `lib/errors` toast dedupe, settings-store + parsing. Tauri APIs are aliased to `src/mock/*` (see `vitest.config.ts`), so + tests never touch a real IPC bridge. +- `cd src-tauri && cargo check` — Rust compile check - `npm run tauri dev` — full app (opens a GUI window; don't run headless) - `npm run dev:mock` — Vite in mock mode (port 1426): vite.config.ts aliases `@tauri-apps/api/core|event` + `@tauri-apps/plugin-opener` to `src/mock/`, @@ -82,24 +121,125 @@ src-tauri/ Rust backend (also a cargo workspace root) - `node scripts/verify-shortcuts.mjs` — headless runtime check of the plan-15 keyboard system against the mock server (palette, shortcuts, editable guard, rebinding/conflicts/persistence) -- `cd src-tauri && cargo run --example smoke -- <create|verify|info|stop|start|delete|cleanup>` +- `node scripts/verify-router-conflict.mjs` — headless runtime check of the + plan-16 port-conflict UX against the mock server (named conflict, fallback + one-click recovery, port-bearing site URLs, SiteDetail banner). The mock + fakes a LocalWP holding 80/443; `window.__LOCALKIT_MOCK__` (mock builds + only) lets the script reach states the UI can't drive on its own. +- `cd src-tauri && cargo run --example smoke -- <create|verify|info|stop|start|reconcile|recover|clone|blueprint|tools|config|adminer|php|delete|cleanup>` — end-to-end lifecycle smoke test against real Docker (no Tauri runtime needed); - uses a scratch data dir under the OS temp dir. + uses a scratch data dir under the OS temp dir. `reconcile` (plan 23) stops the + containers behind LocalKit's back and asserts the reconciler settles + running→stopped, then stopped→running, and that the grace window shields a + fresh command write. `recover` (plan 23) removes the completion marker + forces + `creating` to simulate a killed create, asserts the site reads `incomplete`, + then resumes it back to running/complete. `clone` and `blueprint` (plan 20) + create a marker post on the smoke site, then assert a one-click clone / a + save-then-create-from-blueprint carries the content across with fresh + ports/secrets; both clean up after themselves. `tools` (plan 24) exercises the + site-tools backend against the smoke site: a search-replace dry-run finds the + baked-in home/siteurl without writing, Apply (with a `pre_search_replace` + snapshot) rewrites them and the URL is restored afterward, and the WP_DEBUG + toggle round-trips through wp-config.php (via the root-capable wpcli runner). + `config` (plan 24, split out so it stays fast) reads wp-config.php out of the + running container via `compose cp`, round-trips a write without breaking the + site, and reads/writes the `.env`. `adminer` (plan 24) rewrites the compose + file to add the profile-gated `adminer` service, starts it on demand, and + asserts it serves its login page on db_port + 1000. `php` (plan 26) creates a + self-contained PHP/Laravel stack site (empty skeleton), asserts the built + php-fpm+nginx webroot serves 200 and the skeleton page's PDO probe reports the + bundled mariadb reachable (proves pdo_mysql + the DB wiring), then deletes it. +- `cd src-tauri && cargo run --example docker_smoke [-- run|clean]` — plan-22 + E2E for the generic Docker app kind against real Docker (scratch data dir): + writes a trivial nginx+mariadb compose fixture, inspects it, imports it as a + `docker` site (asserting `.git` is excluded and `.env` gets a + COMPOSE_PROJECT_NAME), checks the app answers HTTP on its published port, the + chosen service is exec-able (the terminal target), a code-only snapshot + (db_bytes 0), then stop/start/delete. +- `node scripts/verify-snapshots.mjs` — headless runtime check of the plan-17 + snapshot UX against the mock server (listing + kind badges, take-with-note, + restore taking a pre-restore snapshot first, delete, a DB pull leaving a + `pre_pull` snapshot, and both delete-site dialog paths). +- `cd src-tauri && cargo run --example snapshot_smoke [-- run|clean]` — plan-17 + E2E against real Docker: snapshots the `smoke` site, deletes post 1 and a + canary file in wp-content, restores, asserts both are back. Run + `smoke -- create` first. - `cd src-tauri && cargo run --example m4_smoke` — M4 push/pull E2E against a mock serverkit-localkit extension (`node examples/mock_localkit_ext.cjs` - first, port 9872); requires the smoke site to exist. + first, port 9872); requires the smoke site to exist. Since plan 18 it also + imports a remote site as a real new local site (containers and all) and + deletes it again, and asserts the multisite refusal provisions nothing. + Since plan 19 it also drives the chunked path: it writes a 110 MB + incompressible filler into the smoke site's wp-content, has the mock refuse + chunks after two land, and asserts the retry re-sends only the missing ones, + that the same >100 MB archive is refused over v1, and that v1 still works + when `/pair` withholds `sync-v2`. Since plan 21 the mock also serves the + ServerKit core probes (public `GET /api/v1/system/health` → + `service: serverkit-api`, and the key-gated `GET /api/v1/setup-health/account`) + so `lk connection add`/`test` and `lk doctor` can validate against it. Since + plan 26 the mock advertises `kinds` in `/pair` and a php remote (id 4), and + `m4_smoke` step 8 drives a php stack cycle: create a real php site, assert a + server that drops php from `kinds` refuses the push, then engine-native + push-db → wipe → pull-db and assert the marker row round-trips (no wp-cli). +- `node scripts/verify-sync-progress.mjs` — headless runtime check of the + plan-19 transfer UX against the mock server (the byte readout advancing + monotonically against a fixed total, the Cancel button appearing only while + bytes move, a cancel resolving neutrally and really stopping the transfer, + `cancelled` in sync history, and an uninterrupted transfer still finishing + green). Its click helper reports disabled buttons instead of silently + no-opping — that is what catches "a terminal stage nobody handled left the + buttons stuck". +- `node scripts/verify-import.mjs` — headless runtime check of the plan-18 + import UX against the mock server (per-row Import buttons, the multisite + refusal and its tooltip, the version-match readout and mismatch warning, + the progress stages, the dashboard origin badge, the duplicate refusal). +- `node scripts/verify-blueprints.mjs` — headless runtime check of the plan-20 + clone + blueprint flows against the mock server (the New Site "From blueprint" + section with plugin/theme chips, selecting one to create-from, a one-click + Clone under a new name, and Save-as-blueprint round-tripping into the dialog). +- `node scripts/verify-site-tools.mjs` — headless runtime check of the plan-24 + Tools tab against the mock server (a WordPress site's Tools tab switching from + the overview; the Database GUI's "Open database" toasting the login; Search & + Replace previewing per-column change counts then applying with the snapshot + shortcut; the Debug toggle seeding the log viewer and Clear emptying it; the + Config editor loading wp-config.php and switching to the .env; a code-only + docker site having no Tools tab). +- `node scripts/verify-multistack.mjs` — headless runtime check of the plan-22 + capability gating against the mock server (the WP/Docker kind badges, a docker + site's SiteDetail hiding WP Admin / credentials / database / wp-cli / clone / + blueprint / push while keeping snapshots+logs+terminal, the WP detail + unchanged, and the New Site "Docker project" tab's inspect→import flow). - `cd src-tauri && cargo test --lib router` — unit tests for the M6 hosts-file - block logic (insert/replace/remove idempotency, CRLF preservation). + block logic (insert/replace/remove idempotency, CRLF preservation) plus the + plan-16 port probe, listener-table parsing, compose port mapping and + `site_url` formatting. +- `cd src-tauri && cargo test --lib snapshot` — plan-17 retention rules + (per-kind cap, manual never pruned) + manifest/gzip round-trips. - `cd src-tauri && cargo run --example m6_smoke` — M6 router E2E against the smoke site; **interactive only** (hosts-file writes trigger UAC/elevation prompts twice). Run `smoke -- create` first, `smoke -- cleanup` after. - `cd src-tauri && cargo run -p lk -- <cmd>` — headless CLI (`lk list | - create | start | stop | restart | delete | info | logs | wp | env | login | - doctor`); shares the GUI's data dir, so use `--data-dir` (or - `LOCALKIT_DATA_DIR`) for throwaway tests. See docs/plans/7_cli.md. + create [--blueprint <id|name>] | clone <site> <new-name> | start | stop | + restart | resume | delete | info | logs | wp | env | login | + snapshot list|create|restore|delete | + blueprint list|save|delete|export|import | import | + connection add|list|test|remove | sites --remote <conn> | + push <site> --code|--db | pull <site> --db | + completions <bash|zsh|fish|powershell> | doctor`); shares the GUI's + data dir, so use `--data-dir` (or + `LOCALKIT_DATA_DIR`) for throwaway tests. See docs/plans/7_cli.md and + docs/plans/21_cli-serverkit.md (the ServerKit surface). +- `node scripts/verify-cli-serverkit.mjs` — headless runtime check of the + plan-21 `lk` ServerKit surface against `examples/mock_localkit_ext.cjs`: + it shells out to the compiled `lk` binary (build it first with + `cargo build -p lk`) with a throwaway data dir and asserts exit codes and + `--json` shapes for connection add/list/test/remove, `sites --remote`, the + bad-key refusal, the push/pull argument errors, and completions for all four + shells. The Docker-backed push/pull path is covered by `m4_smoke`. - CI: `.github/workflows/ci.yml` runs on push/PR to `main`/`dev` — `npm run - build`, `cargo check --workspace --all-targets`, `cargo test --workspace` - (matches Faro's CI shape). + build`, `npm run test` (vitest), `cargo check --workspace --all-targets`, + `cargo test --workspace`. Linux jobs install `libdbus-1-dev` for the plan-25 + keyring secret-service backend. - Releases: `.github/workflows/release.yml` — every push to `main` (i.e. a dev→main merge) auto-bumps the patch version, tags `vX.Y.Z`, builds the desktop app (macOS universal / Windows / Linux) **and** the `lk` CLI for all @@ -117,6 +257,12 @@ src-tauri/ Rust backend (also a cargo workspace root) ## Conventions +- **Git workflow:** two branches, `main` and `dev`. All work happens on `dev` + in small, focused commits; agents **commit locally but never push, merge to + `main`, or open PRs** — the maintainer does that himself (a main merge is + what triggers the release workflow). `CLAUDE.md` is a thin pointer at this + file. PR descriptions follow the `create-pr` skill (`.claude/skills/`); + generated files land in `/.pr` (gitignored). - **Docker:** always shell out to the `docker compose` CLI from Rust (`docker.rs`); never add a Docker API client (bollard etc.). All compose invocations run with `current_dir = <site dir>` so `.env` is picked up. @@ -126,25 +272,137 @@ src-tauri/ Rust backend (also a cargo workspace root) - **Errors:** commands return `Result<T, String>` with user-displayable messages; `docker::friendly_error` maps common "Docker not running" stderr. - **DB:** forward-only migrations only — bump `user_version` and add an - `if version < N` block; never edit migration 1. + `if version < N` block; never edit migration 1. (Latest is migration 7: + plan-23 `status_updated_at` — every command status write stamps it, and + `settle_status` compare-and-swaps on it so the reconciler never clobbers a + newer write. Migration 6 was plan-22 `kind` + `config_json`.) - **Async:** never hold the `Db` mutex guard across `.await` (futures must be Send); lock in a short scope, drop, then await. - **Ports:** site port = first free from 8081; DB host port = site port + 10000. - **Versions:** WP/PHP versions come from allowlists in `site.rs` (`WP_VERSIONS`, `PHP_VERSIONS`) — the UI reads them via the `app_info` command. +- **Site kinds & capabilities (plan 22, 26):** every site has a `kind` + (`wordpress` | `docker` | `php`) and a `SiteConfig` + (`config_json`, migration 6) carrying the de-hardcoded WordPress assumptions: + `service` (terminal/logs), `sync_path` (code archive), `app_port` (router + upstream), and a detected `db_engine`/`db_service`. `Site::capabilities` is + **derived** from kind+config on every read (never stored), and every feature + gates on it instead of assuming WordPress: WP claims all of + `domains, terminal, logs, snapshots, db_gui, db_sync, code_sync, + one_click_login, wp_tools, search_replace`; docker claims + `domains, terminal, logs, snapshots, code_sync`; **php** (plan 26) claims + everything WP does **except** the WP-only trio (`one_click_login`, `wp_tools`, + `search_replace`) — so it keeps `db_gui` + `db_sync` (engine-native, not + wp-cli) via its generated mariadb + Adminer. `render_compose`, + `db_name`/`db_user` and the Adminer/SiteDetail DB creds are kind-aware (read + from `.env`, WP defaults preserved). WordPress is the zero-change + path — the config defaults ARE the old literals. +- **Engine-native DB sync (plan 26):** `dbsync::export_sql`/`import_sql` are the + one dispatch for a site's database — WordPress goes through wp-cli, every + other `db_sync` kind dumps engine-native inside its `db` container + (`config.db_engine`/`db_service`): mariadb-dump/mariadb, mysqldump/mysql, + pg_dump/psql. The DB is brought up + waited on first + (`compose up -d --wait <db>`) so a stopped-site snapshot still dumps, and the + password is passed as `MYSQL_PWD`/`PGPASSWORD` (never on the command line). + `snapshot::create`/`restore` route through it, so php sites get real DB + snapshots. Every engine × op has an explicit handler or a clean unsupported + error (unit-tested dispatch table). Backend commands refuse a + missing capability via `Site::require(cap, action)` ("… not supported for + <kind> sites"); both frontends **hide** rather than error (gate on + `site.capabilities.*` / `app_info.kinds`). A new kind ships only when every + capability it claims works — docker is code-only until engine-native DB dumps + land (so `db_sync` is off), and clone/blueprints/ServerKit sync stay + WordPress-only (plan 26). Docker apps are **copied** into the managed site dir + (`dockerapp.rs`), never referenced. +- **Status reconciliation (plan 23):** site status is otherwise write-path only, + so `reconcile.rs` settles the DB against Docker's ground truth — **inspect, + settle forward, never guess.** `spawn_loop` (started in `lib.rs run()`) runs + `reconcile_once` at startup and every 60 s; each pass does one + `docker::project_container_states` call (a single `docker ps`, grouped by the + `com.docker.compose.project=localkit-<slug>` label — never N per-site + `compose_ps`) and applies the pure, unit-tested `classify` × `decide` table. + **Forward-only:** it settles via `db.settle_status`, a compare-and-swap on + `status_updated_at`, so a newer command/event write always wins; a 60 s grace + window shields a just-started site from a running→stopped downgrade. Sites with + an in-flight lifecycle command are skipped via `AppState.in_flight` + (`reconcile::InFlight`, an RAII refcounted guard held by every lifecycle path — + start/stop/delete/create/clone/import/blueprint/restore). **No ground truth + (Docker down) → zero settles**, so a Docker Desktop restart never mass-flaps + sites. `degraded` (amber, up-but-unhealthy) is a real status the reconciler and + `site::list` both produce — touchpoints: `StatusBadge`, dashboard/SiteDetail/ + palette (treated as "up" for Open/Stop), tray dot ◐, `lk list`. After a settle: + `tray::refresh` + a `sites-changed` event the frontend re-fetches on. **Docker + health:** `docker::check_cached` (30 s TTL) backs the sidebar's global "Docker + unavailable" pill (the `useDocker` store polls it). **Crash recovery:** each + successful create/import/clone/blueprint writes a `.localkit-install-complete` + marker as its last step (`site::mark_complete`); a startup backfill marks + known-complete sites so legacy rows aren't flagged. A dir without the marker + (and not in flight) reports `incomplete` on `SiteWithStatus`/`SiteDetail`; the + dashboard shows "Setup incomplete" + Resume / Clean up, `site::resume` re-runs + the create tail, `lk resume` / `lk list` mirror it. +- **Update awareness (plan 25):** unsigned releases rule out + `tauri-plugin-updater`, so `update.rs` is a *checker*: `check()` GETs + `api/.../releases/latest`, and the pure, unit-tested `is_newer` compares its + tag to `env!("CARGO_PKG_VERSION")` — never downloads. Surfaced three ways off + one command (`check_for_update`): a Settings → General "Updates" row, a + once-per-version launch toast with a "View release" opener (throttle + `update.lastChecked` + snooze `update.snoozed` in the settings KV, scheduled + frontend-side in `lib/update.ts`), and an informational `lk doctor` line. If + releases ever get signed, swap the checker for the real updater behind the + same Settings row. +- **OS notifications (plan 25):** `tauri-plugin-notification` fires a desktop + notification when a long op finishes **only while the window is unfocused or + closed to tray** (`lib/notify.ts`, gated on `document.hasFocus()`) — the toast + owns in-focus feedback, and double-notifying is worse than either. Wired into + `sites.ts handleEvent` on the `done`/`error` terminal stages (not `cancelled`, + which is deliberate). Settings → General toggle `osNotifications` (default + on); permission is requested once and a denial is remembered, never nagged. + Mock: `src/mock/notification.ts`, aliased in `vite.config.ts` for `--mode + mock`. Capability: `notification:default` in `capabilities/default.json`. - **wp-cli:** the stock `wordpress` image has no wp-cli; use the profile-gated `wpcli` service (`wordpress:cli-php<ver>`) via `docker::compose_run`, and always pass `wp` as the first argument (the cli image's `wp` CMD is replaced by run args, so omitting it makes the entrypoint exec `core` and fail). -- **Events:** long operations emit `site-event` (`{id, stage, message}`); +- **Site tools (plan 24):** the Tools tab on `SiteDetail` (`SiteTools.tsx`, + shown only when the kind claims a tool) hosts four capability-gated panels. + **Database** (`db_gui`): a profile-gated `adminer` service in the compose + template on `db_port + 1000` (`Site::adminer_port`), started on demand + (`docker::compose_up_profile_service`, `--profile tools up -d adminer`) — + `open_site_database` rewrites the deterministic compose file first so sites + created before the feature get it, opens Adminer prefilled with the + `wordpress` DB user (root's password is random/unknowable), and the frontend + copies the password to the clipboard. `render_caddyfile` carries a + `db-<slug>.test` route for `db_gui` sites, with matching `db-<slug>` hosts + entries (`site_slugs`). **Search & Replace** (`search_replace`): + `wordpress::search_replace_report` runs the serialization-safe + `wp search-replace --all-tables --precise --report-changed-only [--dry-run]` + and parses the report (tab-separated in practice, not the ASCII grid — wp-cli + drops the grid when stdout is a pipe); Apply takes a `pre_search_replace` + snapshot first. **Debug** (`wp_tools`): `WP_DEBUG`/`WP_DEBUG_LOG` toggle (log + to file, never screen) + a tail of the bind-mounted `wp-content/debug.log`. + **Config** (`wp_tools`): edits `.env` (a plain host file; save offers + `site::restart` = `compose up -d`, which recreates services whose `.env` + changed) and `wp-config.php` (in the wp-data volume — read/written with + `docker compose cp` against the running container, which runs as the daemon so + it can overwrite the root-owned file; the command gates it on the site + running). Writing `wp-config.php` via a piped `sh -c 'cat > …'` was tried and + abandoned — docker's stdin EOF didn't reach `cat`, hanging the run. Any wp-cli + that must write `wp-config.php` (e.g. `wp config set` for debug) runs through + `docker::compose_run_root` + `--allow-root` (`wordpress::wp_root`), since the + cli image's `www-data` user can't write the root-owned file. +- **Events:** long operations emit `site-event` + (`{id, stage, message, bytes_done?, bytes_total?}`); create stages: files → pulling → containers → waiting → install (re-emitted per attempt) → done | error. The `pulling` stage pre-pulls all images including the profile-gated wpcli (`docker::compose_pull`) so first-run downloads are a labeled stage, not a silent stall. When there is no Tauri app handle (CLI, examples), `site::emit` prints `[stage] message` to stderr instead of dropping the event. On the frontend, `sites.ts handleEvent` - renders these as a single pinned progress toast that resolves into - success/error on done/error. + renders these as a single pinned progress toast that resolves on any + terminal stage (`done` | `error` | `cancelled` — see `isTerminalStage`). + The byte fields are present only during a chunked transfer (`emit_bytes`): + the backend sends raw counters and a bare label, and the frontend composes + "Pushing wp-content — 148 MB / 312 MB". Never format the readout backend-side. - **Settings store (plan 13):** all frontend preferences flow through `stores/settings.ts` over the `app_settings` KV — reads seed synchronously from `window.__LOCALKIT_SETTINGS__` (published by @@ -171,6 +429,22 @@ src-tauri/ Rust backend (also a cargo workspace root) is per-command and always pretty, errors print `error: <msg>` on stderr with exit 1, sites resolve by exact id or case-insensitive slug/name, and destructive commands prompt (default No) with `--yes` required on non-TTY. +- **CLI ServerKit (plan 21):** connections resolve by exact id or + case-insensitive label, same shape as sites (`pick_connection` sits next to + `pick`). `connection add` validates (health + key + `/pair`) *before* + storing and refuses a key that doesn't work; the key comes from a hidden + `rpassword` prompt, `--key`, or `LOCALKIT_API_KEY` (never prompts on a + non-TTY). `connection list` is local-only (no network — `test` does the live + probe) and its `--json` uses a redacted `ConnectionView` so the plaintext + API key never reaches stdout. `push`/`pull` default their target to the + site's linked remote (plan-18 migration-5 `connection_id`/`remote_site_id`); + `--connection`/`--remote-site` override, and are required when the site has + no link. Push/pull exit **2** when the *server* rejects the operation + (`remote_rejected` heuristic over the library's error strings) vs 1 for local + failures, and `--json` prints the resulting `SyncRecord` (read back from + history). `doctor` runs the same connection probe per stored connection but + keeps it informational — a down remote is not a local misconfig, so it never + flips the exit code. `completions` is `clap_complete`. - **Local domains (M6):** `router.rs` runs one shared Caddy project at `<data dir>/router/` (ports 80/443, `host.docker.internal:host-gateway`, routes to site host ports — never touch site compose templates). TLD is @@ -184,6 +458,28 @@ src-tauri/ Rust backend (also a cargo workspace root) Windows — no admin) and records success in settings. Caddyfile regenerates + reloads on site create/start/stop/delete; hosts sync on create/delete only (no UAC spam on start/stop). +- **Router coexistence (plan 16):** host ports are configurable via the + `app_settings` keys `router_http_port` / `router_https_port` (default + 80/443; `router::router_ports`). Container ports stay 80/443 — only the + host mapping moves — so the Caddyfile and the hosts block stay port-blind. + `site_url` is therefore **port-aware**: default ports give the clean + `https://<slug>.test`, any other pair gives `http://<slug>.test:<port>` + and deliberately stays on http (a non-standard https port re-prompts for a + cert exception even with the CA trusted). **`router::site_public_url` is + the single source of truth** for "where does this site live" — tray menu, + WP install URL, one-click login and sync's `local_url` all funnel through + it; never hand-roll the domain-vs-localhost rule again. Frontend mirror: + `lib/domains.ts` (`siteUrl`, `isDefaultPorts`). + Before enabling (and on every `status()` where `enabled && !running`), + `probe_ports` checks who owns the ports. **Probing must consult the OS + listener table** (`Get-NetTCPConnection` / `lsof`), not just a trial bind: + on Windows a socket bound with SO_REUSEADDR (Docker's port publisher does + this) lets you re-bind the same wildcard address, so bind-only probing + reports a busy port as free. Conflicts surface as `RouterStatus.conflicts` + and drive the Settings callout, the SiteDetail banner and `lk doctor`. + Note a failed *enable* leaves `domains_enabled` off (the backend + short-circuits before setting it), so UI must not gate conflict reporting + on the enabled flag. - **System tray (M8):** `tray.rs` owns the tray icon/menu (Tauri 2 built-in `TrayIconBuilder` — no extra crate) plus the close-to-tray interception in `run()`'s `on_window_event`. The `run_in_background` flag lives in @@ -265,8 +561,138 @@ src-tauri/ Rust backend (also a cargo workspace root) .sql.gz → gunzip → `wp db import -` via `docker::compose_run_stdin` → `wp search-replace` remote → local. Ops emit `site-event` stages and record rows in `sync_history` (migration 3). Connections live in - `serverkit_connections` (migration 2); **API keys in plaintext SQLite** — - accepted for v1, revisit with a keyring later. + `serverkit_connections` (migration 2); **API keys live in the OS keyring** + (plan 25, `keystore.rs`) — `db.rs` stores new keys there and blanks the + `api_key` column, migrating any legacy plaintext key into the keyring the + first time the connection is read. Keyring unavailable (headless Linux, + locked keychain, `LOCALKIT_DISABLE_KEYRING`) degrades to the plaintext + column, never a hard failure. `sync::emit` delegates to + `site::emit`, so sync progress prints to stderr in the CLI/examples instead + of vanishing. Bulk transfers use `serverkit::transfer_client` (30 min), not + the 15 s probe client — reqwest's `timeout` is a *total* request budget, so + the short one aborts any payload bigger than a fast link can move in 15 s. + Since plan 19 this v1 path only runs against servers without `sync-v2`. +- **Sync v2 — chunked transfers (plan 19):** `transfer.rs` holds the + substrate (no HTTP in it, so the offset math is unit-testable); + `serverkit::push_chunked` / `download_resumable` are the wire protocol; + `sync.rs` picks between them and v1 **once, at the top** of each operation + via `supports_v2` (`GET /pair` → `sync-v2`). **Keep v1 as one isolated + function per operation** — never sprinkle `if v2` through a shared flow; a + failed capability probe must fall back to v1, because falling back always + works. Uploads: `CHUNK_SIZE` is 8 MiB and is a **const, not a setting**; + each chunk is one request, so reqwest's total-request `timeout` *is* the + per-chunk timeout (the operation is bounded by liveness, not duration). + Resume is nothing but `transfer::remaining` subtracting the offsets `init` + reports — the client persists no state, and offsets it never planned are + ignored rather than trusted. The server processes only inside `finish`, + **after** the whole-file sha256 verifies, which is why an abandoned transfer + can never half-apply; the safe-extract policy still applies to the verified + archive (verified means intact, not friendly). Downloads use HTTP `Range` + + `If-Range` with a client-generated `?session=` that pins one materialized + export server-side — `pull/db` and `pull/code` build their payload per + request, so ranges from two different `mysqldump`/`tar` runs would splice + into garbage; a `200` answering a ranged request means "start over". + **Nothing large is held in memory anymore**: `snapshot::write_wp_content_tgz` + tars into a staging file, `docker::compose_run_reader` streams a dump + decompress→pipe→`wp db import`, and the import untars straight off disk. + Cancel: `AppState.transfers` (`transfer::CancelRegistry`) hands each op a + token checked between chunks; `cancel_sync(site_id)` sets it. **A cancel is + not a failure** — it emits the `cancelled` stage and records + `status: "cancelled"`. Frontend listeners must use `isTerminalStage` from + `stores/sites.ts` rather than hardcoding `done | error`, or they silently + stop resetting when a stage is added. +- **Import (plan 18):** `sync::import_site` clones a remote site into a *new* + local site. Order is the design: `pre_import` checks everything knowable + before provisioning (extension advertises `pull-code`, remote exists, not + multisite, not already imported from that same remote via the migration-5 + `connection_id`/`remote_site_id` columns), so a predictable failure leaves + no half-built site; after `site::reserve` any failure runs `site::cleanup`. + **`wp core install` is never run** — the imported database IS the site, and + `admin_user` is read back from its first administrator (no password stored; + one-click login does not need one). `extract_wp_content` treats the archive + as hostile: plain files/dirs under `wp-content/` only — absolute paths, + `..`, symlinks and hardlinks are refused, never sanitized. Version drift is + a warning, not an error (`match_version` drops the remote patch level and + matches `major.minor` against the allowlist, falling back to newest). + Permalinks are flushed after import or every imported page 404s. Optional + post-import wp-cli steps are wrapped in `optional()` (2 min timeout): they + run after the data has landed, so hanging on one would discard a completed + import. A **php** remote imports through `do_import_php` (plan 26): reserve a + php site, download the `app/` code, generate the infra against the real layout + (`php::write_infra` picks the nginx webroot), build + up, import the DB + engine-native, and patch `APP_URL` — no wp-cli, no `wait_for_config`, no + `core install`. +- **Extension capabilities:** `GET /pair` returns a `features` array; probe it + with `serverkit::has_feature`. **Absent means unsupported, not unknown** — + gate the UI on it rather than discovering a 404 mid-operation. Add new + server capabilities to `FEATURES` in the extension's `localkit.py` (append + only; never rename an entry, clients match the literal string). +- **Per-kind ServerKit sync (plan 26):** `/pair` also returns a `kinds` array + (site kinds the extension can sync) and `/sites` carries a `kind` per site. + **Absent → `["wordpress"]`** (`serverkit::normalize_kinds`), so an old server ↔ + new client is safe. WordPress is always syncable; `serverkit::supports_kind` + gates php on the advertisement. `sync::require_syncable` enforces capability + + kind (wordpress|php only — docker stays local-only) + server support before + provisioning/dumping. DB export/import is engine-native for php via `dbsync`; + code push/import archive the config `sync_path` (`app/` for php, `wp-content` + for WP) through the same `extract_code(root)`. php pull/import patches + `APP_URL` best-effort (no search-replace). The `serverkit-localkit` extension + advertises `KINDS = ['wordpress']` today (serverkit-wordpress is its only + backend); add `'php'` in lockstep with a php-stack backend. +- **`site::create` is split** into `reserve` (validate + unique slug + free + ports + insert the `creating` row) and `write_project_files`, so the import + flow allocates through the same race-free path instead of a parallel copy. + `wordpress::wait_for_config` exists because `site::wait_for_port` is not a + readiness signal: Docker publishes the host port when the container is + *created*, so wp-cli can race the image entrypoint still writing + wp-config.php. Anything shelling into wp-cli right after `compose up` must + wait on it (`site::create` only survives because its install step retries). +- **Snapshots (plan 17):** `snapshot.rs`. A snapshot is a *directory*, not a + DB row — no migration: `<data dir>/snapshots/<site_id>/<ts>/` holding + `manifest.json` + `db.sql.gz` + `wp-content.tar.gz`. The manifest carries + `site_name`/`site_slug` so it stays meaningful after the site row is gone. + Payloads are written **before** the manifest, so a half-written snapshot has + no manifest and `list` skips it instead of offering a broken restore. + `build_wp_content_tgz` lives here and is what `sync::push_code` uploads — + one archive format, so snapshots untar by hand. `create` emits only + `snapshot`-stage events, never `done`/`error`, because it nests inside + push/pull/delete whose progress toast must not resolve early; standalone + callers (the Tauri command) emit the terminal stage themselves. Restore + swaps wp-content's *contents*, never the directory (it is bind-mounted — + removing it breaks the mount), and auto-starts a stopped site for the DB + import. **Every destructive flow snapshots first:** `push_db`/`pull_db` + abort if it fails (never mutate without a net), `site::delete` takes a + `pre_delete` one best-effort (a broken site must stay deletable) and keeps + the snapshot dir unless the caller passes `delete_snapshots`. Retention is + the pure, unit-tested `prunable()`: newest 5 per site per auto kind, + `manual` never pruned. Two transient kinds (`clone_source`, + `blueprint_source`) back the plan-20 flows and are hidden from the + user-facing `list()` (retention still caps orphans via `list_all`). +- **Clone + blueprints (plan 20):** both build on the snapshot engine and share + `snapshot::restore_archives_into` (lay a `(db.sql.gz, wp-content.tar.gz)` pair + onto a fresh site, then rewrite the source URL to the clone's). `site:: + clone_site` snapshots the live source, provisions a target (fresh slug/ports, + fresh DB password + WP salts — **secrets are never copied**; `admin_user`/ + `admin_pass` DO carry over because the copied DB holds them), seeds it, and + deletes the transient snapshot. `blueprint.rs` is the same shape but the source + is a saved recipe under `<data>/blueprints/<slug>/`: `save` hardlinks the + snapshot's artifacts across (`hardlink_or_copy`, copy fallback) so bytes aren't + duplicated, captures the plugin/theme list as **display-only** metadata, and + drops the snapshot; `create_site` reserves a site (versions matched to the + current allowlist via the shared `sync::match_version`), lays the archives + down, and rewrites the URL read back out of the imported DB — **no `wp core + install`**, the recorded database IS the site, `admin_user` comes from its + first administrator with no stored password (mirrors import). A blueprint is + portable as a single `.lkbp` (tar.gz of the three artifacts); `import` is + safe-extract — only the three known filenames, each via `io::copy` so a + crafted link/path can't escape the staging dir. Both flows emit the standard + create/import `site-event` stages and call `router::refresh_routes` + + `refresh_hosts` — clones and blueprint-sites are ordinary sites. +- **Port allocation:** `site::free_port` checks the OS listener table + (`router::listening_ports`), not just a trial bind, and checks the DB port + (site port + 10000) as well as the site port. Bind-only probing is the + plan-16 SO_REUSEADDR trap: a port published by a running container reads as + free, and creation then dies at `compose up` *after* the image pull. - **Design system:** tailwind.config.js remaps the zinc scale to the brand navy surfaces (#0D0F16 bg / #151822 surface / #2A2F40 borders / #9097AB muted) and violet to brand (#6C5CE7 primary, #7A6BEA hover, #B8AFFA lavender accent); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..567d067 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md — LocalKit + +Local WordPress/PHP/Docker development sites, managed as per-site Docker +Compose projects — Tauri 2 desktop app (React + Rust) plus the `lk` CLI. +Push/pull syncs with a ServerKit server via the `serverkit-localkit` +extension. + +**Read [`AGENTS.md`](AGENTS.md) first.** It is the single source of truth for +this repo: project structure, build/test commands, and the binding +conventions (Docker CLI only, forward-only migrations, capability-gated site +kinds, settings store, events, sync protocol). Do not duplicate it here — +when conventions change, update AGENTS.md. + +## Git & release workflow — read this first + +**Two branches, nothing else: `main` and `dev`.** + +- **Do all work on `dev`.** Never create per-feature branches (`feat/…`, + `fix/…`, `chore/…`) — stay on `dev`. +- **Commit locally; never push.** Make small, focused commits on `dev` as you + go. **Do not `git push`, do not merge into `main`, do not open PRs.** The + maintainer reviews the local commits and pushes / merges to `main` himself. +- Merging `dev` into `main` is what triggers the release workflow + (`.github/workflows/release.yml` auto-bumps, tags, and publishes) — another + reason merges are never an agent's job. Use `[skip ci]` in a commit message + when a main push should not release. + +## Working agreements + +- **Plans:** numbered implementation plans live in `docs/plans/` with + `ROADMAP.md` as the tracker. New feature work starts as a plan file (next + number, `Status: ⬜ planned`) and the plan header + ROADMAP row are marked + shipped in the same commit series that finishes it. +- **Verification before "done":** `npm run build`, `npm run test` (vitest), + and `cargo check --workspace --all-targets` must pass; features with a + headless check add one under `scripts/verify-*.mjs`, and Docker-backed + flows extend the `smoke` / `docker_smoke` / `m4_smoke` examples. See the + full list in AGENTS.md → Build / test commands. +- **Both frontends stay thin:** logic lives in `localkit_lib`; the Tauri + commands and the `lk` CLI are wrappers. If the CLI can't do something the + GUI can, that's a smell. +- **PR descriptions** (when the maintainer asks for one) follow the + `create-pr` skill in `.claude/skills/`; generated files land in `.pr/` + (gitignored). diff --git a/README.md b/README.md index 0b334e2..45754df 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ A lean desktop app (think LocalWP, but lighter) that runs each WordPress site as its own isolated Docker Compose project — with `wp-content` bind-mounted to -a plain host folder, so you edit code in your own editor. +a plain host folder, so you edit code in your own editor. Also manages +PHP/Laravel stacks and bring-your-own-compose Docker projects under the same +roof. English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [Português](docs/README.pt.md) @@ -90,20 +92,28 @@ npm run tauri build | **One-Click WordPress Sites**<br>Pick a name, a WordPress version, and a PHP version — done. | **Per-Site Docker Compose Project**<br>`wordpress:<wp>-php<php>-apache` + `mariadb:11`, fully isolated per site. | | **Automatic WordPress Install**<br>Installed via wp-cli, with generated admin credentials handed to you. | **Unique Host Ports**<br>Sites on `http://localhost:8081+`, databases on `18081+` — no conflicts. | | **Lifecycle & Logs**<br>Start / stop / delete, live container status badges, and a container log viewer. | **Local Domains**<br>Optional `http(s)://<slug>.test` URLs via a shared Caddy router on ports 80/443, managed hosts-file block (one-time admin approval), and one-click local-CA trust for HTTPS. | +| **Snapshots & One-Click Restore**<br>Point-in-time copies of the database and `wp-content`, restorable from the site page or the CLI. | **Nothing Destructive Is One-Way**<br>A snapshot is taken automatically before every push, pull, delete and restore — deleting a site keeps one unless you opt out. | +| **Clone & Blueprints**<br>Duplicate any site in one click, or save it as a reusable blueprint (content + plugin/theme recipe) and stamp out new sites from it — portable as a single `.lkbp` file. | **More Than WordPress**<br>Import any existing Docker Compose project, or generate a PHP/Laravel stack (php-fpm + nginx + MariaDB) — same domains, terminals, snapshots, and tray. | +| **Site Tools**<br>Built-in database GUI (Adminer), serialization-safe search-replace with dry-run, WP_DEBUG toggle with a live log viewer, and a `wp-config.php` / `.env` editor. | **Always-Honest Status**<br>A reconciler settles site status against Docker itself (never guesses), flags unhealthy containers, and recovers installs interrupted by a crash. | +| **Plays Well With Others**<br>Port pre-flight before claiming 80/443 — if LocalWP or another tool owns them, LocalKit names the process and offers one-click fallback ports so both apps coexist. | **Local Domains That Degrade Gracefully**<br>Configurable router ports (80/443 by default); on fallback ports sites live at `http://<slug>.test:8080` and everything else keeps working. | ### 🔁 ServerKit Sync | | | |---|---| | **Push Code**<br>Push your local `wp-content` straight to a remote site on your ServerKit server. | **Push / Pull Database**<br>Push the DB, or pull a remote DB into your local site with automatic URL search-replace. | -| **Sync History**<br>Every sync op is recorded per site, with its result. | **Connections**<br>Save, test, and delete server connections; browse remote sites and provision new ones — all through the `serverkit-localkit` extension. | +| **Resumable Transfers**<br>Sync in 8 MiB chunks with byte-level progress. Lose the connection at 99% and the retry re-sends only what was missing — no size ceiling, nothing buffered in RAM. | **Cancel Any Transfer**<br>Stop a push or pull mid-flight. The server only applies a payload once its checksum verifies, so a cancelled sync leaves nothing half-written. | +| **Import a Remote Site**<br>Clone any site on your server down as a *new* local site — wp-content, database and URL rewriting in one step, from the app or `lk import`. | **Sync History**<br>Every sync op is recorded per site, with its result. | +| **Connections**<br>Save, test, and delete server connections; browse remote sites and provision new ones — all through the `serverkit-localkit` extension. | **Capability-Aware**<br>The app asks the extension what it supports, so features an older server can't do are disabled with a reason instead of failing halfway. | ### 🖥️ Desktop & CLI | | | |---|---| | **Dashboard Views**<br>Grid or dense list view for the dashboard, remembered between launches. | **Site Detail Page**<br>Open site / wp-admin, copyable admin + DB credentials, wp-cli info (core version, plugins). | -| **`lk` CLI**<br>Manage sites from the terminal: `lk create`, `start/stop/restart`, `wp` passthrough, `env` exports, `doctor`, JSON output — shares the app's data dir. | **Bind-Mounted Code**<br>`wp-content` lives in a plain host folder, so you edit themes and plugins in your own editor. | +| **System Tray & Notifications**<br>Close to tray with quick site actions in the tray menu; desktop notifications when long operations finish while you're elsewhere. | **OS Keyring & Update Checks**<br>ServerKit API keys live in the OS keyring (Credential Manager / Keychain / Secret Service), and the app tells you when a new release is out. | +| **Command Palette & Shortcuts**<br>mod+K palette over a single command registry, global shortcuts, remappable bindings, cheat-sheet. | **Embedded Terminals**<br>One real PTY per site inside its container, with scrollback that survives navigation, link detection, and ghost-text history. | +| **`lk` CLI**<br>Manage sites from the terminal: `lk create`, `start/stop/restart`, `wp` passthrough, `env` exports, `snapshot`, `clone`, `blueprint`, `connection`, `push`/`pull`, `doctor`, JSON output — shares the app's data dir. | **Bind-Mounted Code**<br>`wp-content` lives in a plain host folder, so you edit themes and plugins in your own editor. | --- @@ -132,9 +142,18 @@ Headless companion binary that shares the app's data dir and database: cd src-tauri cargo run -p lk -- list # or: cargo build -p lk → target/debug/lk lk create "My Blog" # full site create, prints the site URL +lk create --blueprint starter "Client" # stamp a new site from a blueprint +lk clone my-blog my-blog-copy # one-click duplicate lk wp my-blog plugin list # wp-cli passthrough lk env my-blog # eval-able exports: eval $(lk env my-blog) -lk doctor # diagnose Docker / compose / data dir +lk snapshot create my-blog # point-in-time DB + wp-content copy +lk snapshot restore my-blog <id> --yes # roll back to one +lk connection add Prod https://panel.example.com # validate + store a server +lk push my-blog --code # push wp-content to its linked remote +lk pull my-blog --db # pull the remote DB down (URL rewrite) +lk import Production client-blog # clone a server site down as a new local site +lk completions zsh # shell completions (bash/zsh/fish/powershell) +lk doctor # diagnose Docker / router ports / connections lk list --json # machine-readable output ``` @@ -172,20 +191,31 @@ cargo build src/ React 18 + TS + Vite frontend lib/ipc.ts typed wrappers for all Tauri commands (invoke + events) lib/types.ts shared TS types mirroring Rust payloads - stores/ Zustand stores (nav, sites) - pages/ Dashboard (grid + list views), SiteDetail, Settings (modal) - components/ Sidebar, StatusBadge, CopyButton, NewSiteDialog, icons + stores/ Zustand stores (nav, sites, settings, blueprints, toast) + pages/ Dashboard (grid + list views), SiteDetail, Terminal, Settings (modal) + components/ Sidebar, StatusBadge, KindBadge, NewSiteDialog, SiteTools, + SnapshotsPanel, PushPanel, CommandPalette, dialogs, icons mock/ fake @tauri-apps/* modules for `vite --mode mock` (screenshots) src-tauri/ Rust backend src/lib.rs AppState, command registration, app entry src/db.rs rusqlite, forward-only migrations (PRAGMA user_version) src/docker.rs `docker compose` CLI wrapper - src/site.rs Site model, lifecycle, compose/env templates + src/site.rs Site model, lifecycle, kind/capability model, compose/env templates + src/dockerapp.rs generic Docker-app kind (import an existing compose project) + src/php.rs PHP/Laravel stack kind (generated php-fpm + nginx + mariadb) src/wordpress.rs wp-cli via `docker compose run --rm wpcli` - src/router.rs local domains: shared Caddy router + hosts block + CA trust + src/dbsync.rs engine-native DB export/import dispatch (wp-cli / mysqldump / pg_dump) + src/router.rs local domains: shared Caddy router + hosts block + CA trust + port probe + src/reconcile.rs status reconciler (Docker ground truth, forward-only) + crash recovery + src/snapshot.rs snapshots: DB dump + wp-content archive, restore, retention + src/blueprint.rs save-site-as-blueprint, create-from-blueprint, .lkbp export/import + src/keystore.rs OS keyring for ServerKit API keys + src/update.rs GitHub release update checker src/serverkit.rs ServerKit API client (X-API-Key) - src/sync.rs push/pull orchestration + sync history -scripts/ capture-screenshots.mjs (npm run shots), generate-funding-qr.mjs + src/sync.rs push/pull orchestration + remote-site import + sync history + src/transfer.rs chunked transfers: chunk planning, resume, hashing, cancel + lk/ `lk` CLI (separate workspace crate over localkit_lib) +scripts/ capture-screenshots.mjs (npm run shots), verify-*.mjs headless checks docs/ plans/ ROADMAP.md + numbered implementation plans screenshots/ README screenshots + CAPTURE.md @@ -208,9 +238,49 @@ docs/ - Auth is via `X-API-Key` (create a key in ServerKit → API settings). - Connection test = public `/api/v1/system/health` + key validation against `/api/v1/setup-health/account` + a `/api/v1/localkit/pair` probe that detects the extension. - All sync endpoints live in the `serverkit-localkit` extension (`/api/v1/localkit/...`); without it, LocalKit tells you exactly what's missing. -- **Push code** = in-memory tar.gz of `wp-content/` → multipart POST. **Push DB** = `wp db export` → multipart POST. **Pull DB** = download dump → `wp db import` → `wp search-replace` remote URL → local URL. +- **Transfers are chunked and resumable.** Uploads go up in 8 MiB chunks; the server assembles them, verifies a SHA-256 of the whole archive, and only *then* applies anything — so an interrupted push can never leave the remote site half-updated. Retrying re-sends only the chunks that were actually lost. Downloads resume the same way over HTTP `Range`. This lifts the server's 100 MB request limit, and nothing large is held in memory in either direction. +- Progress is reported in bytes ("Pushing wp-content — 148 MB / 312 MB") and any transfer can be cancelled mid-flight. +- Against a server running an older extension, LocalKit falls back to the v1 single-request upload automatically — one client, both servers. +- **Push code** = tar.gz of `wp-content/`. **Push DB** = `wp db export`. **Pull DB** = download dump → `wp db import` → `wp search-replace` remote URL → local URL. +- **Import** provisions a new local site, then lands the remote `wp-content` (via the extension's `pull/code`) and database on it. WordPress is never installed over the imported database — the database *is* the site, so its posts, users and settings come across intact. Log in with the remote's own accounts (`lk login`, or the app's WP Admin button). +- The app gates Import on the capabilities the extension reports from `GET /pair`, so a server running an older extension shows the button disabled with the reason rather than failing mid-import. Multisite installs are refused outright. +- Downloaded archives are extracted under a strict policy — only plain files and directories under `wp-content/`; absolute paths, `..`, and symlinks are rejected. - Every sync op is recorded in the per-site sync history with its result. -- API keys are stored in **plaintext** in LocalKit's local SQLite DB — accepted for v1, keyring storage is on the roadmap. +- API keys are stored in the **OS keyring** (Windows Credential Manager, macOS Keychain, Linux Secret Service). Legacy plaintext keys in the local SQLite DB are migrated into the keyring on first read; if the keyring is unavailable (headless Linux, locked keychain), LocalKit degrades to the SQLite column — never a hard failure. + +--- + +## 🩺 Troubleshooting + +### "LocalWP / Local by Flywheel is installed" — my `.test` sites show someone else's 404 + +Only one program on your machine can own ports **80** and **443**, and +LocalKit's local-domains router needs them. LocalWP's nginx router binds both +machine-wide *and* answers every unknown local hostname with its own "Site Not +Found" page — so if it wins the port, `http://mysite.test` renders **Local's** +404 rather than anything from LocalKit. + +LocalKit detects this before it can bite: + +- Enabling local domains runs a port pre-flight first. If something else holds + 80/443 it names the process and stops, rather than writing hosts entries that + would point your sites at the other program. +- Settings → **Local domains** shows the conflict with two ways out: **Use + fallback ports** (one click; the router moves to 8080/8443) or **Retry** after + you quit the other app. +- `lk doctor` prints the active router mode and who owns the ports. + +On fallback ports your sites are reachable at +`http://<slug>.test:8080` — the hosts entries are port-blind, so nothing else +changes and both apps can run side by side. LocalKit deliberately uses the +`.test` TLD (RFC 2606) while Local uses `.local`, so the hostnames themselves +never collide; the fight is only ever over the ports. + +Prefer clean `http://<slug>.test` URLs? Quit the other program, set the router +back to 80/443 in Settings → Local domains, and hit Retry. + +> **Note:** switching ports restarts the router and rewrites each running +> site's WordPress `home`/`siteurl`, so bookmarks and absolute URLs follow. --- @@ -219,12 +289,14 @@ docs/ - **M1 — Local site lifecycle** ✅ create/start/stop/delete, compose projects, port allocation - **M2 — WordPress install & detail** ✅ wp-cli install, credentials, logs, wp info - **M3 — ServerKit connection** ✅ save/test connections, extension detection, browse remote sites -- **M4 — Push / pull** ✅ push code, push DB, pull DB with URL rewrite, sync history -- **M5 — Release polish** ⬜ installers, auto-update, OS keyring for API keys, test suite -- **M6 — Local domains** ✅ `http(s)://<slug>.test` via a shared Caddy router, managed hosts block + local CA trust (plan 6) -- **M7 — CLI (`lk`)** ✅ headless companion binary: lifecycle, wp passthrough, `env`, `doctor`, JSON output (plan 7) - -Full details, per-plan phases, and build order: [`docs/plans/ROADMAP.md`](docs/plans/ROADMAP.md). +- **M4 — Push / pull** ✅ push code, push DB, pull DB with URL rewrite, sync history, import a remote site as a new local site, chunked resumable transfers with byte progress and cancel +- **M5 — Release polish** ✅ update checker, OS keyring for API keys, OS notifications, real test suite; installers ship via the release workflow +- **M6 — Local domains** ✅ `http(s)://<slug>.test` via a shared Caddy router, managed hosts block + local CA trust, port-conflict pre-flight + fallback ports (plan 6, 16) +- **M7 — CLI (`lk`)** ✅ headless companion binary: lifecycle, wp passthrough, `env`, `doctor`, JSON output — plus connections, push/pull, blueprints, completions (plan 7, 21) +- **M8 — System tray** ✅ close-to-tray, quick site actions, single-instance focus (plan 8) +- **M9 — Multi-stack** ✅ kind/capability model, generic Docker-app import, PHP/Laravel stack with engine-native DB sync (plan 22, 26) + +Everything after the milestones is tracked per plan — snapshots (17), remote-site import (18), sync v2 (19), clone & blueprints (20), status reconciliation (23), site tools (24), and more: [`docs/plans/ROADMAP.md`](docs/plans/ROADMAP.md). --- diff --git a/docs/plans/16_router-coexistence.md b/docs/plans/16_router-coexistence.md new file mode 100644 index 0000000..58761ce --- /dev/null +++ b/docs/plans/16_router-coexistence.md @@ -0,0 +1,129 @@ +# 16 — Router coexistence: port-conflict pre-flight + configurable router ports + +Status: ✅ shipped + +> Implementation notes (deviations from the plan as written): +> +> - **The probe cannot be bind-only.** Phase 1 specified +> `TcpListener::bind` as the conflict test. On Windows that produces false +> negatives: a socket bound with `SO_REUSEADDR` — which Docker's port +> publisher uses — lets us bind the *same* wildcard address again, so an +> occupied port reports free. Verified directly: a container published +> 8080, `netstat` showed it LISTENING, `bind(0.0.0.0:8080)` still +> succeeded. `probe_port` now treats the OS listener table +> (`Get-NetTCPConnection` / `lsof`) as the primary signal, with the bind as +> corroboration. +> - **`sync.rs` did not use `site_public_url`.** The Risks section assumed it +> already did; both push and pull hardcoded `http://localhost:<port>`, so +> with local domains on, push baked `<slug>.test` URLs into the remote DB +> and pull knocked the site off its domain. Fixed as part of this plan. +> - **A failed enable leaves `domains_enabled` off**, so the UI must not gate +> conflict reporting on the enabled flag. The SiteDetail banner instead +> targets the persistent hazard (domains on, router later lost its ports) +> and refreshes router status on mount, since `App.tsx` only refreshed at +> startup. +> - Phase 3's UX is verified headlessly by +> `scripts/verify-router-conflict.mjs` (15 checks over the plan's manual +> matrix), which is what caught the last two items. + +Make local domains survive alongside other tools that also claim ports 80/443 +and the `.test`/`.local` hosts space (LocalWP's nginx router is the canonical +case: it binds 80/443 machine-wide and answers *every* unknown local host with +its own "Site Not Found" 404 page, so a LocalKit site at `http://test.test/` +silently hits Local's router instead of LocalKit's Caddy). + +## Motivation + +Today the router only finds out about a port conflict *after* the fact: +`router::port_conflict_hint` string-matches the failed `docker compose up` +stderr and guesses "LocalWP's router, IIS, Skype, or another web server". +Worse, if Local is already bound to 80/443, *its* router keeps answering while +LocalKit's Caddy is down — the user sees a foreign 404 page, not a LocalKit +error, and has no idea the two apps are fighting. There is no recovery path +short of quitting the other app. Hosts-file entries coexist fine (both tools +manage their own marked blocks), so the entire conflict is about who owns +80/443. + +## Design + +### Phase 1 — Port pre-flight probe (`src-tauri/src/router.rs`) + +- `probe_ports(http: u16, https: u16) -> Vec<PortConflict>`: try + `std::net::TcpListener::bind(("0.0.0.0", port))` for each router port; a + failed bind means something else owns it. Pure std, no Docker round-trip. +- `identify_port_owner(port) -> Option<String>`: best-effort process name for + the conflict message. Windows: PowerShell + `Get-NetTCPConnection -LocalPort <p> -State Listen` → `OwningProcess` → + `Get-Process -Id` (spawn via `docker::no_window`); macOS/Linux: + `lsof -nP -iTCP:<p> -sTCP:LISTEN`. Failure to identify is fine — the message + falls back to the generic hint list. +- `PortConflict { port, process: Option<String> }` — serializable, surfaced in + `RouterStatus` as `conflicts: Vec<PortConflict>`. +- `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 `<http>:80` / `<https>: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: `<data dir>/snapshots/<site_id>/<ts>/` 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 <site>` 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 <site id>` 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 + parent> 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<Site, String>`: + 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 <remote_url> <local_url> --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 <connection> <remote-site> [--name <n>]` — 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<u8>` — 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<AtomicBool>` + 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/<id>` 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/<kind>/init`, `PUT /push/<kind>/chunk`, + `POST /push/<kind>/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/<id>` 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<Site, String>`: + 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 <source_url> <target_url> --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 <site> <new-name>`. + +### Phase 2 — Blueprints + +- Storage: `<data dir>/blueprints/<slug>/` = `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 <name>`. +- Portability: `lk blueprint export <name> -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 <name>` does the live probe, and `lk doctor` probes every + connection at once. Both `list` outputs redact the API key. +- **push/pull gained `--remote-site <id|name>`.** 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 <name> <url>` — 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 <name>` re-runs validation. + `lk connection remove <name>` — 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 <connection>` — remote site listing via the extension + (new read-only wrapper over `serverkit.rs`). +- `lk push <site> --code|--db [--connection <name>]` and `lk pull <site> + --db [--connection <name>]`. `--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: <msg>` stderr + convention. +- `--json` on push/pull prints the resulting `SyncRecord`. + +### Phase 3 — Completions + doctor + +- `lk completions <bash|zsh|fish|powershell>` 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 (`<slug>.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<ReconcileEvent>`: 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<String>` 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-<slug>.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:<adminer_port>`. +- **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-<slug>.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 <from> <to> --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 <bool> --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/<id>`. +- `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:<ver>-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-<slug>.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)://<slug>.test` via a shared Caddy router + managed hosts block + local CA trust (plan 6), layered on top of the always-working `localhost:<port>` 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 <site> <args...>` 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 <connection> <remote-site>` (plan 18) — the first ServerKit + command in the CLI; the rest lands with plan 21 +- ✅ `lk clone <site> <new-name>`, `lk blueprint list|save|delete|export|import` + and `lk create --blueprint <id|name>` (plan 20) +- ✅ ServerKit from the CLI (plan 21): `lk connection add/list/test/remove`, + `lk sites --remote <conn>`, `lk push <site> --code|--db`, `lk pull <site> + --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 <bash|zsh|fish|powershell>` + 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 (`<slug>.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 <slug>.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<site::Site, String> { + 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::<Vec<_>>(), + 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<site::Site> = { + 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<String> = { + 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 - <what landed before the kill>`, + // 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("<?php // remote\n")), + Buffer.alloc(1024), // end of archive + ]); + return zlib.gzipSync(tar); +} + function parseMultipart(body, boundary) { // Minimal parser (latin1 = byte-preserving): { fields: {name: value}, file: {filename, data} } const text = body.toString("latin1"); @@ -32,72 +125,303 @@ function parseMultipart(body, boundary) { return out; } -const server = http.createServer((req, res) => { +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }); +} + +// 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 -- <create|verify|info|stop|start|delete|cleanup> +//! Usage: cargo run --example smoke -- <create|verify|info|stop|start|reconcile|recover|clone|blueprint|tools|config|adminer|php|delete|cleanup> //! //! 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<String, String> { + 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<String, String> { + 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<String> { + 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("<?php"), "wp-config.php read did not return PHP"); + assert!(wpconfig.contains("DB_NAME"), "wp-config.php missing expected define"); + println!("read wp-config.php ({} bytes)", wpconfig.len()); + + // Write a harmless comment back, confirm it persists, confirm the site still + // serves (valid PHP preserved), then restore the original. + let marker = "// localkit smoke marker"; + let edited = format!("{}\n{marker}\n", wpconfig.trim_end()); + wordpress::write_wp_config(&dir, svc, &edited).await?; + let reread = wordpress::read_wp_config(&dir, svc).await?; + assert!(reread.contains(marker), "wp-config.php edit did not persist"); + let home = http_code(&format!("http://localhost:{}/", s.port)); + assert!(["200", "301", "302"].contains(&home.as_str()), "site broke after wp-config write: {home}"); + wordpress::write_wp_config(&dir, svc, &wpconfig).await?; + let restored = wordpress::read_wp_config(&dir, svc).await?; + assert!(!restored.contains(marker), "wp-config.php was not restored"); + println!("wp-config.php write round-trip OK"); + + // .env is a plain host file. + let env = site::read_env_file(&dir)?; + assert!(env.contains("WP_PORT"), ".env missing WP_PORT"); + site::write_env_file(&dir, &env)?; // no-op rewrite, must not error + println!("read/write .env OK"); + + println!("CONFIG OK (wp-config.php cp round-trip + .env)"); + Ok(()) +} + +/// Adminer sidecar verification (plan 24): rewrite the compose file to add the +/// profile-gated `adminer` service (the smoke site predates the feature), start +/// it on demand, and assert it serves its login page on db_port + 1000. Stops +/// just Adminer afterward. +async fn adminer_smoke(state: &AppState) -> 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<site::Site> = { + 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<String, String> { + 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<String, String> { + 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<site::Site, String> { + 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<String, String> { + 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::<Vec<_>>() + .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<String>, + /// Remote site to target (numeric id or name; defaults to the link) + #[arg(long)] + remote_site: Option<String>, + /// 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<String>, + /// Remote site to target (numeric id or name; defaults to the link) + #[arg(long)] + remote_site: Option<String>, + /// 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<String>, + /// 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<String>, - /// PHP version (allowlist lives in the app) + /// PHP version (allowlist lives in the app; ignored with --blueprint) #[arg(long)] php_version: Option<String>, + /// For --kind php: import an existing PHP project folder instead of an + /// empty Laravel-ready skeleton + #[arg(long)] + from: Option<PathBuf>, + /// Create from a saved blueprint (its id or name) instead of a blank install + #[arg(long)] + blueprint: Option<String>, + /// 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<String>, + /// 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<String>, + /// 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<String>, + /// 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<String>, + /// 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 <id>.lkbp in the current directory) + #[arg(short, long)] + output: Option<PathBuf>, + }, + + /// 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<String>) -> Self { + Self { message: message.into(), code: 1 } + } + /// Exit code 2 — the remote rejected the operation (see `sync_err`). + fn rejected(message: impl Into<String>) -> Self { + Self { message: message.into(), code: 2 } } +} - let state = make_state(cli)?; +impl From<String> 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 <name>`.", 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 <site>` 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!("{:<w$} ", dim(h), w = w[i]); + } + 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!("{:<w$}", c, w = w[i]); + let cell = match (i, c.as_str()) { + (1, "running") => 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<String>, + php_version: &Option<String>, + 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 <name>)")?; + + // 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 <site id>` 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 <site> <name>`.", + 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<String>, + 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<ServerKitConnection, String> { + 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 <name> <url>`."; + +fn load_connections(state: &AppState) -> Result<Vec<ServerKitConnection>, 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<ServerKitConnection, String> { + 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::<Vec<_>>().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<i64, String> { + if let Ok(id) = query.parse::<i64>() { + 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::<Vec<_>>() + .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<String, String> { + 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 <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<ConnectionView> = 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 <name> <url>`.", + 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 <name>`", 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 <connection>` — 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 <name>`.", 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<ServerKitConnection>, + site: &site::Site, + flag: Option<&str>, +) -> Result<ServerKitConnection, String> { + 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!("{:<w$} ", dim(h), w = w[i]); + match conns.len() { + 0 => 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 <name>. available: {}", + site.slug, + conns.iter().map(|c| c.label.as_str()).collect::<Vec<_>>().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!("{:<w$}", c, w = w[i]); - let cell = match (i, c.as_str()) { - (1, "running") => 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<i64, String> { + 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 <id|name> (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<String> { + 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<SyncRecord, String> { + 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<String>, - php_version: &Option<String>, + 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<SyncRecord> = 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 <shell>` — 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<PathBuf>) -> 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:<port>"); + 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<AppState, String> { 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<const N: usize>(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!("{:<width$}", h, width = w[i]))); + } + println!(); + for r in rows { + for (i, c) in r.iter().enumerate() { + print!("{:<width$} ", c, width = w[i]); + } + println!(); + } +} + /// Render eval-able export lines for a shell. fn render_exports(shell: Shell, pairs: &[(String, String)]) -> 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<site::Site> { @@ -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 +//! <data dir>/blueprints/<slug>/ +//! 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<BlueprintPlugin>, + 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 `<base>`, `<base>-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<Blueprint, String> { + 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<Vec<Blueprint>, 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<Blueprint, String> { + 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::<Vec<_>>().join(", "); + if available.is_empty() { + Err(format!("no blueprint named `{query}` — there are none yet. save one with `lk blueprint save <site> <name>`.")) + } 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<String>, +) -> Result<Blueprint, String> { + 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<String>, + snapshot_id: &str, +) -> Result<Blueprint, String> { + // 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<String> { + 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<Vec<BlueprintPlugin>, 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<Blueprint, String> { + 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<R: std::io::Read>( + archive: &mut tar::Archive<R>, + 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<Blueprint, String> { + 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<String>, +) -> Result<site::Site, String> { + 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<site::Site, String> { + 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<String> { + 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<u8> { + 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<Site> { - 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<Vec<Site>, 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<bool, String> { + 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<ServerKitConnection, String> { - 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<Vec<ServerKitConnection>, 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<Vec<String>, 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<Vec<String>, 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<String, String> { + 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<String, String> { + 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<Option<(Instant, DockerStatus)>> { + static CACHE: OnceLock<Mutex<Option<(Instant, DockerStatus)>>> = 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 <profile> up +/// -d <service>`. 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<String, String> { + compose(dir, &["config", "--format", "json"]).await +} + /// Run a one-off command in a compose service, e.g. wp-cli: /// `docker compose run --rm -T <service> <args...>` pub async fn compose_run(dir: &Path, service: &str, args: &[&str]) -> Result<String, String> { @@ -130,6 +200,18 @@ pub async fn compose_run(dir: &Path, service: &str, args: &[&str]) -> Result<Str compose(dir, &full).await } +/// Like `compose_run`, but runs the one-off container as **root** +/// (`--user root`). Needed for commands that mutate root-owned files inside a +/// named volume: the wordpress image writes `wp-config.php` as root into the +/// `wp-data` volume, so the cli image's default `www-data` user cannot edit it — +/// `wp config set` fails with "wp-config.php is not writable" (plan 24). The +/// caller must also pass wp-cli's `--allow-root` (it refuses root otherwise). +pub async fn compose_run_root(dir: &Path, service: &str, args: &[&str]) -> Result<String, String> { + 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<String, String> { + 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<u8>` 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<String, String> { 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<St compose(dir, &full).await } +/// Start a single service and wait for it to be healthy/running: +/// `docker compose up -d --wait <service>`. 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<String, String> { + let env_flags: Vec<String> = 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<String, String> { + use tokio::io::AsyncWriteExt; + if !dir.exists() { + return Err(format!("site directory not found: {}", dir.display())); + } + let env_flags: Vec<String> = 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 <service>:<src> <dest>` 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 <src> <service>:<dest>`. The copy runs through the Docker +/// daemon (root), so it can overwrite a root-owned file inside a volume that the +/// cli user cannot — which is why the config editor writes `wp-config.php` this +/// way rather than piping into the container (plan 24). +pub async fn compose_cp_into(dir: &Path, src: &Path, service: &str, dest: &str) -> Result<(), String> { + let src_arg = src.to_string_lossy().to_string(); + let to = format!("{service}:{dest}"); + compose(dir, &["cp", &src_arg, &to]).await.map(|_| ()) +} + pub async fn compose_ps(dir: &Path) -> Result<Vec<ContainerInfo>, String> { let stdout = compose(dir, &["ps", "--format", "json"]).await?; parse_ps(&stdout) } +/// Ground-truth container states for every LocalKit compose project, from a +/// single `docker ps` pass (plan 23). One subprocess for all sites beats N +/// per-site `compose ps` calls every reconcile tick. Keyed by compose project +/// name (`com.docker.compose.project`, which is `localkit-<slug>` for every +/// LocalKit site — WordPress via the compose `name:`, docker apps via +/// `COMPOSE_PROJECT_NAME`). A project with no containers is simply absent from +/// the map. `--all` so exited containers are visible (a stopped project reads +/// as present-but-down, not gone). +pub async fn project_container_states() -> Result<HashMap<String, Vec<ContainerInfo>>, String> { + let output = no_window(Command::new("docker").args(["ps", "--all", "--format", "json"])) + .output() + .await + .map_err(|e| format!("failed to run docker ps: {e}"))?; + if !output.status.success() { + return Err(friendly_error(&String::from_utf8_lossy(&output.stderr))); + } + Ok(parse_ps_projects(&String::from_utf8_lossy(&output.stdout))) +} + +/// Read one value out of a `docker ps` `Labels` string +/// (`k1=v1,k2=v2,...`). Returns the first match, `None` if the key is absent. +fn label_value(labels: &str, key: &str) -> Option<String> { + labels.split(',').find_map(|kv| { + let (k, v) = kv.split_once('=')?; + (k.trim() == key).then(|| v.trim().to_string()) + }) +} + +#[derive(Deserialize)] +struct PsProjectEntry { + #[serde(rename = "Labels")] + labels: Option<String>, + #[serde(rename = "Service")] + service: Option<String>, + #[serde(rename = "State")] + state: Option<String>, + #[serde(rename = "Status")] + status: Option<String>, +} + +/// Group `docker ps --format json` rows by their compose project. Accepts both +/// a JSON array and NDJSON (older CLIs), and skips any row without the compose +/// project/service labels (a non-LocalKit container). The service comes from +/// the compose label; some CLIs also expose a bare `Service` field, used as a +/// fallback. +fn parse_ps_projects(stdout: &str) -> HashMap<String, Vec<ContainerInfo>> { + let trimmed = stdout.trim(); + let mut map: HashMap<String, Vec<ContainerInfo>> = HashMap::new(); + if trimmed.is_empty() { + return map; + } + let entries: Vec<PsProjectEntry> = if trimmed.starts_with('[') { + serde_json::from_str(trimmed).unwrap_or_default() + } else { + trimmed + .lines() + .filter_map(|l| serde_json::from_str::<PsProjectEntry>(l.trim()).ok()) + .collect() + }; + for entry in entries { + let labels = entry.labels.unwrap_or_default(); + let Some(project) = label_value(&labels, "com.docker.compose.project") else { + continue; + }; + let Some(service) = label_value(&labels, "com.docker.compose.service") + .or(entry.service) + else { + continue; + }; + map.entry(project).or_default().push(ContainerInfo { + service, + state: entry.state.unwrap_or_default(), + status: entry.status.unwrap_or_default(), + }); + } + map +} + #[derive(Deserialize)] struct PsEntry { #[serde(rename = "Service")] @@ -241,3 +536,50 @@ fn parse_ps(stdout: &str) -> Result<Vec<ContainerInfo>, String> { Ok(out) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn label_value_reads_one_compose_label() { + let labels = "com.docker.compose.project=localkit-blog,com.docker.compose.service=wordpress,foo=bar"; + assert_eq!(label_value(labels, "com.docker.compose.project").as_deref(), Some("localkit-blog")); + assert_eq!(label_value(labels, "com.docker.compose.service").as_deref(), Some("wordpress")); + assert_eq!(label_value(labels, "missing"), None); + } + + #[test] + fn parse_ps_projects_groups_ndjson_by_project_and_skips_strays() { + // Two LocalKit projects plus a non-compose container (no labels) that + // must be ignored. + let stdout = concat!( + r#"{"Labels":"com.docker.compose.project=localkit-blog,com.docker.compose.service=wordpress","State":"running","Status":"Up 3 minutes"}"#, "\n", + r#"{"Labels":"com.docker.compose.project=localkit-blog,com.docker.compose.service=db","State":"running","Status":"Up 3 minutes (healthy)"}"#, "\n", + r#"{"Labels":"com.docker.compose.project=localkit-api,com.docker.compose.service=app","State":"exited","Status":"Exited (0) 1 minute ago"}"#, "\n", + r#"{"Labels":"maintainer=someone","State":"running","Status":"Up"}"#, "\n", + ); + let map = parse_ps_projects(stdout); + assert_eq!(map.len(), 2); + let blog = &map["localkit-blog"]; + assert_eq!(blog.len(), 2); + assert!(blog.iter().any(|c| c.service == "wordpress" && c.state == "running")); + let api = &map["localkit-api"]; + assert_eq!(api.len(), 1); + assert_eq!(api[0].service, "app"); + assert_eq!(api[0].state, "exited"); + } + + #[test] + fn parse_ps_projects_accepts_a_json_array_too() { + let stdout = r#"[{"Labels":"com.docker.compose.project=localkit-x,com.docker.compose.service=web","State":"restarting","Status":"Restarting (1) 2 seconds ago"}]"#; + let map = parse_ps_projects(stdout); + assert_eq!(map["localkit-x"][0].state, "restarting"); + } + + #[test] + fn parse_ps_projects_handles_empty_output() { + assert!(parse_ps_projects("").is_empty()); + assert!(parse_ps_projects(" \n ").is_empty()); + } +} diff --git a/src-tauri/src/dockerapp.rs b/src-tauri/src/dockerapp.rs new file mode 100644 index 0000000..767ea17 --- /dev/null +++ b/src-tauri/src/dockerapp.rs @@ -0,0 +1,474 @@ +//! Generic "bring your own compose" Docker app kind (plan 22 phase 2). +//! +//! Unlike a WordPress site, LocalKit does not generate the compose project — it +//! **copies** an existing one the user points at into the managed site dir +//! (owned, not referenced: an external dir is a backup/locking nightmare). The +//! user picks which service is the app and its published port; LocalKit records +//! that in `SiteConfig` and everything the Phase-1 de-hardcoding unlocked — +//! lifecycle, logs, terminal, local domain, tray, `lk` — works for free. +//! +//! DB detection is captured (`config.db_engine`/`db_service`) but a docker app +//! stays code-only for now: engine-native dumps are a follow-up, so `db_sync` +//! stays off (see `site::Capabilities::for_kind`). + +use serde::Serialize; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +use crate::{ + docker, router, + site::{self, SiteConfig}, + AppState, +}; + +/// Directory names excluded from the copy by default — heavy and regenerable. +/// The import can opt out (`include_all`) to copy them anyway. +pub const DEFAULT_EXCLUDES: &[&str] = &[".git", "node_modules", "vendor"]; + +/// Compose filenames LocalKit looks for, in Docker's own precedence order. +const COMPOSE_NAMES: &[&str] = &[ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +]; + +/// Map a recognized database image to its engine tag. Matched on the repository +/// component so `mariadb`, `bitnami/mariadb` and `docker.io/library/mysql:8` +/// all resolve. `None` = not a database we know how to dump. +fn detect_db_engine(image: &str) -> Option<&'static str> { + let img = image.to_lowercase(); + // Drop any registry/namespace prefix and the tag/digest. + let repo = img.rsplit('/').next().unwrap_or(&img); + let repo = repo.split(['@', ':']).next().unwrap_or(repo); + if repo == "mariadb" { + Some("mariadb") + } else if repo == "mysql" { + Some("mysql") + } else if repo == "postgres" || repo == "postgresql" { + Some("postgres") + } else { + None + } +} + +/// A service found in the compose project (for the import dialog's picker). +#[derive(Debug, Clone, Serialize)] +pub struct DockerService { + pub name: String, + pub image: String, + /// Host ports this service publishes; the first is the suggested app port. + pub published_ports: Vec<u16>, + /// The recognized DB engine tag when this service is a database. + pub db_engine: Option<String>, +} + +/// What the import dialog needs to know about a chosen folder before creating. +#[derive(Debug, Clone, Serialize)] +pub struct DockerProjectInspection { + /// The compose file that was found (bare filename). + pub compose_file: String, + pub services: Vec<DockerService>, + /// Suggested app service: the first non-DB service that publishes a port. + pub suggested_service: Option<String>, + pub suggested_port: Option<u16>, + /// The recognized DB engine among the services (captured, not yet synced). + pub db_engine: Option<String>, + pub db_service: Option<String>, + /// Bytes to copy after applying the default excludes — shown before the + /// user confirms, so a huge project is not copied by surprise. + pub copy_bytes: u64, + /// The default excludes applied to that estimate. + pub excluded: Vec<String>, +} + +fn find_compose(dir: &Path) -> Option<String> { + COMPOSE_NAMES + .iter() + .find(|n| dir.join(n).is_file()) + .map(|s| s.to_string()) +} + +/// Pull `published` out of a `docker compose config` port entry, which may be a +/// string (`"8080"`) or a bare number depending on the source compose. +fn parse_published(value: &serde_json::Value) -> Option<u16> { + match value { + serde_json::Value::String(s) => s.split('/').next()?.parse().ok(), + serde_json::Value::Number(n) => u16::try_from(n.as_u64()?).ok(), + _ => None, + } +} + +/// Parse the normalized `docker compose config --format json` into services. +fn parse_services(config: &serde_json::Value) -> Vec<DockerService> { + let Some(services) = config.get("services").and_then(|s| s.as_object()) else { + return Vec::new(); + }; + let mut out: Vec<DockerService> = services + .iter() + .map(|(name, svc)| { + let image = svc + .get("image") + .and_then(|i| i.as_str()) + .unwrap_or_default() + .to_string(); + let published_ports = svc + .get("ports") + .and_then(|p| p.as_array()) + .map(|ports| { + ports + .iter() + .filter_map(|p| p.get("published").and_then(parse_published)) + .collect() + }) + .unwrap_or_default(); + DockerService { + name: name.clone(), + image: image.clone(), + published_ports, + db_engine: detect_db_engine(&image).map(str::to_string), + } + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Recursively sum file sizes under `dir`, skipping any directory or file whose +/// name is in `excludes` (checked at every level, so a nested `node_modules` +/// is skipped too). Best-effort — an unreadable entry counts as zero. +fn dir_size(dir: &Path, excludes: &[&str]) -> u64 { + let mut total = 0u64; + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if excludes.iter().any(|e| *e == name.to_string_lossy()) { + continue; + } + match entry.file_type() { + Ok(ft) if ft.is_dir() => total += dir_size(&entry.path(), excludes), + Ok(ft) if ft.is_file() => { + if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } + _ => {} + } + } + total +} + +/// Inspect a candidate Docker project folder for the import dialog. +pub async fn inspect(source_dir: &Path) -> Result<DockerProjectInspection, String> { + if !source_dir.is_dir() { + return Err(format!("{} is not a folder", source_dir.display())); + } + let compose_file = find_compose(source_dir).ok_or_else(|| { + "no compose file found — the folder needs a docker-compose.yml or compose.yml".to_string() + })?; + + let json = docker::compose_config(source_dir).await.map_err(|e| { + format!("the compose project could not be read (is it valid?): {e}") + })?; + let value: serde_json::Value = + serde_json::from_str(&json).map_err(|e| format!("could not parse the compose project: {e}"))?; + let services = parse_services(&value); + if services.is_empty() { + return Err("the compose project defines no services".into()); + } + + let db = services.iter().find(|s| s.db_engine.is_some()); + let app = services + .iter() + .find(|s| s.db_engine.is_none() && !s.published_ports.is_empty()); + + Ok(DockerProjectInspection { + compose_file, + db_engine: db.and_then(|s| s.db_engine.clone()), + db_service: db.map(|s| s.name.clone()), + suggested_service: app.map(|s| s.name.clone()), + suggested_port: app.and_then(|s| s.published_ports.first().copied()), + copy_bytes: dir_size(source_dir, DEFAULT_EXCLUDES), + excluded: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(), + services, + }) +} + +/// Recursively copy `src` into `dst`, skipping names in `excludes` and symlinks +/// (a symlink could point outside the tree — refuse it rather than follow it). +/// Shared with the plan-26 php import ("bring your own code into a generated +/// stack" is the same copy problem as importing a whole compose project). +pub(crate) fn copy_tree(src: &Path, dst: &Path, excludes: &[&str]) -> Result<(), String> { + std::fs::create_dir_all(dst) + .map_err(|e| format!("failed to create {}: {e}", dst.display()))?; + let entries = + std::fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))?; + for entry in entries.flatten() { + let name = entry.file_name(); + if excludes.iter().any(|e| *e == name.to_string_lossy()) { + continue; + } + let from = entry.path(); + let to = dst.join(&name); + match entry.file_type() { + Ok(ft) if ft.is_dir() => copy_tree(&from, &to, excludes)?, + Ok(ft) if ft.is_file() => { + std::fs::copy(&from, &to) + .map_err(|e| format!("failed to copy {}: {e}", from.display()))?; + } + // Skip symlinks, sockets, fifos — a compose project is plain files. + _ => {} + } + } + Ok(()) +} + +/// Give the copied project a deterministic compose project name via `.env` +/// (`COMPOSE_PROJECT_NAME=localkit-<slug>`). Appends to an existing `.env` +/// rather than clobbering it, and only if the key is not already set. +fn ensure_project_name_env(dir: &Path, slug: &str) -> Result<(), String> { + let env_path = dir.join(".env"); + let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); + if existing + .lines() + .any(|l| l.trim_start().starts_with("COMPOSE_PROJECT_NAME")) + { + return Ok(()); + } + let mut content = existing; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&format!("COMPOSE_PROJECT_NAME=localkit-{slug}\n")); + std::fs::write(&env_path, content).map_err(|e| format!("failed to write .env: {e}")) +} + +/// Import a Docker project as a new local site. +/// +/// Copies the folder into a managed site dir, records the chosen app service + +/// port (and any detected DB engine) in `SiteConfig`, then brings it up. On any +/// failure after the site is reserved, the half-built site is cleaned up +/// wholesale, exactly like the WordPress create/import flows. +pub async fn import_project( + app: Option<&AppHandle>, + state: &AppState, + name: String, + source_dir: PathBuf, + service: String, + app_port: u16, + include_all: bool, +) -> Result<site::Site, String> { + // Re-inspect at import time: it validates the folder and recaptures the DB + // engine, so a stale dialog cannot smuggle in a bad service/port. + let inspection = inspect(&source_dir).await?; + if !inspection.services.iter().any(|s| s.name == service) { + return Err(format!( + "no service named `{service}` in the compose project" + )); + } + if app_port == 0 { + return Err("the app port must be between 1 and 65535".into()); + } + + let config = SiteConfig { + service, + // A docker app's "code" is the whole copied project. + sync_path: ".".to_string(), + app_port: Some(app_port), + db_engine: inspection.db_engine.clone(), + db_service: inspection.db_service.clone(), + }; + + let site = site::reserve( + state, + name, + site::KIND_DOCKER.to_string(), + String::new(), + String::new(), + config, + None, + ) + .await?; + + // Own this site's status until the import finishes (plan 23). + let _guard = state.in_flight.guard(&site.id); + let excludes: &[&str] = if include_all { &[] } else { DEFAULT_EXCLUDES }; + match do_import(app, state, &site, &source_dir, excludes, app_port).await { + Ok(site) => { + let url = router::site_public_url(state, &site); + site::emit( + app, + &site.id, + "done", + &format!("{} imported — now running at {url}", site.name), + ); + Ok(site) + } + Err(e) => { + site::emit(app, &site.id, "error", &format!("Import failed: {e}")); + let _ = site::cleanup(state, &site).await; + Err(e) + } + } +} + +async fn do_import( + app: Option<&AppHandle>, + state: &AppState, + site: &site::Site, + source_dir: &Path, + excludes: &[&str], + app_port: u16, +) -> Result<site::Site, String> { + let dir = site.dir(); + let id = site.id.as_str(); + + site::emit(app, id, "files", "Copying the Docker project..."); + copy_tree(source_dir, &dir, excludes)?; + ensure_project_name_env(&dir, &site.slug)?; + + site::emit( + app, + id, + "pulling", + "Pulling images (first run can take a few minutes)...", + ); + // Best-effort: `up` pulls anything still missing, so a registry hiccup here + // must not fail the import outright. + let _ = docker::compose_pull_all(&dir).await; + + site::emit(app, id, "containers", "Starting containers..."); + docker::compose_up(&dir).await?; + + // Wait for the app's published port, but don't fail the import if it never + // answers: a generic app may be a worker, a slow starter, or non-HTTP. + site::emit(app, id, "waiting", "Waiting for the app to come online..."); + let _ = site::wait_for_port(app_port, 120).await; + + let mut running = site.clone(); + running.status = "running".into(); + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + } + // Last step: the completion marker (plan 23) — its absence flags a killed + // import. + site::mark_complete(&dir); + // A docker app is an ordinary site to the router/tray — it gets a domain. + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + Ok(running) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_database_images_by_repository() { + assert_eq!(detect_db_engine("mariadb:11"), Some("mariadb")); + assert_eq!(detect_db_engine("mysql:8.0"), Some("mysql")); + assert_eq!(detect_db_engine("postgres:16-alpine"), Some("postgres")); + assert_eq!(detect_db_engine("bitnami/postgresql:16"), Some("postgres")); + assert_eq!(detect_db_engine("docker.io/library/mariadb:latest"), Some("mariadb")); + // Not a database, and not a false positive on a lookalike name. + assert_eq!(detect_db_engine("nginx:latest"), None); + assert_eq!(detect_db_engine("my-mysql-admin:1"), None); + assert_eq!(detect_db_engine("postgrest/postgrest"), None); + } + + #[test] + fn parses_services_ports_and_db_engine_from_compose_config() { + let json = serde_json::json!({ + "services": { + "web": { "image": "nginx:latest", "ports": [ + { "target": 80, "published": "8091", "protocol": "tcp" } + ]}, + "db": { "image": "postgres:16-alpine" }, + "worker": { "image": "python:3.12" } + } + }); + let services = parse_services(&json); + // Sorted by name. + assert_eq!(services.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["db", "web", "worker"]); + let web = services.iter().find(|s| s.name == "web").unwrap(); + assert_eq!(web.published_ports, vec![8091]); + assert!(web.db_engine.is_none()); + let db = services.iter().find(|s| s.name == "db").unwrap(); + assert_eq!(db.db_engine.as_deref(), Some("postgres")); + assert!(db.published_ports.is_empty()); + } + + #[test] + fn parses_a_numeric_published_port() { + let json = serde_json::json!({ + "services": { "app": { "image": "caddy", "ports": [ { "published": 3000, "target": 3000 } ] } } + }); + let services = parse_services(&json); + assert_eq!(services[0].published_ports, vec![3000]); + } + + #[test] + fn dir_size_applies_the_ignore_list_at_every_level() { + let root = std::env::temp_dir().join(format!("lk-dockerapp-size-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir_all(root.join("node_modules/pkg")).unwrap(); + std::fs::create_dir_all(root.join("src/node_modules")).unwrap(); + std::fs::write(root.join("compose.yml"), vec![0u8; 100]).unwrap(); + std::fs::write(root.join("src/app.js"), vec![0u8; 50]).unwrap(); + std::fs::write(root.join("node_modules/pkg/index.js"), vec![0u8; 9999]).unwrap(); + std::fs::write(root.join("src/node_modules/dep.js"), vec![0u8; 8888]).unwrap(); + + // Only compose.yml (100) + src/app.js (50) count; both node_modules skip. + assert_eq!(dir_size(&root, DEFAULT_EXCLUDES), 150); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn copy_tree_skips_excluded_dirs_and_reproduces_the_rest() { + let base = std::env::temp_dir().join(format!("lk-dockerapp-copy-{}", std::process::id())); + let src = base.join("src"); + let dst = base.join("dst"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(src.join("app")).unwrap(); + std::fs::create_dir_all(src.join(".git")).unwrap(); + std::fs::create_dir_all(src.join("node_modules")).unwrap(); + std::fs::write(src.join("docker-compose.yml"), b"services: {}").unwrap(); + std::fs::write(src.join("app/main.py"), b"print(1)").unwrap(); + std::fs::write(src.join(".git/HEAD"), b"ref").unwrap(); + std::fs::write(src.join("node_modules/x.js"), b"x").unwrap(); + + copy_tree(&src, &dst, DEFAULT_EXCLUDES).unwrap(); + assert!(dst.join("docker-compose.yml").is_file()); + assert_eq!(std::fs::read(dst.join("app/main.py")).unwrap(), b"print(1)"); + assert!(!dst.join(".git").exists(), ".git must be excluded"); + assert!(!dst.join("node_modules").exists(), "node_modules must be excluded"); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn ensure_project_name_env_appends_without_clobbering() { + let dir = std::env::temp_dir().join(format!("lk-dockerapp-env-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(".env"), "EXISTING=1").unwrap(); + + ensure_project_name_env(&dir, "my-api").unwrap(); + let content = std::fs::read_to_string(dir.join(".env")).unwrap(); + assert!(content.contains("EXISTING=1"), "existing keys preserved"); + assert!(content.contains("COMPOSE_PROJECT_NAME=localkit-my-api")); + + // Idempotent: a second call does not duplicate the key. + ensure_project_name_env(&dir, "my-api").unwrap(); + let content = std::fs::read_to_string(dir.join(".env")).unwrap(); + assert_eq!(content.matches("COMPOSE_PROJECT_NAME").count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/keystore.rs b/src-tauri/src/keystore.rs new file mode 100644 index 0000000..77f8c0d --- /dev/null +++ b/src-tauri/src/keystore.rs @@ -0,0 +1,114 @@ +//! OS keyring storage for ServerKit API keys (plan 25). +//! +//! Keys live in the platform credential store — Windows Credential Manager, +//! macOS Keychain, Linux Secret Service — under service `localkit`, account +//! `connection/<id>`. This replaces the plaintext `serverkit_connections.api_key` +//! column as the source of truth; `db.rs` migrates legacy plaintext keys into +//! the keyring the first time a connection is read. +//! +//! **Graceful degradation is the whole contract.** On a machine with no +//! keyring — headless Linux, a locked keychain, or the `LOCALKIT_DISABLE_KEYRING` +//! escape hatch — every call here is a no-op that returns "not stored", so the +//! caller falls back to the SQLite column instead of failing hard. `lk` on a +//! server keeps working; the only cost is the key sits in the DB as before. + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Credential-store service name. The account within it is `connection/<id>`. +const SERVICE: &str = "localkit"; + +/// So the "keyring unavailable" note is logged once per process, not per read. +static WARNED: AtomicBool = AtomicBool::new(false); + +/// Force the SQLite fallback. Set for hermetic tests and by anyone who would +/// rather keep keys in the local DB (e.g. a shared service account on a box +/// whose keyring can't be unlocked non-interactively). +fn disabled() -> bool { + std::env::var_os("LOCALKIT_DISABLE_KEYRING").is_some() +} + +fn account(connection_id: &str) -> String { + format!("connection/{connection_id}") +} + +fn entry(connection_id: &str) -> Option<keyring::Entry> { + if disabled() { + return None; + } + match keyring::Entry::new(SERVICE, &account(connection_id)) { + Ok(e) => Some(e), + Err(e) => { + warn_once(&e.to_string()); + None + } + } +} + +fn warn_once(msg: &str) { + if !WARNED.swap(true, Ordering::Relaxed) { + eprintln!( + "[keystore] OS keyring unavailable — ServerKit API keys will be kept \ + in the local database instead: {msg}" + ); + } +} + +/// Store `key` for `connection_id`. Returns `true` only when it actually +/// landed in the keyring; `false` tells the caller to fall back to SQLite. +pub fn store(connection_id: &str, key: &str) -> bool { + let Some(entry) = entry(connection_id) else { + return false; + }; + match entry.set_password(key) { + Ok(()) => true, + Err(e) => { + warn_once(&e.to_string()); + false + } + } +} + +/// Retrieve the key for `connection_id`, or `None` if it isn't in the keyring +/// (never stored there, or the keyring is unavailable). +pub fn retrieve(connection_id: &str) -> Option<String> { + let entry = entry(connection_id)?; + match entry.get_password() { + Ok(k) => Some(k), + Err(keyring::Error::NoEntry) => None, + Err(e) => { + warn_once(&e.to_string()); + None + } + } +} + +/// Best-effort delete of a connection's key. A missing entry is success — +/// removing a key that was never in the keyring (SQLite-fallback machine) is a +/// no-op, not an error. +pub fn delete(connection_id: &str) { + let Some(entry) = entry(connection_id) else { + return; + }; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) => warn_once(&e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// With the escape hatch set, every operation degrades to the "not stored" + /// path — this is exactly the headless-server contract, and it's what keeps + /// the connection round-trip tests in `db.rs` hermetic on CI. + #[test] + fn disabled_keyring_is_a_no_op() { + // SAFETY: single-threaded test; no other code reads the var concurrently. + std::env::set_var("LOCALKIT_DISABLE_KEYRING", "1"); + assert!(!store("keystore-test-id", "secret")); + assert_eq!(retrieve("keystore-test-id"), None); + delete("keystore-test-id"); // must not panic + std::env::remove_var("LOCALKIT_DISABLE_KEYRING"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd32bb5..d1b336f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,20 @@ +pub mod blueprint; pub mod db; +pub mod dbsync; pub mod docker; +pub mod dockerapp; +pub mod keystore; +pub mod php; +pub mod reconcile; pub mod router; pub mod serverkit; pub mod site; +pub mod snapshot; pub mod sync; pub mod terminal; +pub mod transfer; pub mod tray; +pub mod update; pub mod wordpress; use std::path::PathBuf; @@ -21,6 +30,20 @@ pub struct AppState { pub db: Mutex<Db>, pub data_dir: PathBuf, pub terminals: terminal::PtyManager, + /// Cancel flags for in-flight chunked syncs, keyed by site id (plan 19). + pub transfers: transfer::CancelRegistry, + /// Sites with an in-flight lifecycle command, shared with every command + /// path so the reconciler skips them (plan 23). + pub in_flight: reconcile::InFlight, +} + +/// The base capability set advertised for a kind (plan 22). `docker`'s +/// `db_sync` is the code-only default here — it flips on per-site when a +/// recognized DB engine is in the compose. +#[derive(Serialize)] +struct KindInfo { + kind: String, + capabilities: site::Capabilities, } #[derive(Serialize)] @@ -29,11 +52,23 @@ struct AppInfo { sites_dir: String, wp_versions: Vec<String>, php_versions: Vec<String>, + /// Every site kind and the capabilities it claims, so both frontends can + /// gate UI on the same matrix the backend enforces. + kinds: Vec<KindInfo>, +} + +#[tauri::command] +async fn check_docker(force: Option<bool>) -> docker::DockerStatus { + // Cached for 30 s (plan 23); the sidebar polls this. `force` re-checks now. + docker::check_cached(force.unwrap_or(false)).await } +/// Check GitHub for a newer release (plan 25). Never downloads — the frontend +/// links to the release page. Throttle/snooze live in the settings KV, so this +/// command is a pure "check now". #[tauri::command] -async fn check_docker() -> docker::DockerStatus { - docker::check().await +async fn check_for_update() -> Result<update::UpdateInfo, String> { + update::check().await } #[tauri::command] @@ -43,6 +78,20 @@ fn app_info(state: State<AppState>) -> AppInfo { sites_dir: state.data_dir.join("sites").to_string_lossy().to_string(), wp_versions: site::WP_VERSIONS.iter().map(|s| s.to_string()).collect(), php_versions: site::PHP_VERSIONS.iter().map(|s| s.to_string()).collect(), + kinds: vec![ + KindInfo { + kind: site::KIND_WORDPRESS.to_string(), + capabilities: site::Capabilities::WORDPRESS, + }, + KindInfo { + kind: site::KIND_DOCKER.to_string(), + capabilities: site::Capabilities::DOCKER, + }, + KindInfo { + kind: site::KIND_PHP.to_string(), + capabilities: site::Capabilities::PHP, + }, + ], } } @@ -69,6 +118,83 @@ async fn create_site( Ok(site) } +/// Inspect a folder as a candidate Docker project (plan 22): its services, +/// suggested app service + port, DB engine, and copy size. Read-only. +#[tauri::command] +async fn inspect_docker_project( + path: String, +) -> Result<dockerapp::DockerProjectInspection, String> { + dockerapp::inspect(std::path::Path::new(&path)).await +} + +/// Import a Docker project as a new local site (plan 22): copy the folder into a +/// managed site dir, record the app service/port, and bring it up. +#[tauri::command] +async fn import_docker_project( + app: AppHandle, + state: State<'_, AppState>, + name: String, + path: String, + service: String, + app_port: u16, + include_all: Option<bool>, +) -> Result<Site, String> { + let site = dockerapp::import_project( + Some(&app), + &state, + name, + std::path::PathBuf::from(path), + service, + app_port, + include_all.unwrap_or(false), + ) + .await?; + tray::refresh(&app); + Ok(site) +} + +/// Create a new PHP/Laravel stack site (plan 26): an empty Laravel-ready +/// skeleton, or (with `path`) importing an existing PHP project folder. +#[tauri::command] +async fn create_php_site( + app: AppHandle, + state: State<'_, AppState>, + name: String, + php_version: String, + path: Option<String>, + include_all: Option<bool>, +) -> Result<Site, String> { + let source = path + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .map(std::path::PathBuf::from); + let site = php::create_php_site( + Some(&app), + &state, + name, + php_version, + source, + include_all.unwrap_or(false), + ) + .await?; + tray::refresh(&app); + Ok(site) +} + +/// Clone an existing local site into a brand-new one (plan 20). +#[tauri::command] +async fn clone_site( + app: AppHandle, + state: State<'_, AppState>, + id: String, + new_name: String, +) -> Result<Site, String> { + let site = site::clone_site(Some(&app), &state, &id, new_name).await?; + // A new running site has to reach the tray menu like any other. + tray::refresh(&app); + Ok(site) +} + #[tauri::command] async fn start_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<Site, String> { let site = site::start(&state, &id).await?; @@ -83,9 +209,24 @@ async fn stop_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Re Ok(site) } +/// Finish a half-created site (plan 23): re-run the create tail and mark it +/// complete. The "Resume setup" action on an incomplete site. #[tauri::command] -async fn delete_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<(), String> { - site::delete(&state, &id).await?; +async fn resume_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<Site, String> { + let site = site::resume(Some(&app), &state, &id).await?; + tray::refresh(&app); + Ok(site) +} + +#[tauri::command] +async fn delete_site( + app: AppHandle, + state: State<'_, AppState>, + id: String, + delete_snapshots: Option<bool>, +) -> Result<(), String> { + // Default: keep the snapshots (including the pre_delete one this takes). + site::delete(Some(&app), &state, &id, delete_snapshots.unwrap_or(false)).await?; tray::refresh(&app); Ok(()) } @@ -98,9 +239,226 @@ async fn site_logs(state: State<'_, AppState>, id: String, tail: Option<u32>) -> #[tauri::command] async fn wp_cli_info(state: State<'_, AppState>, id: String) -> Result<wordpress::WpInfo, String> { let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "WordPress info")?; wordpress::info(&s.dir()).await } +// --------------------------------------------------------------------------- +// Site tools (plan 24) — search-replace, debug mode + log, config editor +// --------------------------------------------------------------------------- + +/// Serialization-safe search-replace across all tables (plan 24). +/// +/// `dry_run` counts without writing — the UI runs it first so the cost is +/// visible before committing. An applied run (`dry_run = false`) takes a +/// `pre_search_replace` snapshot first, so it is reversible from the Snapshots +/// panel; the snapshot emits its own `snapshot` progress and this resolves the +/// pinned toast with a `done`/`error` of its own. +#[tauri::command] +async fn site_search_replace( + app: AppHandle, + state: State<'_, AppState>, + id: String, + from: String, + to: String, + dry_run: bool, +) -> Result<wordpress::SearchReplaceResult, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.search_replace, "Search & replace")?; + if from.is_empty() { + return Err("The search value is required.".into()); + } + + if dry_run { + // No snapshot, no events — a dry run mutates nothing. + return wordpress::search_replace_report(&s.dir(), &from, &to, true).await; + } + + if let Err(e) = snapshot::create( + Some(&app), + &state, + &id, + snapshot::KIND_PRE_SEARCH_REPLACE, + Some(format!("before replacing \"{from}\" → \"{to}\"")), + ) + .await + { + let msg = format!("Snapshot before search-replace failed, nothing was changed: {e}"); + site::emit(Some(&app), &id, "error", &msg); + return Err(msg); + } + + site::emit(Some(&app), &id, "search-replace", "Replacing across all tables..."); + match wordpress::search_replace_report(&s.dir(), &from, &to, false).await { + Ok(result) => { + site::emit( + Some(&app), + &id, + "done", + &format!( + "Replaced {} occurrence{} across {} column{}", + result.total, + if result.total == 1 { "" } else { "s" }, + result.changes.len(), + if result.changes.len() == 1 { "" } else { "s" }, + ), + ); + Ok(result) + } + Err(e) => { + site::emit(Some(&app), &id, "error", &format!("Search-replace failed: {e}")); + Err(e) + } + } +} + +/// Where Adminer opened, plus the DB login to pre-fill (plan 24). Adminer can't +/// take the password in the URL, so the frontend copies it to the clipboard. +#[derive(Serialize)] +struct AdminerInfo { + url: String, + username: String, + password: String, +} + +/// Start the Adminer database GUI for a site and return its URL + DB login. +/// +/// Adminer is a profile-gated service (off by default). Sites created before +/// this feature don't have it in their compose file, so the deterministic +/// template is rewritten first (leaving wordpress/db unchanged), then Adminer is +/// started on demand. The login uses the site's `wordpress` DB user — the root +/// password is random (`MYSQL_RANDOM_ROOT_PASSWORD`) and unknowable, so the plan +/// note about `username=root` can't apply here (plan 24). +#[tauri::command] +async fn open_site_database( + state: State<'_, AppState>, + id: String, +) -> Result<AdminerInfo, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.db_gui, "The database GUI")?; + let dir = s.dir(); + // Ensure the compose file carries the adminer service (deterministic render, + // so this is safe and leaves the running wordpress/db containers alone). + std::fs::write(dir.join("docker-compose.yml"), site::render_compose(&s)) + .map_err(|e| format!("failed to update docker-compose.yml: {e}"))?; + // Start Adminer; depends_on brings the db up if it isn't already. + docker::compose_up_profile_service(&dir, "tools", "adminer").await?; + // Keep the Caddyfile's db-<slug>.test route current (no-op/UAC-free; hosts + // entries are synced through the normal create/delete path). + router::refresh_routes(&state).await; + + let base = router::adminer_public_url(&state, &s); + Ok(AdminerInfo { + // Prefill the server + username + database; Adminer takes the password + // in its form, not the URL. + url: format!("{base}/?server=db&username=wordpress&db=wordpress"), + username: "wordpress".to_string(), + password: site::db_password(&dir), + }) +} + +/// WP_DEBUG state + debug-log size (plan 24). +#[tauri::command] +async fn site_debug_status( + state: State<'_, AppState>, + id: String, +) -> Result<wordpress::DebugStatus, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "Debug mode")?; + wordpress::debug_status(&s.dir()).await +} + +/// Toggle WP_DEBUG + WP_DEBUG_LOG (log to file, never to screen) (plan 24). +#[tauri::command] +async fn set_site_debug( + state: State<'_, AppState>, + id: String, + enabled: bool, +) -> Result<wordpress::DebugStatus, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "Debug mode")?; + wordpress::set_debug(&s.dir(), enabled).await +} + +/// Tail of `wp-content/debug.log` (plain host read — it is bind-mounted) (plan 24). +#[tauri::command] +fn read_site_debug_log(state: State<AppState>, id: String) -> Result<String, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "The debug log")?; + Ok(wordpress::read_debug_log(&s.dir())) +} + +/// Truncate the debug log (plan 24). +#[tauri::command] +fn clear_site_debug_log(state: State<AppState>, id: String) -> Result<(), String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "The debug log")?; + wordpress::clear_debug_log(&s.dir()) +} + +/// Confirm the site's app container is running (for actions that need a live +/// container — terminal, one-click login, wp-config editing). +async fn ensure_running(s: &Site) -> Result<(), String> { + let containers = docker::compose_ps(&s.dir()).await?; + if containers + .iter() + .any(|c| c.service == s.app_service() && c.state == "running") + { + Ok(()) + } else { + Err(format!("\"{}\" is not running — start the site first.", s.name)) + } +} + +/// Read a site config file for the editor: `file` is `wp-config` or `env` (plan 24). +#[tauri::command] +async fn read_site_config_file( + state: State<'_, AppState>, + id: String, + file: String, +) -> Result<String, String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "The config editor")?; + match file.as_str() { + // wp-config.php lives in the volume — copied out of the running container. + "wp-config" => { + ensure_running(&s).await?; + wordpress::read_wp_config(&s.dir(), s.app_service()).await + } + "env" => site::read_env_file(&s.dir()), + other => Err(format!("unknown config file: {other}")), + } +} + +/// Overwrite a site config file (plan 24). `.env` changes need a restart to take +/// effect (the editor offers one); `wp-config.php` is read live by PHP. +#[tauri::command] +async fn write_site_config_file( + state: State<'_, AppState>, + id: String, + file: String, + contents: String, +) -> Result<(), String> { + let s = site::get(&state, &id)?; + s.require(s.capabilities.wp_tools, "The config editor")?; + match file.as_str() { + "wp-config" => { + ensure_running(&s).await?; + wordpress::write_wp_config(&s.dir(), s.app_service(), &contents).await + } + "env" => site::write_env_file(&s.dir(), &contents), + other => Err(format!("unknown config file: {other}")), + } +} + +/// Restart a site (recreate) so an edited `.env` takes effect (plan 24). +#[tauri::command] +async fn restart_site(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<Site, String> { + let site = site::restart(&state, &id).await?; + tray::refresh(&app); + Ok(site) +} + // --------------------------------------------------------------------------- // One-click WP Admin login (one-time token + MU plugin) // --------------------------------------------------------------------------- @@ -112,10 +470,11 @@ async fn login_site( user_id: Option<u64>, ) -> Result<String, String> { let s = site::get(&state, &id)?; + s.require(s.capabilities.one_click_login, "One-click login")?; let containers = docker::compose_ps(&s.dir()).await?; if !containers .iter() - .any(|c| c.service == "wordpress" && c.state == "running") + .any(|c| c.service == s.app_service() && c.state == "running") { return Err(format!( "\"{}\" is not running — start the site first.", @@ -133,9 +492,111 @@ async fn site_wp_users( id: String, ) -> Result<Vec<wordpress::WpUser>, String> { let s = site::get(&state, &id)?; + s.require(s.capabilities.one_click_login, "The WordPress user list")?; wordpress::users(&s.dir()).await } +// --------------------------------------------------------------------------- +// Snapshots (plan 17) — point-in-time DB + wp-content copies with restore +// --------------------------------------------------------------------------- + +#[tauri::command] +fn list_snapshots(state: State<AppState>, site_id: String) -> Result<Vec<snapshot::Snapshot>, String> { + snapshot::list(&state, &site_id) +} + +#[tauri::command] +async fn create_snapshot( + app: AppHandle, + state: State<'_, AppState>, + site_id: String, + note: Option<String>, +) -> Result<snapshot::Snapshot, String> { + // Standalone snapshots own their terminal event: `create` deliberately + // stays silent on done/error so it can nest inside push/pull/delete. + match snapshot::create(Some(&app), &state, &site_id, snapshot::KIND_MANUAL, note).await { + Ok(snap) => { + site::emit( + Some(&app), + &site_id, + "done", + &format!("Snapshot of {} taken", snap.site_name), + ); + Ok(snap) + } + Err(e) => { + site::emit(Some(&app), &site_id, "error", &format!("Snapshot failed: {e}")); + Err(e) + } + } +} + +#[tauri::command] +async fn restore_snapshot( + app: AppHandle, + state: State<'_, AppState>, + site_id: String, + snapshot_id: String, +) -> Result<(), String> { + let result = snapshot::restore(Some(&app), &state, &site_id, &snapshot_id).await; + // Restore can auto-start a stopped site, so the tray must be rebuilt + // either way (a failure may still have started it). + tray::refresh(&app); + match result { + Ok(message) => { + site::emit(Some(&app), &site_id, "done", &message); + Ok(()) + } + Err(e) => { + site::emit(Some(&app), &site_id, "error", &format!("Restore failed: {e}")); + Err(e) + } + } +} + +#[tauri::command] +fn delete_snapshot(state: State<AppState>, site_id: String, snapshot_id: String) -> Result<(), String> { + snapshot::delete(&state, &site_id, &snapshot_id) +} + +// --------------------------------------------------------------------------- +// Blueprints (plan 20) — save a site as a reusable template, create from one +// --------------------------------------------------------------------------- + +#[tauri::command] +async fn save_blueprint( + app: AppHandle, + state: State<'_, AppState>, + site_id: String, + name: String, + description: Option<String>, +) -> Result<blueprint::Blueprint, String> { + blueprint::save(Some(&app), &state, &site_id, name, description).await +} + +#[tauri::command] +fn list_blueprints(state: State<AppState>) -> Result<Vec<blueprint::Blueprint>, String> { + blueprint::list(&state) +} + +#[tauri::command] +fn delete_blueprint(state: State<AppState>, id: String) -> Result<(), String> { + blueprint::delete(&state, &id) +} + +#[tauri::command] +async fn create_site_from_blueprint( + app: AppHandle, + state: State<'_, AppState>, + blueprint_id: String, + name: Option<String>, +) -> Result<Site, String> { + let site = blueprint::create_site(Some(&app), &state, &blueprint_id, name).await?; + // A new running site has to reach the tray menu like any other. + tray::refresh(&app); + Ok(site) +} + // --------------------------------------------------------------------------- // ServerKit connections (M3, read-only) // --------------------------------------------------------------------------- @@ -252,8 +713,35 @@ fn list_sync_history(state: State<AppState>, site_id: String) -> Result<Vec<sync sync::history(&state, &site_id) } +/// Ask the in-flight chunked sync for a site to stop (plan 19). +/// +/// Returns whether there was one to cancel. The transfer notices between +/// chunks and unwinds through the normal error path; nothing on the server is +/// half-applied, because processing only ever runs after a completed upload +/// verifies its hash. +#[tauri::command] +fn cancel_sync(state: State<AppState>, site_id: String) -> bool { + state.transfers.cancel(&site_id) +} + +/// Clone a remote ServerKit site down as a brand-new local site (plan 18). +#[tauri::command] +async fn import_remote_site( + app: AppHandle, + state: State<'_, AppState>, + connection_id: String, + remote_site_id: i64, + name: Option<String>, +) -> Result<Site, String> { + let site = sync::import_site(Some(&app), &state, &connection_id, remote_site_id, name).await?; + // A new running site has to reach the tray menu like any other. + tray::refresh(&app); + Ok(site) +} + // --------------------------------------------------------------------------- -// Local domains (M6) — shared Caddy router on ports 80/443 +// Local domains (M6) — shared Caddy router on ports 80/443 (configurable +// since plan 16, for coexistence with LocalWP & other port-80 owners) // --------------------------------------------------------------------------- #[tauri::command] @@ -261,6 +749,15 @@ async fn router_status(state: State<'_, AppState>) -> Result<router::RouterStatu router::status(&state).await } +#[tauri::command] +async fn set_router_ports( + state: State<'_, AppState>, + http: u16, + https: u16, +) -> Result<router::RouterStatus, String> { + router::set_ports(&state, http, https).await +} + #[tauri::command] async fn set_domains_enabled( state: State<'_, AppState>, @@ -317,10 +814,11 @@ async fn terminal_open( rows: Option<u32>, ) -> Result<String, String> { let site = site::get(&state, &site_id)?; + site.require(site.capabilities.terminal, "Opening a terminal")?; let containers = docker::compose_ps(&site.dir()).await?; let running = containers .iter() - .any(|c| c.service == "wordpress" && c.state == "running"); + .any(|c| c.service == site.app_service() && c.state == "running"); if !running { return Err(format!( "\"{}\" is not running — start the site first.", @@ -329,7 +827,7 @@ async fn terminal_open( } state .terminals - .open(&app, &site.dir(), cols.unwrap_or(80), rows.unwrap_or(24)) + .open(&app, &site.dir(), site.app_service(), cols.unwrap_or(80), rows.unwrap_or(24)) } #[tauri::command] @@ -371,6 +869,9 @@ pub fn run() { .unwrap_or_else(|| PathBuf::from(".")) .join("LocalKit"); let db = Db::open(&data_dir.join("localkit.db")).expect("failed to open LocalKit database"); + // Backfill completion markers for already-complete sites before anything + // reads them, so pre-plan-23 sites are never flagged "Setup incomplete". + reconcile::backfill_markers(&db); let settings_init_script = build_settings_init_script(&db); tauri::Builder::default() @@ -379,10 +880,15 @@ pub fn run() { tray::show_main_window(app); })) .plugin(tauri_plugin_opener::init()) + // OS desktop notifications for long-op completions (plan 25). Fired from + // the frontend only when the window is unfocused/closed-to-tray. + .plugin(tauri_plugin_notification::init()) .manage(AppState { db: Mutex::new(db), data_dir, terminals: terminal::PtyManager::new(), + transfers: Default::default(), + in_flight: Default::default(), }) .setup(move |app| { // Main window is built in code (not tauri.conf.json) so the @@ -394,6 +900,9 @@ pub fn run() { .initialization_script(&settings_init_script) .build()?; tray::setup(app.handle())?; + // Settle DB status against Docker's ground truth: once now (so the + // dashboard opens honest), then every 60 s (plan 23). + reconcile::spawn_loop(app.handle().clone()); Ok(()) }) .on_window_event(|window, event| { @@ -407,17 +916,40 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ check_docker, + check_for_update, app_info, list_sites, get_site, create_site, + create_php_site, + inspect_docker_project, + import_docker_project, + clone_site, start_site, stop_site, + resume_site, delete_site, site_logs, wp_cli_info, + site_search_replace, + open_site_database, + site_debug_status, + set_site_debug, + read_site_debug_log, + clear_site_debug_log, + read_site_config_file, + write_site_config_file, + restart_site, login_site, site_wp_users, + list_snapshots, + create_snapshot, + restore_snapshot, + delete_snapshot, + save_blueprint, + list_blueprints, + delete_blueprint, + create_site_from_blueprint, save_serverkit_connection, list_serverkit_connections, delete_serverkit_connection, @@ -427,9 +959,12 @@ pub fn run() { push_site_code, push_site_db, pull_site_db, + import_remote_site, list_sync_history, + cancel_sync, router_status, set_domains_enabled, + set_router_ports, trust_router_ca, get_app_setting, set_app_setting, diff --git a/src-tauri/src/php.rs b/src-tauri/src/php.rs new file mode 100644 index 0000000..8a0892e --- /dev/null +++ b/src-tauri/src/php.rs @@ -0,0 +1,567 @@ +//! Generated PHP/Laravel stack (plan 26). +//! +//! The second first-class multi-stack kind after WordPress. Unlike a +//! bring-your-own docker app (`dockerapp.rs`), LocalKit *generates* the compose +//! project — `app` (php-fpm, built from a tiny Dockerfile that adds `pdo_mysql` +//! + Composer so a fresh Laravel app can talk to the bundled database), `web` +//! (nginx serving the `public/` webroot), `db` (mariadb, same template as the +//! WordPress stack) and a profile-gated `adminer`. +//! +//! Creation is one of two shapes: +//! * an empty Laravel-ready skeleton (a `public/index.php` webroot the user +//! then runs `composer create-project` over from the built-in terminal), or +//! * importing an existing PHP project folder into the site's `app/` directory +//! (the same ignore-list copy as the docker import). +//! +//! There is no framework installer inside the app — the terminal is right there. +//! The database is synced engine-native (mysqldump/mysql), not via wp-cli, so +//! `php` claims `db_sync` (see `site::Capabilities::PHP` and `dbsync.rs`). + +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +use crate::{ + docker, dockerapp, router, + site::{self, SiteConfig}, + AppState, +}; + +/// Compose service the terminal/logs target and code sync use for a php site. +pub const APP_SERVICE: &str = "app"; +/// The php site's code lives here under the site dir (bind-mounted to the app + +/// web containers); this is also its `sync_path`, so snapshots/code-sync archive +/// the application code and not the generated infra files. +pub const APP_DIR: &str = "app"; + +/// The engine + service of the bundled database — recorded in `SiteConfig` so +/// the engine-native DB sync (plan 26 phase 2) dispatches without re-detecting. +const DB_ENGINE: &str = "mariadb"; +const DB_SERVICE: &str = "db"; +/// Laravel's conventional database name/user; the app container gets these as +/// `DB_DATABASE`/`DB_USERNAME` so a `.env`-less first run still connects. +const DB_NAME: &str = "laravel"; +const DB_USER: &str = "laravel"; + +/// The `SiteConfig` every php site carries. Deterministic, so a rewrite (Adminer +/// on-demand start) reproduces it exactly. Shared with the import flow (plan 26 +/// phase 3), which reserves a php site the same way a fresh create does. +pub(crate) fn config() -> SiteConfig { + SiteConfig { + service: APP_SERVICE.to_string(), + sync_path: APP_DIR.to_string(), + app_port: None, + db_engine: Some(DB_ENGINE.to_string()), + db_service: Some(DB_SERVICE.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +/// The generated compose project for a php site. Mirrors the WordPress template's +/// conventions (mariadb `db` block, profile-gated `adminer` on `db_port + 1000`), +/// but the app is php-fpm behind nginx instead of the apache wordpress image. +pub fn render_compose(site: &site::Site) -> String { + format!( + r#"name: localkit-{slug} + +services: + # php-fpm, built from ./docker/Dockerfile so pdo_mysql + Composer are present + # (the stock php-fpm image ships neither, and a Laravel app needs both). + app: + build: + context: ./docker + dockerfile: Dockerfile + restart: unless-stopped + working_dir: /var/www/html + volumes: + - ./{app_dir}:/var/www/html + environment: + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: "3306" + DB_DATABASE: ${{DB_NAME}} + DB_USERNAME: ${{DB_USER}} + DB_PASSWORD: ${{DB_PASSWORD}} + depends_on: + db: + condition: service_healthy + + web: + image: nginx:alpine + restart: unless-stopped + ports: + - "${{WEB_PORT}}:80" + volumes: + - ./{app_dir}:/var/www/html + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + + db: + image: mariadb:11 + restart: unless-stopped + ports: + - "${{DB_PORT}}:3306" + environment: + MYSQL_DATABASE: ${{DB_NAME}} + MYSQL_USER: ${{DB_USER}} + MYSQL_PASSWORD: ${{DB_PASSWORD}} + MYSQL_RANDOM_ROOT_PASSWORD: "1" + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 3s + retries: 24 + + # Adminer database GUI (plan 24) — profile-gated + off by default, started on + # demand from Tools -> Database. Deterministic host port (db_port + 1000). + adminer: + image: adminer:4-standalone + profiles: ["tools"] + restart: unless-stopped + ports: + - "{adminer_port}:8080" + environment: + ADMINER_DEFAULT_SERVER: db + depends_on: + db: + condition: service_healthy + +volumes: + db-data: +"#, + slug = site.slug, + app_dir = APP_DIR, + adminer_port = site.adminer_port(), + ) +} + +/// The php-fpm image, built once per site. `FROM php:<ver>-fpm` keeps the app on +/// the allowlisted PHP version (`PHP_VERSIONS`), then adds only what a Laravel +/// app can't run without: `pdo_mysql` for the bundled mariadb, `zip`/`unzip`/git +/// for `composer create-project`, and Composer itself. Exotic extensions are a +/// documented "edit ./docker/Dockerfile" path (plan 26 risks). +fn render_dockerfile(php_version: &str) -> String { + format!( + r#"# Generated by LocalKit (plan 26). Edit to add PHP extensions your app needs. +FROM php:{php}-fpm + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends git unzip libzip-dev; \ + docker-php-ext-install pdo_mysql zip; \ + rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +"#, + php = php_version, + ) +} + +/// nginx vhost pointing at `webroot` (the `public/` dir for a Laravel-style +/// project, else the app root). Standard `try_files ... /index.php` front +/// controller so pretty URLs work out of the box. +fn render_nginx(webroot: &str) -> String { + format!( + r#"server {{ + listen 80; + server_name _; + root {webroot}; + index index.php index.html; + client_max_body_size 64m; + + location / {{ + try_files $uri $uri/ /index.php?$query_string; + }} + + location ~ \.php$ {{ + fastcgi_pass app:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} + + location ~ /\.(?!well-known).* {{ + deny all; + }} +}} +"#, + webroot = webroot, + ) +} + +fn render_env(site: &site::Site, db_password: &str) -> String { + format!( + "WEB_PORT={}\nDB_PORT={}\nDB_NAME={DB_NAME}\nDB_USER={DB_USER}\nDB_PASSWORD={}\n", + site.port, + site.db_port(), + db_password, + ) +} + +/// The skeleton webroot page for an empty create: confirms the stack works and +/// checks database connectivity, so the site answers with something real the +/// moment the containers come up (before the user has run Composer). +const SKELETON_INDEX: &str = r#"<?php +// LocalKit PHP/Laravel starter. Replace this with your app +// (e.g. `composer create-project laravel/laravel .` from the terminal). +$connected = false; +$err = ''; +try { + $pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s', getenv('DB_HOST') ?: 'db', getenv('DB_DATABASE') ?: 'laravel'), + getenv('DB_USERNAME') ?: 'laravel', + getenv('DB_PASSWORD') ?: '', + [PDO::ATTR_TIMEOUT => 3] + ); + $connected = true; +} catch (Throwable $e) { + $err = $e->getMessage(); +} +?> +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>LocalKit PHP site + + + +
+

Your PHP stack is running 🎉

+

PHP via php-fpm + nginx.

+

Database: + + connected + + not reachable + +

+

Drop your code into the app/ directory, or run + composer create-project laravel/laravel . from the terminal.

+
+ + +"#; + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +/// Create a new PHP/Laravel site. +/// +/// `source` is `None` for an empty Laravel-ready skeleton, or a folder to import +/// existing code from (copied into the site's `app/` dir, docker-import excludes +/// applied unless `include_all`). Emits the same `site-event` stages the other +/// create flows do so the progress toast works unchanged. +pub async fn create_php_site( + app: Option<&AppHandle>, + state: &AppState, + name: String, + php_version: String, + source: Option, + include_all: bool, +) -> Result { + if !site::PHP_VERSIONS.contains(&php_version.as_str()) { + return Err(format!("unsupported PHP version: {php_version}")); + } + if let Some(src) = source.as_deref() { + if !src.is_dir() { + return Err(format!("{} is not a folder", src.display())); + } + } + + let site = site::reserve( + state, + name, + site::KIND_PHP.to_string(), + String::new(), + php_version, + config(), + None, + ) + .await?; + + // Own this site's status until the create finishes (plan 23). + let _guard = state.in_flight.guard(&site.id); + match do_create(app, state, &site, source.as_deref(), include_all).await { + Ok(site) => { + let url = router::site_public_url(state, &site); + site::emit( + app, + &site.id, + "done", + &format!("{} is ready at {url}", site.name), + ); + Ok(site) + } + Err(e) => { + site::emit(app, &site.id, "error", &format!("Creation failed: {e}")); + let _ = site::cleanup(state, &site).await; + Err(e) + } + } +} + +async fn do_create( + app: Option<&AppHandle>, + state: &AppState, + site: &site::Site, + source: Option<&Path>, + include_all: bool, +) -> Result { + let dir = site.dir(); + let id = site.id.as_str(); + + site::emit(app, id, "files", "Writing project files..."); + write_project_files(site, source, include_all)?; + + site::emit( + app, + id, + "pulling", + "Building the PHP image (first run can take a few minutes)...", + ); + // Pull the base images up front for a labeled stage; best-effort because + // `build`/`up` fetch anything still missing anyway. + let _ = docker::compose_pull(&dir, &["web", "db"]).await; + docker::compose_build(&dir).await?; + + site::emit(app, id, "containers", "Starting containers..."); + docker::compose_up(&dir).await?; + + // The web port answering means nginx is up; a php app may still be a blank + // skeleton, so don't fail the create if it never responds (mirrors docker). + site::emit(app, id, "waiting", "Waiting for the app to come online..."); + let _ = site::wait_for_port(site.port, 180).await; + + let mut running = site.clone(); + running.status = "running".into(); + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + } + // Last step: the completion marker (plan 23) — its absence flags a killed + // create. + site::mark_complete(&dir); + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + Ok(running) +} + +/// Write a php site's generated files: the `app/` code directory (an empty +/// skeleton or a copy of `source`) plus the generated infra (`docker/Dockerfile`, +/// `nginx.conf`, `docker-compose.yml`, `.env`). +fn write_project_files( + site: &site::Site, + source: Option<&Path>, + include_all: bool, +) -> Result<(), String> { + let app_dir = ensure_dirs(site)?; + match source { + Some(src) => { + let excludes: &[&str] = if include_all { &[] } else { dockerapp::DEFAULT_EXCLUDES }; + dockerapp::copy_tree(src, &app_dir, excludes)?; + } + None => { + let public = app_dir.join("public"); + std::fs::create_dir_all(&public) + .map_err(|e| format!("failed to create the webroot: {e}"))?; + std::fs::write(public.join("index.php"), SKELETON_INDEX) + .map_err(|e| format!("failed to write the skeleton index: {e}"))?; + } + } + write_infra(site) +} + +/// Create a php site's directory skeleton: the site dir, `docker/`, and an empty +/// `app/`. Returns the `app/` path. Shared by create and import (plan 26 phase 3 +/// extracts the remote code into `app/` between this and `write_infra`). +pub(crate) fn ensure_dirs(site: &site::Site) -> Result { + let dir = site.dir(); + std::fs::create_dir_all(dir.join("docker")) + .map_err(|e| format!("failed to create the project directory: {e}"))?; + let app_dir = dir.join(APP_DIR); + std::fs::create_dir_all(&app_dir) + .map_err(|e| format!("failed to create the app directory: {e}"))?; + Ok(app_dir) +} + +/// Write the generated infra files (Dockerfile, nginx.conf, compose, `.env`). +/// Called *after* `app/` is populated so the nginx webroot is detected from the +/// real project layout — a Laravel-style `public/` serves from there, a plain +/// PHP project without one serves from the app root. +pub(crate) fn write_infra(site: &site::Site) -> Result<(), String> { + let dir = site.dir(); + let app_dir = dir.join(APP_DIR); + let webroot = if app_dir.join("public").is_dir() { + "/var/www/html/public" + } else { + "/var/www/html" + }; + let db_password = site::random_password(24); + std::fs::write(dir.join("docker").join("Dockerfile"), render_dockerfile(&site.php_version)) + .map_err(|e| format!("failed to write the Dockerfile: {e}"))?; + std::fs::write(dir.join("nginx.conf"), render_nginx(webroot)) + .map_err(|e| format!("failed to write nginx.conf: {e}"))?; + std::fs::write(dir.join("docker-compose.yml"), render_compose(site)) + .map_err(|e| format!("failed to write docker-compose.yml: {e}"))?; + std::fs::write(dir.join(".env"), render_env(site, &db_password)) + .map_err(|e| format!("failed to write .env: {e}"))?; + Ok(()) +} + +/// Best-effort patch of `APP_URL` in the app's own `.env` (Laravel convention) +/// after a pull/import (plan 26). Unlike WordPress there is no serialization-safe +/// search-replace to run — URL config is the app's own concern — so this only +/// touches the one well-known key, and only if an `app/.env` exists. Never fails +/// the sync: a php app may not be Laravel, or may have no `.env` at all. +pub fn patch_app_url(site_dir: &Path, sync_path: &str, url: &str) { + let env_path = site_dir.join(sync_path).join(".env"); + let Ok(existing) = std::fs::read_to_string(&env_path) else { + return; // no app/.env — nothing to patch + }; + let mut replaced = false; + let mut out: Vec = existing + .lines() + .map(|line| { + if line.trim_start().starts_with("APP_URL=") { + replaced = true; + format!("APP_URL={url}") + } else { + line.to_string() + } + }) + .collect(); + if !replaced { + out.push(format!("APP_URL={url}")); + } + let mut body = out.join("\n"); + if existing.ends_with('\n') { + body.push('\n'); + } + let _ = std::fs::write(&env_path, body); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::site::{Capabilities, Site}; + + fn php_site() -> Site { + let mut s = Site { + id: "p".into(), + name: "Shop".into(), + slug: "shop".into(), + path: "/tmp/shop".into(), + port: 8081, + wp_version: String::new(), + php_version: "8.3".into(), + status: "running".into(), + status_updated_at: "2026-01-01T00:00:00Z".into(), + admin_user: String::new(), + admin_pass: String::new(), + created_at: "2026-01-01T00:00:00Z".into(), + connection_id: None, + remote_site_id: None, + kind: site::KIND_PHP.into(), + config: config(), + capabilities: Capabilities::default(), + }; + s.refresh_capabilities(); + s + } + + #[test] + fn compose_has_the_php_web_and_db_services_plus_profile_gated_adminer() { + let yml = render_compose(&php_site()); + assert!(yml.contains("name: localkit-shop"), "{yml}"); + assert!(yml.contains("dockerfile: Dockerfile"), "app builds from a Dockerfile"); + assert!(yml.contains("image: nginx:alpine"), "web service"); + assert!(yml.contains("image: mariadb:11"), "db service"); + // Adminer is profile-gated on db_port + 1000 (18081 + 1000). + assert!(yml.contains("\"19081:8080\""), "adminer host port:\n{yml}"); + assert_eq!(yml.matches("profiles: [\"tools\"]").count(), 1, "only adminer is gated"); + } + + #[test] + fn env_records_laravel_db_credentials_and_the_ports() { + let env = render_env(&php_site(), "s3cret"); + assert!(env.contains("WEB_PORT=8081")); + assert!(env.contains("DB_PORT=18081")); + assert!(env.contains("DB_NAME=laravel")); + assert!(env.contains("DB_USER=laravel")); + assert!(env.contains("DB_PASSWORD=s3cret")); + } + + #[test] + fn dockerfile_pins_the_php_version_and_adds_pdo_mysql_and_composer() { + let df = render_dockerfile("8.2"); + assert!(df.contains("FROM php:8.2-fpm")); + assert!(df.contains("docker-php-ext-install pdo_mysql")); + assert!(df.contains("composer:2")); + } + + #[test] + fn nginx_points_at_the_given_webroot_with_a_front_controller() { + let conf = render_nginx("/var/www/html/public"); + assert!(conf.contains("root /var/www/html/public;")); + assert!(conf.contains("try_files $uri $uri/ /index.php?$query_string;")); + assert!(conf.contains("fastcgi_pass app:9000;")); + } + + #[test] + fn patch_app_url_replaces_or_appends_only_when_an_app_env_exists() { + let base = std::env::temp_dir().join(format!("lk-php-appurl-{}", std::process::id())); + let app = base.join("app"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&app).unwrap(); + + // No app/.env → no-op, no panic, no file created. + patch_app_url(&base, "app", "http://shop.test"); + assert!(!app.join(".env").exists()); + + // Existing APP_URL is replaced; other keys are untouched. + std::fs::write(app.join(".env"), "APP_NAME=Shop\nAPP_URL=http://old\nDB_HOST=db\n").unwrap(); + patch_app_url(&base, "app", "http://shop.test"); + let env = std::fs::read_to_string(app.join(".env")).unwrap(); + assert!(env.contains("APP_URL=http://shop.test")); + assert!(!env.contains("http://old")); + assert!(env.contains("APP_NAME=Shop") && env.contains("DB_HOST=db")); + + // Missing APP_URL is appended. + std::fs::write(app.join(".env"), "APP_NAME=Shop\n").unwrap(); + patch_app_url(&base, "app", "http://shop.test"); + let env = std::fs::read_to_string(app.join(".env")).unwrap(); + assert!(env.contains("APP_URL=http://shop.test")); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn config_is_the_deterministic_php_shape() { + let cfg = config(); + assert_eq!(cfg.service, "app"); + assert_eq!(cfg.sync_path, "app"); + assert_eq!(cfg.db_engine.as_deref(), Some("mariadb")); + assert_eq!(cfg.db_service.as_deref(), Some("db")); + assert_eq!(cfg.app_port, None); + } +} diff --git a/src-tauri/src/reconcile.rs b/src-tauri/src/reconcile.rs new file mode 100644 index 0000000..df72c49 --- /dev/null +++ b/src-tauri/src/reconcile.rs @@ -0,0 +1,394 @@ +//! Status reconciliation + crash recovery (plan 23). +//! +//! Site status is otherwise write-path only — commands set `running`/`stopped` +//! on success — so reality drifts: Docker Desktop restarts, a container is +//! `docker stop`ed from outside, the app is killed mid-create. This module +//! settles the DB's stored status against Docker's ground truth. The rule is +//! **inspect ground truth, settle forward, never guess**: it never downgrades a +//! status a newer command/event set (the `settle_status` compare-and-swap in +//! `db.rs`), and with no ground truth (Docker down) it suspends rather than +//! flap every site to stopped. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use tauri::{Emitter, Manager}; + +use crate::{db::Db, docker, site, AppState}; + +/// How recently a `running` write must have happened for the reconciler to +/// leave an empty ground truth alone (grace window for slow container starts). +const GRACE_SECS: i64 = 60; + +/// Interval between reconcile passes while the app runs. +const TICK_SECS: u64 = 60; + +/// What Docker's ground truth says about a site's app service right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Observed { + /// The app service container is up (and not failing a health check). + Running, + /// The app service container is up but restarting or unhealthy. + Degraded, + /// No running app service container — stopped, exited, or absent. + Down, +} + +/// Classify a compose project's containers for a given app service. An app +/// service missing from the pass (project absent, or container removed) reads +/// as `Down`. +pub fn classify(containers: &[docker::ContainerInfo], app_service: &str) -> Observed { + let Some(c) = containers.iter().find(|c| c.service == app_service) else { + return Observed::Down; + }; + let state = c.state.to_lowercase(); + let status = c.status.to_lowercase(); + if state == "restarting" || status.contains("unhealthy") { + Observed::Degraded + } else if state == "running" { + // "Up (health: starting)" is still coming up, not yet degraded. + Observed::Running + } else { + // created / exited / paused / dead → not serving. + Observed::Down + } +} + +/// Decide the status a site should settle to, or `None` to leave it untouched. +/// +/// Pure and exhaustively unit-tested — the whole decision table lives here. +/// `recently_started` is true when the site's status was written within the +/// grace window; it suppresses the `running`→`stopped` downgrade so a slow +/// start is not flapped to stopped before its containers finish coming up. +pub fn decide(db_status: &str, observed: Observed, recently_started: bool) -> Option<&'static str> { + match (db_status, observed) { + // A create in flight or half-finished — the reconciler never touches + // `creating`; recovering it is Phase 2's job (incomplete detection). + ("creating", _) => None, + + // Already agrees with the ground truth: nothing to do. + ("running", Observed::Running) => None, + ("stopped", Observed::Down) => None, + ("degraded", Observed::Degraded) => None, + + // Ground truth is unhealthy → surface `degraded` from any other state. + (_, Observed::Degraded) => Some("degraded"), + + // External start: the DB says down, a container is up. + ("stopped", Observed::Running) | ("degraded", Observed::Running) => Some("running"), + + // External stop: the DB says up, no container — but respect the grace + // window for a running site whose containers are still starting. + ("running", Observed::Down) if recently_started => None, + ("running", Observed::Down) | ("degraded", Observed::Down) => Some("stopped"), + + // Any other (unknown/legacy) stored status: settle toward the truth. + (_, Observed::Running) => Some("running"), + (_, Observed::Down) => Some("stopped"), + } +} + +/// A status settle that was applied this pass — for logging + the caller's +/// tray refresh / `sites-changed` emit. +#[derive(Debug, Clone)] +pub struct ReconcileEvent { + pub site_id: String, + pub slug: String, + pub from: String, + pub to: String, + pub reason: &'static str, +} + +fn reason_for(from: &str, to: &str) -> &'static str { + match (from, to) { + (_, "degraded") => "unhealthy", + (_, "running") => "external start", + ("running", "stopped") | ("degraded", "stopped") => "external stop", + _ => "settled", + } +} + +/// True when `status_updated_at` (RFC3339) is within the grace window of `now`. +/// An empty/unparseable timestamp is "long ago" → false. +fn recently_started(status_updated_at: &str, now: chrono::DateTime) -> bool { + match chrono::DateTime::parse_from_rfc3339(status_updated_at) { + Ok(ts) => (now - ts.with_timezone(&chrono::Utc)).num_seconds() < GRACE_SECS, + Err(_) => false, + } +} + +/// The compose project name for a site — `localkit-` for every kind. +fn project_name(slug: &str) -> String { + format!("localkit-{slug}") +} + +/// One reconcile pass: settle every site's stored status against Docker's +/// ground truth, forward-only. Returns the settles that landed. No ground truth +/// (Docker down) → an empty result and zero writes: the reconciler suspends +/// rather than flap every site to stopped when Docker Desktop restarts. +pub async fn reconcile_once(state: &AppState) -> Vec { + // 1. Snapshot the sites (short lock, no await held). + let sites = { + let Ok(db) = state.db.lock() else { + return Vec::new(); + }; + db.list_sites().unwrap_or_default() + }; + if sites.is_empty() { + return Vec::new(); + } + + // 2. Ground truth in one pass. On error (daemon down) → suspend. + let truth = match docker::project_container_states().await { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + + let now = chrono::Utc::now(); + let empty: Vec = Vec::new(); + let mut events = Vec::new(); + for s in sites { + // A site with an in-flight lifecycle command owns its own truth right + // now — its events are authoritative, so an inspect must not race it. + if state.in_flight.contains(&s.id) { + continue; + } + let containers = truth.get(&project_name(&s.slug)).unwrap_or(&empty); + let observed = classify(containers, s.app_service()); + let recently = recently_started(&s.status_updated_at, now); + let Some(target) = decide(&s.status, observed, recently) else { + continue; + }; + // Forward-only compare-and-swap: only lands if no command/event wrote a + // newer status since we read the row. + let applied = { + let Ok(db) = state.db.lock() else { + continue; + }; + db.settle_status(&s.id, target, &s.status_updated_at) + .unwrap_or(false) + }; + if applied { + let reason = reason_for(&s.status, target); + eprintln!("reconciled: site {} {}→{} ({reason})", s.slug, s.status, target); + events.push(ReconcileEvent { + site_id: s.id, + slug: s.slug, + from: s.status, + to: target.to_string(), + reason, + }); + } + } + events +} + +/// One-time startup backfill (plan 23): mark every already-complete site +/// (running/stopped/degraded, directory present) with the completion marker. +/// This is what keeps a pre-plan-23 site — or a create that crashed after +/// `set_status` but before the marker write — from being mistaken for a +/// half-created one. Run synchronously before the window loads so the first +/// `list_sites` is already honest. +pub fn backfill_markers(db: &Db) { + for s in db.list_sites().unwrap_or_default() { + let done = matches!(s.status.as_str(), "running" | "stopped" | "degraded"); + let dir = s.dir(); + if done && dir.exists() && !site::is_complete(&dir) { + site::mark_complete(&dir); + } + } +} + +/// Start the background reconcile loop: one pass immediately (so the dashboard +/// opens honest on cold start), then every 60 s. After any pass that settled +/// something, the tray is rebuilt and a `sites-changed` event tells the +/// frontend to re-fetch. Runs for the life of the app. +pub fn spawn_loop(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + let events = { + let state = app.state::(); + reconcile_once(&state).await + }; + if !events.is_empty() { + // A settled status must reach the tray menu/tooltip and the + // dashboard, same as any lifecycle change. + crate::tray::refresh(&app); + let _ = app.emit("sites-changed", ()); + } + tokio::time::sleep(std::time::Duration::from_secs(TICK_SECS)).await; + } + }); +} + +/// Per-site set of in-flight lifecycle commands, shared across every command +/// path (GUI commands, `lk`, tray spawns). The reconciler skips any site in +/// this set. RAII + refcounted: `guard()` returns a handle that removes the id +/// on drop, and nested guards for the same site are safe. +#[derive(Clone, Default)] +pub struct InFlight { + inner: Arc>>, +} + +impl InFlight { + /// Mark a site as having an in-flight command for the guard's lifetime. + pub fn guard(&self, site_id: &str) -> InFlightGuard { + if let Ok(mut map) = self.inner.lock() { + *map.entry(site_id.to_string()).or_insert(0) += 1; + } + InFlightGuard { + site_id: site_id.to_string(), + registry: self.clone(), + } + } + + pub fn contains(&self, site_id: &str) -> bool { + self.inner + .lock() + .map(|m| m.contains_key(site_id)) + .unwrap_or(false) + } +} + +/// Handle held by a running command; drops the site from the in-flight set when +/// the last guard for it goes away. +pub struct InFlightGuard { + site_id: String, + registry: InFlight, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + if let Ok(mut map) = self.registry.inner.lock() { + if let Some(n) = map.get_mut(&self.site_id) { + *n -= 1; + if *n == 0 { + map.remove(&self.site_id); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests — the decision table (every db-status × observation × recency) and the +// container classifier. The forward-only compare-and-swap itself is tested in +// db.rs (`settle_status_is_a_compare_and_swap_on_the_timestamp`). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn ci(service: &str, state: &str, status: &str) -> docker::ContainerInfo { + docker::ContainerInfo { + service: service.into(), + state: state.into(), + status: status.into(), + } + } + + #[test] + fn classify_running_healthy_is_running() { + let cs = [ci("wordpress", "running", "Up 3 minutes (healthy)")]; + assert_eq!(classify(&cs, "wordpress"), Observed::Running); + } + + #[test] + fn classify_starting_health_is_still_running_not_degraded() { + let cs = [ci("wordpress", "running", "Up 2 seconds (health: starting)")]; + assert_eq!(classify(&cs, "wordpress"), Observed::Running); + } + + #[test] + fn classify_unhealthy_or_restarting_is_degraded() { + let unhealthy = [ci("wordpress", "running", "Up 5 minutes (unhealthy)")]; + assert_eq!(classify(&unhealthy, "wordpress"), Observed::Degraded); + let restarting = [ci("wordpress", "restarting", "Restarting (1) 1 second ago")]; + assert_eq!(classify(&restarting, "wordpress"), Observed::Degraded); + } + + #[test] + fn classify_exited_or_absent_is_down() { + let exited = [ci("wordpress", "exited", "Exited (0) 1 minute ago")]; + assert_eq!(classify(&exited, "wordpress"), Observed::Down); + // The app service isn't in the pass at all (only its db is up). + let other = [ci("db", "running", "Up 1 minute")]; + assert_eq!(classify(&other, "wordpress"), Observed::Down); + assert_eq!(classify(&[], "wordpress"), Observed::Down); + } + + #[test] + fn decide_leaves_agreeing_states_alone() { + assert_eq!(decide("running", Observed::Running, false), None); + assert_eq!(decide("stopped", Observed::Down, false), None); + assert_eq!(decide("degraded", Observed::Degraded, false), None); + } + + #[test] + fn decide_settles_external_stop_unless_within_grace() { + // Container vanished and the running write is old → settle to stopped. + assert_eq!(decide("running", Observed::Down, false), Some("stopped")); + // …but a just-started site whose containers are still coming up is left + // alone (the grace window). + assert_eq!(decide("running", Observed::Down, true), None); + } + + #[test] + fn decide_settles_external_start() { + assert_eq!(decide("stopped", Observed::Running, false), Some("running")); + // Recency never blocks an upgrade — grace only guards the downgrade. + assert_eq!(decide("stopped", Observed::Running, true), Some("running")); + } + + #[test] + fn decide_surfaces_degraded_from_any_state() { + assert_eq!(decide("running", Observed::Degraded, false), Some("degraded")); + assert_eq!(decide("stopped", Observed::Degraded, false), Some("degraded")); + assert_eq!(decide("degraded", Observed::Degraded, false), None); + } + + #[test] + fn decide_recovers_from_degraded() { + assert_eq!(decide("degraded", Observed::Running, false), Some("running")); + assert_eq!(decide("degraded", Observed::Down, false), Some("stopped")); + } + + #[test] + fn decide_never_touches_a_creating_site() { + for obs in [Observed::Running, Observed::Degraded, Observed::Down] { + assert_eq!(decide("creating", obs, false), None); + assert_eq!(decide("creating", obs, true), None); + } + } + + #[test] + fn decide_settles_an_unknown_status_toward_the_truth() { + assert_eq!(decide("weird", Observed::Running, false), Some("running")); + assert_eq!(decide("weird", Observed::Down, false), Some("stopped")); + assert_eq!(decide("weird", Observed::Degraded, false), Some("degraded")); + } + + #[test] + fn recently_started_reads_the_grace_window() { + let now = chrono::Utc::now(); + let just = (now - chrono::Duration::seconds(5)).to_rfc3339(); + let old = (now - chrono::Duration::seconds(120)).to_rfc3339(); + assert!(recently_started(&just, now)); + assert!(!recently_started(&old, now)); + assert!(!recently_started("", now), "empty timestamp is long ago"); + } + + #[test] + fn in_flight_guard_refcounts_and_clears_on_drop() { + let reg = InFlight::default(); + assert!(!reg.contains("s1")); + let g1 = reg.guard("s1"); + let g2 = reg.guard("s1"); + assert!(reg.contains("s1")); + drop(g1); + assert!(reg.contains("s1"), "still held by the second guard"); + drop(g2); + assert!(!reg.contains("s1"), "cleared when the last guard drops"); + } +} diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index fd3b3bf..ffcfd79 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -23,6 +23,14 @@ pub const TLD: &str = "test"; const KEY_ENABLED: &str = "domains_enabled"; const KEY_CA_TRUSTED: &str = "router_ca_trusted"; const KEY_LAST_ERROR: &str = "router_last_error"; +/// Default router host ports (the clean-URL mode). +pub const DEFAULT_HTTP_PORT: u16 = 80; +pub const DEFAULT_HTTPS_PORT: u16 = 443; +/// Suggested fallback pair when another program owns 80/443. +pub const FALLBACK_HTTP_PORT: u16 = 8080; +pub const FALLBACK_HTTPS_PORT: u16 = 8443; +const KEY_HTTP_PORT: &str = "router_http_port"; +const KEY_HTTPS_PORT: &str = "router_https_port"; /// Path of Caddy's local-CA root cert inside the container. const CA_CERT_CONTAINER_PATH: &str = "/data/caddy/pki/authorities/local/root.crt"; /// Managed-block markers in the OS hosts file. @@ -35,6 +43,51 @@ pub struct RouterStatus { pub running: bool, pub ca_trusted: bool, pub error: Option, + /// Router ports another program is holding (empty = free, or the router + /// itself is up and holding them legitimately). + pub conflicts: Vec, + pub http_port: u16, + pub https_port: u16, +} + +/// A router port held by some other program, with a best-effort owner name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortConflict { + pub port: u16, + pub process: Option, +} + +/// Host ports the Caddy router publishes on. Container ports are always +/// 80/443 — only the host side moves, so the Caddyfile is port-blind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct RouterPorts { + pub http: u16, + pub https: u16, +} + +impl Default for RouterPorts { + fn default() -> Self { + Self { http: DEFAULT_HTTP_PORT, https: DEFAULT_HTTPS_PORT } + } +} + +impl RouterPorts { + /// Clean-URL mode: browsers imply 80/443, so no `:port` suffix is needed. + pub fn is_default(&self) -> bool { + self.http == DEFAULT_HTTP_PORT && self.https == DEFAULT_HTTPS_PORT + } + + fn validate(&self) -> Result<(), String> { + for port in [self.http, self.https] { + if port == 0 { + return Err("Router ports must be between 1 and 65535.".into()); + } + } + if self.http == self.https { + return Err("The HTTP and HTTPS router ports must be different.".into()); + } + Ok(()) + } } pub fn router_dir(data_dir: &Path) -> PathBuf { @@ -42,32 +95,66 @@ pub fn router_dir(data_dir: &Path) -> PathBuf { } /// The URL a site is reachable at through the router. -pub fn site_url(slug: &str, ca_trusted: bool) -> String { +/// +/// On the default ports this is the clean `http(s)://.test`. On fallback +/// ports the browser needs the port spelled out, and we deliberately stay on +/// http: a non-standard https port would prompt for a second certificate +/// exception even after the CA is trusted. +pub fn site_url(slug: &str, ca_trusted: bool, ports: RouterPorts) -> String { + if !ports.is_default() { + return format!("http://{slug}.{TLD}:{}", ports.http); + } let scheme = if ca_trusted { "https" } else { "http" }; format!("{scheme}://{slug}.{TLD}") } /// The URL a site should be opened at (mirrors the frontend's `siteUrl`): /// its `*.test` domain when local domains are enabled, else `localhost:`. +/// The single source of truth for "where does this site live" — tray menu, +/// one-click login, WP install URL and the CLI all funnel through here. pub fn site_public_url(state: &AppState, site: &Site) -> String { let (domains_on, ca_trusted) = enabled_and_trusted(state); - if domains_on { - site_url(&site.slug, ca_trusted) + if domains_on && site.capabilities.domains { + site_url(&site.slug, ca_trusted, router_ports(state)) + } else { + // The app's own port for a docker project whose compose publishes on a + // different port than the reserved site port; the site port for WP. + format!("http://localhost:{}", site.config.upstream_port(site.port)) + } +} + +/// Where a site's Adminer database GUI is reached (plan 24): `db-.test` +/// when local domains are on and the site has a db GUI, else +/// `localhost:`. Mirrors `site_public_url`; a non-default https +/// port stays on http for the same reason (a second cert-exception prompt). +pub fn adminer_public_url(state: &AppState, site: &Site) -> String { + let (domains_on, ca_trusted) = enabled_and_trusted(state); + if domains_on && site.capabilities.domains && site.capabilities.db_gui { + let ports = router_ports(state); + if !ports.is_default() { + format!("http://db-{}.{TLD}:{}", site.slug, ports.http) + } else { + let scheme = if ca_trusted { "https" } else { "http" }; + format!("{scheme}://db-{}.{TLD}", site.slug) + } } else { - format!("http://localhost:{}", site.port) + format!("http://localhost:{}", site.adminer_port()) } } -fn render_compose() -> String { - r#"name: localkit-router +fn render_compose(ports: RouterPorts) -> String { + // Container ports stay 80/443 — only the host mapping moves, so the + // Caddyfile (and the hosts block) are unaffected by fallback mode. + format!( + r#"name: localkit-router services: caddy: image: caddy:2 restart: unless-stopped ports: - - "80:80" - - "443:443" + - "{http}:80" + - "{https}:443" # Route to sites via their published host ports — no shared network, # no changes to per-site compose projects. extra_hosts: @@ -78,28 +165,43 @@ services: volumes: caddy-data: -"# - .to_string() +"#, + http = ports.http, + https = ports.https, + ) } fn render_caddyfile(sites: &[Site]) -> String { let mut out = String::from("# Generated by LocalKit — do not edit by hand.\n\n"); - for site in sites { + for site in sites.iter().filter(|s| s.capabilities.domains) { + // Proxy to the app's upstream port — the site port for WordPress, or a + // docker project's own published port when it differs (plan 22). out.push_str(&format!( "http://{slug}.{TLD} {{\n\treverse_proxy host.docker.internal:{port}\n}}\n\n\ https://{slug}.{TLD} {{\n\ttls internal\n\treverse_proxy host.docker.internal:{port}\n}}\n\n", slug = site.slug, - port = site.port, + port = site.config.upstream_port(site.port), )); + // Adminer database GUI at db-.test when the kind has a db GUI + // (plan 24). The route exists whether or not Adminer is currently + // running — starting it is on-demand from Tools -> Database. + if site.capabilities.db_gui { + out.push_str(&format!( + "http://db-{slug}.{TLD} {{\n\treverse_proxy host.docker.internal:{port}\n}}\n\n\ + https://db-{slug}.{TLD} {{\n\ttls internal\n\treverse_proxy host.docker.internal:{port}\n}}\n\n", + slug = site.slug, + port = site.adminer_port(), + )); + } } out } /// Write the router compose project + Caddyfile for the given sites. -fn write_files(data_dir: &Path, sites: &[Site]) -> Result { +fn write_files(data_dir: &Path, sites: &[Site], ports: RouterPorts) -> Result { let dir = router_dir(data_dir); std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create router directory: {e}"))?; - std::fs::write(dir.join("docker-compose.yml"), render_compose()) + std::fs::write(dir.join("docker-compose.yml"), render_compose(ports)) .map_err(|e| format!("failed to write router docker-compose.yml: {e}"))?; std::fs::write(dir.join("Caddyfile"), render_caddyfile(sites)) .map_err(|e| format!("failed to write Caddyfile: {e}"))?; @@ -117,7 +219,9 @@ async fn reload(dir: &Path) -> Result<(), String> { docker::compose_restart(dir).await } -/// Add a "what's probably holding the port" hint to bind failures. +/// Add a "what's probably holding the port" hint to bind failures. Used only +/// as a backstop — the pre-flight probe (`probe_ports`) catches the common +/// case *before* we touch the hosts file or Docker. fn port_conflict_hint(err: &str) -> String { let lower = err.to_lowercase(); if lower.contains("port is already allocated") @@ -126,15 +230,189 @@ fn port_conflict_hint(err: &str) -> String { || lower.contains("permission denied") && lower.contains("80") { format!( - "Could not start the local-domains router: ports 80/443 appear to be in use \ + "Could not start the local-domains router: its ports appear to be in use \ by another program (LocalWP's router, IIS, Skype, or another web server). \ - Stop whatever is bound to port 80/443 and try again.\n\nDetails: {err}" + Stop it, or switch LocalKit to fallback ports in Settings → Domains.\ + \n\nDetails: {err}" ) } else { format!("Could not start the local-domains router: {err}") } } +// --------------------------------------------------------------------------- +// Port pre-flight (plan 16) +// +// LocalWP's nginx router is the canonical conflict: it binds 80/443 +// machine-wide and answers *every* unknown local host with its own "Site Not +// Found" page, so without this probe a LocalKit site at `http://x.test/` +// silently hits Local's router while LocalKit's Caddy is down. Probing with a +// plain `TcpListener::bind` costs nothing and runs before any hosts-file or +// Docker mutation, so it can never race our own containers. +// --------------------------------------------------------------------------- + +/// Can we bind `port`? Checks both the wildcard and the loopback address: on +/// Windows a program bound only to `127.0.0.1:80` still wins loopback traffic +/// even though `0.0.0.0:80` binds fine, which is exactly the case that makes +/// a site silently answer from the other app's router. +/// +/// NOT sufficient on its own — see `probe_port`. +fn bind_free(port: u16) -> bool { + use std::net::{Ipv4Addr, TcpListener}; + TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).is_ok() + && TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_ok() +} + +/// Is `port` held by something else? Combines two independent signals, +/// because neither is reliable alone: +/// +/// - the OS listener table (`Get-NetTCPConnection` / `lsof`) — authoritative, +/// and it names the owner; +/// - a probe bind — catches listeners the query misses or can't see. +/// +/// Bind-probing alone is a false-negative trap on Windows: a socket bound +/// with SO_REUSEADDR (Docker's port publisher does exactly this) lets us bind +/// the *same* address again, so a genuinely busy port reports free. Verified +/// on a machine where a container published 8080: the wildcard bind succeeded +/// while `netstat` showed it LISTENING. +async fn probe_port(port: u16) -> Option { + let process = identify_port_owner(port).await; + if process.is_some() || !bind_free(port) { + Some(PortConflict { port, process }) + } else { + None + } +} + +/// Best-effort process name holding `port` (`None` when we can't tell — the +/// message then falls back to the generic "another web server" hint). +async fn identify_port_owner(port: u16) -> Option { + #[cfg(target_os = "windows")] + let output = { + let ps = format!( + "$c = Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue \ + | Select-Object -First 1; \ + if ($c) {{ (Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName }}" + ); + docker::no_window( + tokio::process::Command::new("powershell").args(["-NoProfile", "-Command", &ps]), + ) + .output() + .await + }; + #[cfg(not(target_os = "windows"))] + let output = docker::no_window(tokio::process::Command::new("lsof").args([ + "-nP", + &format!("-iTCP:{port}"), + "-sTCP:LISTEN", + "-F", + "c", + ])) + .output() + .await; + + let out = output.ok()?; + let stdout = String::from_utf8_lossy(&out.stdout); + let name = stdout + .lines() + // `lsof -F c` prefixes the command name with 'c'; PowerShell prints it bare. + .filter_map(|l| Some(l.trim()).filter(|l| !l.is_empty())) + .map(|l| l.strip_prefix('c').unwrap_or(l)) + .next()? + .to_string(); + Some(name).filter(|n| !n.is_empty()) +} + +/// Every TCP port in LISTEN state on the host, from one OS query. +/// +/// `probe_port` answers "who holds *this* port" and costs a subprocess per +/// call; port *allocation* needs the whole set at once (site.rs walks upward +/// from 8081), so it gets this instead — one spawn, no owner lookup. +/// +/// Same authority as `probe_port` and for the same reason: a bare bind test +/// misses ports published by Docker (SO_REUSEADDR lets the wildcard address be +/// re-bound), which would hand out a port that then fails at `compose up`. +/// Best effort — an empty set on error just falls back to bind-only checks. +pub async fn listening_ports() -> std::collections::HashSet { + #[cfg(target_os = "windows")] + let output = docker::no_window(tokio::process::Command::new("powershell").args([ + "-NoProfile", + "-Command", + "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue \ + | Select-Object -ExpandProperty LocalPort", + ])) + .output() + .await; + #[cfg(not(target_os = "windows"))] + let output = docker::no_window(tokio::process::Command::new("lsof").args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-F", + "n", + ])) + .output() + .await; + + match output { + Ok(out) => parse_listening_ports(&String::from_utf8_lossy(&out.stdout)), + Err(_) => std::collections::HashSet::new(), + } +} + +/// Parse the listener query output. Windows prints one bare port per line; +/// `lsof -F n` prints `n:` (addr may be `*`, IPv4, or a bracketed +/// IPv6 literal), interleaved with other `-F` field lines we ignore. +fn parse_listening_ports(stdout: &str) -> std::collections::HashSet { + stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let candidate = match line.strip_prefix('n') { + // lsof: take everything after the last ':' (IPv6 has several). + Some(addr) => addr.rsplit(':').next()?, + None => line, + }; + candidate.parse::().ok() + }) + .collect() +} + +/// Which of the router's ports are held by something else. +pub async fn probe_ports(http: u16, https: u16) -> Vec { + let mut conflicts = Vec::new(); + for port in [http, https] { + if let Some(conflict) = probe_port(port).await { + conflicts.push(conflict); + } + } + conflicts +} + +/// User-facing explanation of a port conflict. Pure (unit-tested); the +/// remediation half depends on whether the user is already on fallback ports. +fn conflict_message(conflicts: &[PortConflict], on_default_ports: bool) -> String { + let held = conflicts + .iter() + .map(|c| match &c.process { + Some(p) => format!("port {} is held by {p}", c.port), + None => format!("port {} is in use", c.port), + }) + .collect::>() + .join(", "); + let fix = if on_default_ports { + "Quit the other program (LocalWP's router, IIS, Skype, or another web server), \ + or switch LocalKit to fallback ports (8080/8443) in Settings → Domains." + } else { + "Quit whatever is holding those ports, or pick different router ports in \ + Settings → Domains." + }; + format!("Local domains could not start: {held}. {fix}") +} + // --------------------------------------------------------------------------- // Hosts file management (`.test` does not auto-resolve — browsers AND the OS // resolver need `127.0.0.1 .test` entries). Edits are made inside a @@ -321,11 +599,16 @@ async fn sync_hosts(slugs: &[String]) -> Result<(), String> { } fn site_slugs(state: &AppState) -> Vec { - list_sites(state) - .unwrap_or_default() - .iter() - .map(|s| s.slug.clone()) - .collect() + let mut slugs = Vec::new(); + for s in list_sites(state).unwrap_or_default().iter().filter(|s| s.capabilities.domains) { + slugs.push(s.slug.clone()); + // Adminer's `db-.test` needs its own hosts entry too, or the + // Caddy db-route the caddyfile carries never resolves (plan 24). + if s.capabilities.db_gui { + slugs.push(format!("db-{}", s.slug)); + } + } + slugs } fn get_flag(state: &AppState, key: &str) -> Result { @@ -349,9 +632,29 @@ fn list_sites(state: &AppState) -> Result, String> { db.list_sites() } +/// Configured router host ports (`app_settings` KV — no migration). Never +/// fails: unset or unparseable values fall back to 80/443. +pub fn router_ports(state: &AppState) -> RouterPorts { + let Ok(db) = state.db.lock() else { + return RouterPorts::default(); + }; + let read = |key: &str, fallback: u16| -> u16 { + db.get_setting(key) + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(fallback) + }; + RouterPorts { + http: read(KEY_HTTP_PORT, DEFAULT_HTTP_PORT), + https: read(KEY_HTTPS_PORT, DEFAULT_HTTPS_PORT), + } +} + /// (domains_enabled, ca_trusted) — used by site creation to pick the /// WordPress install URL. Never fails; defaults to (false, false). -pub fn enabled_and_trusted(state: &AppState) -> (bool, bool) { +fn enabled_and_trusted(state: &AppState) -> (bool, bool) { let Ok(db) = state.db.lock() else { return (false, false); }; @@ -374,34 +677,57 @@ pub async fn status(state: &AppState) -> Result { db.get_setting(KEY_LAST_ERROR)?.filter(|s| !s.is_empty()), ) }; - let dir = router_dir(&state.data_dir); - let running = if dir.join("docker-compose.yml").exists() { - match docker::compose_ps(&dir).await { - Ok(containers) => containers - .iter() - .any(|c| c.service == "caddy" && c.state == "running"), - Err(_) => false, - } + let running = is_running(state).await; + let ports = router_ports(state); + // Diagnose a persistent conflict on every status read, so reopening the + // app while LocalWP still holds 80/443 shows the same named cause instead + // of a bare "router is not running". + let conflicts = if enabled && !running { + probe_ports(ports.http, ports.https).await } else { - false + Vec::new() }; Ok(RouterStatus { enabled, running, ca_trusted, error: last_error, + conflicts, + http_port: ports.http, + https_port: ports.https, }) } +/// Is our own Caddy container up? (Distinguishes "port 80 is busy because the +/// router owns it" from a real foreign conflict.) +async fn is_running(state: &AppState) -> bool { + let dir = router_dir(&state.data_dir); + if !dir.join("docker-compose.yml").exists() { + return false; + } + match docker::compose_ps(&dir).await { + Ok(containers) => containers + .iter() + .any(|c| c.service == "caddy" && c.state == "running"), + Err(_) => false, + } +} + /// Best-effort rewrite of `home`/`siteurl` for every running site. /// Returns messages for the sites that failed (never fails the caller). async fn rewrite_site_urls(state: &AppState, to_domains: bool) -> Vec { let ca_trusted = get_flag(state, KEY_CA_TRUSTED).unwrap_or(false); + let ports = router_ports(state); let sites = list_sites(state).unwrap_or_default(); let mut failures = Vec::new(); - for site in sites.iter().filter(|s| s.status == "running") { + // Only WordPress-shaped sites have `home`/`siteurl` to rewrite; a docker + // app is reached at its domain with nothing baked into a database. + for site in sites + .iter() + .filter(|s| s.status == "running" && s.capabilities.search_replace) + { let url = if to_domains { - site_url(&site.slug, ca_trusted) + site_url(&site.slug, ca_trusted, ports) } else { format!("http://localhost:{}", site.port) }; @@ -414,8 +740,24 @@ async fn rewrite_site_urls(state: &AppState, to_domains: bool) -> Vec { pub async fn set_enabled(state: &AppState, enabled: bool) -> Result { if enabled { + // Pre-flight BEFORE touching the hosts file: writing `127.0.0.1 + // .test` while another program owns port 80 would point every + // site at that program's router (LocalWP answers unknown hosts with + // its own 404), which looks like LocalKit is broken. + let ports = router_ports(state); + if !is_running(state).await { + let conflicts = probe_ports(ports.http, ports.https).await; + if !conflicts.is_empty() { + let msg = conflict_message(&conflicts, ports.is_default()); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + st.conflicts = conflicts; + return Ok(st); + } + } let sites = list_sites(state)?; - let dir = write_files(&state.data_dir, &sites)?; + let dir = write_files(&state.data_dir, &sites, ports)?; // Hosts entries first: if the user declines elevation, nothing else // changes and the flag stays off. if let Err(e) = sync_hosts(&site_slugs(state)).await { @@ -495,10 +837,78 @@ pub async fn refresh_routes(state: &AppState) { return; } let Ok(sites) = list_sites(state) else { return }; - let Ok(dir) = write_files(&state.data_dir, &sites) else { return }; + let ports = router_ports(state); + let Ok(dir) = write_files(&state.data_dir, &sites, ports) else { return }; let _ = reload(&dir).await; } +/// Change the router's host ports (fallback mode). Validates, pre-flights the +/// *new* ports, regenerates compose, restarts the router on them, and rewrites +/// running sites' WordPress URLs — the same path the enable toggle uses, so +/// `home`/`siteurl` never drift from where the site is actually served. +pub async fn set_ports(state: &AppState, http: u16, https: u16) -> Result { + let ports = RouterPorts { http, https }; + ports.validate()?; + if ports == router_ports(state) { + return status(state).await; + } + + let enabled = get_flag(state, KEY_ENABLED).unwrap_or(false); + let dir = router_dir(&state.data_dir); + // Free the old ports before probing the new ones — otherwise a swap that + // reuses one of them would see our own container as the conflict. + if enabled && dir.join("docker-compose.yml").exists() { + let _ = docker::compose_down(&dir, false).await; + } + if enabled { + let conflicts = probe_ports(ports.http, ports.https).await; + if !conflicts.is_empty() { + // Leave the old ports in settings: the router is down either way, + // but the user's previous working config is worth preserving. + let msg = conflict_message(&conflicts, ports.is_default()); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + st.conflicts = conflicts; + return Ok(st); + } + } + + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_setting(KEY_HTTP_PORT, &ports.http.to_string())?; + db.set_setting(KEY_HTTPS_PORT, &ports.https.to_string())?; + } + + if !enabled { + // Ports are recorded; the router starts on them at the next enable. + return status(state).await; + } + + let sites = list_sites(state)?; + let dir = write_files(&state.data_dir, &sites, ports)?; + if let Err(e) = docker::compose_up(&dir).await { + let msg = port_conflict_hint(&e); + set_last_error(state, Some(&msg)); + let mut st = status(state).await?; + st.error = Some(msg); + return Ok(st); + } + set_last_error(state, None); + let _ = reload(&dir).await; + let failures = rewrite_site_urls(state, true).await; + let mut st = status(state).await?; + if !failures.is_empty() { + st.error = Some(format!( + "Router restarted on ports {}/{}, but the WordPress URL rewrite failed for: {}", + ports.http, + ports.https, + failures.join("; ") + )); + } + Ok(st) +} + /// Reconcile the managed hosts block after site create/delete (the slug set /// changed). Elevated; best-effort — failures are recorded as the router's /// last error rather than failing the site operation. @@ -589,6 +999,30 @@ mod tests { names.iter().map(|s| s.to_string()).collect() } + #[test] + fn parses_the_windows_listener_table() { + // `Select-Object -ExpandProperty LocalPort`: one bare port per line. + let ports = parse_listening_ports("80\r\n443\r\n8081\r\n18081\r\n"); + assert_eq!(ports.len(), 4); + assert!(ports.contains(&8081) && ports.contains(&18081)); + } + + #[test] + fn parses_lsof_listener_output() { + // `lsof -F n` interleaves `p` lines; IPv6 names carry extra colons. + let ports = parse_listening_ports("p123\nn*:8081\nn127.0.0.1:18081\np456\nn[::1]:443\n"); + assert_eq!(ports.len(), 3); + assert!(ports.contains(&8081)); + assert!(ports.contains(&18081)); + assert!(ports.contains(&443), "IPv6 literal should not eat the port"); + } + + #[test] + fn listener_parsing_ignores_junk() { + let ports = parse_listening_ports("\nLocalPort\n-------\n\nnot-a-port\n99999999\n8082\n"); + assert_eq!(ports, std::collections::HashSet::from([8082])); + } + const SAMPLE: &str = "# Copyright (c) Microsoft Corp.\r\n\ \r\n\ 127.0.0.1 localhost\r\n\ @@ -647,6 +1081,206 @@ mod tests { assert!(out.contains("127.0.0.1 x.test")); } + // --- plan 16: port pre-flight ----------------------------------------- + + #[test] + fn bind_free_reports_true_for_an_unbound_port() { + // Bind an ephemeral port, learn its number, release it, then probe. + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + assert!(bind_free(port)); + } + + #[test] + fn bind_free_reports_false_while_a_port_is_held() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(!bind_free(port), "a bound loopback port is not free"); + drop(listener); + } + + #[tokio::test] + async fn probe_catches_a_wildcard_listener_that_still_allows_rebinding() { + // The Windows SO_REUSEADDR trap: a wildcard listener can be re-bound, + // so `bind_free` alone reports the port free. The OS listener table + // is what actually catches it, so `probe_port` must still flag it. + let listener = std::net::TcpListener::bind(("0.0.0.0", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!( + probe_port(port).await.is_some(), + "a port with a live wildcard listener must be reported as in use \ + (bind_free said free={})", + bind_free(port) + ); + drop(listener); + } + + #[tokio::test] + async fn probe_returns_empty_when_ports_are_free() { + let a = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let b = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let (pa, pb) = (a.local_addr().unwrap().port(), b.local_addr().unwrap().port()); + drop(a); + drop(b); + assert_eq!(probe_ports(pa, pb).await, Vec::new()); + } + + #[tokio::test] + async fn probe_reports_the_held_port_only() { + let held = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let taken = held.local_addr().unwrap().port(); + let free_listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let free = free_listener.local_addr().unwrap().port(); + drop(free_listener); + + let conflicts = probe_ports(taken, free).await; + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].port, taken); + drop(held); + } + + // --- plan 16: configurable ports -------------------------------------- + + #[test] + fn site_url_is_clean_on_default_ports() { + let d = RouterPorts::default(); + assert_eq!(site_url("acme", false, d), "http://acme.test"); + assert_eq!(site_url("acme", true, d), "https://acme.test"); + } + + #[test] + fn site_url_carries_the_port_in_fallback_mode() { + let fb = RouterPorts { http: FALLBACK_HTTP_PORT, https: FALLBACK_HTTPS_PORT }; + assert_eq!(site_url("acme", false, fb), "http://acme.test:8080"); + // Even with a trusted CA we stay on http: a non-standard https port + // would prompt for a second certificate exception. + assert_eq!(site_url("acme", true, fb), "http://acme.test:8080"); + } + + #[test] + fn render_compose_maps_host_ports_to_container_80_443() { + let yml = render_compose(RouterPorts { http: 8080, https: 8443 }); + assert!(yml.contains("\"8080:80\""), "{yml}"); + assert!(yml.contains("\"8443:443\""), "{yml}"); + // The default render is unchanged from the M6 template. + let default_yml = render_compose(RouterPorts::default()); + assert!(default_yml.contains("\"80:80\"")); + assert!(default_yml.contains("\"443:443\"")); + } + + #[test] + fn caddyfile_is_port_blind() { + // Only the host mapping moves — the Caddyfile never mentions host ports. + let sites: Vec = Vec::new(); + assert_eq!(render_caddyfile(&sites), render_caddyfile(&sites)); + assert!(!render_caddyfile(&sites).contains("8080")); + } + + /// Build a Site for the routing tests. `app_port` overrides the upstream + /// (a docker project on its own published port); `None` = the site port. + fn site_for(slug: &str, port: u16, kind: &str, app_port: Option) -> Site { + let mut s = Site { + id: slug.into(), + name: slug.into(), + slug: slug.into(), + path: format!("/tmp/{slug}"), + port, + wp_version: String::new(), + php_version: String::new(), + status: "running".into(), + status_updated_at: "2026-07-21T00:00:00Z".into(), + admin_user: "admin".into(), + admin_pass: String::new(), + created_at: "2026-07-21T00:00:00Z".into(), + connection_id: None, + remote_site_id: None, + kind: kind.into(), + config: crate::site::SiteConfig { app_port, ..Default::default() }, + capabilities: crate::site::Capabilities::default(), + }; + s.refresh_capabilities(); + s + } + + /// Plan 22: a WordPress site routes to its site port, while a docker app + /// routes to its own published app port — the "domain → app port" contract. + #[test] + fn caddyfile_routes_a_docker_app_to_its_app_port() { + let wp = site_for("blog", 8081, crate::site::KIND_WORDPRESS, None); + let app = site_for("api", 8090, crate::site::KIND_DOCKER, Some(3000)); + let out = render_caddyfile(&[wp, app]); + assert!(out.contains("http://blog.test")); + assert!(out.contains("reverse_proxy host.docker.internal:8081"), "WP → site port:\n{out}"); + assert!(out.contains("http://api.test")); + assert!( + out.contains("reverse_proxy host.docker.internal:3000"), + "docker → app_port, not the reserved site port:\n{out}" + ); + assert!(!out.contains("8090"), "the reserved site port must not be the upstream:\n{out}"); + } + + /// Plan 24: a db-GUI site gets a `db-.test` route to its Adminer port + /// (db_port + 1000); a code-only docker app does not. + #[test] + fn caddyfile_adds_a_db_route_for_db_gui_sites_only() { + let wp = site_for("blog", 8081, crate::site::KIND_WORDPRESS, None); + let app = site_for("api", 8090, crate::site::KIND_DOCKER, Some(3000)); + let out = render_caddyfile(&[wp, app]); + // blog: adminer_port = 8081 + 10000 + 1000 = 19081. + assert!(out.contains("http://db-blog.test"), "{out}"); + assert!(out.contains("reverse_proxy host.docker.internal:19081"), "{out}"); + assert!(!out.contains("db-api.test"), "a code-only docker app must not get a db route:\n{out}"); + } + + #[test] + fn port_validation_rejects_zero_and_duplicates() { + assert!(RouterPorts::default().validate().is_ok()); + assert!(RouterPorts { http: 0, https: 443 }.validate().is_err()); + assert!(RouterPorts { http: 8080, https: 8080 }.validate().is_err()); + assert!(RouterPorts { http: 8080, https: 8443 }.validate().is_ok()); + } + + #[test] + fn is_default_only_for_80_443() { + assert!(RouterPorts::default().is_default()); + assert!(!RouterPorts { http: 8080, https: 443 }.is_default()); + assert!(!RouterPorts { http: 80, https: 8443 }.is_default()); + } + + #[test] + fn conflict_message_names_the_process_and_offers_fallback() { + let msg = conflict_message( + &[PortConflict { port: 80, process: Some("httpd.exe".into()) }], + true, + ); + assert!(msg.contains("port 80 is held by httpd.exe"), "{msg}"); + assert!(msg.contains("fallback ports (8080/8443)"), "{msg}"); + } + + #[test] + fn conflict_message_falls_back_when_the_owner_is_unknown() { + let msg = conflict_message( + &[ + PortConflict { port: 80, process: None }, + PortConflict { port: 443, process: None }, + ], + true, + ); + assert!(msg.contains("port 80 is in use"), "{msg}"); + assert!(msg.contains("port 443 is in use"), "{msg}"); + } + + #[test] + fn conflict_message_on_fallback_ports_does_not_suggest_fallback_again() { + let msg = conflict_message( + &[PortConflict { port: 8080, process: Some("node".into()) }], + false, + ); + assert!(msg.contains("port 8080 is held by node"), "{msg}"); + assert!(!msg.contains("8080/8443"), "must not loop the same advice: {msg}"); + } + #[test] fn staged_content_round_trips_through_temp_file() { // Mirrors what sync_hosts stages for the elevated writer. diff --git a/src-tauri/src/serverkit.rs b/src-tauri/src/serverkit.rs index 61bb7d1..4611929 100644 --- a/src-tauri/src/serverkit.rs +++ b/src-tauri/src/serverkit.rs @@ -18,6 +18,24 @@ pub struct ServerKitConnection { pub created_at: String, } +/// Extension capability names reported by `GET /pair` (plan 18). An older +/// extension simply omits one, and the matching UI is disabled instead of +/// failing halfway through an operation. +pub const FEATURE_PULL_CODE: &str = "pull-code"; +/// Chunked, resumable transfers (plan 19). Absent = talk v1 to this server. +pub const FEATURE_SYNC_V2: &str = "sync-v2"; + +/// Per-chunk request budget. reqwest's `timeout` is a *total* request budget, +/// so with one chunk per request this is exactly the per-chunk timeout the +/// plan calls for: the whole operation is bounded by liveness, not duration, +/// and a two-hour upload never trips a clock as long as chunks keep landing. +const CHUNK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// How many times one chunk is retried before the transfer gives up. A failed +/// transfer is resumable anyway, so this only saves the user from having to +/// press the button again after a momentary blip. +const CHUNK_ATTEMPTS: u32 = 3; + /// Result of a successful connection test. #[derive(Debug, Clone, Serialize)] pub struct ServerKitInfo { @@ -29,9 +47,17 @@ pub struct ServerKitInfo { pub api_key_valid: bool, /// Whether the serverkit-localkit extension (M4 push/pull) is installed. pub localkit_extension: bool, + /// Capabilities the extension advertises. Empty for extensions predating + /// the `features` array — callers must treat "absent" as "unsupported", + /// never as "unknown, try anyway". + pub features: Vec, + /// Site kinds this extension can sync (plan 26). Absent/empty means a + /// pre-plan-26 server that only knows WordPress — callers fall back to + /// `["wordpress"]` so an old server ↔ new client stays safe. + pub kinds: Vec, } -/// A remote WordPress site as listed by `GET /api/v1/wordpress/sites`. +/// A remote site as listed by `GET /api/v1/localkit/sites`. #[derive(Debug, Clone, Serialize)] pub struct RemoteWpSite { pub id: i64, @@ -39,15 +65,34 @@ pub struct RemoteWpSite { pub url: Option, pub status: String, pub wp_version: Option, + pub php_version: Option, + /// Stack kind (plan 26): `wordpress` (default for a pre-plan-26 server that + /// omits it) | `php`. Gates which import/sync the client offers. + pub kind: String, + /// Multisite installs are refused by the import flow — one local compose + /// project cannot represent a network of sites. + pub multisite: bool, pub environment_count: i64, } const USER_AGENT: &str = concat!("LocalKit/", env!("CARGO_PKG_VERSION")); fn client() -> Result { + build_client(std::time::Duration::from_secs(15)) +} + +/// Client for archive/dump transfers. The 15 s probe timeout is a *total* +/// request budget in reqwest, so it would abort any real push or pull the +/// moment the payload outgrew a fast link — bulk transfers get their own +/// generous ceiling instead. +fn transfer_client() -> Result { + build_client(std::time::Duration::from_secs(1800)) +} + +fn build_client(timeout: std::time::Duration) -> Result { reqwest::Client::builder() .user_agent(USER_AGENT) - .timeout(std::time::Duration::from_secs(15)) + .timeout(timeout) .build() .map_err(|e| format!("failed to build HTTP client: {e}")) } @@ -135,14 +180,13 @@ pub async fn test_connection(url: &str, api_key: &str) -> Result return Err(format!("API key validation failed with HTTP {code}.")), } - // Step 3 (best-effort): is the serverkit-localkit extension installed? - let localkit_extension = http - .get(format!("{base}/api/v1/localkit/pair")) - .header("X-API-Key", api_key) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false); + // Step 3 (best-effort): is the serverkit-localkit extension installed, and + // what can this build of it do? + let pair = pair(&base, api_key).await; + let localkit_extension = pair.is_some(); + let (features, kinds) = pair + .map(|p| (p.features, normalize_kinds(p.kinds))) + .unwrap_or_default(); Ok(ServerKitInfo { status: health.status.unwrap_or_else(|| "unknown".into()), @@ -152,9 +196,75 @@ pub async fn test_connection(url: &str, api_key: &str) -> Result) -> Vec { + if kinds.is_empty() { + vec![crate::site::KIND_WORDPRESS.to_string()] + } else { + kinds + } +} + +#[derive(Deserialize, Default)] +struct PairResponse { + #[serde(default)] + features: Vec, + #[serde(default)] + kinds: Vec, +} + +/// `GET /pair` — extension presence probe. `None` means "not installed or +/// unreachable"; the features list is empty on extensions predating plan 18. +async fn pair(base: &str, api_key: &str) -> Option { + let resp = client() + .ok()? + .get(format!("{base}/api/v1/localkit/pair")) + .header("X-API-Key", api_key) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + // A 200 without a parseable body still proves the extension is there. + Some(resp.json().await.unwrap_or_default()) +} + +/// Does this server's extension advertise `feature`? +/// +/// Used to gate the Import flow: without `pull-code` there is no way to fetch +/// the remote `wp-content`, and finding that out mid-import would leave a +/// half-built local site behind. +pub async fn has_feature(url: &str, api_key: &str, feature: &str) -> Result { + let base = normalize_base_url(url)?; + Ok(pair(&base, api_key) + .await + .is_some_and(|p| p.features.iter().any(|f| f == feature))) +} + +/// Can this server's extension sync a site of `kind` (plan 26)? +/// +/// WordPress is always supported (every extension version can do it); other +/// kinds require the server to advertise them in `/pair`'s `kinds`. A failed +/// probe answers "no" for non-WordPress kinds — never start a per-kind sync the +/// server can't finish. +pub async fn supports_kind(url: &str, api_key: &str, kind: &str) -> Result { + if kind == crate::site::KIND_WORDPRESS { + return Ok(true); + } + let base = normalize_base_url(url)?; + Ok(pair(&base, api_key) + .await + .map(|p| normalize_kinds(p.kinds)) + .is_some_and(|kinds| kinds.iter().any(|k| k == kind))) +} + #[derive(Deserialize)] struct SitesResponse { #[serde(default)] @@ -168,11 +278,22 @@ struct RawSite { name: Option, #[serde(default)] url: Option, + /// Explicit alias of `url` added by the plan-18 extension; either may be + /// absent depending on the extension version. + #[serde(default)] + site_url: Option, #[serde(default)] status: Option, #[serde(default)] wp_version: Option, #[serde(default)] + php_version: Option, + /// Stack kind (plan 26). Absent on a pre-plan-26 server → WordPress. + #[serde(default)] + kind: Option, + #[serde(default)] + multisite: bool, + #[serde(default)] environment_count: i64, } @@ -220,9 +341,15 @@ pub async fn list_wp_sites(url: &str, api_key: &str) -> Result .map(|s| RemoteWpSite { id: s.id, name: s.name.unwrap_or_else(|| format!("site-{}", s.id)), - url: s.url, + url: s.url.or(s.site_url).filter(|u| !u.is_empty()), status: s.status.unwrap_or_else(|| "unknown".into()), wp_version: s.wp_version, + php_version: s.php_version, + kind: s + .kind + .filter(|k| !k.is_empty()) + .unwrap_or_else(|| crate::site::KIND_WORDPRESS.to_string()), + multisite: s.multisite, environment_count: s.environment_count, }) .collect()) @@ -247,7 +374,7 @@ async fn post_multipart( file_bytes: Vec, ) -> Result { let base = normalize_base_url(url)?; - let http = client()?; + let http = transfer_client()?; let mut form = reqwest::multipart::Form::new(); for (k, v) in fields { form = form.text((*k).to_string(), v.clone()); @@ -320,10 +447,35 @@ pub async fn push_db( /// Download a gzipped SQL dump of a remote site (`GET /api/v1/localkit/pull/db`). pub async fn pull_db(url: &str, api_key: &str, remote_site_id: i64) -> Result, String> { + download(url, api_key, "/api/v1/localkit/pull/db", remote_site_id, "database dump").await +} + +/// Download a tar.gz of a remote site's `wp-content` +/// (`GET /api/v1/localkit/pull/code`, plan 18). +/// +/// Only available on extensions advertising the `pull-code` feature — callers +/// should check `has_feature` first so the failure surfaces before any local +/// site has been provisioned. +pub async fn pull_code(url: &str, api_key: &str, remote_site_id: i64) -> Result, String> { + download(url, api_key, "/api/v1/localkit/pull/code", remote_site_id, "wp-content archive").await +} + +/// Shared `GET ?site_id=` binary download against the extension. +/// +/// Downloads are not bounded by the server's 100 MB upload limit, but they are +/// still read fully into memory here — plan 19 (chunked sync) is what lifts +/// that for genuinely large sites. +async fn download( + url: &str, + api_key: &str, + path: &str, + remote_site_id: i64, + what: &str, +) -> Result, String> { let base = normalize_base_url(url)?; - let http = client()?; + let http = transfer_client()?; let resp = http - .get(format!("{base}/api/v1/localkit/pull/db")) + .get(format!("{base}{path}")) .query(&[("site_id", remote_site_id.to_string())]) .header("X-API-Key", api_key) .send() @@ -345,7 +497,320 @@ pub async fn pull_db(url: &str, api_key: &str, remote_site_id: i64) -> Result = &'a (dyn Fn(u64, u64) + Send + Sync); + +#[derive(Deserialize)] +struct InitResponse { + transfer_id: String, + #[serde(default)] + chunk_size: Option, + /// Offsets the server already holds — subtracting these from our own + /// chunk plan is the entirety of resume. + #[serde(default)] + received: Vec, +} + +/// Map an extension error response onto something a user can act on. +fn api_error(what: &str, code: u16, body: &str) -> String { + match code { + 404 => extract_error(body).unwrap_or_else(|| { + "The serverkit-localkit extension is not installed on this ServerKit server (404).".into() + }), + 401 | 403 => "The API key was rejected (or lacks admin rights). Check the key.".into(), + 413 => "The upload is too large for the server (ServerKit limit is 100MB).".into(), + _ => extract_error(body).unwrap_or_else(|| format!("{what} failed with HTTP {code}.")), + } +} + +/// Upload a staged payload in chunks, resuming whatever a previous attempt left +/// behind (`POST init` → `PUT chunk`… → `POST finish`). +/// +/// `kind` is `"code"` or `"db"`. The server only processes anything in +/// `finish`, and only after the whole-file hash verifies — so abandoning this +/// mid-way (cancel, crash, dropped link) can never leave the remote site half +/// updated. +#[allow(clippy::too_many_arguments)] +pub async fn push_chunked( + url: &str, + api_key: &str, + kind: &str, + remote_site_id: i64, + local_url: Option<&str>, + staged: &crate::transfer::Staged, + cancel: &crate::transfer::CancelToken, + progress: ProgressFn<'_>, +) -> Result { + use crate::transfer; + + let base = normalize_base_url(url)?; + let http = build_client(CHUNK_TIMEOUT)?; + let total = staged.total(); + + let mut init_body = serde_json::json!({ + "site_id": remote_site_id, + "total_bytes": total, + "chunk_size": transfer::CHUNK_SIZE, + "sha256": staged.sha256(), + "filename": if kind == "code" { "wp-content.tar.gz" } else { "dump.sql" }, + }); + if let Some(u) = local_url { + init_body["local_url"] = serde_json::Value::String(u.to_string()); + } + + let init: InitResponse = post_json( + &http, + &base, + &format!("/api/v1/localkit/push/{kind}/init"), + api_key, + &init_body, + "Starting the upload", + ) + .await?; + + // The server owns the offsets, so if it reports a chunk size, that is the + // one the plan has to be built from. + let chunk_size = init.chunk_size.filter(|c| *c > 0).unwrap_or(transfer::CHUNK_SIZE); + let plan = transfer::remaining(total, chunk_size, &init.received); + let mut done = total.saturating_sub(transfer::bytes_of(&plan)); + progress(done, total); + + for chunk in plan { + cancel.check()?; + let bytes = staged.read_chunk(chunk)?; + let chunk_sha = transfer::sha256_hex(&bytes); + put_chunk(&http, &base, api_key, kind, &init.transfer_id, chunk, &chunk_sha, bytes, cancel) + .await?; + done += chunk.len; + progress(done, total); + } + + cancel.check()?; + // `finish` runs the server-side extract/import, which can take minutes on a + // big site — it gets the generous transfer budget, not the chunk one. + post_json( + &transfer_client()?, + &base, + &format!("/api/v1/localkit/push/{kind}/finish"), + api_key, + &serde_json::json!({ "transfer_id": init.transfer_id }), + "Finishing the upload", + ) + .await +} + +async fn post_json( + http: &reqwest::Client, + base: &str, + path: &str, + api_key: &str, + body: &serde_json::Value, + what: &str, +) -> Result { + let resp = http + .post(format!("{base}{path}")) + .header("X-API-Key", api_key) + .json(body) + .send() + .await + .map_err(|e| request_error(base, &e))?; + let code = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + if !(200..300).contains(&code) { + return Err(api_error(what, code, &text)); + } + serde_json::from_str(&text).map_err(|e| format!("{what}: unexpected server response ({e})")) +} + +/// PUT one chunk, retrying transient failures. +/// +/// Only transport errors and 5xx are retried: a 4xx means the server rejected +/// what we sent (bad offset, failed checksum), and sending the identical bytes +/// again would fail identically. +#[allow(clippy::too_many_arguments)] +async fn put_chunk( + http: &reqwest::Client, + base: &str, + api_key: &str, + kind: &str, + transfer_id: &str, + chunk: crate::transfer::Chunk, + chunk_sha: &str, + bytes: Vec, + cancel: &crate::transfer::CancelToken, +) -> Result<(), String> { + let url = format!("{base}/api/v1/localkit/push/{kind}/chunk"); + let mut last = String::new(); + for attempt in 1..=CHUNK_ATTEMPTS { + cancel.check()?; + let result = http + .put(&url) + .header("X-API-Key", api_key) + .header("Content-Type", "application/octet-stream") + .query(&[ + ("transfer_id", transfer_id.to_string()), + ("offset", chunk.offset.to_string()), + ("sha256", chunk_sha.to_string()), + ]) + .body(bytes.clone()) + .send() + .await; + + match result { + Ok(resp) => { + let code = resp.status().as_u16(); + if (200..300).contains(&code) { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + last = api_error(&format!("Uploading the chunk at {}", chunk.offset), code, &body); + if code < 500 { + return Err(last); + } + } + Err(e) => last = request_error(base, &e), + } + + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + } + Err(last) +} + +/// Download to a temp file with byte progress, resume and cancel. +/// +/// No protocol invention: HTTP `Range` already is the chunked protocol in this +/// direction. `session` pins one materialized export on the server so that the +/// ranges of an interrupted download all come from the same bytes; a server +/// that ignores it (or has reaped the session) answers a range request with +/// the whole body, and the `200` branch below simply starts over. +pub async fn download_resumable( + url: &str, + api_key: &str, + path: &str, + remote_site_id: i64, + what: &str, + cancel: &crate::transfer::CancelToken, + progress: ProgressFn<'_>, +) -> Result { + use std::io::Write; + + let base = normalize_base_url(url)?; + let http = build_client(CHUNK_TIMEOUT)?; + let session = uuid::Uuid::new_v4().simple().to_string(); + let temp = crate::transfer::TempFile::new("download")?; + + let mut etag: Option = None; + let mut last = String::new(); + + for attempt in 1..=CHUNK_ATTEMPTS { + cancel.check()?; + let have = temp.len(); + + let mut req = http + .get(format!("{base}{path}")) + .query(&[ + ("site_id", remote_site_id.to_string()), + ("session", session.clone()), + ]) + .header("X-API-Key", api_key); + if have > 0 { + req = req.header("Range", format!("bytes={have}-")); + // If-Range makes the resume safe: if the export changed under us, + // the server owes us a 200 with the whole body rather than a tail + // that would splice into nonsense. + if let Some(tag) = &etag { + req = req.header("If-Range", tag.clone()); + } + } + + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + last = request_error(&base, &e); + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + continue; + } + }; + + let code = resp.status().as_u16(); + if code != 200 && code != 206 { + let body = resp.text().await.unwrap_or_default(); + return Err(api_error(&format!("Downloading the {what}"), code, &body)); + } + + etag = resp + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + // A 200 to a ranged request means "here is everything" — the partial + // file is worthless, so throw it away rather than appending a second + // copy of the head onto it. + let mut done = if code == 206 { have } else { 0 }; + if code == 200 && have > 0 { + temp.truncate()?; + } + let total = resp.content_length().map(|len| done + len).unwrap_or(0); + progress(done, total); + + let mut file = std::fs::OpenOptions::new() + .write(true) + .append(true) + .open(temp.path()) + .map_err(|e| format!("failed to open the download file: {e}"))?; + + let mut resp = resp; + let mut stalled = None; + loop { + if cancel.cancelled() { + return Err(crate::transfer::CANCELLED.to_string()); + } + match resp.chunk().await { + Ok(Some(bytes)) => { + file.write_all(&bytes) + .map_err(|e| format!("failed to write the download: {e}"))?; + done += bytes.len() as u64; + progress(done, total.max(done)); + } + Ok(None) => break, + Err(e) => { + // Mid-stream failure: flush what we have and resume from + // there on the next attempt. + stalled = Some(format!("failed to download the {what}: {e}")); + break; + } + } + } + file.flush().map_err(|e| format!("failed to write the download: {e}"))?; + drop(file); + + match stalled { + None => return Ok(temp), + Some(e) => { + last = e; + if attempt < CHUNK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + } + } + } + Err(last) } /// Provision a new remote WordPress site (`POST /api/v1/localkit/sites`). @@ -378,3 +843,32 @@ pub async fn create_remote_site( .unwrap_or_else(|| format!("Creating the remote site failed with HTTP {code}."))), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A server that advertises no kinds (pre-plan-26) is treated as WordPress- + /// only, so the client's per-kind gate has exactly one rule. + #[test] + fn absent_kinds_normalize_to_wordpress_only() { + assert_eq!(normalize_kinds(vec![]), vec!["wordpress".to_string()]); + } + + /// An advertised list is passed through verbatim (order + extras preserved). + #[test] + fn advertised_kinds_pass_through() { + let kinds = vec!["wordpress".to_string(), "php".to_string()]; + assert_eq!(normalize_kinds(kinds.clone()), kinds); + } + + /// A `/sites` entry without a `kind` deserializes to a WordPress remote; an + /// explicit one is carried through. + #[test] + fn raw_site_defaults_kind_to_wordpress() { + let wp: RawSite = serde_json::from_str(r#"{"id":1,"name":"blog"}"#).unwrap(); + assert_eq!(wp.kind, None); + let php: RawSite = serde_json::from_str(r#"{"id":2,"name":"app","kind":"php"}"#).unwrap(); + assert_eq!(php.kind.as_deref(), Some("php")); + } +} diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index 4deee53..33ed676 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -15,6 +15,158 @@ pub const DEFAULT_ADMIN_USER: &str = "admin"; pub const BASE_PORT: u16 = 8081; /// Host DB port = site port + this offset (8081 -> 18081). pub const DB_PORT_OFFSET: u16 = 10000; +/// Host Adminer port = DB port + this offset (18081 -> 19081), deterministic so +/// the profile-gated `adminer` service needs no allocator change (plan 24). +pub const ADMINER_PORT_OFFSET: u16 = 1000; + +/// Site kinds. WordPress is the reference implementation with every capability; +/// `docker` is a bring-your-own-compose project (plan 22); `php` is a generated +/// PHP/Laravel stack (plan 26). The stored default is `wordpress`, so every +/// pre-plan-22 row migrates cleanly. +pub const KIND_WORDPRESS: &str = "wordpress"; +pub const KIND_DOCKER: &str = "docker"; +pub const KIND_PHP: &str = "php"; + +/// Per-kind settings, persisted as the `config_json` column (plan 22). +/// +/// Every field is de-hardcoded from a WordPress assumption LocalKit used to +/// bake in: the terminal/log service name, the code-sync path, the router +/// upstream port, and (for docker apps) which compose service is a recognized +/// database engine. The defaults ARE the WordPress values, so a legacy row with +/// `config_json = '{}'` deserializes to exactly the behaviour it had before. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SiteConfig { + /// Compose service the terminal shells into and single-service logs read. + #[serde(default = "SiteConfig::default_service")] + pub service: String, + /// Path under the site directory that code sync + snapshots archive. + #[serde(default = "SiteConfig::default_sync_path")] + pub sync_path: String, + /// Host port the router proxies to; `None` = the site's own `port`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_port: Option, + /// Recognized DB engine in a docker app's compose (`mysql`|`mariadb`| + /// `postgres`), which flips on `db_sync`. `None` = a code-only app. + /// WordPress leaves this unset — it always has `db_sync` via wp-cli. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db_engine: Option, + /// The compose service of that DB engine, for native dumps (plan 22 phase 2). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db_service: Option, +} + +impl Default for SiteConfig { + fn default() -> Self { + Self { + service: Self::default_service(), + sync_path: Self::default_sync_path(), + app_port: None, + db_engine: None, + db_service: None, + } + } +} + +impl SiteConfig { + fn default_service() -> String { + KIND_WORDPRESS.to_string() + } + fn default_sync_path() -> String { + "wp-content".to_string() + } + /// The host port the router should proxy to — the app's own port when a + /// docker project publishes on a different one, else the site port. + pub fn upstream_port(&self, site_port: u16) -> u16 { + self.app_port.unwrap_or(site_port) + } +} + +/// What a site's kind (plus config) supports. Every feature in the app checks +/// one of these instead of assuming WordPress (plan 22). WordPress = all true; +/// docker = `domains, terminal, logs, snapshots, code_sync` (and `db_sync` when +/// a recognized DB engine is in its compose). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct Capabilities { + pub domains: bool, + pub terminal: bool, + pub logs: bool, + pub snapshots: bool, + pub db_gui: bool, + pub db_sync: bool, + pub code_sync: bool, + pub one_click_login: bool, + pub wp_tools: bool, + pub search_replace: bool, +} + +impl Capabilities { + pub const WORDPRESS: Self = Self { + domains: true, + terminal: true, + logs: true, + snapshots: true, + db_gui: true, + db_sync: true, + code_sync: true, + one_click_login: true, + wp_tools: true, + search_replace: true, + }; + pub const DOCKER: Self = Self { + domains: true, + terminal: true, + logs: true, + snapshots: true, + db_gui: false, + db_sync: false, + code_sync: true, + one_click_login: false, + wp_tools: false, + search_replace: false, + }; + /// PHP/Laravel stack (plan 26): everything a WordPress site claims *except* + /// the WP-specific trio (one-click login, the WP tools tab, search-replace). + /// Its database is synced engine-native (`db_sync`) and it gets the Adminer + /// GUI (`db_gui`), a bundled compose template like WordPress — so unlike a + /// bring-your-own docker app it can carry a first-class DB. + pub const PHP: Self = Self { + domains: true, + terminal: true, + logs: true, + snapshots: true, + db_gui: true, + db_sync: true, + code_sync: true, + one_click_login: false, + wp_tools: false, + search_replace: false, + }; + + /// Derive the capability set for a kind + its config. Every kind × every + /// capability is an explicit decision here (unit-tested), never an `if` + /// scattered through a feature. + pub fn for_kind(kind: &str, _config: &SiteConfig) -> Self { + match kind { + // Docker apps are code-only for now: `config.db_engine` is detected + // and stored so engine-native DB snapshots/dumps can land later, but + // `db_sync` stays off until they actually work — a kind must not + // claim a capability it can't deliver (the plan's own guardrail). + KIND_DOCKER => Self::DOCKER, + // PHP/Laravel: a generated stack with a bundled mariadb, so it has + // real DB sync + an Adminer GUI, but none of the WordPress-only tools. + KIND_PHP => Self::PHP, + // `wordpress` and any unknown/legacy kind fall back to the fully + // capable WordPress set — the safe default for a pre-plan-22 row. + _ => Self::WORDPRESS, + } + } +} + +impl Default for Capabilities { + fn default() -> Self { + Self::WORDPRESS + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Site { @@ -26,9 +178,39 @@ pub struct Site { pub wp_version: String, pub php_version: String, pub status: String, + /// When `status` was last written (RFC3339, UTC). The reconciler's + /// forward-only guard: a settle only lands if the stored timestamp still + /// matches what the reconciler observed, so a newer command/event write is + /// never clobbered by a stale inspect — and vice versa (plan 23). Empty on + /// a pre-plan-23 row, which sorts as "long ago". + #[serde(default)] + pub status_updated_at: String, pub admin_user: String, pub admin_pass: String, pub created_at: String, + /// Plan 18 — where this site came from. Both are set together when a site + /// is imported from a ServerKit server, and `None` on hand-made sites; + /// they let a future pull default to the right remote. + #[serde(default)] + pub connection_id: Option, + #[serde(default)] + pub remote_site_id: Option, + /// Plan 22 — stack kind (`wordpress` | `docker`) and its per-kind settings. + /// `kind` defaults to WordPress so legacy rows migrate cleanly; `config` + /// defaults to the WordPress values (service `wordpress`, sync path + /// `wp-content`). + #[serde(default = "default_kind")] + pub kind: String, + #[serde(default)] + pub config: SiteConfig, + /// Derived, read-only: what this site supports. Recomputed from `kind` + + /// `config` at every read (never persisted), so it can never drift. + #[serde(default, skip_deserializing)] + pub capabilities: Capabilities, +} + +fn default_kind() -> String { + KIND_WORDPRESS.to_string() } impl Site { @@ -36,9 +218,57 @@ impl Site { self.port + DB_PORT_OFFSET } + /// Host port the Adminer database GUI is published on: `db_port + 1000` + /// (plan 24). A deterministic offset, so the profile-gated `adminer` service + /// needs no allocator change — it is mapped in the compose template. + pub fn adminer_port(&self) -> u16 { + self.db_port() + ADMINER_PORT_OFFSET + } + pub fn dir(&self) -> PathBuf { PathBuf::from(&self.path) } + + /// Recompute `capabilities` from the current `kind`/`config`. Call after + /// building or mutating a `Site` so the derived field stays in sync. + pub fn refresh_capabilities(&mut self) { + self.capabilities = Capabilities::for_kind(&self.kind, &self.config); + } + + /// The live-status service to watch — `wordpress` for a WP site, the chosen + /// app service for a docker project. + pub fn app_service(&self) -> &str { + &self.config.service + } + + /// Guard a capability-gated command with a clean, user-displayable refusal + /// (the frontends hide the affordance; this catches a direct invoke / CLI). + pub fn require(&self, cap: bool, action: &str) -> Result<(), String> { + if cap { + Ok(()) + } else { + Err(format!( + "{action} is not supported for {} sites.", + self.kind + )) + } + } +} + +/// Filename of the completion marker written as the last step of a successful +/// create/import/clone (plan 23). A site directory that lacks it is a +/// half-created site — a create killed mid-flight. +pub const INSTALL_MARKER: &str = ".localkit-install-complete"; + +/// Write the completion marker (best-effort — a marker write failure must not +/// fail an otherwise-finished create; the next startup backfill re-adds it). +pub(crate) fn mark_complete(dir: &Path) { + let _ = std::fs::write(dir.join(INSTALL_MARKER), b""); +} + +/// Whether a site directory carries the completion marker. +pub fn is_complete(dir: &Path) -> bool { + dir.join(INSTALL_MARKER).exists() } /// A site row plus its live container status. @@ -47,6 +277,10 @@ pub struct SiteWithStatus { #[serde(flatten)] pub site: Site, pub live_status: String, + /// A half-created site (plan 23): its directory exists but the completion + /// marker is absent and no create is in flight. The UI offers Resume / Clean + /// up instead of the usual actions. + pub incomplete: bool, } /// Detail payload for the site page (includes DB credentials from .env). @@ -55,6 +289,8 @@ pub struct SiteDetail { #[serde(flatten)] pub site: Site, pub live_status: String, + /// See `SiteWithStatus::incomplete` (plan 23). + pub incomplete: bool, pub db_host: String, pub db_port: u16, pub db_name: String, @@ -67,12 +303,45 @@ pub struct SiteEvent { pub id: String, pub stage: String, pub message: String, + /// Byte counters, present only during a chunked transfer (plan 19). + /// Absent everywhere else, so every non-transfer stage keeps rendering + /// as the plain stage message it always was. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_done: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_total: Option, } /// Emit a progress event to the frontend. `app` is optional so the lifecycle /// can also be driven from tests / example binaries / the `lk` CLI without a /// Tauri runtime; in that case progress is printed to stderr instead. pub(crate) fn emit(app: Option<&AppHandle>, id: &str, stage: &str, message: &str) { + dispatch(app, id, stage, message, None, None); +} + +/// Emit a transfer-progress event carrying byte counters. +/// +/// These fire once per chunk, so the frontend gets a real byte readout instead +/// of one coarse "Uploading..." that sits there for ten minutes. +pub(crate) fn emit_bytes( + app: Option<&AppHandle>, + id: &str, + stage: &str, + message: &str, + done: u64, + total: u64, +) { + dispatch(app, id, stage, message, Some(done), Some(total)); +} + +fn dispatch( + app: Option<&AppHandle>, + id: &str, + stage: &str, + message: &str, + bytes_done: Option, + bytes_total: Option, +) { match app { Some(app) => { let _ = app.emit( @@ -81,10 +350,19 @@ pub(crate) fn emit(app: Option<&AppHandle>, id: &str, stage: &str, message: &str id: id.to_string(), stage: stage.to_string(), message: message.to_string(), + bytes_done, + bytes_total, }, ); } - None => eprintln!("[{stage}] {message}"), + None => match (bytes_done, bytes_total) { + (Some(done), Some(total)) => eprintln!( + "[{stage}] {message} ({} / {})", + crate::transfer::human_bytes(done), + crate::transfer::human_bytes(total) + ), + _ => eprintln!("[{stage}] {message}"), + }, } } @@ -119,16 +397,29 @@ fn unique_slug(state: &AppState, base: &str) -> Result { Err("could not generate a unique slug".into()) } -/// Pick a free host port starting at BASE_PORT: not used by another site and -/// not already bound on the host. -fn free_port(state: &AppState) -> Result { +/// Pick a free host port starting at BASE_PORT: not used by another site, and +/// with neither it nor its DB port already held on the host. +/// +/// The host check consults the OS listener table, not just a trial bind. A +/// bind-only test is the plan-16 SO_REUSEADDR trap all over again: Docker's +/// port publisher binds the wildcard address with SO_REUSEADDR, so binding +/// 127.0.0.1:8081 still succeeds while a container is published on 8081 — we +/// would hand out that port and creation would die at `compose up`, after the +/// image pull, with a raw Docker error. Both ports matter: only the site port +/// was ever checked, so a free site port with a taken DB port failed the same +/// way. +async fn free_port(state: &AppState) -> Result { let used = { let db = state.db.lock().map_err(|e| e.to_string())?; db.used_ports()? }; + let listening = router::listening_ports().await; + let free = |port: u16| -> bool { + !listening.contains(&port) && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() + }; let mut port = BASE_PORT; loop { - if !used.contains(&port) && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + if !used.contains(&port) && free(port) && free(port + DB_PORT_OFFSET) { return Ok(port); } port += 1; @@ -156,7 +447,17 @@ pub fn site_dir(data_dir: &Path, slug: &str) -> PathBuf { // Compose / env templates // --------------------------------------------------------------------------- +/// Render the deterministic compose file for a site, dispatched on kind. The +/// render is deterministic so a rewrite (e.g. Adminer's on-demand start) is safe +/// on a running site — it only ever adds the profile-gated tooling services. pub fn render_compose(site: &Site) -> String { + match site.kind.as_str() { + KIND_PHP => crate::php::render_compose(site), + _ => render_wordpress_compose(site), + } +} + +fn render_wordpress_compose(site: &Site) -> String { format!( r#"name: localkit-{slug} @@ -215,6 +516,21 @@ services: - wp-data:/var/www/html - ./wp-content:/var/www/html/wp-content + # Adminer database GUI (single-file PHP, ~0.5 MB). Profile-gated + off by + # default; started on demand from Tools -> Database (plan 24). The host port is + # deterministic (db_port + 1000), so no allocator change is needed. + adminer: + image: adminer:4-standalone + profiles: ["tools"] + restart: unless-stopped + ports: + - "{adminer_port}:8080" + environment: + ADMINER_DEFAULT_SERVER: db + depends_on: + db: + condition: service_healthy + volumes: wp-data: db-data: @@ -222,9 +538,28 @@ volumes: slug = site.slug, wp = site.wp_version, php = site.php_version, + adminer_port = site.adminer_port(), ) } +/// The site's application DB password from `.env`, for pre-filling the Adminer +/// login (plan 24). Empty when `.env` is missing. +pub fn db_password(dir: &Path) -> String { + read_env_value(dir, "DB_PASSWORD").unwrap_or_default() +} + +/// The site's application DB name from `.env`. Defaults to `wordpress` so a WP +/// site whose `.env` predates this reader is unchanged; a `php` site records its +/// own (`laravel`). +pub fn db_name(dir: &Path) -> String { + read_env_value(dir, "DB_NAME").unwrap_or_else(|| "wordpress".to_string()) +} + +/// The site's application DB user from `.env` (defaults to `wordpress`, as above). +pub fn db_user(dir: &Path) -> String { + read_env_value(dir, "DB_USER").unwrap_or_else(|| "wordpress".to_string()) +} + pub fn render_env(site: &Site, db_password: &str) -> String { format!( "WP_PORT={}\nDB_PORT={}\nDB_NAME=wordpress\nDB_USER=wordpress\nDB_PASSWORD={}\n", @@ -234,6 +569,18 @@ pub fn render_env(site: &Site, db_password: &str) -> String { ) } +/// Read the raw `.env` for the config editor (plan 24). A plain host file — +/// unlike `wp-config.php`, which lives in the wp-data volume. +pub fn read_env_file(dir: &Path) -> Result { + std::fs::read_to_string(dir.join(".env")).map_err(|e| format!("failed to read .env: {e}")) +} + +/// Overwrite the `.env` (plan 24). Compose only picks changes up on the next +/// `up` (recreate), which is why the editor offers a restart afterward. +pub fn write_env_file(dir: &Path, contents: &str) -> Result<(), String> { + std::fs::write(dir.join(".env"), contents).map_err(|e| format!("failed to write .env: {e}")) +} + fn read_env_value(dir: &Path, key: &str) -> Option { let content = std::fs::read_to_string(dir.join(".env")).ok()?; for line in content.lines() { @@ -250,29 +597,54 @@ fn read_env_value(dir: &Path, key: &str) -> Option { // Lifecycle // --------------------------------------------------------------------------- -pub async fn create( - app: Option<&AppHandle>, +/// Where a new site came from (plan 18): `None` for a hand-made site, or the +/// connection + remote site id it was imported from. +pub type Origin = Option<(String, i64)>; + +/// Reserve a site: validate versions, allocate a unique slug and free ports, +/// and insert the `creating` row. Shared by `create` and `sync::import_site` — +/// both need an identical reservation, and doing it in one place is what keeps +/// slug/port allocation race-free across the two entry points. +/// +/// `kind`/`config` carry the plan-22 stack (WordPress callers pass +/// `KIND_WORDPRESS` + `SiteConfig::default()`). WP/PHP version validation only +/// runs for the WordPress kind — a docker project has no such versions. +pub(crate) async fn reserve( state: &AppState, name: String, + kind: String, wp_version: String, php_version: String, + config: SiteConfig, + origin: Origin, ) -> Result { let name = name.trim().to_string(); if name.is_empty() { return Err("Site name is required".into()); } - if !WP_VERSIONS.contains(&wp_version.as_str()) { - return Err(format!("unsupported WordPress version: {wp_version}")); - } - if !PHP_VERSIONS.contains(&php_version.as_str()) { + if kind == KIND_WORDPRESS { + if !WP_VERSIONS.contains(&wp_version.as_str()) { + return Err(format!("unsupported WordPress version: {wp_version}")); + } + if !PHP_VERSIONS.contains(&php_version.as_str()) { + return Err(format!("unsupported PHP version: {php_version}")); + } + } else if kind == KIND_PHP && !PHP_VERSIONS.contains(&php_version.as_str()) { + // A php site has no WordPress version, but its PHP version is still from + // the allowlist (it becomes the app image's `FROM php:-fpm` tag). return Err(format!("unsupported PHP version: {php_version}")); } let slug = unique_slug(state, &slugify(&name))?; - let port = free_port(state)?; + let port = free_port(state).await?; let dir = site_dir(&state.data_dir, &slug); + let (connection_id, remote_site_id) = match origin { + Some((c, r)) => (Some(c), Some(r)), + None => (None, None), + }; - let site = Site { + let created_at = chrono::Utc::now().to_rfc3339(); + let mut site = Site { id: Uuid::new_v4().to_string(), name, slug, @@ -281,15 +653,58 @@ pub async fn create( wp_version, php_version, status: "creating".into(), + status_updated_at: created_at.clone(), admin_user: DEFAULT_ADMIN_USER.into(), admin_pass: String::new(), - created_at: chrono::Utc::now().to_rfc3339(), + created_at, + connection_id, + remote_site_id, + kind, + config, + capabilities: Capabilities::default(), }; + site.refresh_capabilities(); { let db = state.db.lock().map_err(|e| e.to_string())?; db.insert_site(&site)?; } + Ok(site) +} + +/// Write a reserved site's project files: directory, compose file, `.env`, and +/// the one-click-login MU plugin. +pub(crate) fn write_project_files(site: &Site) -> Result<(), String> { + let dir = site.dir(); + std::fs::create_dir_all(dir.join("wp-content")) + .map_err(|e| format!("failed to create site directory: {e}"))?; + let db_password = random_password(24); + std::fs::write(dir.join("docker-compose.yml"), render_compose(site)) + .map_err(|e| format!("failed to write docker-compose.yml: {e}"))?; + std::fs::write(dir.join(".env"), render_env(site, &db_password)) + .map_err(|e| format!("failed to write .env: {e}"))?; + wordpress::ensure_login_plugin(&dir) +} + +pub async fn create( + app: Option<&AppHandle>, + state: &AppState, + name: String, + wp_version: String, + php_version: String, +) -> Result { + let site = reserve( + state, + name, + KIND_WORDPRESS.to_string(), + wp_version, + php_version, + SiteConfig::default(), + None, + ) + .await?; + // Own this site's status until the create finishes (plan 23). + let _guard = state.in_flight.guard(&site.id); match do_create(app, state, &site).await { Ok(site) => Ok(site), Err(e) => { @@ -304,14 +719,7 @@ async fn do_create(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Re let dir = site.dir(); emit(app, &site.id, "files", "Writing project files..."); - std::fs::create_dir_all(dir.join("wp-content")) - .map_err(|e| format!("failed to create site directory: {e}"))?; - let db_password = random_password(24); - std::fs::write(dir.join("docker-compose.yml"), render_compose(site)) - .map_err(|e| format!("failed to write docker-compose.yml: {e}"))?; - std::fs::write(dir.join(".env"), render_env(site, &db_password)) - .map_err(|e| format!("failed to write .env: {e}"))?; - wordpress::ensure_login_plugin(&dir)?; + write_project_files(site)?; emit( app, @@ -332,13 +740,9 @@ async fn do_create(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Re emit(app, &site.id, "waiting", "Waiting for WordPress to come online..."); wait_for_port(site.port, 180).await?; - // Install at the site's local domain when the router is enabled (M6). - let (domains_on, ca_trusted) = router::enabled_and_trusted(state); - let install_url = if domains_on { - router::site_url(&site.slug, ca_trusted) - } else { - format!("http://localhost:{}", site.port) - }; + // Install at the site's local domain when the router is enabled (M6), + // including the `:port` suffix in fallback mode (plan 16). + let install_url = router::site_public_url(state, site); let admin_pass = random_password(16); wordpress::install(&dir, site, &admin_pass, &install_url, app).await?; @@ -350,6 +754,9 @@ async fn do_create(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Re db.set_status(&site.id, "running")?; db.update_credentials(&site.id, &site.admin_user, &site.admin_pass)?; } + // Last step: the completion marker. Its absence is what flags a create that + // was killed mid-flight (plan 23). + mark_complete(&dir); // Add the new site to the router's Caddyfile + hosts block (no-op when disabled). router::refresh_routes(state).await; router::refresh_hosts(state).await; @@ -362,18 +769,200 @@ async fn do_create(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Re Ok(site) } -async fn cleanup(state: &AppState, site: &Site) -> Result<(), String> { +// --------------------------------------------------------------------------- +// Clone (plan 20) +// --------------------------------------------------------------------------- + +/// Clone an existing local site into a brand-new one. +/// +/// Built directly on the plan-17 snapshot engine: the source is snapshotted, +/// a fresh target is provisioned (unique slug, fresh ports, fresh DB password +/// and WP salts — secrets are never copied), and the snapshot's data is laid +/// down on top, then its baked-in URLs are search-replaced to the clone's. +/// +/// Emits the same `site-event` stages the create/import flows do, so the +/// progress toast works unchanged: `snapshot` (against the source) → +/// `files` → `containers` → `waiting` → `import` → `done` (against the clone). +pub async fn clone_site( + app: Option<&AppHandle>, + state: &AppState, + source_id: &str, + new_name: String, +) -> Result { + let source = get(state, source_id)?; + // Clone provisions a WordPress-shaped target (compose/env/wp-cli); a docker + // project is not clonable through this flow yet (plan 26). + source.require(source.kind == KIND_WORDPRESS, "Cloning")?; + + // 1. Snapshot the source. This reuses the retry-heavy DB export and the + // shared archive format, and gives the clone a consistent point-in-time + // copy. `snapshot::create` emits its own `snapshot`-stage progress. + let snap = crate::snapshot::create( + app, + state, + source_id, + crate::snapshot::KIND_CLONE_SOURCE, + Some(format!("cloning {}", source.name)), + ) + .await + .map_err(|e| format!("could not snapshot the source site: {e}"))?; + + // 2. Reserve the target: unique slug, fresh ports, `creating` row. Same + // versions as the source so the snapshot's DB/plugins land on a matching + // stack. A hand-made clone has no remote origin. + let target = match reserve( + state, + new_name, + source.kind.clone(), + source.wp_version.clone(), + source.php_version.clone(), + source.config.clone(), + None, + ) + .await + { + Ok(t) => t, + Err(e) => { + // Nothing was provisioned yet; just drop the transient snapshot. + let _ = crate::snapshot::delete(state, source_id, &snap.id); + return Err(e); + } + }; + + let _guard = state.in_flight.guard(&target.id); + match do_clone(app, state, &source, &snap.id, &target).await { + Ok(site) => { + // The clone_source snapshot is an implementation detail — prune it + // aggressively the moment it has served its purpose. + let _ = crate::snapshot::delete(state, source_id, &snap.id); + let url = router::site_public_url(state, &site); + emit( + app, + &site.id, + "done", + &format!("{} cloned from {} — now running at {url}", site.name, source.name), + ); + Ok(site) + } + Err(e) => { + let _ = crate::snapshot::delete(state, source_id, &snap.id); + emit(app, &target.id, "error", &format!("Clone failed: {e}")); + let _ = cleanup(state, &target).await; + Err(e) + } + } +} + +/// The provisioning half of a clone: everything after the source snapshot and +/// the target reservation, so a failure here can be cleaned up wholesale. +async fn do_clone( + app: Option<&AppHandle>, + state: &AppState, + source: &Site, + snapshot_id: &str, + target: &Site, +) -> Result { + let dir = target.dir(); + let id = target.id.as_str(); + + emit(app, id, "files", "Writing project files..."); + write_project_files(target)?; + + // The source runs the same WP/PHP versions, so its images are already + // pulled; `compose_up` fetches anything missing rather than stalling + // silently, but there is normally nothing to fetch. + emit(app, id, "containers", "Starting Docker containers..."); + docker::compose_up(&dir).await?; + + emit(app, id, "waiting", "Waiting for WordPress to come online..."); + wait_for_port(target.port, 180).await?; + // The port answering is not the same as WordPress being ready (see + // `wordpress::wait_for_config`); without this the first wp-cli call races + // the image entrypoint still writing wp-config.php. + wordpress::wait_for_config(&dir, 24).await?; + + // 3. Lay the source's database + wp-content down onto the fresh target. + emit(app, id, "import", &format!("Copying {}'s content...", source.name)); + crate::snapshot::restore_into(state, &source.id, snapshot_id, target).await?; + // The archive brought the source's mu-plugins over the one just written; + // one-click login must survive the clone. + wordpress::ensure_login_plugin(&dir)?; + + // 4. Rewrite the source's baked-in URLs to the clone's own public URL. + let source_url = router::site_public_url(state, source); + let target_url = router::site_public_url(state, target); + emit(app, id, "import", "Rewriting URLs to the clone..."); + wordpress::update_site_urls(&dir, &target_url).await?; + if source_url != target_url { + wordpress::search_replace(&dir, &source_url, &target_url).await?; + } + // Permalinks are rules tied to the old host; regenerate or every page 404s. + // Best effort — a rewrite/cache hiccup must not throw away a live clone. + let _ = docker::compose_run(&dir, "wpcli", &["wp", "rewrite", "flush"]).await; + let _ = docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"]).await; + + // 5. The clone's admin login is the source's: the copied database carries + // the source's users table, so the source's WP admin password works + // here too. (The MySQL/WP secrets in `.env`/wp-config are fresh — those + // are what "never copy secrets" refers to.) + let mut site = target.clone(); + site.status = "running".into(); + site.admin_user = source.admin_user.clone(); + site.admin_pass = source.admin_pass.clone(); + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + db.update_credentials(id, &site.admin_user, &site.admin_pass)?; + } + mark_complete(&dir); + // A new running site joins the router's Caddyfile + hosts block (no-op when + // local domains are disabled). + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + Ok(site) +} + +/// Remove a site's project directory, retrying briefly on a transient lock. +/// +/// On Windows, `docker compose down` can return while Docker Desktop still holds +/// a handle on a bind-mounted file for a moment, so an immediate `remove_dir_all` +/// fails with "The process cannot access the file because it is being used by +/// another process" (os error 32). A few short retries clear it; using +/// `tokio::time::sleep` (not `thread::sleep`) keeps the executor thread free. +pub(crate) async fn remove_site_dir(dir: &Path) -> std::io::Result<()> { + const ATTEMPTS: u32 = 10; + let mut last = None; + for attempt in 1..=ATTEMPTS { + match std::fs::remove_dir_all(dir) { + Ok(()) => return Ok(()), + // Already gone (e.g. a prior partial attempt finished the job). + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + last = Some(e); + if attempt < ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + } + } + } + } + Err(last.expect("loop runs at least once")) +} + +/// Undo a partial creation: tear the compose project down, remove the files, +/// and drop the DB row. Shared with the import flow — a failed import must not +/// leave a half-built site on the dashboard. +pub(crate) async fn cleanup(state: &AppState, site: &Site) -> Result<(), String> { let dir = site.dir(); if dir.exists() { let _ = docker::compose_down(&dir, true).await; - let _ = std::fs::remove_dir_all(&dir); + let _ = remove_site_dir(&dir).await; } let db = state.db.lock().map_err(|e| e.to_string())?; db.delete_site(&site.id) } /// Wait until something (Apache) accepts TCP connections on the site port. -async fn wait_for_port(port: u16, timeout_secs: u64) -> Result<(), String> { +pub(crate) async fn wait_for_port(port: u16, timeout_secs: u64) -> Result<(), String> { let addr = format!("127.0.0.1:{port}"); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); while std::time::Instant::now() < deadline { @@ -394,18 +983,22 @@ pub fn detail(state: &AppState, id: &str) -> Result { let site = get(state, id)?; let dir = site.dir(); let db_password = read_env_value(&dir, "DB_PASSWORD").unwrap_or_default(); + let incomplete = dir.exists() && !is_complete(&dir) && !state.in_flight.contains(&site.id); Ok(SiteDetail { db_port: site.db_port(), live_status: site.status.clone(), + incomplete, db_host: "127.0.0.1".into(), - db_name: "wordpress".into(), - db_user: "wordpress".into(), + db_name: db_name(&dir), + db_user: db_user(&dir), db_password, site, }) } pub async fn start(state: &AppState, id: &str) -> Result { + // Hold the reconciler off this site while its status is in flight (plan 23). + let _guard = state.in_flight.guard(id); let site = get(state, id)?; docker::compose_up(&site.dir()).await?; { @@ -417,6 +1010,7 @@ pub async fn start(state: &AppState, id: &str) -> Result { } pub async fn stop(state: &AppState, id: &str) -> Result { + let _guard = state.in_flight.guard(id); let site = get(state, id)?; docker::compose_down(&site.dir(), false).await?; { @@ -427,13 +1021,156 @@ pub async fn stop(state: &AppState, id: &str) -> Result { get(state, id) } -pub async fn delete(state: &AppState, id: &str) -> Result<(), String> { +/// Restart a site so an edited `.env` takes effect (plan 24 config editor). +/// +/// `docker compose up -d` recreates any service whose resolved config changed — +/// including `.env` values — which a plain `compose restart` would NOT pick up. +/// Leaves the site running regardless of its prior state. +pub async fn restart(state: &AppState, id: &str) -> Result { + let _guard = state.in_flight.guard(id); + let site = get(state, id)?; + docker::compose_up(&site.dir()).await?; + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + } + router::refresh_routes(state).await; + get(state, id) +} + +/// Finish a half-created site (plan 23): a create killed mid-flight left +/// `status = creating` and no completion marker. Re-runs the tail — bring the +/// containers up, wait, and (for a fresh WordPress create that never installed) +/// run `wp core install` — then marks it complete. The containers exist and the +/// images are pulled, so this is normally just the wait + install tail. +pub async fn resume(app: Option<&AppHandle>, state: &AppState, id: &str) -> Result { + let _guard = state.in_flight.guard(id); let site = get(state, id)?; + if !site.dir().exists() { + return Err(format!( + "\"{}\" has no project directory to resume — clean it up instead.", + site.name + )); + } + match do_resume(app, state, &site).await { + Ok(site) => { + let url = router::site_public_url(state, &site); + emit( + app, + &site.id, + "done", + &format!("{} setup finished — now running at {url}", site.name), + ); + Ok(site) + } + Err(e) => { + emit(app, &site.id, "error", &format!("Resume failed: {e}")); + Err(e) + } + } +} + +async fn do_resume(app: Option<&AppHandle>, state: &AppState, site: &Site) -> Result { let dir = site.dir(); + let id = site.id.as_str(); + + // The compose project is the first, fast create stage, so it is almost + // always present. If a WordPress site's is somehow missing we can + // regenerate it; a docker app's project was copied and cannot be rebuilt. + if site.kind == KIND_WORDPRESS && !dir.join("docker-compose.yml").exists() { + write_project_files(site)?; + } + + emit(app, id, "containers", "Starting Docker containers..."); + docker::compose_up(&dir).await?; + + emit(app, id, "waiting", "Waiting for the app to come online..."); + let _ = wait_for_port(site.config.upstream_port(site.port), 180).await; + + let mut resumed = site.clone(); + if site.kind == KIND_WORDPRESS { + wordpress::wait_for_config(&dir, 24).await?; + if !wordpress::is_installed(&dir).await { + // An imported/clone/blueprint site's data lands via an archive, not + // `wp core install` — if it never installed, that data step never + // ran and cannot be reconstructed here. Only a fresh create's tail + // is safe to re-run. + if site.connection_id.is_some() { + return Err( + "This imported site never finished importing its data — clean it up and import it again." + .into(), + ); + } + let admin_pass = random_password(16); + let install_url = router::site_public_url(state, site); + emit(app, id, "install", "Finishing WordPress setup..."); + wordpress::install(&dir, site, &admin_pass, &install_url, app).await?; + resumed.admin_pass = admin_pass; + let db = state.db.lock().map_err(|e| e.to_string())?; + db.update_credentials(id, &resumed.admin_user, &resumed.admin_pass)?; + } + } + + resumed.status = "running".into(); + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + } + mark_complete(&dir); + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + Ok(resumed) +} + +/// Delete a site. Unless `delete_snapshots` is set, a `pre_delete` snapshot is +/// taken first and the site's snapshot directory survives the deletion — the +/// only copy of the data once the containers, volumes and files are gone +/// (plan 17; also the groundwork for a future "restore deleted site"). +/// +/// The snapshot is best effort: a site whose Docker stack is broken must still +/// be deletable, so a snapshot failure is reported through the event stream +/// rather than blocking the delete. +pub async fn delete( + app: Option<&AppHandle>, + state: &AppState, + id: &str, + delete_snapshots: bool, +) -> Result<(), String> { + let _guard = state.in_flight.guard(id); + let site = get(state, id)?; + let dir = site.dir(); + + if !delete_snapshots && dir.exists() { + emit(app, id, "snapshot", "Taking a snapshot before deleting..."); + if let Err(e) = crate::snapshot::create( + app, + state, + id, + crate::snapshot::KIND_PRE_DELETE, + Some(format!("before deleting {}", site.name)), + ) + .await + { + emit( + app, + id, + "snapshot", + &format!("Could not snapshot before deleting ({e}) — deleting anyway"), + ); + } + } + if dir.exists() { // Best effort: even if Docker is down we still remove local state. let _ = docker::compose_down(&dir, true).await; - std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to remove site directory: {e}"))?; + // Retry through the transient Windows bind-mount lock `compose down` + // can leave behind (see `remove_site_dir`). + remove_site_dir(&dir) + .await + .map_err(|e| format!("failed to remove site directory: {e}"))?; + } + if delete_snapshots { + let _ = crate::snapshot::delete_all(&state.data_dir, id); } { let db = state.db.lock().map_err(|e| e.to_string())?; @@ -454,20 +1191,22 @@ pub async fn list(state: &AppState) -> Result, String> { let mut out = Vec::new(); for site in sites { let live_status = match docker::compose_ps(&site.dir()).await { - Ok(containers) => { - if containers - .iter() - .any(|c| c.service == "wordpress" && c.state == "running") - { - "running".to_string() - } else { - "stopped".to_string() - } - } + // Same classifier the reconciler uses, so a running-but-unhealthy + // container reads as `degraded` in the live view too (plan 23) — not + // as a plain `running` that hides the problem. + Ok(containers) => match crate::reconcile::classify(&containers, site.app_service()) { + crate::reconcile::Observed::Running => "running".to_string(), + crate::reconcile::Observed::Degraded => "degraded".to_string(), + crate::reconcile::Observed::Down => "stopped".to_string(), + }, // Docker unavailable/off: fall back to the stored status. Err(_) => site.status.clone(), }; - out.push(SiteWithStatus { site, live_status }); + // Half-created? Dir present, completion marker absent, no create in + // flight (plan 23). Startup backfill marks known-complete legacy sites. + let incomplete = + site.dir().exists() && !is_complete(&site.dir()) && !state.in_flight.contains(&site.id); + out.push(SiteWithStatus { site, live_status, incomplete }); } Ok(out) } @@ -476,3 +1215,208 @@ pub async fn logs(state: &AppState, id: &str, tail: u32) -> Result/snapshots/// +//! manifest.json the Snapshot struct below +//! db.sql.gz `wp db export -`, gzipped +//! wp-content.tar.gz the bind-mounted wp-content dir +//! ``` +//! +//! The archive format is deliberately the same one `sync::push_code` uploads +//! (`build_wp_content_tgz` lives here and is shared), so a snapshot is +//! restorable by hand with `tar -xzf` if LocalKit is not around. +//! +//! Snapshots are taken automatically before every destructive operation +//! (push, pull, delete, and restore itself) — see `kind` below. + +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +use crate::{docker, site, wordpress, AppState}; + +/// Manual snapshots are never auto-pruned; every other kind is capped at +/// `RETENTION` per site per kind after each create. +pub const KIND_MANUAL: &str = "manual"; +pub const KIND_PRE_PUSH: &str = "pre_push"; +pub const KIND_PRE_PULL: &str = "pre_pull"; +pub const KIND_PRE_DELETE: &str = "pre_delete"; +pub const KIND_PRE_RESTORE: &str = "pre_restore"; +/// Auto snapshot taken before an applied search-replace (plan 24). Like the +/// other `pre_*` kinds it is the undo button — the dry-run-first flow means the +/// user has already seen the cost, and this is what makes Apply reversible. +pub const KIND_PRE_SEARCH_REPLACE: &str = "pre_search_replace"; +/// Transient snapshot a clone takes of its source (plan 20). It exists only to +/// seed the new site and is deleted the moment the clone finishes — an +/// implementation detail, not a snapshot the user asked for. +pub const KIND_CLONE_SOURCE: &str = "clone_source"; +/// Transient snapshot `save_blueprint` takes to capture consistent artifacts +/// (plan 20). Like `clone_source`, its bytes are hardlinked into the blueprint +/// and the snapshot itself is deleted — not a user snapshot. +pub const KIND_BLUEPRINT_SOURCE: &str = "blueprint_source"; + +pub const KINDS: &[&str] = &[ + KIND_MANUAL, + KIND_PRE_PUSH, + KIND_PRE_PULL, + KIND_PRE_DELETE, + KIND_PRE_RESTORE, + KIND_PRE_SEARCH_REPLACE, + KIND_CLONE_SOURCE, + KIND_BLUEPRINT_SOURCE, +]; + +/// Transient kinds hidden from the user-facing listing: they back the clone +/// and blueprint flows and are deleted the instant they have served their +/// purpose, so surfacing them would only confuse. +fn is_transient(kind: &str) -> bool { + kind == KIND_CLONE_SOURCE || kind == KIND_BLUEPRINT_SOURCE +} + +/// How many auto snapshots to keep per site per kind. +const RETENTION: usize = 5; + +const DB_FILE: &str = "db.sql.gz"; +const CODE_FILE: &str = "wp-content.tar.gz"; +const MANIFEST_FILE: &str = "manifest.json"; + +/// `manifest.json` — everything the UI/CLI needs without touching the archives. +/// Kept richer than strictly necessary (name/slug/wp_version) so a snapshot +/// stays meaningful after its site row is gone (deleted site). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snapshot { + /// Sortable timestamp id; also the directory name. + pub id: String, + pub site_id: String, + pub site_name: String, + pub site_slug: String, + pub created_at: String, + /// manual | pre_push | pre_pull | pre_delete | pre_restore + pub kind: String, + pub note: String, + pub db_bytes: u64, + pub code_bytes: u64, + pub wp_version: String, +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +pub fn snapshots_root(data_dir: &Path) -> PathBuf { + data_dir.join("snapshots") +} + +pub fn site_snapshots_dir(data_dir: &Path, site_id: &str) -> PathBuf { + snapshots_root(data_dir).join(site_id) +} + +fn snapshot_dir(data_dir: &Path, site_id: &str, id: &str) -> PathBuf { + site_snapshots_dir(data_dir, site_id).join(id) +} + +/// Timestamp id, filesystem-safe on Windows (no colons). +fn new_id() -> String { + chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f").to_string() +} + +// --------------------------------------------------------------------------- +// Archive helpers (shared with sync::push_code) +// --------------------------------------------------------------------------- + +/// Bundle the site's code directory (`sync_path`) as a tar.gz in memory. +/// `sync_path` is the site's `config.sync_path` — `wp-content` for a WP site +/// (plan 22). +pub(crate) fn build_wp_content_tgz(site_dir: &Path, sync_path: &str) -> Result, String> { + let mut buf = Vec::new(); + write_wp_content_tgz(site_dir, sync_path, &mut buf)?; + Ok(buf) +} + +/// Stream the same archive into an arbitrary writer. +/// +/// This is the form sync v2 uses (plan 19): the tar/gzip pipeline runs +/// straight into a staging file, so a site with a real `uploads/` directory +/// never has to exist as a `Vec` first. `build_wp_content_tgz` is now just +/// this with a `Vec` on the end. +/// +/// Entries are prefixed with `sync_path`, so an archive is self-describing and +/// `restore_wp_content` unpacks it back into the same relative location. +pub(crate) fn write_wp_content_tgz( + site_dir: &Path, + sync_path: &str, + out: &mut dyn std::io::Write, +) -> Result<(), String> { + let content = site_dir.join(sync_path); + if !content.is_dir() { + return Err(format!("{sync_path} directory not found in the local site")); + } + let enc = flate2::write::GzEncoder::new(out, flate2::Compression::fast()); + let mut builder = tar::Builder::new(enc); + builder + .append_dir_all(sync_path, &content) + .map_err(|e| format!("failed to bundle {sync_path}: {e}"))?; + // Finish both layers explicitly: letting the encoder write its trailer on + // drop would discard the error, and a truncated gzip only shows up much + // later as an unreadable archive. + builder + .into_inner() + .map_err(|e| format!("failed to finalize archive: {e}"))? + .finish() + .map_err(|e| format!("failed to finalize archive: {e}"))?; + Ok(()) +} + +fn gzip(bytes: &[u8]) -> Result, String> { + use std::io::Write; + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + enc.write_all(bytes) + .map_err(|e| format!("failed to compress database dump: {e}"))?; + enc.finish() + .map_err(|e| format!("failed to compress database dump: {e}")) +} + +fn gunzip(bytes: &[u8]) -> Result, String> { + let mut out = Vec::new(); + flate2::read::GzDecoder::new(bytes) + .read_to_end(&mut out) + .map_err(|e| format!("failed to decompress snapshot dump: {e}"))?; + Ok(out) +} + +// --------------------------------------------------------------------------- +// Retention (pure — unit tested) +// --------------------------------------------------------------------------- + +/// Ids to prune after a create: for every *auto* kind keep the newest +/// `RETENTION`, drop the rest. `manual` snapshots are never auto-pruned — +/// they are the ones the user deliberately took. +pub fn prunable(snapshots: &[Snapshot]) -> Vec { + let mut sorted: Vec<&Snapshot> = snapshots.iter().collect(); + // Newest first. `id` is a fixed-width timestamp, so lexical == chronological. + sorted.sort_by(|a, b| b.id.cmp(&a.id)); + + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + let mut out = Vec::new(); + for s in sorted { + if s.kind == KIND_MANUAL { + continue; + } + let n = seen.entry(s.kind.as_str()).or_insert(0); + *n += 1; + if *n > RETENTION { + out.push(s.id.clone()); + } + } + out +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +/// Every snapshot on disk for a site, newest first — including the transient +/// `clone_source` ones. Internal: retention (`prune`) needs to see them to cap +/// any orphaned by a hard crash mid-clone; the user-facing `list` hides them. +/// A directory whose manifest is missing or unreadable is skipped rather than +/// failing the whole listing. +fn list_all(state: &AppState, site_id: &str) -> Result, String> { + let dir = site_snapshots_dir(&state.data_dir, site_id); + if !dir.is_dir() { + return Ok(vec![]); + } + let entries = + std::fs::read_dir(&dir).map_err(|e| format!("failed to read snapshots directory: {e}"))?; + let mut out = Vec::new(); + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + if let Ok(text) = std::fs::read_to_string(entry.path().join(MANIFEST_FILE)) { + if let Ok(snap) = serde_json::from_str::(&text) { + out.push(snap); + } + } + } + out.sort_by(|a, b| b.id.cmp(&a.id)); + Ok(out) +} + +/// User-facing snapshot listing, newest first. Hides the transient `*_source` +/// snapshots the clone/blueprint flows use — internal details, not user +/// snapshots. +pub fn list(state: &AppState, site_id: &str) -> Result, String> { + Ok(list_all(state, site_id)? + .into_iter() + .filter(|s| !is_transient(&s.kind)) + .collect()) +} + +fn read_manifest(data_dir: &Path, site_id: &str, id: &str) -> Result { + let path = snapshot_dir(data_dir, site_id, id).join(MANIFEST_FILE); + let text = std::fs::read_to_string(&path).map_err(|_| format!("snapshot `{id}` not found"))?; + serde_json::from_str(&text).map_err(|e| format!("snapshot `{id}` has an unreadable manifest: {e}")) +} + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +/// Take a snapshot of a site: DB dump + wp-content archive + manifest. +/// +/// Emits `snapshot`-stage progress only — never `done`/`error`, because this +/// also runs *inside* longer operations (push/pull/delete) whose own progress +/// toast must not be resolved early. Standalone callers emit the terminal +/// stage themselves. +/// +/// Works on a stopped site: `docker compose run wpcli` brings the `db` service +/// up (and waits for its healthcheck) through the compose `depends_on`. +pub async fn create( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + kind: &str, + note: Option, +) -> Result { + if !KINDS.contains(&kind) { + return Err(format!("unknown snapshot kind: {kind}")); + } + let s = site::get(state, site_id)?; + let dir = s.dir(); + if !dir.exists() { + return Err(format!("site directory not found: {}", dir.display())); + } + + // WordPress (and any db_sync kind) exports its database; a code-only docker + // app snapshots its files alone, so `db.sql.gz` is empty and restore knows + // to skip the import. The export is engine-native for php (mysqldump), wp-cli + // for WordPress — `dbsync::export_sql` dispatches (plan 26). + let db_gz = if s.capabilities.db_sync { + site::emit(app, site_id, "snapshot", "Exporting database..."); + let sql = crate::dbsync::export_sql(&s, &dir).await?; + gzip(sql.as_bytes())? + } else { + Vec::new() + }; + + let what = if s.config.sync_path == "." { + "the project" + } else { + s.config.sync_path.as_str() + }; + site::emit(app, site_id, "snapshot", &format!("Archiving {what}...")); + let code_tgz = build_wp_content_tgz(&dir, &s.config.sync_path)?; + + let snap = Snapshot { + id: new_id(), + site_id: s.id.clone(), + site_name: s.name.clone(), + site_slug: s.slug.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + kind: kind.to_string(), + note: note.unwrap_or_default(), + db_bytes: db_gz.len() as u64, + code_bytes: code_tgz.len() as u64, + wp_version: s.wp_version.clone(), + }; + + let out = snapshot_dir(&state.data_dir, site_id, &snap.id); + std::fs::create_dir_all(&out) + .map_err(|e| format!("failed to create snapshot directory: {e}"))?; + // Write the payloads before the manifest: a half-written snapshot has no + // manifest, so `list` skips it instead of offering a broken restore. + std::fs::write(out.join(DB_FILE), &db_gz) + .map_err(|e| format!("failed to write database dump: {e}"))?; + std::fs::write(out.join(CODE_FILE), &code_tgz) + .map_err(|e| format!("failed to write wp-content archive: {e}"))?; + let manifest = + serde_json::to_string_pretty(&snap).map_err(|e| format!("failed to write manifest: {e}"))?; + std::fs::write(out.join(MANIFEST_FILE), manifest) + .map_err(|e| format!("failed to write manifest: {e}"))?; + + prune(state, site_id); + Ok(snap) +} + +/// Apply the retention policy. Best effort — a failed prune must never fail +/// the snapshot that triggered it. +fn prune(state: &AppState, site_id: &str) { + let Ok(all) = list_all(state, site_id) else { return }; + for id in prunable(&all) { + let _ = std::fs::remove_dir_all(snapshot_dir(&state.data_dir, site_id, &id)); + } +} + +// --------------------------------------------------------------------------- +// Restore +// --------------------------------------------------------------------------- + +/// Roll a site back to a snapshot: DB import + wp-content replace + cache +/// flush. Takes a `pre_restore` snapshot first — restoring is destructive too. +/// +/// Returns a user-facing summary (mentions the auto-start when it happened). +pub async fn restore( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + snapshot_id: &str, +) -> Result { + // Restore can auto-start the site for the DB import; own its status until + // the restore finishes so the reconciler doesn't race it (plan 23). + let _guard = state.in_flight.guard(site_id); + let snap = read_manifest(&state.data_dir, site_id, snapshot_id)?; + let s = site::get(state, site_id)?; + let dir = s.dir(); + let src = snapshot_dir(&state.data_dir, site_id, snapshot_id); + + // Read the archives before mutating anything: a corrupt snapshot should + // fail while the site is still intact. A code-only (docker) snapshot has an + // empty `db.sql.gz`, so there is nothing to import. + let db_gz = std::fs::read(src.join(DB_FILE)).unwrap_or_default(); + let sql = if db_gz.is_empty() { + None + } else { + Some(gunzip(&db_gz)?) + }; + let code_tgz = std::fs::read(src.join(CODE_FILE)) + .map_err(|e| format!("failed to read the snapshot's code archive: {e}"))?; + + site::emit(app, site_id, "restore", "Taking a pre-restore snapshot..."); + create( + app, + state, + site_id, + KIND_PRE_RESTORE, + Some(format!("before restoring {}", snap.created_at)), + ) + .await + .map_err(|e| format!("pre-restore snapshot failed, nothing was changed: {e}"))?; + + // A database import needs the stack up; a code-only restore does not, so + // only auto-start when there is actually a database to import. + let mut started = false; + if sql.is_some() && !is_running(&dir, s.app_service()).await { + site::emit(app, site_id, "restore", "Starting the site..."); + site::start(state, site_id).await?; + started = true; + } + + if let Some(sql) = &sql { + site::emit(app, site_id, "restore", "Importing database..."); + // Engine-native for php, wp-cli for WordPress (plan 26). + crate::dbsync::import_sql(&s, &dir, sql).await?; + } + + let what = if s.config.sync_path == "." { + "files" + } else { + s.config.sync_path.as_str() + }; + site::emit(app, site_id, "restore", &format!("Restoring {what}...")); + restore_wp_content(&dir, &s.config.sync_path, &code_tgz)?; + + // Object/transient caches can outlive the import (WordPress only — a docker + // app has no wp-cli). + if s.capabilities.wp_tools { + let _ = docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"]).await; + } + + let when = &snap.created_at; + Ok(if started { + format!("{} restored to the snapshot from {when} (the site was stopped, so it was started)", s.name) + } else { + format!("{} restored to the snapshot from {when}", s.name) + }) +} + +async fn is_running(dir: &Path, service: &str) -> bool { + docker::compose_ps(dir) + .await + .map(|cs| { + cs.iter() + .any(|c| c.service == service && c.state == "running") + }) + .unwrap_or(false) +} + +/// Replace `sync_path` (`wp-content` for a WP site) with the archived copy. The +/// directory itself is kept (it is bind-mounted into the running containers — +/// removing it would break the mount); only its contents are swapped. +fn restore_wp_content(site_dir: &Path, sync_path: &str, tgz: &[u8]) -> Result<(), String> { + let content = site_dir.join(sync_path); + if content.is_dir() { + let entries = std::fs::read_dir(&content) + .map_err(|e| format!("failed to read {sync_path}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + let removed = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + removed.map_err(|e| { + format!("failed to clear {sync_path} ({}): {e}", path.display()) + })?; + } + } else { + std::fs::create_dir_all(&content) + .map_err(|e| format!("failed to create {sync_path}: {e}"))?; + } + // Entries are prefixed with `sync_path`, so unpack at the site root. + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz)); + archive + .unpack(site_dir) + .map_err(|e| format!("failed to restore {sync_path}: {e}")) +} + +/// Seed a *target* site from a snapshot taken of a *different* site (plan 20). +/// +/// The clone flow snapshots a source site, provisions a fresh target, and lays +/// the source's data down onto it here. Distinct from `restore` on three +/// counts: the snapshot lives under another site's id (`source_id`), no +/// `pre_restore` snapshot is taken (the target is brand-new — there is nothing +/// worth preserving), and the source site is never touched. The target must +/// already be running: importing its database needs the stack up. +/// +/// The archives are the same format as `restore` reads, so this reuses +/// `restore_wp_content` — the wp-content bytes are ours, not hostile remote +/// input, so the plain contents-swap is the right tool (no safe-extract dance). +pub async fn restore_into( + state: &AppState, + source_id: &str, + snapshot_id: &str, + target: &site::Site, +) -> Result<(), String> { + let (db_gz, code_tgz) = artifact_paths(&state.data_dir, source_id, snapshot_id); + restore_archives_into(&db_gz, &code_tgz, target).await +} + +/// Lay a `(db.sql.gz, wp-content.tar.gz)` pair down onto a target site, by +/// path. The shared core of `restore_into` (snapshot dir) and the blueprint +/// create flow (blueprint dir) — same archive format, one implementation. The +/// target must already be running (the DB import needs the stack up). +pub async fn restore_archives_into( + db_gz: &Path, + code_tgz: &Path, + target: &site::Site, +) -> Result<(), String> { + let db_bytes = std::fs::read(db_gz) + .map_err(|e| format!("failed to read the database dump: {e}"))?; + let sql = gunzip(&db_bytes)?; + let code = std::fs::read(code_tgz) + .map_err(|e| format!("failed to read the wp-content archive: {e}"))?; + + let dir = target.dir(); + wordpress::import_db(&dir, &sql).await?; + restore_wp_content(&dir, &target.config.sync_path, &code)?; + // Object/transient caches from the fresh install can outlive the import. + let _ = docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"]).await; + Ok(()) +} + +/// Absolute paths of a snapshot's two archive files (DB dump, wp-content). +/// The blueprint flow hardlinks these out of the snapshot dir (plan 20). +pub fn artifact_paths(data_dir: &Path, site_id: &str, snapshot_id: &str) -> (PathBuf, PathBuf) { + let dir = snapshot_dir(data_dir, site_id, snapshot_id); + (dir.join(DB_FILE), dir.join(CODE_FILE)) +} + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +pub fn delete(state: &AppState, site_id: &str, snapshot_id: &str) -> Result<(), String> { + let dir = snapshot_dir(&state.data_dir, site_id, snapshot_id); + if !dir.is_dir() { + return Err(format!("snapshot `{snapshot_id}` not found")); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to delete snapshot: {e}")) +} + +/// Drop every snapshot for a site — only ever called when the user explicitly +/// ticks "also delete snapshots" while deleting the site. +pub fn delete_all(data_dir: &Path, site_id: &str) -> Result<(), String> { + let dir = site_snapshots_dir(data_dir, site_id); + if !dir.is_dir() { + return Ok(()); + } + std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to delete snapshots: {e}")) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn snap(id: &str, kind: &str) -> Snapshot { + Snapshot { + id: id.into(), + site_id: "site-1".into(), + site_name: "Site One".into(), + site_slug: "site-one".into(), + created_at: "2026-07-20T10:00:00Z".into(), + kind: kind.into(), + note: String::new(), + db_bytes: 1024, + code_bytes: 2048, + wp_version: "6.7".into(), + } + } + + /// Ids sort chronologically, so "newest RETENTION" is a lexical top-N. + fn auto_run(kind: &str, n: usize) -> Vec { + (1..=n) + .map(|i| snap(&format!("20260720-{i:06}-000"), kind)) + .collect() + } + + #[test] + fn keeps_everything_under_the_cap() { + let all = auto_run(KIND_PRE_PULL, RETENTION); + assert!(prunable(&all).is_empty()); + } + + #[test] + fn prunes_oldest_auto_snapshots_beyond_the_cap() { + let all = auto_run(KIND_PRE_PULL, RETENTION + 3); + let pruned = prunable(&all); + assert_eq!(pruned.len(), 3); + // The three oldest go, the newest RETENTION stay. + assert!(pruned.contains(&"20260720-000001-000".to_string())); + assert!(pruned.contains(&"20260720-000003-000".to_string())); + assert!(!pruned.contains(&"20260720-000004-000".to_string())); + } + + #[test] + fn retention_is_per_kind() { + let mut all = auto_run(KIND_PRE_PULL, RETENTION); + all.extend(auto_run(KIND_PRE_PUSH, RETENTION)); + // Both kinds are at the cap on their own; neither is over it together. + assert!(prunable(&all).is_empty()); + } + + #[test] + fn manual_snapshots_are_never_pruned() { + let all = auto_run(KIND_MANUAL, RETENTION * 3); + assert!(prunable(&all).is_empty()); + } + + #[test] + fn manual_snapshots_do_not_shield_auto_ones() { + let mut all = auto_run(KIND_MANUAL, 20); + all.extend(auto_run(KIND_PRE_DELETE, RETENTION + 1)); + assert_eq!(prunable(&all).len(), 1); + } + + #[test] + fn manifest_round_trips() { + let original = snap("20260720-120000-000", KIND_PRE_PUSH); + let text = serde_json::to_string_pretty(&original).unwrap(); + let back: Snapshot = serde_json::from_str(&text).unwrap(); + assert_eq!(back.id, original.id); + assert_eq!(back.kind, KIND_PRE_PUSH); + assert_eq!(back.site_slug, "site-one"); + assert_eq!(back.db_bytes, 1024); + assert_eq!(back.wp_version, "6.7"); + } + + #[test] + fn gzip_round_trips() { + let payload = b"-- MySQL dump\nCREATE TABLE wp_posts;\n"; + let back = gunzip(&gzip(payload).unwrap()).unwrap(); + assert_eq!(back, payload); + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 0967090..648c89f 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -1,12 +1,13 @@ -//! Push/pull orchestration between a local site and a ServerKit server (M4). +//! Push/pull orchestration between a local site and a ServerKit server (M4), +//! plus importing a remote site as a brand-new local one (plan 18). use serde::{Deserialize, Serialize}; use std::io::Read; -use std::path::Path; -use tauri::{AppHandle, Emitter}; +use std::path::{Component, Path, PathBuf}; +use tauri::AppHandle; use uuid::Uuid; -use crate::{serverkit, site, wordpress, AppState}; +use crate::{docker, php, router, serverkit, site, snapshot, transfer, wordpress, AppState}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncRecord { @@ -14,23 +15,17 @@ pub struct SyncRecord { pub site_id: String, pub connection_id: String, pub direction: String, // "push" | "pull" - pub kind: String, // "code" | "db" + pub kind: String, // "code" | "db" | "import" pub status: String, // "success" | "error" pub message: String, pub created_at: String, } +/// Sync progress goes through the same emitter as the site lifecycle, so that +/// with no Tauri app handle (the `lk` CLI, examples) stages print to stderr +/// instead of vanishing — a multi-minute import must not look like a hang. fn emit(app: Option<&AppHandle>, id: &str, stage: &str, message: &str) { - if let Some(app) = app { - let _ = app.emit( - "site-event", - site::SiteEvent { - id: id.to_string(), - stage: stage.to_string(), - message: message.to_string(), - }, - ); - } + site::emit(app, id, stage, message); } fn record(state: &AppState, rec: &SyncRecord) { @@ -46,26 +41,6 @@ fn load(state: &AppState, connection_id: &str, site_id: &str) -> Result<(serverk Ok((conn, site)) } -/// Bundle the site's wp-content directory as a tar.gz in memory. -fn build_wp_content_tgz(site_dir: &Path) -> Result, String> { - let wp_content = site_dir.join("wp-content"); - if !wp_content.is_dir() { - return Err("wp-content directory not found in the local site".into()); - } - let mut buf = Vec::new(); - { - let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::fast()); - let mut builder = tar::Builder::new(enc); - builder - .append_dir_all("wp-content", &wp_content) - .map_err(|e| format!("failed to bundle wp-content: {e}"))?; - builder - .finish() - .map_err(|e| format!("failed to finalize archive: {e}"))?; - } - Ok(buf) -} - async fn run( app: Option<&AppHandle>, state: &AppState, @@ -93,6 +68,27 @@ async fn run( ); Ok(()) } + // A user pressing Cancel travels the error path but is not a failure: + // it gets its own terminal stage and history status so the UI can say + // "cancelled" in neutral colours instead of flashing a red error. + Err(e) if transfer::is_cancel(&e) => { + let message = format!("{direction} {kind} cancelled"); + emit(app, site_id, "cancelled", &message); + record( + state, + &SyncRecord { + id: Uuid::new_v4().to_string(), + site_id: site_id.to_string(), + connection_id: connection_id.to_string(), + direction: direction.to_string(), + kind: kind.to_string(), + status: "cancelled".to_string(), + message: message.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + Err(message) + } Err(e) => { emit(app, site_id, "error", &format!("{direction} {kind} failed: {e}")); record( @@ -113,6 +109,66 @@ async fn run( } } +// --------------------------------------------------------------------------- +// Protocol selection (plan 19) +// --------------------------------------------------------------------------- + +/// Does this server speak the chunked protocol? +/// +/// One client, both servers: a server without `sync-v2` gets the v1 monolithic +/// path untouched. A failed probe answers "no" on purpose — falling back to v1 +/// always works, so a blip on `/pair` must not fail the push outright. +async fn supports_v2(conn: &serverkit::ServerKitConnection) -> bool { + serverkit::has_feature(&conn.url, &conn.api_key, serverkit::FEATURE_SYNC_V2) + .await + .unwrap_or(false) +} + +/// Gate a per-kind ServerKit sync (plan 26). +/// +/// Three things must hold: the site claims the capability (`db_sync`/`code_sync`), +/// its kind is one LocalKit syncs over ServerKit (WordPress or php — a bring- +/// your-own docker project can't be matched to a server app, so it stays +/// local-only), and the server's extension advertises that kind. Checking the +/// server *before* provisioning/dumping means an old server ↔ new client never +/// fails half-way through. +async fn require_syncable( + site: &site::Site, + conn: &serverkit::ServerKitConnection, + cap: bool, + action: &str, +) -> Result<(), String> { + site.require(cap, action)?; + if site.kind != site::KIND_WORDPRESS && site.kind != site::KIND_PHP { + return Err(format!("{action} is not supported for {} sites.", site.kind)); + } + if !serverkit::supports_kind(&conn.url, &conn.api_key, &site.kind).await? { + return Err(format!( + "{} does not support syncing {} sites — update the serverkit-localkit extension on the server.", + conn.label, site.kind + )); + } + Ok(()) +} + +/// Byte-progress reporter for a transfer stage. +/// +/// The event carries raw counters and a bare label; formatting the +/// "148 MB / 312 MB" readout is the frontend's job (and `site::emit_bytes` +/// does it for stderr when there is no frontend). +fn reporter<'a>( + app: Option<&'a AppHandle>, + site_id: &'a str, + stage: &'a str, + label: &'a str, +) -> impl Fn(u64, u64) + Send + Sync + 'a { + move |done, total| site::emit_bytes(app, site_id, stage, label, done, total) +} + +// --------------------------------------------------------------------------- +// Push code +// --------------------------------------------------------------------------- + pub async fn push_code( app: Option<&AppHandle>, state: &AppState, @@ -121,17 +177,103 @@ pub async fn push_code( remote_site_id: i64, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; - emit(app, site_id, "push", "Bundling wp-content..."); - let tgz = build_wp_content_tgz(&site.dir())?; - let size_mb = tgz.len() as f64 / 1_048_576.0; - emit(app, site_id, "push", &format!("Uploading wp-content ({size_mb:.1} MB)...")); - run(app, state, connection_id, site_id, "push", "code", async move { - serverkit::push_code(&conn.url, &conn.api_key, remote_site_id, tgz).await?; - Ok(format!("{} code pushed to remote site #{remote_site_id}", site.name)) + require_syncable(&site, &conn, site.capabilities.code_sync, "ServerKit push").await?; + let v2 = supports_v2(&conn).await; + let cancel = state.transfers.begin(site_id); + run(app, state, connection_id, site_id, "push", "code", async { + if v2 { + push_code_v2(app, &conn, &site, site_id, remote_site_id, &cancel).await + } else { + push_code_v1(app, &conn, &site, site_id, remote_site_id).await + } }) .await } +/// Sync v1: build the whole archive in memory, POST it in one multipart +/// request. Deliberately left as one isolated function rather than a set of +/// `if v2` branches sprinkled through the v2 flow — the two protocols share +/// nothing but their inputs and their success message. +async fn push_code_v1( + app: Option<&AppHandle>, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + site_id: &str, + remote_site_id: i64, +) -> Result { + let what = &site.config.sync_path; + emit(app, site_id, "push", &format!("Bundling {what}...")); + let tgz = snapshot::build_wp_content_tgz(&site.dir(), what)?; + let size = transfer::human_bytes(tgz.len() as u64); + emit(app, site_id, "push", &format!("Uploading {what} ({size})...")); + serverkit::push_code(&conn.url, &conn.api_key, remote_site_id, tgz).await?; + Ok(format!("{} code pushed to remote site #{remote_site_id}", site.name)) +} + +/// Sync v2: tar straight into a staging file, then upload it in chunks. +/// +/// The archive never exists as a `Vec`, which is what makes a site with a +/// real `uploads/` directory pushable at all — and the chunking is what gets +/// it past the server's 100 MB request limit. +async fn push_code_v2( + app: Option<&AppHandle>, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + site_id: &str, + remote_site_id: i64, + cancel: &transfer::CancelToken, +) -> Result { + let what = site.config.sync_path.clone(); + emit(app, site_id, "push", &format!("Bundling {what}...")); + let dir = site.dir(); + let staged = + transfer::stage("code", |w| snapshot::write_wp_content_tgz(&dir, &what, w))?; + cancel.check()?; + + let size = transfer::human_bytes(staged.total()); + let push_label = format!("Pushing {what}"); + let progress = reporter(app, site_id, "push", &push_label); + serverkit::push_chunked( + &conn.url, + &conn.api_key, + "code", + remote_site_id, + None, + &staged, + cancel, + &progress, + ) + .await?; + Ok(format!( + "{} code pushed to remote site #{remote_site_id} ({size})", + site.name + )) +} + +/// Local safety net before a sync mutates something (plan 17). +/// +/// A failure here aborts the sync: never mutate without a net. The note +/// records which connection/remote the operation was aimed at, so the +/// snapshot list reads as a history of "what did I sync, and against what". +async fn pre_sync_snapshot( + app: Option<&AppHandle>, + state: &AppState, + site_id: &str, + kind: &str, + conn: &serverkit::ServerKitConnection, + remote_site_id: i64, +) -> Result<(), String> { + let note = format!("{} (#{remote_site_id} on {})", conn.label, conn.url); + snapshot::create(app, state, site_id, kind, Some(note)) + .await + .map(|_| ()) + .map_err(|e| format!("pre-sync snapshot failed, nothing was synced: {e}")) +} + +// --------------------------------------------------------------------------- +// Push database +// --------------------------------------------------------------------------- + pub async fn push_db( app: Option<&AppHandle>, state: &AppState, @@ -140,17 +282,36 @@ pub async fn push_db( remote_site_id: i64, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; - emit(app, site_id, "push", "Exporting local database..."); - let dump_path = std::env::temp_dir().join(format!("localkit-dump-{}.sql", site.slug)); - wordpress::export_db(&site.dir(), &dump_path).await?; - let sql = std::fs::read(&dump_path).map_err(|e| format!("failed to read dump: {e}")); - let _ = std::fs::remove_file(&dump_path); - let sql = sql?; - - let local_url = format!("http://localhost:{}", site.port); - emit(app, site_id, "push", "Uploading database dump..."); - run(app, state, connection_id, site_id, "push", "db", async move { - serverkit::push_db(&conn.url, &conn.api_key, remote_site_id, &local_url, sql).await?; + require_syncable(&site, &conn, site.capabilities.db_sync, "ServerKit push").await?; + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PUSH, &conn, remote_site_id).await?; + + // Whatever the site is actually served at — its `.test` domain (with the + // port in fallback mode) when local domains are on. The server rewrites + // local -> remote with this, so a hardcoded localhost: would leave + // `.test` URLs baked into the remote database. + let local_url = router::site_public_url(state, &site); + let v2 = supports_v2(&conn).await; + let cancel = state.transfers.begin(site_id); + run(app, state, connection_id, site_id, "push", "db", async { + let dump = export_dump(app, &site, site_id).await?; + if v2 { + let progress = reporter(app, site_id, "push", "Pushing database"); + serverkit::push_chunked( + &conn.url, + &conn.api_key, + "db", + remote_site_id, + Some(&local_url), + &dump, + &cancel, + &progress, + ) + .await?; + } else { + emit(app, site_id, "push", "Uploading database dump..."); + let sql = std::fs::read(dump.path()).map_err(|e| format!("failed to read dump: {e}"))?; + serverkit::push_db(&conn.url, &conn.api_key, remote_site_id, &local_url, sql).await?; + } Ok(format!( "{} database pushed to remote site #{remote_site_id} (URLs rewritten to remote)", site.name @@ -159,6 +320,28 @@ pub async fn push_db( .await } +/// Export the local database to a self-deleting staged file. +/// +/// Staged rather than read into a `Vec` because v2 uploads it chunk by chunk +/// straight off disk; the v1 path reads it back, which is what it did before. +async fn export_dump( + app: Option<&AppHandle>, + site: &site::Site, + site_id: &str, +) -> Result { + emit(app, site_id, "push", "Exporting local database..."); + // A TempFile from the start, so a failed export cleans up after itself + // instead of leaving a partial dump behind. Engine-native for php, wp-cli for + // WordPress — `dbsync::export_to_file` dispatches (plan 26). + let dump = transfer::TempFile::new(&format!("dump-{}", site.slug))?; + crate::dbsync::export_to_file(site, &site.dir(), dump.path()).await?; + transfer::Staged::adopt_temp(dump) +} + +// --------------------------------------------------------------------------- +// Pull database +// --------------------------------------------------------------------------- + pub async fn pull_db( app: Option<&AppHandle>, state: &AppState, @@ -168,25 +351,70 @@ pub async fn pull_db( remote_url: Option, ) -> Result<(), String> { let (conn, site) = load(state, connection_id, site_id)?; - emit(app, site_id, "pull", "Downloading remote database dump..."); - let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote_site_id).await?; - - // Decompress the .sql.gz dump. - let mut sql = Vec::new(); - flate2::read::GzDecoder::new(&gz[..]) - .read_to_end(&mut sql) - .map_err(|e| format!("failed to decompress remote dump: {e}"))?; - - let local_url = format!("http://localhost:{}", site.port); - emit(app, site_id, "pull", "Importing database into local site..."); - run(app, state, connection_id, site_id, "pull", "db", async move { - wordpress::import_db(&site.dir(), &sql).await?; - wordpress::update_site_urls(&site.dir(), &local_url).await?; + require_syncable(&site, &conn, site.capabilities.db_sync, "ServerKit pull").await?; + let v2 = supports_v2(&conn).await; + + // v1 snapshots before the download because it has no way to stop one + // half-way. v2 does: the download is cancellable, so the snapshot is taken + // after it, once something is actually about to be overwritten — a + // cancelled pull then leaves no pointless `pre_pull` snapshot behind. + if !v2 { + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id).await?; + } + + // Same rule on the way back in: pulling must land the site on its current + // public URL, not silently knock it off its domain onto localhost. + let local_url = router::site_public_url(state, &site); + let cancel = state.transfers.begin(site_id); + let dir = site.dir(); + + run(app, state, connection_id, site_id, "pull", "db", async { + if v2 { + emit(app, site_id, "pull", "Downloading remote database dump..."); + let progress = reporter(app, site_id, "pull", "Pulling database"); + let gz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/db", + remote_site_id, + "database dump", + &cancel, + &progress, + ) + .await?; + cancel.check()?; + pre_sync_snapshot(app, state, site_id, snapshot::KIND_PRE_PULL, &conn, remote_site_id) + .await?; + emit(app, site_id, "pull", "Importing database into local site..."); + // Streams decompress -> pipe -> engine client; the dump never + // exists decompressed in memory. Engine-native for php (plan 26). + crate::dbsync::import_from_gz(&site, &dir, gz.path()).await?; + } else { + emit(app, site_id, "pull", "Downloading remote database dump..."); + let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote_site_id).await?; + let mut sql = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut sql) + .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + emit(app, site_id, "pull", "Importing database into local site..."); + crate::dbsync::import_sql(&site, &dir, &sql).await?; + } + + // URL rewrite is kind-specific: WordPress runs a serialization-safe + // search-replace across all tables; php has no such tool, so it gets a + // best-effort APP_URL patch (Laravel convention) — URL config is the + // app's own concern (plan 26). let mut msg = format!("Remote database imported into {}", site.name); - if let Some(remote) = remote_url.filter(|u| !u.is_empty() && *u != local_url) { - emit(app, site_id, "pull", "Rewriting URLs remote -> local..."); - wordpress::search_replace(&site.dir(), &remote, &local_url).await?; - msg = format!("{msg} (URLs rewritten to local)"); + if site.kind == site::KIND_WORDPRESS { + wordpress::update_site_urls(&dir, &local_url).await?; + if let Some(remote) = remote_url.as_deref().filter(|u| !u.is_empty() && *u != local_url) { + emit(app, site_id, "pull", "Rewriting URLs remote -> local..."); + wordpress::search_replace(&dir, remote, &local_url).await?; + msg = format!("{msg} (URLs rewritten to local)"); + } + } else { + crate::php::patch_app_url(&dir, &site.config.sync_path, &local_url); + msg = format!("{msg} (APP_URL patched, best-effort)"); } Ok(msg) }) @@ -197,3 +425,823 @@ pub fn history(state: &AppState, site_id: &str) -> Result, Strin let db = state.db.lock().map_err(|e| e.to_string())?; db.list_sync(site_id, 20) } + +// --------------------------------------------------------------------------- +// Import a remote site as a new local site (plan 18) +// --------------------------------------------------------------------------- + +/// Safe-extract policy for a downloaded code archive — the client-side mirror of +/// the server's `_safe_extract_tar_gz`. +/// +/// The archive comes off a remote server we do not fully control, so it is +/// treated as hostile input: an entry may only be a plain file or directory +/// under `root/` (`wp-content` for WordPress, the app `sync_path` for php). +/// Everything else is refused rather than sanitized, because every "clean it up +/// and carry on" branch is a place a crafted archive could write outside the +/// site directory. +fn safe_entry_path(name: &Path, root: &str) -> Result { + let mut out = PathBuf::new(); + for component in name.components() { + match component { + // `./foo` — GNU tar emits these; harmless, just drop them. + Component::CurDir => continue, + Component::Normal(part) => out.push(part), + // Absolute paths, `..`, and Windows drive/UNC prefixes all escape + // the destination directory. + Component::ParentDir => { + return Err(format!("archive entry escapes the site directory: {}", name.display())) + } + Component::RootDir | Component::Prefix(_) => { + return Err(format!("archive entry has an absolute path: {}", name.display())) + } + } + } + if out.as_os_str().is_empty() { + return Err("archive contains an entry with an empty path".into()); + } + if out.components().next() != Some(Component::Normal(root.as_ref())) { + return Err(format!("archive entry is outside {root}: {}", name.display())); + } + Ok(out) +} + +/// Unpack a code tar.gz into `site_dir`, applying `safe_entry_path`. Returns the +/// number of files written. +/// +/// Entries are prefixed with `root/` (`wp-content/` for WordPress, the app +/// `sync_path` for php), matching what `push_code` uploads and what a snapshot +/// archives — one archive format in both directions. +/// +/// Takes a reader rather than a byte slice so the import can untar straight +/// off the downloaded file (plan 19): a 4 GB remote directory should never +/// need 4 GB of RAM to land. +fn extract_code(tgz: R, site_dir: &Path, root: &str) -> Result { + use tar::EntryType; + + let dest = site_dir + .canonicalize() + .map_err(|e| format!("site directory is unusable: {e}"))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz)); + let entries = archive + .entries() + .map_err(|e| format!("the downloaded wp-content archive is unreadable: {e}"))?; + + let mut files = 0usize; + for entry in entries { + let mut entry = entry.map_err(|e| format!("the downloaded wp-content archive is unreadable: {e}"))?; + let path = entry + .path() + .map_err(|e| format!("archive entry has an unreadable path: {e}"))? + .into_owned(); + let rel = safe_entry_path(&path, root)?; + let target = dest.join(&rel); + + match entry.header().entry_type() { + EntryType::Directory => { + std::fs::create_dir_all(&target) + .map_err(|e| format!("failed to create {}: {e}", rel.display()))?; + } + EntryType::Regular | EntryType::Continuous => { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", rel.display()))?; + } + let mut out = std::fs::File::create(&target) + .map_err(|e| format!("failed to write {}: {e}", rel.display()))?; + std::io::copy(&mut entry, &mut out) + .map_err(|e| format!("failed to write {}: {e}", rel.display()))?; + files += 1; + } + // Symlinks and hardlinks are the classic escape hatch: the path + // check above passes, then the *link target* points anywhere. + EntryType::Symlink | EntryType::Link => { + return Err(format!( + "archive contains a link, which is not allowed: {}", + rel.display() + )) + } + // GNU long-name/PAX metadata entries carry no payload of their own + // (the tar crate has already applied them to the real entry). + EntryType::GNULongName | EntryType::GNULongLink | EntryType::XHeader | EntryType::XGlobalHeader => {} + other => { + return Err(format!( + "archive contains an unsupported entry type ({other:?}): {}", + rel.display() + )) + } + } + } + if files == 0 { + return Err(format!("the remote {root} archive contained no files")); + } + Ok(files) +} + +/// Pick the local image version closest to what the remote reports. +/// +/// Remote versions carry a patch level (`6.7.2`) that our image allowlist does +/// not, so the match is on `major.minor`. Returns the chosen version and +/// whether it was an exact match — an inexact one is surfaced as a warning +/// rather than an error, because a small version gap almost always still runs. +pub(crate) fn match_version(available: &[&str], remote: Option<&str>) -> (String, bool) { + let newest = available[0].to_string(); + let Some(remote) = remote.map(str::trim).filter(|v| !v.is_empty()) else { + return (newest, false); + }; + let major_minor: String = { + let mut parts = remote.split('.'); + match (parts.next(), parts.next()) { + (Some(a), Some(b)) => format!("{a}.{b}"), + _ => remote.to_string(), + } + }; + match available.iter().find(|v| **v == major_minor) { + Some(v) => (v.to_string(), true), + None => (newest, false), + } +} + +/// Clone a remote ServerKit site into a brand-new local site. +/// +/// Unlike `pull_db`, which overwrites an existing local site, this provisions +/// one: fresh slug, ports and compose project, then the remote `wp-content` +/// and database on top. `wp core install` is deliberately never run — the +/// imported database *is* the site, and installing over it would replace the +/// content the user came for. +pub async fn import_site( + app: Option<&AppHandle>, + state: &AppState, + connection_id: &str, + remote_site_id: i64, + local_name: Option, +) -> Result { + let conn = { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.get_connection(connection_id)? + }; + + // Everything that can be known before provisioning is checked before + // provisioning: a failure here must leave no half-built site behind. + let remote = pre_import(state, &conn, remote_site_id).await?; + let name = local_name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| remote.name.clone()); + + let v2 = supports_v2(&conn).await; + let is_php = remote.kind == site::KIND_PHP; + + // Reserve the local site with the right kind. A php import has no WordPress + // version and a php-shaped config; a WordPress import matches both versions. + let (site, exact) = if is_php { + let (php_version, php_exact) = match_version(site::PHP_VERSIONS, remote.php_version.as_deref()); + let site = site::reserve( + state, + name, + site::KIND_PHP.to_string(), + String::new(), + php_version, + php::config(), + Some((conn.id.clone(), remote_site_id)), + ) + .await?; + (site, (true, php_exact)) + } else { + let (wp_version, wp_exact) = match_version(site::WP_VERSIONS, remote.wp_version.as_deref()); + let (php_version, php_exact) = match_version(site::PHP_VERSIONS, remote.php_version.as_deref()); + let site = site::reserve( + state, + name, + site::KIND_WORDPRESS.to_string(), + wp_version, + php_version, + site::SiteConfig::default(), + Some((conn.id.clone(), remote_site_id)), + ) + .await?; + (site, (wp_exact, php_exact)) + }; + + // The cancel token is keyed on the *new* site's id — that is what the UI + // shows progress against, so it is what a Cancel button can address. + let cancel = state.transfers.begin(&site.id); + // Own this site's status until the import finishes (plan 23). + let _guard = state.in_flight.guard(&site.id); + + // From here on a failure owns cleanup — the site row and directory exist. + let result = if is_php { + do_import_php(app, state, &conn, &site, &remote, v2, &cancel).await + } else { + do_import(app, state, &conn, &site, &remote, exact, v2, &cancel).await + }; + match result { + Ok(message) => { + emit(app, &site.id, "done", &message); + record( + state, + &SyncRecord { + id: Uuid::new_v4().to_string(), + site_id: site.id.clone(), + connection_id: conn.id.clone(), + direction: "pull".into(), + kind: "import".into(), + status: "success".into(), + message, + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + site::get(state, &site.id) + } + Err(e) => { + let cancelled = transfer::is_cancel(&e); + let message = if cancelled { + "Import cancelled".to_string() + } else { + format!("Import failed: {e}") + }; + emit(app, &site.id, if cancelled { "cancelled" } else { "error" }, &message); + // The sync record is keyed to a site that is about to disappear, + // so the failure is reported through the event stream only. + let _ = site::cleanup(state, &site).await; + Err(message) + } + } +} + +/// Checks that must pass *before* a local site is provisioned: the extension +/// can serve code, the remote site exists and is importable, and we are not +/// about to make a second copy of something already imported. +async fn pre_import( + state: &AppState, + conn: &serverkit::ServerKitConnection, + remote_site_id: i64, +) -> Result { + if !serverkit::has_feature(&conn.url, &conn.api_key, serverkit::FEATURE_PULL_CODE).await? { + return Err(format!( + "The serverkit-localkit extension on {} is too old to import sites \ + (no pull/code endpoint). Update the extension on the server.", + conn.label + )); + } + + let sites = serverkit::list_wp_sites(&conn.url, &conn.api_key).await?; + let remote = sites + .into_iter() + .find(|s| s.id == remote_site_id) + .ok_or_else(|| format!("Remote site #{remote_site_id} was not found on {}.", conn.label))?; + + // LocalKit only imports kinds it can provision (wordpress | php) AND the + // server's extension advertises — checked before provisioning (plan 26). + if remote.kind != site::KIND_WORDPRESS && remote.kind != site::KIND_PHP { + return Err(format!( + "\"{}\" is a {} site, which LocalKit cannot import.", + remote.name, remote.kind + )); + } + if !serverkit::supports_kind(&conn.url, &conn.api_key, &remote.kind).await? { + return Err(format!( + "{} does not support importing {} sites — update the serverkit-localkit extension.", + conn.label, remote.kind + )); + } + + // A network of sites cannot be represented by one local compose project; + // refuse rather than produce a half-broken copy (WordPress-only concept). + if remote.multisite { + return Err(format!( + "\"{}\" is a WordPress multisite install, which LocalKit cannot import.", + remote.name + )); + } + + let existing = { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.sites_from_remote(&conn.id, remote_site_id)? + }; + if let Some(s) = existing.first() { + return Err(format!( + "\"{}\" was already imported from {} as the local site \"{}\". \ + Pull its database into that site instead of importing a second copy.", + remote.name, conn.label, s.name + )); + } + Ok(remote) +} + +/// The provisioning half of an import. Returns the success message. +#[allow(clippy::too_many_arguments)] +async fn do_import( + app: Option<&AppHandle>, + state: &AppState, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + remote: &serverkit::RemoteWpSite, + exact: (bool, bool), + v2: bool, + cancel: &transfer::CancelToken, +) -> Result { + let dir = site.dir(); + let id = site.id.as_str(); + let (wp_exact, php_exact) = exact; + + emit(app, id, "files", "Writing project files..."); + site::write_project_files(site)?; + if !wp_exact || !php_exact { + emit( + app, + id, + "files", + &format!( + "Remote runs WordPress {} / PHP {}; importing onto WordPress {} / PHP {}.", + remote.wp_version.as_deref().unwrap_or("unknown"), + remote.php_version.as_deref().unwrap_or("unknown"), + site.wp_version, + site.php_version, + ), + ); + } + + emit( + app, + id, + "pulling", + "Downloading WordPress images (first run can take a few minutes)...", + ); + docker::compose_pull(&dir, &["wordpress", "db", "wpcli"]).await?; + + emit(app, id, "code", "Downloading remote wp-content..."); + let files = if v2 { + let progress = reporter(app, id, "code", "Downloading wp-content"); + let tgz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/code", + remote.id, + "wp-content archive", + cancel, + &progress, + ) + .await?; + cancel.check()?; + let size = transfer::human_bytes(tgz.len()); + emit(app, id, "code", &format!("Extracting wp-content ({size})...")); + let file = std::fs::File::open(tgz.path()) + .map_err(|e| format!("failed to reopen the downloaded archive: {e}"))?; + extract_code(std::io::BufReader::new(file), &dir, "wp-content")? + } else { + let tgz = serverkit::pull_code(&conn.url, &conn.api_key, remote.id).await?; + let size = transfer::human_bytes(tgz.len() as u64); + emit(app, id, "code", &format!("Extracting wp-content ({size})...")); + extract_code(&tgz[..], &dir, "wp-content")? + }; + // The archive may have brought its own mu-plugins directory over the one + // written a moment ago; one-click login must survive the import. + wordpress::ensure_login_plugin(&dir)?; + + emit(app, id, "containers", "Starting Docker containers..."); + docker::compose_up(&dir).await?; + + emit(app, id, "waiting", "Waiting for WordPress to come online..."); + site::wait_for_port(site.port, 180).await?; + // The port answering is not the same as WordPress being ready — see + // `wait_for_config`. Without this the first wp-cli call below dies with + // "'wp-config.php' not found". + wordpress::wait_for_config(&dir, 24).await?; + + emit(app, id, "install", "Downloading remote database..."); + // No `wp core install` anywhere in here: the imported database IS the + // site. Installing would overwrite the content this whole flow exists to + // bring down. + if v2 { + let progress = reporter(app, id, "install", "Downloading database"); + let gz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/db", + remote.id, + "database dump", + cancel, + &progress, + ) + .await?; + cancel.check()?; + emit(app, id, "install", "Importing remote database..."); + wordpress::import_db_from_gz(&dir, gz.path()).await?; + } else { + let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote.id).await?; + let mut sql = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut sql) + .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + drop(gz); + emit(app, id, "install", "Importing remote database..."); + wordpress::import_db(&dir, &sql).await?; + } + + let local_url = router::site_public_url(state, site); + emit(app, id, "install", "Rewriting URLs remote -> local..."); + wordpress::update_site_urls(&dir, &local_url).await?; + if let Some(remote_url) = remote.url.as_deref().filter(|u| !u.is_empty() && *u != local_url) { + wordpress::search_replace(&dir, remote_url, &local_url).await?; + } + // Permalinks are stored as rules tied to the old host; regenerate them or + // every imported page 404s. Best effort — a pretty-permalink failure must + // not throw away a successful import. + optional(docker::compose_run(&dir, "wpcli", &["wp", "rewrite", "flush"])).await; + optional(docker::compose_run(&dir, "wpcli", &["wp", "cache", "flush"])).await; + + // The local admin_user comes from the imported users table — the stock + // `admin` this site was reserved with does not exist in the remote data. + let admin_user = imported_admin(&dir).await.unwrap_or_else(|| site.admin_user.clone()); + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + // No password: the remote's hash is unknown to us, and one-click login + // does not need one. Storing a fake would be worse than storing none. + db.update_credentials(id, &admin_user, "")?; + } + // Last step: the completion marker (plan 23) — its absence flags a killed + // import. + site::mark_complete(&dir); + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + + Ok(format!( + "{} imported from {} ({files} files) — now running at {local_url}", + site.name, conn.label + )) +} + +/// The provisioning half of a **php** import (plan 26 phase 3). Mirrors +/// `do_import` minus every WordPress-specific step: no wp-cli, no `wait_for_config`, +/// no login MU plugin, no `wp core install`. The remote's `app/` code lands +/// first, so the infra (nginx webroot, compose) is generated against the real +/// project layout; then the image builds, the stack comes up, and the database +/// is imported engine-native. +async fn do_import_php( + app: Option<&AppHandle>, + state: &AppState, + conn: &serverkit::ServerKitConnection, + site: &site::Site, + remote: &serverkit::RemoteWpSite, + v2: bool, + cancel: &transfer::CancelToken, +) -> Result { + let dir = site.dir(); + let id = site.id.as_str(); + let sync_path = site.config.sync_path.clone(); + + emit(app, id, "files", "Creating the project directory..."); + php::ensure_dirs(site)?; + + emit(app, id, "code", "Downloading remote application code..."); + let files = if v2 { + let progress = reporter(app, id, "code", "Downloading code"); + let tgz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/code", + remote.id, + "code archive", + cancel, + &progress, + ) + .await?; + cancel.check()?; + let size = transfer::human_bytes(tgz.len()); + emit(app, id, "code", &format!("Extracting code ({size})...")); + let file = std::fs::File::open(tgz.path()) + .map_err(|e| format!("failed to reopen the downloaded archive: {e}"))?; + extract_code(std::io::BufReader::new(file), &dir, &sync_path)? + } else { + let tgz = serverkit::pull_code(&conn.url, &conn.api_key, remote.id).await?; + let size = transfer::human_bytes(tgz.len() as u64); + emit(app, id, "code", &format!("Extracting code ({size})...")); + extract_code(&tgz[..], &dir, &sync_path)? + }; + + // Generate the infra now that app/ holds the real project — the nginx + // webroot is detected from whether the imported code has a public/ dir. + emit(app, id, "files", "Writing project files..."); + php::write_infra(site)?; + + emit( + app, + id, + "pulling", + "Building the PHP image (first run can take a few minutes)...", + ); + let _ = docker::compose_pull(&dir, &["web", "db"]).await; + docker::compose_build(&dir).await?; + + emit(app, id, "containers", "Starting containers..."); + docker::compose_up(&dir).await?; + + emit(app, id, "waiting", "Waiting for the app to come online..."); + let _ = site::wait_for_port(site.port, 180).await; + + emit(app, id, "install", "Downloading remote database..."); + if v2 { + let progress = reporter(app, id, "install", "Downloading database"); + let gz = serverkit::download_resumable( + &conn.url, + &conn.api_key, + "/api/v1/localkit/pull/db", + remote.id, + "database dump", + cancel, + &progress, + ) + .await?; + cancel.check()?; + emit(app, id, "install", "Importing remote database..."); + crate::dbsync::import_from_gz(site, &dir, gz.path()).await?; + } else { + let gz = serverkit::pull_db(&conn.url, &conn.api_key, remote.id).await?; + let mut sql = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut sql) + .map_err(|e| format!("failed to decompress remote dump: {e}"))?; + drop(gz); + emit(app, id, "install", "Importing remote database..."); + crate::dbsync::import_sql(site, &dir, &sql).await?; + } + + // Best-effort APP_URL patch (Laravel convention) — URL config is the app's. + let local_url = router::site_public_url(state, site); + php::patch_app_url(&dir, &sync_path, &local_url); + + { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.set_status(id, "running")?; + } + site::mark_complete(&dir); + router::refresh_routes(state).await; + router::refresh_hosts(state).await; + + Ok(format!( + "{} imported from {} ({files} files) — now running at {local_url}", + site.name, conn.label + )) +} + +/// How long an optional post-import wp-cli call may take before it is given up +/// on. These run *after* the site's data is already in place, so hanging on one +/// would throw away a completed import — and `docker compose run` can hang +/// indefinitely if the daemon leaves a container in a bad state (observed with +/// a container Docker reported as "Up" that had no processes left inside). +const OPTIONAL_STEP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// Run a best-effort step, discarding both failures and hangs. +async fn optional(op: impl std::future::Future>) -> Option { + tokio::time::timeout(OPTIONAL_STEP_TIMEOUT, op).await.ok()?.ok() +} + +/// First administrator in the freshly imported database, for `admin_user`. +/// Optional: falling back to the reserved `admin` is better than failing an +/// import whose data already landed. +async fn imported_admin(dir: &Path) -> Option { + let out = optional(docker::compose_run( + dir, + "wpcli", + &["wp", "user", "list", "--role=administrator", "--field=user_login"], + )) + .await?; + out.lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(str::to_string) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // -- version matching --------------------------------------------------- + + #[test] + fn version_match_ignores_the_remote_patch_level() { + let (v, exact) = match_version(site::WP_VERSIONS, Some("6.6.4")); + assert_eq!(v, "6.6"); + assert!(exact); + } + + #[test] + fn version_match_falls_back_to_the_newest_and_flags_it() { + // 6.2 predates the allowlist entirely. + let (v, exact) = match_version(site::WP_VERSIONS, Some("6.2.1")); + assert_eq!(v, site::WP_VERSIONS[0]); + assert!(!exact, "an unavailable remote version must not report as exact"); + } + + #[test] + fn version_match_handles_a_remote_that_reports_nothing() { + for missing in [None, Some(""), Some(" ")] { + let (v, exact) = match_version(site::PHP_VERSIONS, missing); + assert_eq!(v, site::PHP_VERSIONS[0]); + assert!(!exact); + } + } + + #[test] + fn version_match_accepts_a_bare_major_minor() { + let (v, exact) = match_version(site::PHP_VERSIONS, Some("8.1")); + assert_eq!(v, "8.1"); + assert!(exact); + } + + // -- path policy -------------------------------------------------------- + + #[test] + fn entry_paths_under_wp_content_are_accepted() { + let ok = safe_entry_path(Path::new("wp-content/themes/twenty/style.css"), "wp-content").unwrap(); + assert_eq!(ok, PathBuf::from("wp-content/themes/twenty/style.css")); + } + + /// The root is parameterized (plan 26): a php code archive lives under `app/`. + #[test] + fn entry_paths_under_a_custom_root_are_accepted() { + let ok = safe_entry_path(Path::new("app/public/index.php"), "app").unwrap(); + assert_eq!(ok, PathBuf::from("app/public/index.php")); + // ...and the wp-content root rejects an app-rooted entry, and vice versa. + assert!(safe_entry_path(Path::new("app/x"), "wp-content").is_err()); + assert!(safe_entry_path(Path::new("wp-content/x"), "app").is_err()); + } + + #[test] + fn leading_current_dir_is_stripped() { + let ok = safe_entry_path(Path::new("./wp-content/plugins/x.php"), "wp-content").unwrap(); + assert_eq!(ok, PathBuf::from("wp-content/plugins/x.php")); + } + + #[test] + fn traversal_is_rejected() { + for evil in [ + "wp-content/../../etc/passwd", + "wp-content/themes/../../../x", + "../wp-content/x", + ] { + assert!( + safe_entry_path(Path::new(evil), "wp-content").is_err(), + "traversal slipped through: {evil}" + ); + } + } + + #[test] + fn absolute_paths_are_rejected() { + assert!(safe_entry_path(Path::new("/etc/passwd"), "wp-content").is_err()); + #[cfg(windows)] + assert!(safe_entry_path(Path::new(r"C:\Windows\system32\evil.dll"), "wp-content").is_err()); + } + + #[test] + fn entries_outside_wp_content_are_rejected() { + for evil in ["wp-config.php", "html/wp-content/x", "wp-contents/x"] { + assert!( + safe_entry_path(Path::new(evil), "wp-content").is_err(), + "entry outside wp-content slipped through: {evil}" + ); + } + } + + // -- extraction against real archives ----------------------------------- + + /// Raw ustar entry writer. + /// + /// `tar::Builder` deliberately refuses to emit `..` paths — which is + /// precisely the archive this extractor exists to survive. So the hostile + /// fixtures are assembled header-byte by header-byte instead of through + /// the safe API, which is the only way to prove the check does anything. + fn raw_entry(out: &mut Vec, name: &str, kind: tar::EntryType, link: &str, body: &[u8]) { + let mut header = tar::Header::new_ustar(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_entry_type(kind); + { + // ustar layout: name at 0..100, linkname at 157..257. + let bytes = header.as_mut_bytes(); + bytes[..name.len()].copy_from_slice(name.as_bytes()); + bytes[157..157 + link.len()].copy_from_slice(link.as_bytes()); + } + header.set_cksum(); + out.extend_from_slice(header.as_bytes()); + out.extend_from_slice(body); + out.extend(std::iter::repeat(0u8).take((512 - body.len() % 512) % 512)); + } + + fn gz(build: impl FnOnce(&mut Vec)) -> Vec { + let mut tar_bytes = Vec::new(); + build(&mut tar_bytes); + // Two zero blocks = end of archive. + tar_bytes.extend(std::iter::repeat(0u8).take(1024)); + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + enc.write_all(&tar_bytes).unwrap(); + enc.finish().unwrap() + } + + fn file_entry(out: &mut Vec, name: &str, body: &[u8]) { + raw_entry(out, name, tar::EntryType::Regular, "", body); + } + + fn link_entry(out: &mut Vec, name: &str, target: &str) { + raw_entry(out, name, tar::EntryType::Symlink, target, &[]); + } + + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "localkit-extract-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn extracts_a_normal_archive() { + let dir = scratch("ok"); + let tgz = gz(|b| { + file_entry(b, "wp-content/themes/mytheme/style.css", b"body{}"); + file_entry(b, "wp-content/plugins/hello.php", b" Result { @@ -66,7 +69,7 @@ impl PtyManager { .map_err(|e| format!("failed to open PTY: {e}"))?; let mut cmd = CommandBuilder::new("docker"); - cmd.args(["compose", "exec", "wordpress", "bash"]); + cmd.args(["compose", "exec", service, "bash"]); cmd.cwd(site_dir); cmd.env("TERM", "xterm-256color"); diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs new file mode 100644 index 0000000..092cb7c --- /dev/null +++ b/src-tauri/src/transfer.rs @@ -0,0 +1,676 @@ +//! Chunked, resumable transfer primitives (plan 19). +//! +//! Sync v1 built the whole payload in a `Vec`, POSTed it in one request and +//! hoped: bounded by the server's 100 MB body limit, no progress beyond coarse +//! stages, and a dropped connection at 99% meant starting over. This module is +//! the substrate that replaces it — everything here is deliberately pure or +//! filesystem-only, with no HTTP in sight, so the offset math and the resume +//! rule can be unit-tested without a server. +//! +//! The shape of a v2 transfer: +//! +//! 1. **Stage** the payload to a temp file, hashing it in the same pass +//! (`stage` / `adopt`) — a `Staged` deletes itself on drop, so no failure +//! path leaks a multi-hundred-MB file into the temp dir. +//! 2. **Plan** the upload as `Chunk`s, subtracting whatever the server already +//! confirmed (`remaining`) — that subtraction *is* resume. +//! 3. **Send** each chunk, checking a `CancelToken` between them. +//! +//! Chunk size is a const, not a setting: 8 MiB keeps request counts low on +//! LAN-ish links without making the progress bar jumpy, and a knob here would +//! only ever be turned to a worse value. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; + +/// Bytes per chunk. See the module docs for why this is not configurable. +pub const CHUNK_SIZE: u64 = 8 * 1024 * 1024; + +/// Error text a cancelled transfer fails with. Callers compare against this +/// (`is_cancel`) to report "cancelled" instead of "failed" — a user pressing +/// Cancel is not an error condition, it just travels the error path. +pub const CANCELLED: &str = "cancelled"; + +/// Was this error a user cancel rather than a real failure? +pub fn is_cancel(e: &str) -> bool { + e == CANCELLED +} + +// --------------------------------------------------------------------------- +// Chunk planning +// --------------------------------------------------------------------------- + +/// One byte range of a staged payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Chunk { + pub offset: u64, + pub len: u64, +} + +impl Chunk { + pub fn end(&self) -> u64 { + self.offset + self.len + } +} + +/// Every chunk of a `total`-byte payload, in order. +/// +/// A zero-byte payload yields no chunks — `finish` still runs, and the server +/// verifies the hash of nothing against the hash of nothing. That is the +/// correct behavior for an empty archive even though the callers above refuse +/// to produce one. +pub fn chunks(total: u64, chunk_size: u64) -> Vec { + assert!(chunk_size > 0, "chunk_size must be positive"); + let mut out = Vec::new(); + let mut offset = 0u64; + while offset < total { + out.push(Chunk { + offset, + // The last chunk is short; every other one is full. + len: chunk_size.min(total - offset), + }); + offset += chunk_size; + } + out +} + +/// The chunks still to send, given the offsets the server says it already has. +/// +/// This subtraction is the whole of resume: `init` reports what survived a +/// previous attempt, and the client simply skips those. Unknown offsets in +/// `confirmed` (a server confirming something we never sent, or a chunk size +/// that changed between attempts) are ignored rather than trusted — the plan +/// is always derived from *our* view of the payload. +pub fn remaining(total: u64, chunk_size: u64, confirmed: &[u64]) -> Vec { + chunks(total, chunk_size) + .into_iter() + .filter(|c| !confirmed.contains(&c.offset)) + .collect() +} + +/// Total bytes covered by a chunk list — what "already done" means for the +/// progress readout when a resumed transfer starts part-way through. +pub fn bytes_of(chunks: &[Chunk]) -> u64 { + chunks.iter().map(|c| c.len).sum() +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/// A writer that hashes everything passing through it. +/// +/// Used so a payload is hashed *while* it is built rather than in a second +/// read pass — for a multi-GB `wp-content` archive that halves the disk IO and +/// keeps peak memory at one buffer. +pub struct HashWriter { + inner: W, + hasher: Sha256, + written: u64, +} + +impl HashWriter { + pub fn new(inner: W) -> Self { + Self { inner, hasher: Sha256::new(), written: 0 } + } + + /// Consume the writer, returning the wrapped writer, the hex digest and the + /// byte count. + pub fn finish(self) -> (W, String, u64) { + (self.inner, hex(&self.hasher.finalize()), self.written) + } +} + +impl Write for HashWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Hash only what the inner writer actually accepted, or a short write + // would poison the digest with bytes that never reached the file. + let n = self.inner.write(buf)?; + self.hasher.update(&buf[..n]); + self.written += n as u64; + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + hex(&h.finalize()) +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write as _; + let _ = write!(s, "{b:02x}"); + } + s +} + +// --------------------------------------------------------------------------- +// Staged payloads +// --------------------------------------------------------------------------- + +/// A temp file that deletes itself on drop. +/// +/// Every large payload in a v2 transfer lives in one of these — the staged +/// upload on the way out, the streamed download on the way in — so no error +/// path can leave a copy of someone's site sitting in the temp dir. +#[derive(Debug)] +pub struct TempFile { + path: PathBuf, +} + +impl TempFile { + /// Create an empty temp file. `tag` only exists to make a stray file + /// identifiable if the process is killed hard enough to skip `Drop`. + pub fn new(tag: &str) -> Result { + let path = std::env::temp_dir().join(format!( + "localkit-{tag}-{}-{}.tmp", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::File::create(&path) + .map_err(|e| format!("failed to create a temporary file: {e}"))?; + Ok(Self { path }) + } + + /// Adopt a file someone else created; it is deleted on drop just the same. + pub fn adopt(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Bytes currently on disk — for a download, how much has landed so far. + pub fn len(&self) -> u64 { + std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Throw away whatever has been written; used when a server answers a + /// resume request with the whole body instead of the range we asked for. + pub fn truncate(&self) -> Result<(), String> { + std::fs::File::create(&self.path) + .map(|_| ()) + .map_err(|e| format!("failed to reset the temporary file: {e}")) + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// A payload written to a temp file, with its size and hash. +/// +/// The file is removed on drop, including on every error path — a failed push +/// must not leave a copy of the site's `wp-content` sitting in the temp dir. +#[derive(Debug)] +pub struct Staged { + file: TempFile, + total: u64, + sha256: String, +} + +impl Staged { + pub fn path(&self) -> &Path { + self.file.path() + } + + pub fn total(&self) -> u64 { + self.total + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } + + /// Read one chunk back out for sending. + /// + /// Blocking IO on purpose: a chunk is at most `CHUNK_SIZE` off local disk, + /// and the rest of this codebase reads files the same way. The alternative + /// (holding an async file handle across the whole upload) buys nothing and + /// complicates the resume path. + pub fn read_chunk(&self, chunk: Chunk) -> Result, String> { + let mut f = std::fs::File::open(self.path()) + .map_err(|e| format!("failed to reopen the staged payload: {e}"))?; + f.seek(SeekFrom::Start(chunk.offset)) + .map_err(|e| format!("failed to seek the staged payload: {e}"))?; + let mut buf = vec![0u8; chunk.len as usize]; + f.read_exact(&mut buf) + .map_err(|e| format!("failed to read the staged payload: {e}"))?; + Ok(buf) + } + + /// Take ownership of a file someone else wrote (e.g. `wp db export`), + /// hashing it in place. The file is deleted on drop just like a staged one. + pub fn adopt(path: PathBuf) -> Result { + Self::adopt_temp(TempFile::adopt(path)) + } + + /// Promote an already-owned temp file to a staged payload. + pub fn adopt_temp(file: TempFile) -> Result { + let path = file.path().to_path_buf(); + let mut f = std::fs::File::open(&path) + .map_err(|e| format!("failed to open the staged payload: {e}"))?; + let mut hasher = Sha256::new(); + let mut total = 0u64; + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = f + .read(&mut buf) + .map_err(|e| format!("failed to read the staged payload: {e}"))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + total += n as u64; + } + Ok(Self { file, total, sha256: hex(&hasher.finalize()) }) + } +} + +/// Write a payload to a temp file through a hashing writer. +pub fn stage(tag: &str, build: F) -> Result +where + F: FnOnce(&mut dyn Write) -> Result<(), String>, +{ + // The TempFile exists before anything is written, so every error path from + // here on cleans up after itself. + let temp = TempFile::new(tag)?; + let file = std::fs::File::create(temp.path()) + .map_err(|e| format!("failed to create a staging file: {e}"))?; + + // Hash first, buffer second: every byte still passes through the hasher, + // and the buffer keeps the 8 KiB tar writes off the syscall path. + let mut writer = HashWriter::new(std::io::BufWriter::new(file)); + let built = build(&mut writer); + let (mut inner, sha256, total) = writer.finish(); + let flushed = inner + .flush() + .map_err(|e| format!("failed to finish the staging file: {e}")); + + built?; + flushed?; + Ok(Staged { file: temp, total, sha256 }) +} + +/// Human-readable byte count for progress messages. +/// +/// Deliberately coarse — this only ever ends up in a one-line status like +/// "Pushing code — 148 MB / 312 MB", where a third decimal place is noise. +pub fn human_bytes(n: u64) -> String { + const KB: f64 = 1024.0; + let n = n as f64; + if n < KB { + return format!("{n:.0} B"); + } + let units = ["KB", "MB", "GB", "TB"]; + let mut value = n / KB; + let mut unit = 0; + while value >= KB && unit + 1 < units.len() { + value /= KB; + unit += 1; + } + if value >= 100.0 { + format!("{value:.0} {}", units[unit]) + } else { + format!("{value:.1} {}", units[unit]) + } +} + +// --------------------------------------------------------------------------- +// Cancellation +// --------------------------------------------------------------------------- + +/// Per-site cancel flags for in-flight transfers. +/// +/// Keyed by site id because that is what the UI has to offer a Cancel button +/// against — one sync per site at a time is already the assumption everywhere +/// else (the pinned progress toast, `busyId`). +#[derive(Clone, Default)] +pub struct CancelRegistry { + inner: Arc>>>, +} + +impl CancelRegistry { + /// Register a cancellable operation for `site_id`. + /// + /// Replaces any token left behind by a previous operation, so a stale flag + /// can never cancel a fresh transfer the moment it starts. + pub fn begin(&self, site_id: &str) -> CancelToken { + let flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut map) = self.inner.lock() { + map.insert(site_id.to_string(), flag.clone()); + } + CancelToken { site_id: site_id.to_string(), flag, registry: self.clone() } + } + + /// Ask the transfer for `site_id` to stop. Returns whether one was running. + pub fn cancel(&self, site_id: &str) -> bool { + match self.inner.lock() { + Ok(map) => match map.get(site_id) { + Some(flag) => { + flag.store(true, Ordering::SeqCst); + true + } + None => false, + }, + Err(_) => false, + } + } +} + +/// Handle held by the running transfer; deregisters itself on drop. +pub struct CancelToken { + site_id: String, + flag: Arc, + registry: CancelRegistry, +} + +impl CancelToken { + /// `Err(CANCELLED)` once the user has asked to stop. Called between chunks, + /// which is also the only place a transfer can stop cleanly: a half-sent + /// chunk is simply never confirmed, and the server reaps the transfer. + pub fn check(&self) -> Result<(), String> { + if self.flag.load(Ordering::SeqCst) { + Err(CANCELLED.to_string()) + } else { + Ok(()) + } + } + + pub fn cancelled(&self) -> bool { + self.flag.load(Ordering::SeqCst) + } +} + +impl Drop for CancelToken { + fn drop(&mut self) { + if let Ok(mut map) = self.registry.inner.lock() { + // Only remove our own token — a newer transfer for the same site + // may already have replaced it, and dropping that one would leave + // it uncancellable. + if map.get(&self.site_id).is_some_and(|f| Arc::ptr_eq(f, &self.flag)) { + map.remove(&self.site_id); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- chunk math --------------------------------------------------------- + + #[test] + fn chunks_cover_the_payload_exactly() { + for total in [0u64, 1, 9, 10, 11, 99, 100, 101] { + let cs = 10; + let plan = chunks(total, cs); + assert_eq!(bytes_of(&plan), total, "coverage gap at total={total}"); + // Contiguous, in order, no overlaps. + let mut expected = 0; + for c in &plan { + assert_eq!(c.offset, expected, "gap/overlap at total={total}"); + assert!(c.len > 0 && c.len <= cs); + expected = c.end(); + } + assert_eq!(expected, total); + } + } + + #[test] + fn the_last_chunk_is_short_and_the_rest_are_full() { + let plan = chunks(25, 10); + assert_eq!( + plan, + vec![ + Chunk { offset: 0, len: 10 }, + Chunk { offset: 10, len: 10 }, + Chunk { offset: 20, len: 5 }, + ] + ); + } + + #[test] + fn an_exact_multiple_produces_no_empty_trailing_chunk() { + let plan = chunks(20, 10); + assert_eq!(plan.len(), 2); + assert_eq!(plan.last().unwrap().len, 10); + } + + #[test] + fn an_empty_payload_has_no_chunks() { + assert!(chunks(0, CHUNK_SIZE).is_empty()); + } + + // -- resume ------------------------------------------------------------- + + #[test] + fn confirmed_offsets_are_skipped() { + let left = remaining(25, 10, &[0, 20]); + assert_eq!(left, vec![Chunk { offset: 10, len: 10 }]); + } + + #[test] + fn nothing_confirmed_means_everything_is_sent() { + assert_eq!(remaining(25, 10, &[]), chunks(25, 10)); + } + + #[test] + fn everything_confirmed_means_nothing_is_sent() { + assert!(remaining(25, 10, &[0, 10, 20]).is_empty()); + } + + #[test] + fn offsets_the_client_never_planned_are_ignored() { + // A server echoing an offset from a differently-chunked attempt must + // not be able to make us skip a real chunk. + let left = remaining(25, 10, &[5, 15, 99, 1_000_000]); + assert_eq!(left, chunks(25, 10), "a bogus offset suppressed a real chunk"); + } + + // -- hashing ------------------------------------------------------------ + + #[test] + fn hash_writer_matches_a_one_shot_hash() { + let payload: Vec = (0..10_000u32).map(|i| (i % 251) as u8).collect(); + let mut w = HashWriter::new(Vec::new()); + // Written in uneven pieces: the digest must not depend on write sizes. + for piece in payload.chunks(777) { + w.write_all(piece).unwrap(); + } + let (out, digest, written) = w.finish(); + assert_eq!(out, payload); + assert_eq!(written, payload.len() as u64); + assert_eq!(digest, sha256_hex(&payload)); + } + + #[test] + fn sha256_is_the_known_answer() { + // Anchors the hex encoding against a value that is not self-generated. + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + // -- staging ------------------------------------------------------------ + + #[test] + fn staging_hashes_and_sizes_what_it_wrote() { + let payload: Vec = (0..5_000u32).map(|i| (i % 97) as u8).collect(); + let staged = stage("test-stage", |w| { + w.write_all(&payload).map_err(|e| e.to_string()) + }) + .unwrap(); + + assert_eq!(staged.total(), payload.len() as u64); + assert_eq!(staged.sha256(), sha256_hex(&payload)); + assert_eq!(std::fs::read(staged.path()).unwrap(), payload); + } + + #[test] + fn reassembling_the_chunks_reproduces_the_payload() { + let payload: Vec = (0..30_000u32).map(|i| (i % 253) as u8).collect(); + let staged = stage("test-chunks", |w| { + w.write_all(&payload).map_err(|e| e.to_string()) + }) + .unwrap(); + + // Send them out of order, exactly as a resumed transfer would. + let plan = chunks(staged.total(), 4096); + let mut assembled = vec![0u8; payload.len()]; + for chunk in plan.iter().rev() { + let bytes = staged.read_chunk(*chunk).unwrap(); + assert_eq!(bytes.len(), chunk.len as usize); + assembled[chunk.offset as usize..chunk.end() as usize].copy_from_slice(&bytes); + } + assert_eq!(assembled, payload); + assert_eq!(sha256_hex(&assembled), staged.sha256()); + } + + #[test] + fn a_staged_file_is_removed_on_drop() { + let path = { + let staged = stage("test-drop", |w| w.write_all(b"x").map_err(|e| e.to_string())).unwrap(); + staged.path().to_path_buf() + }; + assert!(!path.exists(), "the staged file outlived its Staged"); + } + + #[test] + fn a_failed_build_still_cleans_up() { + let err = stage("test-fail", |w| { + w.write_all(b"partial").map_err(|e| e.to_string())?; + Err("bundling blew up".into()) + }) + .unwrap_err(); + assert_eq!(err, "bundling blew up"); + // Nothing to assert on the path (we never got one) beyond: no panic, + // and the temp dir is not accumulating — covered by the drop test. + } + + #[test] + fn adopting_a_file_hashes_it_and_takes_ownership() { + let path = std::env::temp_dir().join(format!("localkit-adopt-{}.sql", std::process::id())); + std::fs::write(&path, b"SELECT 1;").unwrap(); + let (total, digest) = { + let staged = Staged::adopt(path.clone()).unwrap(); + (staged.total(), staged.sha256().to_string()) + }; + assert_eq!(total, 9); + assert_eq!(digest, sha256_hex(b"SELECT 1;")); + assert!(!path.exists(), "adopt did not take ownership of the file"); + } + + #[test] + fn a_temp_file_tracks_its_length_and_resets() { + let temp = TempFile::new("test-temp").unwrap(); + assert!(temp.is_empty()); + std::fs::write(temp.path(), b"0123456789").unwrap(); + assert_eq!(temp.len(), 10); + temp.truncate().unwrap(); + assert_eq!(temp.len(), 0, "truncate left bytes behind"); + } + + #[test] + fn a_temp_file_is_removed_on_drop() { + let path = { + let temp = TempFile::new("test-temp-drop").unwrap(); + temp.path().to_path_buf() + }; + assert!(!path.exists(), "the temp file outlived its TempFile"); + } + + // -- formatting --------------------------------------------------------- + + #[test] + fn byte_counts_read_the_way_a_progress_line_should() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(999), "999 B"); + assert_eq!(human_bytes(1024), "1.0 KB"); + assert_eq!(human_bytes(1_048_576), "1.0 MB"); + assert_eq!(human_bytes(157_286_400), "150 MB"); + assert_eq!(human_bytes(3_221_225_472), "3.0 GB"); + } + + // -- cancellation ------------------------------------------------------- + + #[test] + fn a_token_reports_cancellation() { + let reg = CancelRegistry::default(); + let token = reg.begin("site-a"); + assert!(token.check().is_ok()); + + assert!(reg.cancel("site-a"), "cancel did not find the running transfer"); + assert!(token.cancelled()); + assert_eq!(token.check().unwrap_err(), CANCELLED); + assert!(is_cancel(&token.check().unwrap_err())); + } + + #[test] + fn cancelling_an_idle_site_is_a_no_op() { + let reg = CancelRegistry::default(); + assert!(!reg.cancel("nobody-home")); + } + + #[test] + fn a_token_deregisters_itself() { + let reg = CancelRegistry::default(); + drop(reg.begin("site-a")); + assert!(!reg.cancel("site-a"), "a finished transfer is still cancellable"); + } + + #[test] + fn cancels_do_not_leak_across_sites() { + let reg = CancelRegistry::default(); + let a = reg.begin("site-a"); + let b = reg.begin("site-b"); + reg.cancel("site-a"); + assert!(a.cancelled()); + assert!(!b.cancelled(), "cancelling one site stopped another"); + } + + #[test] + fn a_stale_flag_cannot_cancel_the_next_transfer() { + let reg = CancelRegistry::default(); + let first = reg.begin("site-a"); + reg.cancel("site-a"); + assert!(first.cancelled()); + + // Second run of the same site: fresh flag, and dropping the old token + // must not deregister the new one. + let second = reg.begin("site-a"); + drop(first); + assert!(!second.cancelled(), "a stale flag cancelled a fresh transfer"); + assert!(reg.cancel("site-a"), "the fresh transfer was deregistered by the stale token"); + assert!(second.cancelled()); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 0118605..56de2bf 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -99,14 +99,10 @@ fn sites(app: &AppHandle) -> Vec { } /// URL a site's "Open in browser" should hit: `.test` when local -/// domains are on, otherwise the always-working `localhost:`. +/// domains are on (port-aware in fallback mode), otherwise the always-working +/// `localhost:`. fn site_url(state: &AppState, site: &site::Site) -> String { - let (domains_on, trusted) = router::enabled_and_trusted(state); - if domains_on { - router::site_url(&site.slug, trusted) - } else { - format!("http://localhost:{}", site.port) - } + router::site_public_url(state, site) } fn build_menu(app: &AppHandle) -> tauri::Result> { @@ -123,15 +119,21 @@ fn build_menu(app: &AppHandle) -> tauri::Result> { let state = app.state::(); let mut submenu = SubmenuBuilder::with_id(app, "sites", "Sites"); for site in &sites { - let running = site.status == "running"; - let dot = if running { "●" } else { "○" }; + // "up" covers degraded (containers running but unhealthy, plan 23): + // still openable and stoppable, with its own half-filled dot. + let up = site.status == "running" || site.status == "degraded"; + let dot = match site.status.as_str() { + "running" => "●", + "degraded" => "◐", + _ => "○", + }; let open = MenuItemBuilder::with_id( format!("{ID_OPEN}{}", site.id), format!("Open {} in browser", site_url(&state, site)), ) - .enabled(running) + .enabled(up) .build(app)?; - let toggle = if running { + let toggle = if up { MenuItemBuilder::with_id(format!("{ID_STOP}{}", site.id), "Stop").build(app)? } else { MenuItemBuilder::with_id(format!("{ID_START}{}", site.id), "Start").build(app)? diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs new file mode 100644 index 0000000..2dd6a46 --- /dev/null +++ b/src-tauri/src/update.rs @@ -0,0 +1,155 @@ +//! In-app update awareness (plan 25). +//! +//! Releases are unsigned, and `tauri-plugin-updater` requires signed artifacts, +//! so this is deliberately a *checker*, not an updater: ask GitHub for the +//! latest release tag and compare it to the compiled-in version. It never +//! downloads or installs anything — the UI just links to the release page. +//! If releases become signed later, this is the seam to swap for the real +//! updater behind the same Settings row. + +use serde::{Deserialize, Serialize}; + +/// The version this build reports as — the crate version, which the release +/// workflow tags as `vX.Y.Z`. +pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const OWNER_REPO: &str = "jhd3197/LocalKit"; +const USER_AGENT: &str = concat!("LocalKit/", env!("CARGO_PKG_VERSION")); + +/// Result of an update check, shared by the GUI command and `lk doctor`. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateInfo { + /// This build's version (no leading `v`). + pub current: String, + /// The latest published release's version (no leading `v`). + pub latest: String, + /// The GitHub release page for `latest`, to open in a browser. + pub url: String, + /// Whether `latest` is strictly newer than `current`. + pub update_available: bool, +} + +#[derive(Deserialize)] +struct GhRelease { + #[serde(default)] + tag_name: String, + #[serde(default)] + html_url: String, +} + +/// Ask GitHub for the newest published release and compare it to this build. +/// +/// The `/releases/latest` endpoint already excludes drafts and pre-releases, so +/// a checker never nags about an in-progress draft the release workflow left +/// behind. Any network/parse failure is an `Err` the caller treats as "couldn't +/// check" — never as "up to date" and never as a hard failure. +pub async fn check() -> Result { + let client = reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let url = format!("https://api.github.com/repos/{OWNER_REPO}/releases/latest"); + let resp = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("could not reach GitHub to check for updates: {e}"))?; + if !resp.status().is_success() { + return Err(format!( + "GitHub returned HTTP {} when checking for updates.", + resp.status() + )); + } + let release: GhRelease = resp + .json() + .await + .map_err(|e| format!("could not parse the GitHub release response: {e}"))?; + + let tag = release.tag_name.trim(); + if tag.is_empty() { + return Err("GitHub did not report a latest release tag.".into()); + } + let page = if release.html_url.trim().is_empty() { + format!("https://github.com/{OWNER_REPO}/releases/latest") + } else { + release.html_url + }; + Ok(UpdateInfo { + current: normalize(CURRENT_VERSION), + latest: normalize(tag), + url: page, + update_available: is_newer(tag, CURRENT_VERSION), + }) +} + +/// Strip a leading `v`/`V` so `v0.2.0` and `0.2.0` compare equal. +fn normalize(tag: &str) -> String { + tag.trim().trim_start_matches(['v', 'V']).to_string() +} + +/// Numeric version components, dropping any pre-release suffix (`-rc1`). A part +/// that isn't a number reads as 0, so an unparseable tag degrades to "not +/// newer" rather than a false alarm. +fn parts(v: &str) -> Vec { + normalize(v) + .split('-') + .next() + .unwrap_or("") + .split('.') + .map(|p| p.parse::().unwrap_or(0)) + .collect() +} + +/// Is `latest` strictly newer than `current`? Compares numeric components left +/// to right, zero-padding the shorter one. +pub fn is_newer(latest: &str, current: &str) -> bool { + let a = parts(latest); + let b = parts(current); + for i in 0..a.len().max(b.len()) { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + if x != y { + return x > y; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_versions_are_detected() { + assert!(is_newer("0.2.0", "0.1.1")); + assert!(is_newer("v0.1.2", "0.1.1")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(is_newer("0.1.10", "0.1.9")); // numeric, not lexical + } + + #[test] + fn equal_or_older_is_not_an_update() { + assert!(!is_newer("0.1.1", "0.1.1")); + assert!(!is_newer("v0.1.1", "0.1.1")); // the leading v is not a difference + assert!(!is_newer("0.1.0", "0.1.1")); + assert!(!is_newer("0.0.9", "0.1.0")); + } + + #[test] + fn a_prerelease_suffix_is_ignored_and_junk_never_false_alarms() { + assert!(!is_newer("0.1.1-rc1", "0.1.1")); + assert!(is_newer("0.2.0-rc1", "0.1.1")); + // Totally unparseable tags must never read as an available update. + assert!(!is_newer("nightly", "0.1.1")); + assert!(!is_newer("", "0.1.1")); + } + + #[test] + fn mismatched_component_counts_zero_pad() { + assert!(is_newer("0.2", "0.1.9")); + assert!(!is_newer("0.1", "0.1.0")); + assert!(is_newer("1", "0.9.9")); + } +} diff --git a/src-tauri/src/wordpress.rs b/src-tauri/src/wordpress.rs index e8ee84b..7450570 100644 --- a/src-tauri/src/wordpress.rs +++ b/src-tauri/src/wordpress.rs @@ -6,8 +6,9 @@ //! `docker compose run --rm -T wpcli wp `. use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; use tauri::AppHandle; +use uuid::Uuid; use crate::docker; use crate::site::{self, Site}; @@ -217,6 +218,17 @@ async fn wp(dir: &Path, args: &[&str]) -> Result { docker::compose_run(dir, "wpcli", &full).await } +/// Run wp-cli as root, for commands that write `wp-config.php` (root-owned in +/// the wp-data volume — see `docker::compose_run_root`). `--allow-root` is +/// appended because wp-cli refuses to run as root without it. +async fn wp_root(dir: &Path, args: &[&str]) -> Result { + let mut full: Vec<&str> = Vec::with_capacity(args.len() + 2); + full.push("wp"); + full.extend_from_slice(args); + full.push("--allow-root"); + docker::compose_run_root(dir, "wpcli", &full).await +} + /// Auto-install WordPress with generated admin credentials. /// `url` is the public URL the site will be reached at (localhost: or, /// when local domains are enabled, http(s)://.test). @@ -273,6 +285,35 @@ pub async fn install( Err(format!("WordPress install failed: {last_err}")) } +/// Whether WordPress core is already installed (plan 23 resume: don't re-run +/// `core install` on a site whose database already holds one). +pub async fn is_installed(dir: &Path) -> bool { + wp(dir, &["core", "is-installed"]).await.is_ok() +} + +/// Wait until wp-cli can see the site's `wp-config.php`. +/// +/// `site::wait_for_port` is not a sufficient readiness signal on its own: +/// Docker publishes the host port as soon as the container is *created*, so a +/// TCP connect succeeds while the wordpress image's entrypoint is still +/// unpacking core and writing wp-config.php. `install` happens to survive this +/// because it retries for a minute; anything else that shells into wp-cli +/// straight after `compose up` has to wait explicitly, or it fails with a bare +/// "'wp-config.php' not found". +pub async fn wait_for_config(dir: &Path, attempts: u32) -> Result<(), String> { + let mut last = String::new(); + for attempt in 1..=attempts { + match wp(dir, &["config", "path"]).await { + Ok(_) => return Ok(()), + Err(e) => last = e, + } + if attempt < attempts { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + Err(format!("WordPress did not finish initializing: {last}")) +} + /// Read-only info for the UI: core version + plugin list. pub async fn info(dir: &Path) -> Result { let core_version = wp(dir, &["core", "version"]).await?.trim().to_string(); @@ -298,11 +339,52 @@ pub async fn export_db(dir: &Path, dest: &Path) -> Result<(), String> { std::fs::write(dest, sql).map_err(|e| format!("failed to write database dump: {e}")) } -/// Import a SQL dump through wp-cli's stdin (`wp db import -`). +/// Import a SQL dump. +/// +/// The dump is staged to a transient file inside the bind-mounted `wp-content` +/// and imported with `wp db import ` — **not** piped to `wp db import -` +/// on stdin. `docker compose run -T` does not reliably propagate stdin EOF into +/// the container on all platforms (observed hanging indefinitely on Windows — +/// the same reason the config editor writes `wp-config.php` via `compose cp` +/// rather than piped stdin), so a piped import can wait forever for input that +/// already ended. Reading a file has no EOF to lose. pub async fn import_db(dir: &Path, sql: &[u8]) -> Result<(), String> { - docker::compose_run_stdin(dir, "wpcli", &["wp", "db", "import", "-"], sql) - .await - .map(|_| ()) + import_db_via_file(dir, |path| std::fs::write(path, sql)).await +} + +/// Import a gzipped dump, decompressing it to the transient file on the way in +/// (streamed off disk, never held decompressed in memory — plan 19). +pub async fn import_db_from_gz(dir: &Path, gz_path: &Path) -> Result<(), String> { + import_db_via_file(dir, |path| { + let src = std::fs::File::open(gz_path)?; + let mut reader = flate2::read::GzDecoder::new(std::io::BufReader::new(src)); + let mut out = std::fs::File::create(path)?; + std::io::copy(&mut reader, &mut out)?; + Ok(()) + }) + .await +} + +/// Stage a SQL dump into a transient file under the bind-mounted `wp-content` +/// (the one host-writable path the wpcli container sees), run `wp db import` on +/// it, and remove it — always, even on failure. This is what sidesteps the +/// piped-stdin hang (see `import_db`). +async fn import_db_via_file( + dir: &Path, + write_dump: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + let wp_content = dir.join("wp-content"); + std::fs::create_dir_all(&wp_content) + .map_err(|e| format!("failed to prepare the import staging dir: {e}"))?; + let name = format!(".localkit-import-{}.sql", Uuid::new_v4().simple()); + let host_path = wp_content.join(&name); + write_dump(&host_path).map_err(|e| format!("failed to stage the database dump: {e}"))?; + // The wpcli container's workdir is /var/www/html and wp-content is mounted + // there, so this relative path resolves to the staged file inside it. + let rel = format!("wp-content/{name}"); + let result = docker::compose_run(dir, "wpcli", &["wp", "db", "import", &rel]).await; + let _ = std::fs::remove_file(&host_path); + result.map(|_| ()) } /// Serialization-safe URL rewrite across all tables. @@ -312,9 +394,296 @@ pub async fn search_replace(dir: &Path, from: &str, to: &str) -> Result<(), Stri .map(|_| ()) } +/// One `table.column` line of a search-replace report. +#[derive(Debug, Clone, Serialize)] +pub struct SearchReplaceChange { + pub table: String, + pub column: String, + pub count: u64, +} + +/// The outcome of a search-replace: total replacements and the per-column +/// breakdown wp-cli reports (only changed columns, `--report-changed-only`). +#[derive(Debug, Clone, Serialize)] +pub struct SearchReplaceResult { + pub dry_run: bool, + pub total: u64, + pub changes: Vec, +} + +/// Run a serialization-safe search-replace and report what changed. +/// +/// `wp search-replace --all-tables --precise --report-changed-only +/// [--dry-run]` — never a raw SQL `REPLACE`, so PHP-serialized values (widget +/// data, options) survive. `--dry-run` counts without writing, which is what the +/// UI runs first so the user sees the cost before committing. The table output +/// is parsed into a structured result; the total is the sum of the per-column +/// counts. +pub async fn search_replace_report( + dir: &Path, + from: &str, + to: &str, + dry_run: bool, +) -> Result { + let mut args = vec![ + "search-replace", + from, + to, + "--all-tables", + "--precise", + "--report-changed-only", + ]; + if dry_run { + args.push("--dry-run"); + } + let output = wp(dir, &args).await?; + let (total, changes) = parse_search_replace_table(&output); + Ok(SearchReplaceResult { dry_run, total, changes }) +} + +/// Parse wp-cli's search-replace report into (total, per-column rows). +/// +/// The columns are `Table Column Replacements Type`, and +/// `--report-changed-only` shows only rows with a non-zero count (a zero-change +/// run prints no table at all → empty stdout → 0 changes). wp-cli emits this in +/// one of two shapes depending on whether stdout is a TTY: the plain +/// tab-separated form (what LocalKit gets, since it captures a pipe) or the +/// bordered `| a | b |` grid. Both are handled. Column *positions* come from the +/// header row rather than being assumed, so a reordered/extra column still maps. +fn parse_search_replace_table(output: &str) -> (u64, Vec) { + let cells_of = |line: &str| -> Vec { + if line.contains('|') { + // Bordered grid: `| a | b |` — drop the empty ends the outer pipes + // produce, but keep genuinely empty inner cells. + let mut cells: Vec = line.split('|').map(|c| c.trim().to_string()).collect(); + if cells.first().is_some_and(|s| s.is_empty()) { + cells.remove(0); + } + if cells.last().is_some_and(|s| s.is_empty()) { + cells.pop(); + } + cells + } else { + // Tab-separated (the non-TTY form wp-cli actually gives us). + line.split('\t').map(|c| c.trim().to_string()).collect() + } + }; + + let rows: Vec> = output + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('+') && (l.contains('|') || l.contains('\t'))) + .map(cells_of) + .collect(); + + // Locate the header and the columns we care about within it. + let Some(header_idx) = rows + .iter() + .position(|r| r.iter().any(|c| c.eq_ignore_ascii_case("Replacements"))) + else { + return (0, Vec::new()); + }; + let header = &rows[header_idx]; + let col = |name: &str| header.iter().position(|c| c.eq_ignore_ascii_case(name)); + let (table_i, column_i, count_i) = (col("Table"), col("Column"), col("Replacements")); + let Some(count_i) = count_i else { + return (0, Vec::new()); + }; + + let mut total = 0u64; + let mut changes = Vec::new(); + for row in &rows[header_idx + 1..] { + let count = row.get(count_i).and_then(|c| c.parse::().ok()); + let Some(count) = count else { continue }; + total += count; + changes.push(SearchReplaceChange { + table: table_i.and_then(|i| row.get(i)).cloned().unwrap_or_default(), + column: column_i.and_then(|i| row.get(i)).cloned().unwrap_or_default(), + count, + }); + } + (total, changes) +} + +// --------------------------------------------------------------------------- +// Debug mode + log viewer (plan 24) +// --------------------------------------------------------------------------- + +/// WP_DEBUG state + the size of the debug log, for the Tools → Debug UI. +#[derive(Debug, Clone, Serialize)] +pub struct DebugStatus { + pub enabled: bool, + /// Bytes in `wp-content/debug.log` (0 when absent), so the UI can say + /// "empty" without shipping the whole file just to check. + pub log_bytes: u64, +} + +/// Host path of the WordPress debug log. `WP_DEBUG_LOG = true` logs to +/// `WP_CONTENT_DIR/debug.log`, and `wp-content` is bind-mounted, so this is a +/// plain host file — read/cleared without touching the container. +fn debug_log_path(dir: &Path) -> PathBuf { + dir.join("wp-content").join("debug.log") +} + +/// Read WP_DEBUG. `wp config get WP_DEBUG` *evaluates* the constant, so a true +/// value prints `1` and a false/undefined one prints empty. +pub async fn debug_status(dir: &Path) -> Result { + let value = wp(dir, &["config", "get", "WP_DEBUG"]).await.unwrap_or_default(); + let value = value.trim(); + let enabled = value == "1" || value.eq_ignore_ascii_case("true"); + let log_bytes = std::fs::metadata(debug_log_path(dir)) + .map(|m| m.len()) + .unwrap_or(0); + Ok(DebugStatus { enabled, log_bytes }) +} + +/// Toggle debug mode: `WP_DEBUG` and `WP_DEBUG_LOG` follow `enabled`, while +/// `WP_DEBUG_DISPLAY` is pinned false — errors go to the log, never to the +/// screen (a visible fatal on a shared preview URL is its own footgun). Writing +/// `wp-config.php` needs the root runner (see `wp_root`). +pub async fn set_debug(dir: &Path, enabled: bool) -> Result { + let raw = if enabled { "true" } else { "false" }; + wp_root(dir, &["config", "set", "WP_DEBUG", raw, "--raw"]).await?; + wp_root(dir, &["config", "set", "WP_DEBUG_LOG", raw, "--raw"]).await?; + wp_root(dir, &["config", "set", "WP_DEBUG_DISPLAY", "false", "--raw"]).await?; + debug_status(dir).await +} + +/// The debug log, tailed to the last ~128 KB so a runaway log never blows up +/// the IPC payload. Empty string when the file does not exist yet. +pub fn read_debug_log(dir: &Path) -> String { + let Ok(bytes) = std::fs::read(debug_log_path(dir)) else { + return String::new(); + }; + const MAX: usize = 128 * 1024; + let slice = if bytes.len() > MAX { + &bytes[bytes.len() - MAX..] + } else { + &bytes[..] + }; + String::from_utf8_lossy(slice).to_string() +} + +/// Truncate the debug log (kept as an empty file so PHP keeps appending to the +/// same inode). A missing log is a no-op. +pub fn clear_debug_log(dir: &Path) -> Result<(), String> { + let path = debug_log_path(dir); + if path.exists() { + std::fs::write(&path, b"").map_err(|e| format!("failed to clear the debug log: {e}"))?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Config editor (plan 24) — wp-config.php lives in the wp-data volume, not on +// the host, so it is copied in/out of the running wordpress container with +// `docker compose cp` (which runs through the daemon as root, so it can +// overwrite the root-owned file). Requires the container to exist — the command +// layer gates this on the site running. +// --------------------------------------------------------------------------- + +/// Path of `wp-config.php` inside the wordpress containers. +const WP_CONFIG_PATH: &str = "/var/www/html/wp-config.php"; + +/// A short-lived host path for staging a `wp-config.php` copy. +fn wp_config_tmp() -> PathBuf { + std::env::temp_dir().join(format!("localkit-wpconfig-{}.php", Uuid::new_v4().simple())) +} + +/// Read `wp-config.php` by copying it out of the wordpress container. +pub async fn read_wp_config(dir: &Path, service: &str) -> Result { + let tmp = wp_config_tmp(); + docker::compose_cp(dir, service, WP_CONFIG_PATH, &tmp).await?; + let content = std::fs::read_to_string(&tmp).map_err(|e| format!("failed to read wp-config.php: {e}")); + let _ = std::fs::remove_file(&tmp); + content +} + +/// Overwrite `wp-config.php` by copying a staged host file into the container. +/// `docker compose cp` runs as the daemon (root), so it can replace the +/// root-owned file — no fragile stdin piping. +pub async fn write_wp_config(dir: &Path, service: &str, contents: &str) -> Result<(), String> { + let tmp = wp_config_tmp(); + std::fs::write(&tmp, contents).map_err(|e| format!("failed to stage wp-config.php: {e}"))?; + let result = docker::compose_cp_into(dir, &tmp, service, WP_CONFIG_PATH).await; + let _ = std::fs::remove_file(&tmp); + result +} + /// Point home/siteurl at the site's local URL. pub async fn update_site_urls(dir: &Path, url: &str) -> Result<(), String> { wp(dir, &["option", "update", "home", url]).await?; wp(dir, &["option", "update", "siteurl", url]).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = "\ ++------------+---------------+--------------+------+ +| Table | Column | Replacements | Type | ++------------+---------------+--------------+------+ +| wp_options | option_value | 3 | PHP | +| wp_posts | post_content | 12 | SQL | +| wp_posts | guid | 4 | SQL | ++------------+---------------+--------------+------+"; + + #[test] + fn parses_the_report_table_into_rows_and_a_total() { + let (total, changes) = parse_search_replace_table(REPORT); + assert_eq!(total, 19); + assert_eq!(changes.len(), 3); + assert_eq!(changes[0].table, "wp_options"); + assert_eq!(changes[0].column, "option_value"); + assert_eq!(changes[0].count, 3); + assert_eq!(changes[1].count, 12); + assert_eq!(changes[2].column, "guid"); + } + + /// The shape LocalKit actually gets: wp-cli drops the ASCII grid when + /// stdout is a pipe and emits a tab-separated table instead (captured from a + /// real `wp search-replace ... --report-changed-only --dry-run`). + #[test] + fn parses_the_tab_separated_form_wp_cli_actually_emits() { + let tsv = "Table\tColumn\tReplacements\tType\n\ + wp_options\toption_value\t2\tPHP\n\ + wp_posts\tpost_content\t2\tPHP\n\ + wp_posts\tguid\t4\tPHP\n\ + wp_users\tuser_url\t1\tPHP"; + let (total, changes) = parse_search_replace_table(tsv); + assert_eq!(total, 9); + assert_eq!(changes.len(), 4); + assert_eq!(changes[0].table, "wp_options"); + assert_eq!(changes[0].column, "option_value"); + assert_eq!(changes[0].count, 2); + assert_eq!(changes[3].table, "wp_users"); + assert_eq!(changes[3].count, 1); + } + + /// A zero-change dry run prints no table (wp-cli sends the "0 replacements" + /// line to stderr, which `compose_run` drops) — that must read as 0, []. + #[test] + fn empty_output_is_zero_changes() { + assert_eq!(parse_search_replace_table("").0, 0); + assert!(parse_search_replace_table(" \n\n").1.is_empty()); + } + + /// Column positions come from the header, so a reordered/extra-column table + /// still maps Replacements correctly and ignores unrelated columns. + #[test] + fn reads_columns_by_header_position() { + let reordered = "\ ++--------------+------------+---------------+ +| Replacements | Table | Column | ++--------------+------------+---------------+ +| 7 | wp_meta | meta_value | ++--------------+------------+---------------+"; + let (total, changes) = parse_search_replace_table(reordered); + assert_eq!(total, 7); + assert_eq!(changes[0].table, "wp_meta"); + assert_eq!(changes[0].column, "meta_value"); + assert_eq!(changes[0].count, 7); + } +} diff --git a/src/App.tsx b/src/App.tsx index 52e05c3..ede5ad8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; -import { onSiteEvent } from "./lib/ipc"; +import { onSiteEvent, onSitesChanged } from "./lib/ipc"; +import { checkForUpdateOnLaunch } from "./lib/update"; +import { useDocker } from "./stores/docker"; import { useNav } from "./stores/nav"; import { useRouter } from "./stores/router"; import { useSites } from "./stores/sites"; @@ -9,6 +11,7 @@ import Toasts from "./components/Toasts"; import CommandPalette from "./components/CommandPalette"; import KeyboardShortcutsDialog from "./components/KeyboardShortcutsDialog"; import NewSiteDialog from "./components/NewSiteDialog"; +import ImportSiteDialog from "./components/ImportSiteDialog"; import Dashboard from "./pages/Dashboard"; import SiteDetail from "./pages/SiteDetail"; import TerminalPage from "./pages/Terminal"; @@ -25,9 +28,24 @@ export default function App() { useEffect(() => { void useSites.getState().refresh(); void useRouter.getState().refresh(); + void useDocker.getState().refresh(); + // Update awareness (plan 25): a throttled, best-effort GitHub check that + // may raise a one-time "update available" toast. Never blocks startup. + void checkForUpdateOnLaunch(); const unlisten = onSiteEvent(handleEvent); + // The reconciler settles status in the background; re-fetch when it does + // so an external stop/start corrects itself without a manual refresh. + const unlistenChanged = onSitesChanged(() => { + void useSites.getState().refresh(); + void useDocker.getState().refresh(); + }); + // Poll Docker health so the "unavailable" pill appears/clears on its own + // (plan 23); the backend caches for 30 s, so this is cheap. + const docker = window.setInterval(() => void useDocker.getState().refresh(), 30_000); return () => { void unlisten.then((f) => f()); + void unlistenChanged.then((f) => f()); + window.clearInterval(docker); }; }, [handleEvent]); @@ -42,6 +60,9 @@ export default function App() { {settingsOpen && } {newSiteOpen && setNewSiteOpen(false)} />} + {/* Opened from Settings → ServerKit, but rendered here so the import + keeps running (and the dialog keeps reporting) if Settings closes. */} + diff --git a/src/components/CloneSiteDialog.tsx b/src/components/CloneSiteDialog.tsx new file mode 100644 index 0000000..dcac7e8 --- /dev/null +++ b/src/components/CloneSiteDialog.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useSites } from "../stores/sites"; +import { useNav } from "../stores/nav"; +import { useDialog } from "../hooks/useDialog"; +import type { Site } from "../lib/types"; + +/** + * Name-the-copy dialog for a one-click site clone (plan 20). The heavy lifting + * — snapshot the source, provision a fresh site, lay the data down and rewrite + * URLs — happens in the backend and streams progress through the pinned toast; + * this only collects the new name and, on success, opens the clone. + */ +export default function CloneSiteDialog({ + source, + onClose, +}: { + source: { id: string; name: string }; + onClose: () => void; +}) { + const cloneSite = useSites((s) => s.cloneSite); + const navigate = useNav((s) => s.navigate); + const { overlayProps, panelProps } = useDialog(onClose); + + const [name, setName] = useState(`${source.name} copy`); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async () => { + setBusy(true); + setError(null); + let site: Site; + try { + site = await cloneSite(source.id, name); + } catch (e) { + setError(typeof e === "string" ? e : String(e)); + setBusy(false); + return; + } + onClose(); + navigate({ name: "site", id: site.id }); + }; + + return ( +
+
+

Clone “{source.name}”

+

+ LocalKit copies this site's database and files into a brand-new site with fresh ports and + credentials — a throwaway copy to test a plugin or theme change. +

+ + + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/src/components/ConfigEditorPanel.tsx b/src/components/ConfigEditorPanel.tsx new file mode 100644 index 0000000..c136c5f --- /dev/null +++ b/src/components/ConfigEditorPanel.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import { ipc } from "../lib/ipc"; +import { errMsg, toastError } from "../lib/errors"; +import { toast } from "../stores/toast"; +import { useSites } from "../stores/sites"; + +/** + * Tools → Config (plan 24). + * + * A plain textarea editor for `wp-config.php` and the site `.env` — no Monaco, + * no diff/backup machinery (snapshots are the safety net). Saving `.env` offers + * a restart, since compose only picks env changes up on a recreate; `wp-config` + * is read live by PHP and needs nothing. Danger styling throughout: editing + * these can break the site. + */ +type ConfigFile = "wp-config" | "env"; + +const FILES: { key: ConfigFile; label: string }[] = [ + { key: "wp-config", label: "wp-config.php" }, + { key: "env", label: ".env" }, +]; + +export default function ConfigEditorPanel({ siteId }: { siteId: string }) { + const restart = useSites((s) => s.restart); + const [file, setFile] = useState("wp-config"); + const [content, setContent] = useState(""); + const [original, setOriginal] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback( + (which: ConfigFile) => { + setLoading(true); + setError(null); + ipc + .readSiteConfigFile(siteId, which) + .then((text) => { + setContent(text); + setOriginal(text); + }) + .catch((e) => { + setError(errMsg(e)); + setContent(""); + setOriginal(""); + }) + .finally(() => setLoading(false)); + }, + [siteId] + ); + + useEffect(() => load(file), [load, file]); + + const dirty = content !== original; + + const save = async () => { + setSaving(true); + try { + await ipc.writeSiteConfigFile(siteId, file, content); + setOriginal(content); + toast.success(`Saved ${FILES.find((f) => f.key === file)?.label}`); + // .env only takes effect on a recreate — offer it. + if (file === "env" && window.confirm("Restart the site now to apply the .env changes?")) { + await restart(siteId); + } + } catch (e) { + toastError(e, "Save config file"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Config

+
+ {FILES.map((f) => ( + + ))} +
+
+ +

+ Editing this can break the site. There is no undo here — take a snapshot first if unsure. +

+ + {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : ( + <> +