diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 000000000..c8d7cadbb --- /dev/null +++ b/.env.development.example @@ -0,0 +1,45 @@ +# Development environment +# Copy to .env.development (or run: cargo xtask dev-setup) + +# Runtime environment +NODE_ENV=development + +# Zzz app directory (config, state, cache, runtime) +PUBLIC_ZZZ_DIR="./.zzz" + +# Comma-separated filesystem paths Zzz can access (e.g., "./projects,~/code") +PUBLIC_ZZZ_SCOPED_DIRS="./src/test/fs1,./src/test/fs2" + +# Server port — zzz_server itself reads ZZZ_PORT/--port and always binds +# loopback (no HOST override). This PORT is read by the `zzz` CLI when +# picking the daemon port; `cargo xtask dev` forces 4461 (the Vite proxy target). +PORT=4461 + +# Frontend SvelteKit env vars (used by $env/static/public in constants.ts) +PUBLIC_ZZZ_SERVER_PROTOCOL=http +PUBLIC_ZZZ_SERVER_HOST=localhost +PUBLIC_ZZZ_SERVER_PORT=5173 +PUBLIC_ZZZ_SERVER_API_PATH="/api" +PUBLIC_ZZZ_SERVER_PROXIED_PORT=4461 +PUBLIC_ZZZ_WEBSOCKET_URL=ws://localhost:4461/api/ws + +# Debug delay in milliseconds for API responses (0 = no delay) +PUBLIC_ZZZ_BACKEND_ARTIFICIAL_DELAY=0 + +# Database — PostgreSQL (the Rust backend connects via tokio-postgres) +DATABASE_URL=postgres://localhost/zzz + +# Auth - cookie signing key (generate with: openssl rand -base64 32) +SECRET_FUZ_COOKIE_KEYS=dev-only-not-for-production-use-000 + +# Security - allowed origins for API requests +# Patterns: https://example.com, https://*.example.com, http://localhost:* +FUZ_ALLOWED_ORIGINS=http://localhost:* + +# Bootstrap token path (for initial admin account creation) +FUZ_BOOTSTRAP_TOKEN_PATH=.zzz/bootstrap_token + +# AI provider API keys (optional, for remote providers) +SECRET_OPENAI_API_KEY= +SECRET_ANTHROPIC_API_KEY= +SECRET_GOOGLE_API_KEY= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 000000000..434d2c57c --- /dev/null +++ b/.env.production.example @@ -0,0 +1,39 @@ +# Production environment +# Copy to .env.production (or run: cargo xtask prod-setup) + +# Zzz app directory (config, state, cache, runtime) +PUBLIC_ZZZ_DIR="./.zzz" + +# Comma-separated filesystem paths Zzz can access +PUBLIC_ZZZ_SCOPED_DIRS= + +# Server port — zzz_server reads ZZZ_PORT/--port (default 4460) and always +# binds loopback (no HOST override). PORT is read by the `zzz` CLI when +# picking the daemon port. +PORT=4460 +DATABASE_URL=postgres://localhost/zzz + +# Auth - cookie signing key (generate with: openssl rand -base64 32) +SECRET_FUZ_COOKIE_KEYS=CHANGE_ME_generate_with_openssl_rand_base64_32 + +# Security - allowed origins for API requests +FUZ_ALLOWED_ORIGINS=http://localhost:* + +# Bootstrap token path (for initial admin account creation) +# FUZ_BOOTSTRAP_TOKEN_PATH=.zzz/bootstrap_token + +# Frontend SvelteKit env vars (used by $env/static/public in constants.ts) +PUBLIC_ZZZ_SERVER_PROTOCOL=http +PUBLIC_ZZZ_SERVER_HOST=localhost +PUBLIC_ZZZ_SERVER_PORT=4460 +PUBLIC_ZZZ_SERVER_API_PATH="/api" +PUBLIC_ZZZ_WEBSOCKET_URL=ws://localhost:4460/api/ws +PUBLIC_ZZZ_SERVER_PROXIED_PORT=4460 + +# Debug delay in milliseconds for API responses (0 = no delay) +PUBLIC_ZZZ_BACKEND_ARTIFICIAL_DELAY=0 + +# AI provider API keys (optional, for remote providers) +SECRET_OPENAI_API_KEY= +SECRET_ANTHROPIC_API_KEY= +SECRET_GOOGLE_API_KEY= diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 14bee0969..6ddd4ab9d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -8,23 +8,48 @@ on: branches: [main] pull_request: branches: ['**'] + # Allow manually re-running the check from the Actions tab. + workflow_dispatch: + +# Cancel a PR's in-progress run when a new commit supersedes it; let main-branch +# runs finish so every commit on main stays verified. +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Least privilege: the check only reads the repo. +permissions: + contents: read jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: - node-version: ['22.15'] + node-version: ['24.14'] steps: - - uses: actions/checkout@v2 + # persist-credentials: false keeps the GITHUB_TOKEN out of .git/config, so a + # compromised build dependency can't read it. If you add a step that pushes + # via git (deploy, tag, generated commit), set this back to true or + # authenticate that step explicitly. + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} + cache: npm - run: npm ci - - run: cp src/lib/server/.env.development.example .env.development - - run: cp src/lib/server/.env.production.example .env.production + - run: cp .env.development.example .env.development + - run: cp .env.production.example .env.production - run: npx svelte-kit sync - run: npx @fuzdev/gro check --workspace --build + + # The cross-backend wire-contract suites (cross_backend_rust / _proxy) run + # against the Rust `zzz_server`. They need a sibling Rust workspace checkout, a + # Postgres service, and a cached cargo build, so they run locally + # (`npm run test:cross`) rather than in CI for now. diff --git a/.gitignore b/.gitignore index 5117c8d01..3f0dc9cfc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ target/ *.pem id_rsa +# Local (per-machine, never shared) +*.local +*.local.* + # Ignore *.ignore *.ignore.* @@ -39,6 +43,9 @@ tmp/ # Workflow /worktree +# Benchmark run artifacts (per-run, machine-specific timings) +/src/benchmarks/cross_impl.latest.json + # Os .DS_Store Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md index 0a5e65b8b..54705799d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,69 +2,99 @@ > nice web things for the tired -`@fuzdev/zzz` — local-first AI forge: chat + files + prompts in one app. -SvelteKit frontend, Hono/Node.js backend, Svelte 5 runes, Zod schemas. -v0.0.1, no auth, no database yet. 26 cell classes, 20 action specs, 4 AI providers. +`@fuzdev/zzz` — local-first AI forge: chat + files + prompts + terminals in one app. +SvelteKit frontend (static SPA), Rust (Axum) backend, Svelte 5 runes, Zod schemas. +v0.0.1. fuz_app auth stack (sessions, bearer tokens, bootstrap), PostgreSQL DB. Cell + Action patterns (generated roster in ./docs/reference.md), 3 AI providers. -For coding conventions, see [`fuz-stack`](../fuz-stack/CLAUDE.md). +zzz has a single **Rust** backend: `crates/zzz_server` (Axum). The frontend +is a prerendered static SPA served by `zzz_server` — no JS runtime in +production. + +For coding conventions, see Skill(fuz-stack). + +## Committing + +`git add` and `git commit` are denied by `.claude/settings.local.json` in +this repo — make the edits and stop, the user commits. ## What zzz Does 1. **Chat** with AI models — multi-thread, multi-model comparison, streaming responses 2. **Edit files** on disk — scoped filesystem, syntax highlighting, multi-tab editor 3. **Build prompts** — reusable content templates composed from text parts and file references -4. **Manage models** — Ollama local models + Claude/ChatGPT/Gemini via BYOK API keys -5. **Symmetric actions** — JSON-RPC 2.0 between frontend and backend, same ActionPeer on both sides +4. **Manage models** — Claude/ChatGPT/Gemini via BYOK API keys +5. **Run terminals** — interactive PTY terminals via xterm.js with preset commands, contextmenu copy, and restart +6. **Symmetric actions** — JSON-RPC 2.0 between frontend and backend, same ActionPeer on both sides ## Key Principles -- **Local-first**: Ollama for local AI, sensitive data stays on your machine, no third-party lock-in +- **Local-first**: your data stays on your machine, no third-party lock-in; providers are opt-in BYOK - **Schema-driven**: Every Cell and Action defined by Zod schemas, validated at boundaries - **Symmetric actions**: Frontend and backend are peers — same ActionPeer code, same spec format - **Cell pattern**: All state is Cell subclasses with `$state`/`$derived` runes, JSON-serializable ## Development Stage -Early development, v0.0.1. Breaking changes are expected and welcome. No authentication — development use only. All state is in-memory (no database yet). The Hono/Node.js backend is a reference implementation that may be replaced by a Rust daemon (`fuzd`). +Early development, v0.0.1. Breaking changes are expected and welcome. fuz_app auth stack on both RPC and WebSocket endpoints (cookie sessions, bearer tokens, daemon tokens, bootstrap flow); WebSocket upgrade requires authentication with event-driven session revocation. PostgreSQL DB for auth; domain state (files, terminals) is in-memory. + +The Rust backend (`crates/zzz_server`, Axum) provides the full auth stack, filesystem, terminals, PostgreSQL, bootstrap, AI providers with SSE streaming, audit emission with listener fan-out, trusted-proxy `client_ip` resolution, login rate limiting (always on; disabled in the test binary), Origin allowlist on every REST + RPC + WS handler. Auth, HTTP, realtime (WS + SSE), dispatch, and DB all come from the spine crates (`fuz_db`, `fuz_auth`, `fuz_http`, `fuz_realtime`, `fuz_actions`); a single `/api/rpc` + `/api/ws` serves the boot-compiled `fuz_actions::ActionRegistry`, with the zzz-specific handlers (workspace, filesystem, terminal, provider, `completion_create`) in `handlers/` and the admin audit-log SSE stream at `GET /api/admin/audit/stream`. AI providers are Anthropic, OpenAI, and Gemini, all with non-streaming and SSE streaming completions. + +The `cross_backend_*` vitest projects (gated behind `FUZ_TEST_CROSS_BACKEND=1`) are the Rust backend's integration tests — they run fuz_app's standard suites against `zzz_server` over real HTTP, verifying wire-shape conformance to the shared fuz_app contract. (A schema-parity snapshot gate exists as a fuz_app capability — `query_schema_snapshot` + `assert_schema_snapshots_equal` — but is not currently wired into zzz's cross-backend projects.) Long-term the CLI and daemon migrate to Rust fuz/fuzd. See [GitHub issues](https://github.com/fuzdev/zzz/issues) for planned work. +## CLI + +zzz has a Rust CLI (`crates/zzz`, argh) for daemon management and browser +launching. See ./crates/CLAUDE.md for the crate layout. + +```bash +zzz # start daemon if needed, open browser +zzz ~/dev/ # open workspace at ~/dev/ +zzz daemon start # start daemon (foreground) +zzz daemon status # show daemon info +zzz init # initialize ~/.zzz/ +``` + +The global daemon runs on port 4460 with state at `~/.zzz/`. The CLI spawns +and discovers the `zzzd` daemon binary (the `[[bin]]` target of the +`zzz_server` crate) — found beside the CLI executable (e.g. `~/.zzz/bin/zzzd`), +with a dev fallback to `./target/debug/zzzd`. Build both with `cargo`: +`cargo build -p zzz` (CLI) and `cargo build -p zzz_server` (daemon → `zzzd`). + ## Docs -- [docs/architecture.md](docs/architecture.md) — Action system, Cell system, content model, data flow -- [docs/development.md](docs/development.md) — Development workflow, extension points, patterns -- [docs/providers.md](docs/providers.md) — AI provider integration, adding new providers -- [src/lib/server/CLAUDE.md](src/lib/server/CLAUDE.md) — Backend server architecture, providers, security +- ./docs/architecture.md — Action system, Cell system, content model, data flow +- ./docs/development.md — Development workflow, extension points, patterns +- ./docs/providers.md — AI provider integration, adding new providers +- ./docs/reference.md — generated action-spec + cell-class tables (`gro gen`) +- ./crates/CLAUDE.md — Rust backend (`zzzd`) + Rust CLI (`crates/zzz`) ## Repository Structure ``` +crates/ # Rust workspace +│ ├── CLAUDE.md # Rust backend docs +│ ├── zzz/ # Rust CLI (argh) — daemon lifecycle, init, open, version +│ ├── xtask/ # Dev automation: `cargo xtask dev` (build + run zzzd + Vite), `dev-setup`/`prod-setup` (env files), `check-release` (dep-graph audit — sanity check #2 of the test-binary pattern). Replaces the former Deno `scripts/*.ts` +│ ├── testing_zzz_server/ # Test-mode binary — wires `fuz_testing::TestingArgon2idHasher` for fast cross-process integration tests. **Never ships in a release.** +│ └── zzz_server/ # Axum JSON-RPC server — full spine consumer (single `/api/rpc` + `/api/ws` on `fuz_actions::ActionRegistry`) +│ └── src/ # `run_app` lifecycle (`lib.rs`) + thin `main.rs`; `handlers/` (App state + `broadcast` shim + per-domain RPC handlers) + `zzz_action_specs/` (spec builders), `provider/` (AI providers), `filer.rs`, `pty_manager.rs`, `scoped_fs.rs`, `error.rs`. Auth / HTTP / realtime (WS + SSE) / dispatch / DB (and the JSON-RPC `notification` builder + error constructors + socket revocation) all come from the spine crates. See ./crates/CLAUDE.md for the full tree. src/ ├── lib/ # Published as @fuzdev/zzz -│ ├── server/ # Backend (Hono/Node.js reference impl) -│ │ ├── backend.ts -│ │ ├── server.ts -│ │ ├── backend_action_handlers.ts -│ │ ├── backend_provider_*.ts # Ollama, Claude, ChatGPT, Gemini -│ │ ├── scoped_fs.ts -│ │ ├── security.ts -│ │ └── backend_action_types.gen.ts -│ │ -│ ├── *.svelte.ts # Cell state classes (26 classes) -│ ├── action_specs.ts # All 20 action spec definitions -│ ├── action_spec.ts # ActionSpec schema -│ ├── action_event.ts # Action lifecycle state machine -│ ├── action_peer.ts # Symmetric send/receive +│ ├── *.svelte.ts # Cell state classes +│ ├── action_specs.ts # Action spec definitions │ ├── cell.svelte.ts # Base Cell class │ ├── cell_classes.ts # Cell class registry │ ├── indexed_collection.svelte.ts │ │ │ ├── *.svelte # UI components -│ ├── action_collections.gen.ts # Generated -│ ├── frontend_action_types.gen.ts -│ └── action_metatypes.gen.ts +│ ├── *.gen.ts # Generators (hand-written) — run `gro gen` +│ ├── action_collections.ts # ↳ generated output (DO NOT EDIT) +│ ├── action_metatypes.ts # ↳ generated output (DO NOT EDIT) +│ └── frontend_action_types.ts # ↳ generated output (DO NOT EDIT) │ -├── routes/ # SvelteKit routes (16 dirs) +├── routes/ # SvelteKit routes (one dir per page) │ ├── about/ │ ├── actions/ │ ├── bots/ @@ -80,15 +110,15 @@ src/ │ ├── repos/ │ ├── settings/ │ ├── tabs/ -│ └── views/ -│ -├── test/ # Tests (not co-located) -│ ├── cell.svelte.*.test.ts -│ ├── action_event.test.ts -│ ├── indexed_collection.svelte.*.test.ts -│ └── ... +│ ├── terminals/ +│ ├── views/ +│ └── workspaces/ │ -└── routes/library.gen.ts # Generated route metadata +└── test/ # Tests (not co-located) + ├── cell.svelte.*.test.ts + ├── action_event.test.ts + ├── indexed_collection.svelte.*.test.ts + └── ... ``` ## Architecture @@ -97,96 +127,161 @@ The two core abstractions are **Cells** (reactive state) and **Actions** (RPC). Content model: `Chat → Thread[] → Turn[] → Part[]` (TextPart or DiskfilePart). Prompts also hold Parts. -See [docs/architecture.md](docs/architecture.md) for detailed data flow, content model, and IndexedCollection docs. +See ./docs/architecture.md for detailed data flow, content model, and IndexedCollection docs. ## Cell Classes -26 registered classes in `src/lib/cell_classes.ts`: - -| Class | Source file | Purpose | -| ---------------- | ------------------------------ | ------------------------------------ | -| `Parts` | `parts.svelte.ts` | Collection of all parts | -| `TextPart` | `part.svelte.ts` | Direct text content | -| `DiskfilePart` | `part.svelte.ts` | File reference content | -| `Capabilities` | `capabilities.svelte.ts` | Feature capability tracking | -| `Chat` | `chat.svelte.ts` | Chat container with threads | -| `Chats` | `chats.svelte.ts` | Collection of chats | -| `Diskfile` | `diskfile.svelte.ts` | Single file on disk | -| `DiskfileTab` | `diskfile_tab.svelte.ts` | Editor tab for a file | -| `DiskfileTabs` | `diskfile_tabs.svelte.ts` | Tab manager | -| `DiskfileHistory`| `diskfile_history.svelte.ts` | File edit history | -| `Diskfiles` | `diskfiles.svelte.ts` | Collection of disk files | -| `DiskfilesEditor`| `diskfiles_editor.svelte.ts` | Multi-file editor state | -| `Model` | `model.svelte.ts` | AI model definition | -| `Models` | `models.svelte.ts` | Model catalog with indexes | -| `Action` | `action.svelte.ts` | Single action event state | -| `Actions` | `actions.svelte.ts` | Action history | -| `Prompt` | `prompt.svelte.ts` | Reusable prompt template | -| `Prompts` | `prompts.svelte.ts` | Collection of prompts | -| `Provider` | `provider.svelte.ts` | AI provider config | -| `Providers` | `providers.svelte.ts` | Collection of providers | -| `Socket` | `socket.svelte.ts` | WebSocket connection state | -| `Turn` | `turn.svelte.ts` | Single conversation message | -| `Thread` | `thread.svelte.ts` | Linear conversation with one model | -| `Threads` | `threads.svelte.ts` | Collection of threads | -| `Time` | `time.svelte.ts` | Reactive time state | -| `Ui` | `ui.svelte.ts` | UI state (menus, layout) | +Registered in `src/lib/cell_classes.ts` — ./docs/reference.md +has the authoritative generated roster and count. Purposes below (`Socket` is +not a Cell — it's a plain `.svelte.ts` wrapper around fuz_app's +`FrontendWebsocketClient`, so it's not listed): + +- `Parts` (`parts.svelte.ts`) — Collection of all parts +- `TextPart` (`part.svelte.ts`) — Direct text content +- `DiskfilePart` (`part.svelte.ts`) — File reference content +- `Capabilities` (`capabilities.svelte.ts`) — Feature capability tracking +- `Chat` (`chat.svelte.ts`) — Chat container with threads +- `Chats` (`chats.svelte.ts`) — Collection of chats +- `Diskfile` (`diskfile.svelte.ts`) — Single file on disk +- `DiskfileTab` (`diskfile_tab.svelte.ts`) — Editor tab for a file +- `DiskfileTabs` (`diskfile_tabs.svelte.ts`) — Tab manager +- `DiskfileHistory` (`diskfile_history.svelte.ts`) — File edit history +- `Diskfiles` (`diskfiles.svelte.ts`) — Collection of disk files +- `DiskfilesEditor` (`diskfiles_editor.svelte.ts`) — Multi-file editor state +- `Model` (`model.svelte.ts`) — AI model definition +- `Models` (`models.svelte.ts`) — Model catalog with indexes +- `Action` (`action.svelte.ts`) — Single action event state +- `Actions` (`actions.svelte.ts`) — Action history +- `Prompt` (`prompt.svelte.ts`) — Reusable prompt template +- `Prompts` (`prompts.svelte.ts`) — Collection of prompts +- `Provider` (`provider.svelte.ts`) — AI provider config +- `Providers` (`providers.svelte.ts`) — Collection of providers +- `Turn` (`turn.svelte.ts`) — Single conversation message +- `Thread` (`thread.svelte.ts`) — Linear conversation with one model +- `Threads` (`threads.svelte.ts`) — Collection of threads +- `Space` (`space.svelte.ts`) — Named grouping of workspace dirs +- `Spaces` (`spaces.svelte.ts`) — Collection of spaces +- `Terminal` (`terminal.svelte.ts`) — PTY terminal process state +- `TerminalPreset` (`terminal_preset.svelte.ts`) — Saved terminal command config +- `Time` (`time.svelte.ts`) — Reactive time state +- `Ui` (`ui.svelte.ts`) — UI state (menus, layout) +- `Workspace` (`workspace.svelte.ts`) — Open workspace directory +- `Workspaces` (`workspaces.svelte.ts`) — Collection of workspaces ## Action Specs -20 specs in `src/lib/action_specs.ts`: - -| Method | Kind | Initiator | Purpose | -| ------------------------ | --------------------- | ---------- | -------------------------------- | -| `ping` | `request_response` | `both` | Health check | -| `session_load` | `request_response` | `frontend` | Load initial session data | -| `filer_change` | `remote_notification` | `backend` | File system change notification | -| `diskfile_update` | `request_response` | `frontend` | Write file content | -| `diskfile_delete` | `request_response` | `frontend` | Delete a file | -| `directory_create` | `request_response` | `frontend` | Create a directory | -| `completion_create` | `request_response` | `frontend` | Start AI completion | -| `completion_progress` | `remote_notification` | `backend` | Stream completion chunks | -| `ollama_progress` | `remote_notification` | `backend` | Ollama model operation progress | -| `toggle_main_menu` | `local_call` | `frontend` | Toggle main menu UI | -| `ollama_list` | `request_response` | `frontend` | List local Ollama models | -| `ollama_ps` | `request_response` | `frontend` | List running Ollama models | -| `ollama_show` | `request_response` | `frontend` | Show Ollama model details | -| `ollama_pull` | `request_response` | `frontend` | Pull Ollama model | -| `ollama_delete` | `request_response` | `frontend` | Delete Ollama model | -| `ollama_copy` | `request_response` | `frontend` | Copy Ollama model | -| `ollama_create` | `request_response` | `frontend` | Create Ollama model | -| `ollama_unload` | `request_response` | `frontend` | Unload Ollama model from memory | -| `provider_load_status` | `request_response` | `frontend` | Check provider availability | -| `provider_update_api_key`| `request_response` | `frontend` | Update provider API key | +Defined in `src/lib/action_specs.ts`. The full list — method, kind, initiator, +auth, and description — is generated into ./docs/reference.md +from the specs themselves (`src/lib/reference.gen.ts`, refreshed by `gro gen`), +so it can't drift. The test-only `_testing_emit_notifications` + +`_testing_notification` specs live in `src/lib/testing_action_specs.ts` and only +register on the live dispatchers when `ZZZ_ENABLE_TEST_ACTIONS=1`. + +The generated tables cover zzz's own TS specs only — the fuz_app methods the +backend registers from the spine (`account_*`, `admin_*`, invites, role +grants, …) never pass through `action_specs.ts`, so they don't appear in +./docs/reference.md; see the Rust Backend section for that surface. ## Development Workflow ### Setup ```bash -cp src/lib/server/.env.development.example .env.development +createdb zzz +cargo xtask dev-setup npm install +cargo xtask dev ``` +The Rust backend (and its native `fuz_pty` PTY dependency) builds via `cargo` +— `cargo xtask dev` runs `cargo build -p zzz_server` on every start. Requires the +sibling Rust workspace checked out alongside this repo (path deps). + +Node dependencies are installed with `npm install`. zzz has no Deno: the dev and +env-setup orchestration is `cargo xtask` (see `crates/xtask/`), so there's no +`deno.json` import map to keep version-synced — npm manages `node_modules`. + ### Daily Commands -| Command | Purpose | -| --------------- | ------------------------------------------ | -| `gro check` | All checks (typecheck, test, gen, format, lint) | -| `gro typecheck` | Type checking only (faster iteration) | -| `gro test` | Run Vitest tests | -| `gro gen` | Regenerate `*.gen.ts` files | -| `gro format` | Format with Prettier | -| `gro build` | Production build | +- `cargo xtask dev` — Dev server: Rust backend + Vite frontend +- `gro check` — All checks (typecheck, test, gen, format, lint) +- `gro typecheck` — Type checking only (faster iteration) +- `gro test` — Run Vitest unit + db tests (cross-backend gated out — see below) +- `npm test` — `gro test` (unit + db; cross-backend projects excluded unless `FUZ_TEST_CROSS_BACKEND=1`) +- `npm run test:cross` — Rust cross-process suites (rust + rust_proxy; needs rust binary + `zzz_test_rust`/`zzz_test_rust_proxy` Postgres DBs) — flag baked in +- `gro gen` — Run `*.gen.ts` generators (regenerate their outputs) +- `gro format` — Format with tsv +- `gro build` — Production build + +`cargo xtask dev` is the dev command — it builds and runs `zzz_server` plus +the Vite frontend. (The user manages the dev server; don't start it yourself.) + +### Rust Backend + +The Rust `zzz_server` (Axum) is zzz's backend. +RPC methods: `ping`, `session_load`, `workspace_*`, +`diskfile_update`, `diskfile_delete`, `directory_create`, `terminal_create`, +`terminal_data_send`, `terminal_resize`, `terminal_close`, +`provider_load_status`, `provider_update_api_key` (keeper-only), +`completion_create`, `account_verify`, `account_session_list`, +`account_session_revoke`, `account_session_revoke_all`, +`account_token_create`, `account_token_list`, `account_token_revoke`, +`admin_session_revoke_all`, `admin_token_revoke_all`. +Those are the zzz-domain methods plus fuz_app's account self-service and +admin-revocation slice; `run_app` also registers the rest of `fuz_auth`'s +standard bundle — admin account/audit/invite management, `app_settings_*`, +and the consent-based `role_grant_*` / `role_grant_offer_*` flow with its own +notifications — plus the protocol specs (`heartbeat`, `cancel`, `peer/ping`). +That spine surface is live on `/api/rpc` + `/api/ws` even though zzz ships no +UI for most of it. +Cookie session auth and bearer token auth (API tokens) +on HTTP and WebSocket, `ScopedFs` path safety, PTY terminals via `fuz_pty` +native crate, and WebSocket connection tracking (`broadcast`/`send_to`). +PostgreSQL via `tokio-postgres`/`deadpool-postgres`, HMAC-SHA256 cookie +signing, blake3 session/token hashing, per-action auth checks with credential +type enforcement, bootstrap endpoint. AI provider system with enum-dispatched +providers — Anthropic, OpenAI, and Gemini all fully implemented (non-streaming + +SSE streaming with connection-targeted `completion_progress` notifications). +Cross-process integration tests in `src/test/cross_backend/*.cross.test.ts` +run fuz_app's standard suites against `zzz_server` over real HTTP, verifying +its JSON-RPC responses conform to the shared fuz_app contract. They cover +the full surface — including the admin role-gated `admin_session_revoke_all` / +`admin_token_revoke_all` handlers and trusted-proxy `client_ip` resolution +(the `cross_backend_rust_proxy` project). `zzz_server`'s own `#[cfg(test)]` +unit tests live in the provider modules (`provider/common.rs`, +`provider/sse.rs`, etc.); auth, origin, and trusted-proxy pure functions are +unit-tested in the spine crates (`fuz_auth`, `fuz_http`). + +```bash +cargo build -p zzz_server # Build +cargo clippy -p zzz_server # Lint +./target/debug/zzzd --port 4460 # Run (requires DATABASE_URL, SECRET_FUZ_COOKIE_KEYS) +cargo xtask dev # Dev server: Rust backend + Vite frontend +npm run test:cross # Rust cross-process suites (rust + rust_proxy; needs rust binary + zzz_test_rust/zzz_test_rust_proxy DBs) — flag baked in +FUZ_TEST_CROSS_BACKEND=1 npx vitest run --project cross_backend_rust # Single project (Rust binary; needs `postgres://localhost/zzz_test_rust`) +FUZ_TEST_CROSS_BACKEND=1 npx vitest run --project cross_backend_rust_proxy # Single project (proxy variant; ZZZ_TRUSTED_PROXIES=127.0.0.1 at boot) +``` + +The `cross_backend_*` vitest projects are gated behind +`FUZ_TEST_CROSS_BACKEND=1` (set in `vite.config.ts`) — they spawn the real +backend binary via `globalSetup`, so a bare `gro test` stays a fast, +infra-free unit+db run and never spawns. The `test:cross` package.json +script (`npm run test:cross`) bakes in the flag; set it manually only for +single-project `--project` runs. + +Requires the sibling Rust workspace checked out alongside this repo (path +deps). Each cross-backend project expects its own PostgreSQL DB — +`zzz_test_rust` and `zzz_test_rust_proxy` (`createdb zzz_test_rust`; +`createdb zzz_test_rust_proxy`). +See ./crates/CLAUDE.md for architecture, endpoints, +prerequisites, and what the integration tests check. ### Naming Conventions -| Thing | Convention | Example | -| ---------------- | ---------------------- | ---------------------- | -| TypeScript files | `snake_case.ts` | `action_peer.ts` | -| Svelte 5 state | `snake_case.svelte.ts` | `chat.svelte.ts` | -| Components | `PascalCase.svelte` | `ChatView.svelte` | -| Tests | `*.test.ts` in `src/test/` | `cell.svelte.base.test.ts` | +- TypeScript files — `snake_case.ts`. Example: `action_dispatcher.ts` +- Svelte 5 state — `snake_case.svelte.ts`. Example: `chat.svelte.ts` +- Components — `PascalCase.svelte`. Example: `ChatView.svelte` +- Tests — `*.test.ts` in `src/test/`. Example: `cell.svelte.base.test.ts` ## Code Patterns @@ -197,32 +292,32 @@ Every piece of state is a Cell subclass: Zod schema defines shape, `$state` rune ```typescript // 1. Schema with CellJson base export const ChatJson = CellJson.extend({ - name: z.string().default(''), - thread_ids: z.array(Uuid).default(() => []), - view_mode: z.enum(['simple', 'multi']).default('simple'), - selected_thread_id: Uuid.nullable().default(null), -}).meta({cell_class_name: 'Chat'}); + name: z.string().default(''), + thread_ids: z.array(Uuid).default(() => []), + view_mode: z.enum(['simple', 'multi']).default('simple'), + selected_thread_id: Uuid.nullable().default(null) +}).meta({ cell_class_name: 'Chat' }); -// 2. Class with $state for schema fields, $derived for computed +// 2. Class with $state.raw for most fields, $state for in-place-mutated arrays export class Chat extends Cell { - name: string = $state()!; - thread_ids: Array = $state()!; - view_mode: ChatViewMode = $state()!; - selected_thread_id: Uuid | null = $state()!; - - readonly threads: Array = $derived.by(() => { - const result: Array = []; - for (const id of this.thread_ids) { - const thread = this.app.threads.items.by_id.get(id); - if (thread) result.push(thread); - } - return result; - }); - - constructor(options: ChatOptions) { - super(ChatJson, options); - this.init(); // Must call at end of constructor - } + name: string = $state.raw()!; + thread_ids: Array = $state()!; // $state because push/splice used + view_mode: ChatViewMode = $state.raw()!; + selected_thread_id: Uuid | null = $state.raw()!; + + readonly threads: Array = $derived.by(() => { + const result: Array = []; + for (const id of this.thread_ids) { + const thread = this.app.threads.items.by_id.get(id); + if (thread) result.push(thread); + } + return result; + }); + + constructor(options: ChatOptions) { + super(ChatJson, options); + this.init(); // Must call at end of constructor + } } ``` @@ -232,27 +327,89 @@ Each action is a plain object with Zod schemas for input/output: ```typescript export const diskfile_update_action_spec = { - method: 'diskfile_update', - kind: 'request_response', - initiator: 'frontend', - auth: 'public', - side_effects: true, - input: z.strictObject({ - path: DiskfilePath, - content: z.string(), - }), - output: z.null(), - async: true, + method: 'diskfile_update', + description: 'Write content to a file on disk', + kind: 'request_response', + initiator: 'frontend', + auth: { account: 'required', actor: 'none' }, + side_effects: true, + input: z.strictObject({ + path: DiskfilePath, + content: z.string() + }), + output: z.null(), + async: true } satisfies ActionSpecUnion; ``` Action kinds: -| Kind | Transport | Pattern | -| --------------------- | ---------------- | ------------------------------- | -| `request_response` | HTTP or WebSocket | Frontend sends, backend replies | -| `remote_notification` | WebSocket only | Backend pushes to frontend | -| `local_call` | None (in-process) | Frontend-only | +- `request_response` — HTTP or WebSocket. Pattern: Frontend sends, backend replies +- `remote_notification` — WebSocket only. Pattern: Backend pushes to frontend +- `local_call` — None (in-process). Pattern: Frontend-only + +### Adding an Action (End-to-End) + +Adding a new action touches up to 5 files. Here's the full workflow: + +**1. Define the spec** in `src/lib/action_specs.ts`: + +```typescript +export const my_action_spec = { + method: 'my_action', + kind: 'request_response', // or 'remote_notification', 'local_call' + initiator: 'frontend', // or 'backend', 'both' + auth: null, // public; or {account: 'required', actor: 'none'} to require a session + side_effects: true, // or null for read-only + input: z.strictObject({ foo: z.string() }), + output: z.strictObject({ bar: z.number() }), + async: true, + description: 'What this action does.' +} satisfies ActionSpecUnion; +``` + +Add it to the `all_action_specs` array at the bottom of the file. + +**2. Run `gro gen`** — regenerates 4 files: + +- `action_collections.ts` — `ActionInputs`/`ActionOutputs` type maps + `ActionEventDatas` +- `action_metatypes.ts` — `ActionMethod` open union, narrow handler enums (`BackendRequestResponseMethod`, `BroadcastActionMethod`, …), `FrontendActionsApi` interface +- `frontend_action_types.ts` — `TypedActionEvent` + `FrontendActionHandlers` +- `docs/reference.md` — the human-readable action-spec + cell-class tables + +**3. Add the backend handler** in the Rust backend (`crates/zzz_server`): +add a spec builder in `zzz_action_specs/` and the handler fn in +`handlers/` (see ./crates/CLAUDE.md). Both HTTP RPC and WebSocket paths +dispatch through the same `ActionRegistry`, so the new handler is picked up +on both transports. + +**4. Add frontend handler** in `src/lib/frontend_action_handlers.ts` — handlers +live inside `create_frontend_action_handlers(frontend)` and reach app state via +the closed-over `frontend` (the action event carries no `app`): + +```typescript +my_action: { + // For request_response: + receive_response: ({data: {output}}) => { /* handle success */ }, + receive_error: ({data: {error}}) => { /* handle error */ }, + // For remote_notification: + receive: ({data: {input}}) => { /* handle notification */ }, +}, +``` + +**5. Call from frontend** via `app.api`: + +```typescript +// Returns Result<{value: OutputType}, {error: JsonrpcError}> +const result = await app.api.my_action({ foo: 'hello' }); +if (result.ok) { + console.log(result.value.bar); // 42 +} +``` + +For `remote_notification` actions, the backend broadcasts via its realtime +connection registry — see the `broadcast` / notification builders in +./crates/CLAUDE.md. ### Zod Schema Conventions @@ -262,8 +419,9 @@ Action kinds: ### State Class Rules -- Schema fields use `$state()!` (non-null assertion, set by `init()`) -- Computed values use `$derived` or `$derived.by(() => ...)` +- Schema fields use `$state.raw()!` by default (non-null assertion, set by `init()`) +- Use `$state()!` only for arrays/objects mutated in place (push, splice, index assignment) +- Computed values use `readonly $derived` or `readonly $derived.by(() => ...)` — always `readonly` unless reassignment is explicitly needed - No `$effect` inside Cell classes — effects belong in components - Constructor must call `this.init()` as the last statement - Always register new Cell classes in `cell_classes.ts` @@ -272,66 +430,91 @@ Action kinds: - `// @slop [Model]` marks LLM-generated code needing review - `// TODO` for work items, `// TODO @api` for API design questions -- Import from `*.js` extensions (ESM convention): `import {Chat} from './chat.svelte.js'` +- Import the real source extension (`.ts` / `.svelte.ts` / `.svelte`): `import {Chat} from './chat.svelte.ts'` - Prefer pure functions; mark mutations with `@mutates` JSDoc tag - Tests in `src/test/`, split by aspect: `cell.svelte.base.test.ts`, `cell.svelte.decoders.test.ts` - UI uses `@fuzdev/fuz_css` style variables and semantic classes, not inline styles ## Zzz App Directory -The `.zzz/` directory stores app data. Configured via `PUBLIC_ZZZ_DIR`. +The `.zzz/` directory stores app data. Configured via `PUBLIC_ZZZ_DIR` +(default `.zzz`, cwd-relative). The CLI's global daemon home `~/.zzz/` is a +distinct directory that shares the name — the two coincide only when the +daemon runs with `~` as its working directory. -| Subdirectory | Purpose | -| ------------ | -------------------------------------- | -| `state/` | Persistent data (completions logs) | -| `cache/` | Regenerable data, safe to delete | -| `run/` | Runtime ephemeral (server.json: PID, port) | +- `state/` — Persistent data (reserved — the Rust backend currently keeps domain state in memory) +- `cache/` — Regenerable data, safe to delete +- `run/` — Runtime ephemeral (daemon.json: PID, port — written by the CLI) All filesystem access goes through `ScopedFs` — path validation, no symlinks, absolute paths only. ## Environment Variables -From `src/lib/server/.env.development.example`: - -| Variable | Purpose | -| ------------------------------------- | ------------------------------------------ | -| `PUBLIC_ZZZ_DIR` | Zzz app directory (default `.zzz`) | -| `PUBLIC_ZZZ_SCOPED_DIRS` | Comma-separated user file paths | -| `PUBLIC_SERVER_PROTOCOL` | `http` or `https` | -| `PUBLIC_SERVER_HOST` | Server hostname | -| `PUBLIC_SERVER_PORT` | SvelteKit dev server port | -| `PUBLIC_SERVER_API_PATH` | API endpoint path | -| `PUBLIC_WEBSOCKET_URL` | WebSocket URL | -| `PUBLIC_SERVER_PROXIED_PORT` | Hono backend port | -| `PUBLIC_BACKEND_ARTIFICIAL_RESPONSE_DELAY` | Testing delay in ms | -| `ALLOWED_ORIGINS` | Origin allowlist patterns | -| `SECRET_OPENAI_API_KEY` | OpenAI API key | -| `SECRET_ANTHROPIC_API_KEY` | Anthropic API key | -| `SECRET_GOOGLE_API_KEY` | Google Gemini API key | -| `SECRET_GITHUB_API_TOKEN` | GitHub API token | +### Server (read by `zzz_server` at boot) + +- `ZZZ_PORT` — HTTP server port (default 4460; `cargo xtask dev` uses 4461); the `--port` flag wins. The bind address is always loopback — there is no `HOST` override. +- `ZZZ_STATIC_DIR` — directory of the built SPA to serve (`--static-dir` wins) +- `ZZZ_TRUSTED_PROXIES` — comma-separated trusted proxy IPs for `client_ip` resolution +- `DATABASE_URL` — PostgreSQL connection (`postgres://`) +- `SECRET_FUZ_COOKIE_KEYS` — HMAC signing keys (min 32 chars) +- `FUZ_ALLOWED_ORIGINS` — Origin patterns for API verification (required in production) +- `FUZ_BOOTSTRAP_TOKEN_PATH` — One-shot admin bootstrap token path +- `PUBLIC_ZZZ_DIR` — Zzz app directory (default `.zzz`) +- `PUBLIC_ZZZ_SCOPED_DIRS` — Comma-separated filesystem paths +- `ZZZ_ENABLE_TEST_ACTIONS` — Register `_testing_*` actions on live dispatchers (integration tests only — must stay unset in prod) +- `SECRET_ANTHROPIC_API_KEY` — Claude API key +- `SECRET_OPENAI_API_KEY` — OpenAI API key +- `SECRET_GOOGLE_API_KEY` — Google Gemini API key + +`NODE_ENV` and `PORT` belong to the CLI/xtask layer, not the server: the CLI +picks the env file by `NODE_ENV` (`.env` when `production`, else +`.env.development`) and reads `PORT` to choose the port it passes to +`zzzd --port`. + +### SvelteKit frontend vars (PUBLIC_ZZZ_\*) + +- `PUBLIC_ZZZ_SERVER_PROTOCOL` — `http` or `https` +- `PUBLIC_ZZZ_SERVER_HOST` — Server hostname (frontend) +- `PUBLIC_ZZZ_SERVER_PORT` — SvelteKit dev server port +- `PUBLIC_ZZZ_SERVER_API_PATH` — API endpoint path +- `PUBLIC_ZZZ_WEBSOCKET_URL` — WebSocket URL +- `PUBLIC_ZZZ_SERVER_PROXIED_PORT` — Backend port (frontend) +- `PUBLIC_ZZZ_BACKEND_ARTIFICIAL_DELAY` — Testing delay (ms) — frontend-only; currently parsed but unwired ## Avoid -- **Never run `gro dev`** — the user manages the dev server -- **Never edit `*.gen.ts` files** — they are regenerated by `gro gen` +- **Don't start the dev server yourself** — the user manages `cargo xtask dev` +- **Never edit generated outputs** (`action_collections.ts`, `action_metatypes.ts`, `frontend_action_types.ts`, `docs/reference.md`) — edit the `*.gen.ts` generators and run `gro gen` - **Use `z.strictObject()`** in action specs, not `z.object()` — unknown keys must be rejected - **No `$effect` in Cell classes** — effects belong in Svelte components only - **Run `gro gen` after changing action specs** — handler types are generated from specs - **Register new Cell classes in `cell_classes.ts`** — the registry must be complete -- **Don't import without `.js` extension** — ESM requires explicit extensions +- **Don't omit import extensions** — use the real source extension (`.ts` / `.svelte.ts` / `.svelte`) ## Known Limitations -- **No authentication** — development use only, anyone with network access can use it -- **No database** — all state is in-memory, lost on restart (pglite planned) +- **WebSocket auth** — Auth is enforced at upgrade time — the spine resolves credentials from the request headers before upgrading (cookie sessions, bearer tokens — bearer silently discarded in browser context via Origin/Referer defense). Per-action auth checks enforce spec-level auth: `keeper` requires `daemon_token` + keeper role; `{role}` requires the named role via `has_role` (matches the HTTP path). Batch JSON-RPC is rejected (not yet supported). Sockets are closed on session/token revocation, logout, and password change via audit events — `token_revoke` closes only the revoked token's sockets (granular), `session_revoke_all` / `token_revoke_all` / `password_change` close all sockets on the account. No per-message session revalidation — event-driven revocation is sufficient. ActionPeer itself has no auth awareness. +- **Bearer auth soft-fails** — bearer resolution soft-fails for invalid/expired/empty tokens (no early error response). Auth enforcement happens downstream via the per-action auth checks, producing `{code: -32001, message: "unauthenticated"}` JSON-RPC errors. Public actions are not blocked by bad bearer credentials. +- **Domain state is in-memory** — auth/accounts are in the PostgreSQL DB, but zzz domain state (files, terminals, workspaces) is in-memory, lost on restart. - **No undo/history** — file edits are permanent -- **No terminal integration** — no shell access from the UI +- **PTY terminals** — terminal spawning uses the `fuz_pty` Rust crate as a native dependency of `zzz_server` (no FFI indirection). `PtyManager` manages spawned processes with async read loops; `terminal_close` cancels the read loop before killing the process. Requires the sibling Rust workspace checked out alongside this repo (path dep). - **No git integration** — no commit/push/pull from the UI - **No MCP/A2A** — protocol support planned but not implemented -- **Backend is reference impl** — may be replaced by Rust daemon (`fuzd`) +- **Backend** — `zzz_server` serves the full RPC surface with the full auth stack. `cargo xtask dev` runs it with the Vite frontend. Anthropic, OpenAI, and Gemini providers fully implemented (non-streaming + SSE streaming). No batch JSON-RPC. A single `/api/rpc` + `/api/ws` serves the boot-compiled `ActionRegistry` (handlers in `handlers/`), plus the admin audit-log SSE stream at `GET /api/admin/audit/stream`. ## fuz_app -zzz is the primary source for the Cell and Action patterns that will become the `fuz_app` package — a shared foundation for Fuz ecosystem apps. - -Last updated: 2026-02-10 +zzz is the reference implementation for Cell and Action patterns. The SAES +runtime lives in `@fuzdev/fuz_app` — zzz imports ActionSpec, ActionEvent, +ActionDispatcher, transports, and `create_rpc_client` from +`@fuzdev/fuz_app/actions/*.ts`. Cell patterns (Cell base class, cell classes, +IndexedCollection) remain in zzz. The generated `TypedActionEvent` alias +intersects fuz_app's generic `ActionEvent` with zzz's `ActionEventDatas` map +for typed input/output in handlers. `Uuid` and `create_uuid` come from +`@fuzdev/fuz_util/id.ts`, imported directly. + +Daemon lifecycle is owned by the Rust CLI (`crates/zzz/src/daemon_lifecycle.rs`) +— it atomically writes `~/.zzz/run/daemon.json` (`{version, pid, port, started, +app_version}`) when spawning `zzzd`, reads it back for discovery/`status`, and +checks PID liveness. The shape follows fuz_app's `DaemonInfo` by convention — +patterns only, no code reuse. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..f1f3906c2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3389 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argh" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033" +dependencies = [ + "argh_derive", + "argh_shared", +] + +[[package]] +name = "argh_derive" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d" +dependencies = [ + "argh_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argh_shared" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" +dependencies = [ + "serde", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuz_actions" +version = "0.1.0" +dependencies = [ + "axum", + "deadpool-postgres", + "futures", + "fuz_auth", + "fuz_db", + "fuz_http", + "fuz_realtime", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "fuz_audit" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "fuz_auth" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "base64", + "blake3", + "deadpool-postgres", + "futures", + "fuz_db", + "fuz_http", + "getrandom 0.3.4", + "hmac 0.12.1", + "lru", + "parking_lot", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tracing", + "uuid", +] + +[[package]] +name = "fuz_crypto" +version = "0.1.0" +dependencies = [ + "base64", + "blake3", + "ed25519-dalek", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", +] + +[[package]] +name = "fuz_db" +version = "0.1.0" +dependencies = [ + "deadpool-postgres", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "fuz_http" +version = "0.1.0" +dependencies = [ + "axum", + "fuz_db", + "fuz_sys", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "fuz_pty" +version = "0.1.0" +dependencies = [ + "libc", +] + +[[package]] +name = "fuz_realtime" +version = "0.1.0" +dependencies = [ + "axum", + "futures", + "futures-util", + "fuz_auth", + "fuz_db", + "fuz_http", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "fuz_sys" +version = "0.1.0" +dependencies = [ + "getrandom 0.3.4", + "libc", + "nix", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "fuz_testing" +version = "0.1.0" +dependencies = [ + "argon2", + "deadpool-postgres", + "futures", + "fuz_actions", + "fuz_auth", + "fuz_crypto", + "fuz_db", + "fuz_http", + "fuz_sys", + "getrandom 0.3.4", + "serde_json", + "tokio", + "tokio-postgres", + "tracing", + "uuid", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.0", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "testing_zzz_server" +version = "0.1.0" +dependencies = [ + "fuz_actions", + "fuz_auth", + "fuz_db", + "fuz_http", + "fuz_sys", + "fuz_testing", + "tokio", + "tracing", + "zzz_server", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "fuz_audit", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zzz" +version = "0.1.0" +dependencies = [ + "argh", + "fuz_sys", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "zzz_server" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "axum-extra", + "base64", + "blake3", + "deadpool-postgres", + "futures-util", + "fuz_actions", + "fuz_auth", + "fuz_db", + "fuz_http", + "fuz_pty", + "fuz_realtime", + "fuz_sys", + "hmac 0.12.1", + "libc", + "notify", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tokio-util", + "tower", + "tower-http", + "tracing", + "uuid", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..cd6d63e3f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,77 @@ +[workspace] +resolver = "2" +members = ["crates/zzz", "crates/zzz_server", "crates/testing_zzz_server", "crates/xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[workspace.dependencies] +argh = "0.1" +fuz_sys = { path = "../private_fuz/crates/fuz_sys" } +fuz_pty = { path = "../private_fuz/crates/fuz_pty" } +fuz_db = { path = "../private_fuz/crates/fuz_db" } +fuz_auth = { path = "../private_fuz/crates/fuz_auth" } +fuz_http = { path = "../private_fuz/crates/fuz_http" } +fuz_realtime = { path = "../private_fuz/crates/fuz_realtime" } +fuz_actions = { path = "../private_fuz/crates/fuz_actions" } +fuz_testing = { path = "../private_fuz/crates/fuz_testing" } +fuz_audit = { path = "../private_fuz/crates/fuz_audit" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +axum = { version = "0.8", features = ["ws"] } +axum-extra = { version = "0.12", features = ["cookie"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tower = "0.5" +tower-http = { version = "0.6", features = ["fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +futures-util = { version = "0.3", features = ["sink"] } +tokio-util = { version = "0.7", features = ["rt"] } +parking_lot = "0.12" +tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } +deadpool-postgres = "0.14" +hmac = "0.12" +sha2 = "0.10" +blake3 = "1" +base64 = "0.22" +uuid = { version = "1", features = ["v4"] } +argon2 = { version = "0.5", features = ["rand"] } +notify = { version = "8", default-features = false, features = ["macos_fsevent"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json"] } + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +trivial_casts = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +similar_names = "allow" +too_many_lines = "allow" +significant_drop_tightening = "allow" +cargo_common_metadata = "allow" +multiple_crate_versions = "allow" +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +expect_used = "warn" +panic = "warn" +todo = "warn" +unwrap_used = "warn" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/README.md b/README.md index af323aee3..28dd3ddf4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **[zzz.software](https://www.zzz.software/)** Zzz, pronounced "zees" like bees, -is a local-first forge for power users and developers. +is a software garage for power users and devs. The idea is to make an integrated cross-platform environment that adapts to your needs and intent while remaining fully open, aligned, and designed for your autonomy. It's both a customizable web UI and local-first backend for power users, @@ -26,37 +26,47 @@ see the issues and [discussions](https://github.com/fuzdev/zzz/discussions). This project is in its early stages, and installing it currently requires some basic technical skills. Eventually there will be a desktop app but -for now you'll need Node 22.15+ -(YMMV with Deno/Bun/etc, although Deno will be used for deployment soon) -and Git to clone the repo. +for now you'll need Node (>=24.14), a Rust toolchain (for the backend), +PostgreSQL, and Git to clone the repo. -Running Zzz locally in development with Node is the supported way to use it right now. +Running Zzz locally in development (`cargo xtask dev`) is the supported way to use it right now. It deploys via SvelteKit's static adapter with diminished capabilities ([zzz.software](https://www.zzz.software/)), -and it will have a production build with the Node adapter and Hono server soon. +and the full app is served by the Rust `zzz_server` backend. + +> The Rust backend depends on sibling crates from the fuz workspace via path +> dependencies (including the native `fuz_pty` terminal crate). They must be +> checked out alongside this repo for `cargo build` to succeed; until they're +> published, building from a bare clone isn't yet reproducible. > Developing on Windows > requires something like [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). -To run Zzz, we need an `.env.development` file in your project root. - -In your terminal, copy over -[src/lib/server/.env.development.example](/src/lib/server/.env.development.example): +After cloning, from the project root: ```bash -cp src/lib/server/.env.development.example .env.development --update=none +# 1. Create the PostgreSQL database the backend connects to +createdb zzz + +# 2. Generate .env.development (idempotent — safe to re-run) +cargo xtask dev-setup + +# 3. Install Node dependencies +npm install + +# 4. Build the Rust backend and start everything (backend + Vite frontend) +cargo xtask dev ``` You can edit `.env.development` with your API keys, or update them at runtime on the `/capabilities` page. -Then: - -```bash -npm run dev -``` +Browse to the location it says, probably `localhost:5173`. -Browse to the location is says, probably `localhost:5173`. +On first run Zzz has no account yet, so it shows a bootstrap form. Copy the +one-time token it points to (`cat .zzz/bootstrap_token`), paste it in, and pick a +username and password to create your admin account — then you're logged in and +ready. ## Roadmap @@ -69,11 +79,11 @@ Browse to the location is says, probably `localhost:5173`. Zzz builds on a great deal of software. -- see the deps in [package.json](package.json) +- see the deps in `package.json` - I started using [Claude](https://claude.ai/) in late 2024 after making the initial prototype, and in late 2025 I started doing much of the coding with Claude Code, Opus 4.5 being the first over some threshold for me for this project - - see `⚠️ AI generated` and similar disclaimers + - see `NOTE: AI-generated` and similar disclaimers ## License 🐦 diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..76ace96e6 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,7 @@ +# Restriction lints (`unwrap_used` / `expect_used` / `panic`) are warn-level +# workspace-wide (see Cargo.toml `[workspace.lints.clippy]`) so production +# panic points need an explicit justified `#[allow]`. Tests panic on assertion +# failure by design, so allow the three there. +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md new file mode 100644 index 000000000..ad9e9a25d --- /dev/null +++ b/crates/CLAUDE.md @@ -0,0 +1,491 @@ +# zzz Rust Backend + +zzz's backend, using axum. Serves the frontend (a prerendered static SPA) and +a single JSON-RPC 2.0 API over HTTP + WebSocket. AI providers are Anthropic, +OpenAI, and Gemini (all full). + +**Workspace layout**: + +- `zzz_server/` — library (`zzz_server`) + production daemon binary (the `[[bin]]` target is named `zzzd`). `pub async fn run_app(options: RunAppOptions)` in `src/lib.rs` owns the full lifecycle (env, signal handler, router build, listener bind, drain). `RunAppOptions` carries: `password_hasher` (production-vs-test swap), `default_addr: SocketAddr` (bind address when `--port`/`ZZZ_PORT` don't supply one; host stays loopback, only the port is overridable), `drain_timeout` (graceful-shutdown drain bound), `force_test_actions` (overrides the `ZZZ_ENABLE_TEST_ACTIONS` env flag), `disable_login_rate_limit` (test binary only), `extra_action_specs_factory` (lets the test binary inject `_testing_reset` without putting `fuz_testing` in the production dep graph), and `pre_migration_hook` (fires after pool creation, before migrations — the test binary wires `fuz_testing::reset_db_on_startup_if_env_set`). `src/main.rs` is the thin production entry — constructs `Argon2idHasher`, calls `run_app` with `force_test_actions: false, extra_action_specs_factory: None`. +- `testing_zzz_server/` — separate test-binary package (its `[[bin]]` target is named `testing_zzzd`) wiring `fuz_testing::TestingArgon2idHasher` (~1-5 ms argon2 vs production's ~30-50 ms) AND `fuz_testing::create_testing_reset_action_spec` (auth-table wipe + fresh-keeper re-seed + consumer-supplied `reset_state(ActionDb)` callback; `credential_types: [DaemonToken]` auth gate). zzz's reset closure ignores the in-tx `ActionDb` handle (its domain state is in-memory, not in PG) — it clears zzz workspaces, calls `pty_manager.kill_all()` (non-destructive — manager stays usable across tests), and wipes the optional `ZZZ_TESTING_SCRATCH_DIR`. Default port 4462 (production is 4460). **Never ships in a release** — enforced by `fuz_release`'s `testing_` manifest filter and the `cargo xtask check-release` dep-graph audit. It is zzz's test binary, spawned by the cross-process integration tests. +- `xtask/` — dev automation (`cargo xtask `, pure `std` + `fuz_audit`, no extra deps). `dev` loads `.env.development`, builds `zzz_server`, then runs `zzzd` (port 4461) + the Vite frontend (5173, proxying `/api`); `dev-setup` / `prod-setup` generate `.env.development` / `.env.production` from the `.example` templates; `check-release` (the dep-graph audit — sanity check #2 of the test-binary pattern) delegates its work to `fuz_audit::run_check_release_cli()`. Dispatch and usage live in xtask itself: bare `cargo xtask` / `help` / `-h` / `--help` print the full subcommand list (exit 0); an unknown subcommand prints an error + usage (exit 1). Marked `[package.metadata.fuz_audit] dev_only = true` so xtask itself is excluded from the production scan. Replaces the former Deno orchestration (`deno.json` + `scripts/*.ts`). +- `zzz/` — Rust CLI (argh). `daemon start/stop/status`, `status`, `init`, `open` (the default command — daemon discovery, detached auto-start, best-effort `workspace_open`, browser launch), and `version` (+ the `--version`/`-v` switch) are implemented, all backed by `daemon_lifecycle.rs` (port-based `daemon.json` I/O, `/health` probe, PID liveness, server-bin discovery, child-env build, ISO timestamp). Tests: unit tests per module, `tests/cli_daemon.rs` (infra-free status read-back), and `tests/cli_e2e.rs` (full `daemon start` ↔ live `testing_zzzd` lifecycle, gated behind `ZZZ_TEST_E2E=1` + Postgres, self-skips otherwise). This is zzz's CLI — the Deno CLI has been removed; build it with `cargo build -p zzz`. + +AI provider system feature-complete for all three providers (Anthropic, +OpenAI, Gemini). Spine consumption is +complete — the spine crates (`fuz_db`, `fuz_auth`, `fuz_http`, +`fuz_realtime`, `fuz_actions`) own auth, HTTP, realtime, and the +boot-compiled `ActionRegistry` dispatch path. A single canonical +`/api/rpc` + `/api/ws` (mounted via `fuz_actions::create_rpc_router` / +`register_action_ws`) serves all dispatch; admin + account specs come +from fuz_auth's `auth_adapter::build_auth_spec_set`, the zzz-specific +workspace / filesystem / terminal / provider specs from +`zzz_action_specs/` (handlers in `handlers/`), and the admin audit-log +SSE stream from `fuz_realtime::audit_stream_router`. `handlers/` holds only +`App` state plus a `broadcast` shim over `App.realtime` (socket revocation +lives on the spine's `ConnectionRegistry` — see Auth below). RPC methods: +`ping`, `session_load`, `workspace_*`, `diskfile_*`, `directory_create`, +`terminal_*`, `provider_load_status`, `provider_update_api_key`, +`completion_create`, `account_verify`, `account_session_list`, +`account_session_revoke`, `account_session_revoke_all`, +`account_token_create`, `account_token_list`, `account_token_revoke`, +`admin_session_revoke_all`, `admin_token_revoke_all`. +Those are the zzz-domain methods plus fuz_app's account self-service and +admin-revocation slice; `auth_adapter::build_auth_spec_set` registers the +rest of `fuz_auth`'s standard bundle too — admin account/audit/invite +management, `app_settings_*`, and the consent-based `role_grant_*` / +`role_grant_offer_*` flow with its own notifications — plus +`fuz_actions::PROTOCOL_ACTION_SPECS` (`heartbeat`, `cancel`, `peer/ping`). +That spine surface is live on `/api/rpc` + `/api/ws` even though zzz ships +no UI for most of it; the spine crates are its source of truth. +`_testing_emit_notifications` is gated behind +`ZZZ_ENABLE_TEST_ACTIONS=1` (set by the integration runner; production +leaves it unset, dispatch returns `method_not_found`). Full auth stack (cookie sessions, bearer tokens, daemon +tokens), account management routes, filesystem actions with `ScopedFs`, +terminal actions via `fuz_pty`, `session_load` returns real provider status +from all registered providers, `workspace_changed`/`filer_change`/ +`terminal_data`/`terminal_exited` notifications, file watching via `notify` +crate with debounced broadcasts and immediate index updates, WebSocket +connection tracking with targeted `completion_progress` streaming +notifications, event-driven socket revocation. Database (PostgreSQL via +`tokio-postgres`/`deadpool-postgres`), HMAC-SHA256 cookie signing, blake3 +session hashing. Anthropic provider uses `reqwest` HTTP client with manual +SSE parsing for streaming completions. + +## Prerequisites + +The sibling Rust workspace must be checked out alongside this repo: + +``` +~/dev/zzz/ (this repo) +/ (path deps: fuz_sys, fuz_pty, plus the 5 spine crates — fuz_db, fuz_auth, fuz_http, fuz_realtime, fuz_actions) +``` + +If a path dep is missing, `cargo build` will fail with +`failed to read .../crates/{crate}/Cargo.toml`. + +**PostgreSQL** is required. Create the development and test databases: + +```bash +createdb zzz # development +createdb zzz_test # manual testing_zzzd runs +createdb zzz_test_rust # cross-backend vitest project: cross_backend_rust +createdb zzz_test_rust_proxy # cross-backend vitest project: cross_backend_rust_proxy +``` + +## Build and Run + +```bash +cargo build --workspace +cargo clippy -p zzz_server # workspace lints: pedantic + nursery +cargo xtask check-release # audit: no production binary depends on fuz_testing / fuz_audit + +# Run (requires DATABASE_URL and SECRET_FUZ_COOKIE_KEYS) +DATABASE_URL=postgres://localhost/zzz \ +SECRET_FUZ_COOKIE_KEYS=dev-only-not-for-production-use-000 \ +./target/debug/zzzd --port 4460 + +# Test binary (cross-process integration tests — fast argon2) +DATABASE_URL=postgres://localhost/zzz_test \ +SECRET_FUZ_COOKIE_KEYS=dev-only-not-for-production-use-000 \ +./target/debug/testing_zzzd + +# Quick smoke test +curl http://localhost:4460/health +curl -X POST http://localhost:4460/api/rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"ping"}' +# → {"jsonrpc":"2.0","id":"1","result":{"ping_id":"1"}} +``` + +CLI args (`--port`, `--static-dir`) take precedence over env vars +(`ZZZ_PORT`, `ZZZ_STATIC_DIR`). + +### Required Environment Variables + +- `DATABASE_URL` — PostgreSQL connection (e.g. `postgres://localhost/zzz`) +- `SECRET_FUZ_COOKIE_KEYS` — HMAC signing keys (min 32 chars, `__` separator for rotation) +- `FUZ_ALLOWED_ORIGINS` — Comma-separated origin allowlist patterns — required, non-empty. The server hard-fails at boot on an empty list, because `fuz_http::check_origin` treats an empty allowlist as allow-all (every origin passes). Dev/prod `.env` set `http://localhost:*`. + +### Optional Environment Variables + +- `FUZ_BOOTSTRAP_TOKEN_PATH` — Path to bootstrap token file +- `PUBLIC_ZZZ_SCOPED_DIRS` — Comma-separated filesystem paths +- `ZZZ_PORT` — Server port (default 4460, CLI overrides) +- `ZZZ_STATIC_DIR` — Static file directory +- `ZZZ_ENABLE_TEST_ACTIONS` — Register `_testing_*` actions on live dispatchers (mirrors Zod `z.stringbool()`: `true`/`1`/`yes`/`on`/`y`/`enabled` opt in; `false`/`0`/`no`/`off`/`n`/`disabled` or unset opt out; case-insensitive; anything else errors at startup. Integration tests only — production must leave unset) +- `ZZZ_TRUSTED_PROXIES` — Comma-separated trusted-proxy entries (IPs and CIDR ranges, e.g. `127.0.0.1,10.0.0.0/8,fe80::/10`). Unset/empty → no XFF trust → `client_ip` falls back to the TCP peer IP on every request (direct-bind behavior). Set when deploying behind nginx / a cloud LB so the trusted-proxy middleware walks `X-Forwarded-For` right-to-left and resolves the real client IP for rate limiting + `audit_log.ip`. Parsed eagerly at startup — invalid entries (malformed IPs, non-aligned CIDRs, out-of-range prefixes) fail server boot. Mirrors fuz_app's `http/proxy.ts`. + +## Endpoints + +- `/api/rpc` (GET) — JSON-RPC 2.0 (cacheable reads, query params) +- `/api/rpc` (POST) — JSON-RPC 2.0 (HTTP transport, auth-gated) +- `/api/account/bootstrap` (POST) — One-shot admin account creation +- `/api/account/signup` (POST) — Public account creation (invite-gated by default; `open_signup=true` opens) +- `/api/account/status` (GET) — Current account info or 401 + bootstrap status +- `/api/account/login` (POST) — Username/password login → session cookie +- `/api/account/logout` (POST) — Invalidate session, close WS connections +- `/api/account/password` (POST) — Change password, revoke all sessions/tokens +- `/api/ws` (GET) — JSON-RPC 2.0 (WebSocket, cookie/bearer/daemon) +- `/api/admin/audit/stream` (GET) — Admin-gated audit-log SSE stream (`text/event-stream`) +- `/health` (GET) — Health check (`{"status":"ok"}`) +- `/*` (GET) — Static files (if `--static-dir`) + +## Auth + +Cookie-based session auth and bearer token auth. These mechanics are +spine behaviors (`fuz_auth` / `fuz_http` / `fuz_realtime`) that `run_app` +composes — `zzz_server` owns none of this code. Summarized here for +orientation; the spine crates are authoritative: + +1. **Keyring** — HMAC-SHA256 cookie signing with key rotation support. + Keys from `SECRET_FUZ_COOKIE_KEYS` env, separated by `__`. First key signs, + all keys verify. + +2. **Cookie format** — `fuz_session` cookie containing signed + `{session_token}:{expires_at}.{base64_signature}`. 30-day expiry, + `Secure; HttpOnly; SameSite=Strict`. + +3. **Session validation** — Cookie → HMAC verify → blake3 hash token → + `auth_session` table lookup → build `RequestContext` (account, actor, + role grants). Sessions touched (last_seen_at updated) fire-and-forget. + +4. **Bearer token auth** — `Authorization: Bearer ` header. Token + hashed with blake3, looked up in `api_token` table. Browser context + silently discarded (Origin/Referer headers present → bearer ignored). Token + `last_used_at` touched fire-and-forget. Sets `CredentialType::ApiToken`. + +5. **Daemon token auth** — `X-Daemon-Token` header. Token is a 43-char + base64url string (32 random bytes), generated at startup and written to + `{zzz_dir}/run/daemon_token`. Rotated every 30 seconds (previous token + accepted during rotation race window). Validated with constant-time + comparison. Resolves the keeper account for the `RequestContext`. Sets + `CredentialType::DaemonToken`. State protected by `tokio::sync::RwLock`. + +6. **Auth pipeline** — Both transports try: daemon token → cookie → bearer. + Daemon token has highest priority (matches fuz_app middleware order). + `ResolvedAuth` carries `credential_type` (`Session`, `ApiToken`, + `DaemonToken`) and optional `token_hash` (session connections only — + bearer and daemon token connections have `None`). + +7. **Per-action auth** — these levels are the enforcement shorthand for the + four-axis `auth` record on each TS spec (`{account, actor, roles?, +credential_types?}` or `null`; see `src/lib/action_specs.ts` and the + generated `docs/reference.md`): + - `public` — `auth: null` or `{account: 'none', actor: 'none'}`; no auth required (`ping`) + - `authenticated` — `{account: 'required', actor: 'none'}`; valid session or bearer token required (workspace_*, session_load, etc.) + - `keeper` — `{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`; requires `DaemonToken` credential type AND keeper role grant (`provider_update_api_key`). API tokens and session cookies cannot access keeper actions even if the account has the keeper role grant. + +8. **Bootstrap** — `POST /bootstrap` creates first admin account with keeper + - admin role grants. Reads token from `FUZ_BOOTSTRAP_TOKEN_PATH`, timing-safe + compare, Argon2 password hashing, all in a transaction with bootstrap_lock. + +9. **Origin verification** — `FUZ_ALLOWED_ORIGINS` patterns checked on requests + with an `Origin` header. Supports exact match, wildcard port + (`http://localhost:*`), subdomain wildcard (`https://*.example.com`). + +10. **Socket revocation** — `close_sockets_for_session(token_hash)`, + `close_sockets_for_token(api_token_id)`, and + `close_sockets_for_account(account_id)` live on the spine's + `fuz_realtime::ConnectionRegistry` (its `SocketRevoker` impl — `App` + itself carries only a `broadcast` shim). They close matching WebSocket + connections by dropping the channel sender; the ws loop breaks on + `recv()` returning `None` and sends a 4001 (`WS_CLOSE_SESSION_REVOKED`) + Close frame so clients can distinguish revocation from normal close. + Invoked by the spine's revocation-emitting handlers and audit-event + listeners: `session_revoke` (per-session), `token_revoke` / + `account_token_revoke` (per-token), and `logout` / `session_revoke_all` + / `token_revoke_all` / `password_change` (account-wide). See "Audit + emission" under Architecture for the listener chain. + +11. **Account status** — `GET /api/account/status` returns account info + + role grants (200) when authenticated, or 401 with optional + `bootstrap_available` flag when not. Consumed by fuz_app's `AuthState` + for the frontend auth gate (bootstrap → login → verified flow). + +12. **Account management** — `POST /api/account/login` (username/password → + session cookie with enumeration prevention via dummy hash), + `POST /api/account/logout` (invalidate session + close WS connections), + `POST /api/account/password` (change password, revoke all sessions + API + tokens, close all WS connections). Session listing and revocation moved + to JSON-RPC: `account_verify`, `account_session_list`, + `account_session_revoke`, `account_session_revoke_all`, + `account_token_create`, `account_token_list`, `account_token_revoke` + (all scoped to the authenticated account), plus the admin role-gated + `admin_session_revoke_all` / `admin_token_revoke_all` which target a + caller-supplied `account_id`. + +## Integration Tests + +The `cross_backend_*` vitest projects run fuz_app's shared cross-process +suites plus zzz-specific suites against the spawned `testing_zzz_server` +binary over real HTTP + WebSocket, verifying its JSON-RPC / SSE responses +conform to the shared fuz_app contract. The tests live in +`src/test/cross_backend/` (TypeScript, not in the Rust crate): + +- **`auth.cross.test.ts`** — invokes fuz_app's + `describe_standard_cross_process_tests` (the cross-process subset of the + standard bundle: ping; JSON-RPC parse / method / request errors over HTTP + + WS; auth enforcement; bearer-token auth on HTTP + WS incl. browser-context + discard and per-token revocation; session + account management; audit + emission; admin role-gated paths). The surface is built in TS from + `action_specs.ts` + fuz_app's standard route bundle via + `create_zzz_app_surface_spec` (`zzz_surface_spec.ts`) — no backend + dependency. The keeper is granted `ROLE_ADMIN` (`extra_keeper_roles`) so + admin-gated cases can drive admin RPC. The bundle omits `rate_limiting`, + `audit_completeness`, and `bootstrap_success` (in-process / FK-structural / + already consumed by globalSetup — see the bundle's module doc). +- **`sse.cross.test.ts`** — `describe_cross_process_sse_tests` against + `GET /api/admin/audit/stream` (the shared `fuz_realtime::audit_stream_router`): + the `: connected` comment, an audit `data:` frame on + `admin_session_revoke_all`, and close-on-revoke. Gated on `capabilities.sse`. +- **`workspace.cross.test.ts`** — workspace open / list / close, idempotency, + not-a-directory + nonexistent errors, and `workspace_changed` broadcast on + open/close (no broadcast on an idempotent open). +- **`filesystem.cross.test.ts`** — scoped `diskfile_update` / `diskfile_delete`, + idempotent `directory_create`, writes into `zzz_dir` + nested subdirs, + path-traversal / out-of-scope / relative-path rejection, and `filer_change` + broadcast on file create in an open workspace. +- **`terminal.cross.test.ts`** — PTY create / read / write / close lifecycle, + `terminal_data` / `terminal_exited` notifications over WS, live resize, + explicit cwd, nonexistent-command handling, and silent-null for missing + terminal IDs. +- **`provider.cross.test.ts`** — `provider_load_status` (no-key status) plus `session_load` + (zzz_dir file listing with contents + recursive subdirectory walk). +- **`completion.cross.test.ts`** — `completion_create` invalid-provider rejection. +- **`peer_ping_ws.cross.test.ts`** — server-initiated `peer/ping` round-trip + (client invokes, server pings back over the same socket, client responder + echoes, server validates) plus security negatives. Invokes fuz_app's shared + `describe_peer_ping_ws_tests`; gated on `capabilities.peer_request` (runs in + the `cross_backend_rust` project). +- **`proxy.cross.test.ts`** — runs only in the `cross_backend_rust_proxy` + project (backend booted with `ZZZ_TRUSTED_PROXIES=127.0.0.1`, which can't be + flipped mid-run). Each test triggers a failed login under a unique username + and asserts the resulting `audit_log.ip` matches the expected resolved client + IP for a given `X-Forwarded-For` + connection-IP combination (no-XFF, + trusted/untrusted hops, malformed entries, IPv6, IPv4-mapped normalization, + leftmost fallback). The resolution itself is `fuz_http::client_ip_middleware` + (spine crate), where the pure functions carry their own `#[cfg(test)]` unit + tests. + +Supporting files: `global_setup.ts` (vitest globalSetup), +`zzz_backend_config.ts` (per-project `BackendConfig` factories), +`zzz_surface_spec.ts` (the TS `AppSurfaceSpec` + RPC endpoints), and +`cross_test_types.ts` (`inject('backend_handle')` typing). + +```bash +npm run test:cross # Both rust projects (rust + rust_proxy) — flag baked in +FUZ_TEST_CROSS_BACKEND=1 npx vitest run --project cross_backend_rust # Single project (Rust binary; postgres://localhost/zzz_test_rust) +FUZ_TEST_CROSS_BACKEND=1 npx vitest run --project cross_backend_rust_proxy # Single project (proxy variant; ZZZ_TRUSTED_PROXIES=127.0.0.1, proxy.cross.test.ts only) +FUZ_TEST_CROSS_BACKEND=1 npx vitest run -t ping # Substring match on test name (vitest -t flag) +``` + +The `cross_backend_*` projects are gated behind `FUZ_TEST_CROSS_BACKEND=1` +in `vite.config.ts` so a bare `gro test` never spawns backends. The +`test:cross` package.json script (`npm run test:cross`) bakes the flag in; +set it manually only for the single-project `--project` runs. + +The harness writes a bootstrap token to a tmpdir, spawns the test binary +via the project's `BackendConfig.start_command`, waits for health, +bootstraps an admin account via `POST /api/account/bootstrap`, then +provides the bootstrapped handle to test files via vitest's +`inject('backend_handle')`. SIGTERM on globalSetup teardown leaves no +stranded ports. Each project targets its own real PostgreSQL DB +(`zzz_test_rust` / `zzz_test_rust_proxy`), with its auth-namespace schema +wiped on backend startup (`FUZ_TESTING_RESET_DB_ON_STARTUP`) and +`_testing_reset` clearing it between tests (preserving the keeper row). + +## Architecture + +``` +crates/zzz_server/src/ +├── lib.rs # `run_app(RunAppOptions)` — full lifecycle: env/config, DB pool + migrations, spine state construction (keyring, daemon token, audit emitter, connection + SSE registries, rate limiters), `ActionRegistry::compile`, file watchers, route composition, graceful shutdown +├── main.rs # Thin production entry — constructs `Argon2idHasher`, calls `run_app` +├── handlers/ # `App` state + the per-domain RPC handlers (spine signature `(Value, ActionContext<'_>, Arc)`, registered into the `ActionRegistry` via `zzz_action_specs::build_*_specs`) +│ ├── mod.rs # `App` long-lived state (workspaces, `db_pool`, `ScopedFs`, `FilerManager`, `PtyManager`, `ProviderManager`, `realtime`, `action_registry` OnceLock) + the `broadcast` shim over `App.realtime` +│ ├── core.rs # ping, session_load, _testing_emit_notifications +│ ├── filesystem.rs # diskfile_update, diskfile_delete, directory_create +│ ├── provider.rs # provider_load_status, provider_update_api_key, completion_create +│ ├── terminal.rs # terminal_create, terminal_data_send, terminal_resize, terminal_close +│ └── workspace.rs # workspace_list, workspace_open, workspace_close (+ workspace_changed broadcast) +├── zzz_action_specs/ # Per-domain `ActionSpec` builders consumed by `run_app`'s `ActionRegistry::compile`; each captures `Arc` and calls the matching `handlers::*` fn +│ ├── mod.rs +│ ├── core.rs +│ ├── filesystem.rs +│ ├── provider.rs +│ ├── terminal.rs +│ └── workspace.rs +├── provider/ # AI provider system +│ ├── mod.rs # ProviderName, ProviderStatus, Provider enum, ProviderManager, CompletionOptions +│ ├── anthropic.rs # AnthropicProvider — Messages API with SSE streaming +│ ├── common.rs # shared provider helpers +│ ├── sse.rs # provider SSE parsing +│ ├── openai.rs # OpenAiProvider — Chat Completions API with SSE streaming +│ └── gemini.rs # GeminiProvider — Generative Language API with SSE streaming +├── filer.rs # Filer + FilerManager (notify crate) — immediate file index updates, debounced filer_change broadcasts +├── pty_manager.rs # PTY terminal manager (fuz_pty crate) → terminal_data/exited notifications +├── scoped_fs.rs # Scoped filesystem — path validation, symlink rejection +└── error.rs # ServerError (Bind, Serve, Database, Config) +``` + +Auth, HTTP / origin / proxy, realtime (WS + SSE), dispatch (`ActionRegistry` + +- `perform_action`), and DB pool / migrations all live in the spine crates + (`fuz_auth` / `fuz_http` / `fuz_realtime` / `fuz_actions` / `fuz_db`) — + `zzz_server` composes them in `run_app`. `handlers/` holds only `App` state + plus a `broadcast` shim over `App.realtime`; socket revocation is the + spine `ConnectionRegistry`'s `SocketRevoker` (see Auth item 10). + +**App + dispatch**: `App` (in `handlers/mod.rs`) holds zzz's long-lived, +non-spine state — `workspaces` (`RwLock`), `db_pool`, `ScopedFs`, +`zzz_dir`, `scoped_dirs`, `FilerManager` (per-watcher ignore config, event +debouncing, in-memory file index, lifetime tracking — permanent for +`zzz_dir`/`scoped_dirs`, workspace-scoped for `workspace_open`), +`PtyManager`, `ProviderManager`, `completion_options`, `enable_test_actions`, +the spine `realtime: Arc`, and the +boot-compiled `action_registry: OnceLock>` +(OnceLock because the spec builders capture `Arc`). Constructed once +in `run_app`, wrapped in `Arc`. Auth keyring, daemon-token state, audit +emitter, rate limiters, allowed-origins, and trusted-proxy config are spine +types built in `run_app` and threaded into the spine route states +(`fuz_auth::AccountRouteState` / `BootstrapRouteState` / `SignupRouteState`, +`fuz_actions::RpcRouteState` / `WsRouteState` (both carrying `notification_sender: Arc` for realtime dispatch fan-out), and +`fuz_realtime::AuditStreamRouteState`) — not fields on `App`. + +**Dispatch + auth run in the spine.** A single `/api/rpc` (via +`fuz_actions::create_rpc_router`) and `/api/ws` (via `register_action_ws` +→ `fuz_realtime::run_ws_connection`) drive the `ActionRegistry`; +`fuz_actions::perform_action` owns the spec lookup, per-action auth +(credential + role gates — keeper actions require the `DaemonToken` +credential type), the transactional `side_effects` wrap, and the +post-commit pending-effects drain. Auth resolution (daemon-token → cookie → +bearer), Origin verification, trusted-proxy client-IP resolution, and rate +limiting are `fuz_auth` / `fuz_http` concerns. Account / bootstrap / signup +REST routes come from fuz_auth's routers; the admin audit-log SSE stream +from `fuz_realtime::audit_stream_router`. + +**Audit emission**: all audit rows go through the spine +`fuz_auth::AuditEmitter` (`spine_audit_emitter`, built in `run_app`), +shared by the account / bootstrap / signup routers and the RPC dispatch +path. Two listener sets hang off its event chain, both registered in +`run_app` after `Arc` exists: + +- `fuz_auth::register_socket_revocation_listeners` — the WS half: closes + matching WebSocket connections on `session_revoke` / `token_revoke` + (granular) and `session_revoke_all` / `token_revoke_all` / + `password_change` / `logout` (account-wide). Revocation-emitting handlers + also call the `SocketRevoker` methods synchronously before emitting, so + revocation lands even if the audit INSERT later fails. +- `fuz_realtime::register_audit_sse_listener` — the SSE half: fans every + audit row to the open `GET /api/admin/audit/stream` subscriptions as one + `data:` frame and closes an account's streams on the account-wide + revocation events. + +Failure-outcome rows never trigger socket / stream close — they carry +caller-submitted metadata (e.g. a failed `session_revoke` records the +submitted `session_id`), so reacting to them would let an authenticated +user disconnect another by guessing an id. The credential-channel +metadata contract, the bootstrap success/failure audit rows, and the +`password_change` `concurrent_change` race row are all spine +(`fuz_auth`) behaviors now — see fuz_app's `auth/` docs for their shapes. + +## Known Issues + +- **No per-message WS session revalidation** — upgrade-time auth only. Event- + driven revocation covers logout and password change (closes matching WS + connections via `close_sockets_for_session`/`close_sockets_for_account`). + Per-message session recheck is not done — the event-driven approach is + sufficient for current needs. +- **error.data omits Zod validation details** — for -32602 (invalid params) + errors, `error.data` omits the Zod issues for security (no schema leak to + unauthenticated callers). The integration test `normalize_error_data` + function tolerates either shape. Future: env-conditional — include the + issues in dev, strip in prod. +- **filer file-size cap** — `filer::MAX_INDEXED_FILE_SIZE` + (4 MiB, `crates/zzz_server/src/filer.rs:23`) caps the in-memory index: files + over 4 MiB carry their metadata but store `contents: None`. This bounds + memory under workspaces containing large lockfiles or build outputs. + The cross-backend integration tests don't exercise files >4 MiB. + +## Known Limitations + +- RPC methods: `ping`, `session_load`, `workspace_*`, `diskfile_update`, `diskfile_delete`, `directory_create`, `terminal_*`, `provider_load_status`, `provider_update_api_key` (keeper-only), `completion_create`, `account_verify`, `account_session_list`, `account_session_revoke`, `account_session_revoke_all`, `account_token_create`, `account_token_list`, `account_token_revoke`, `admin_session_revoke_all` (admin-only), `admin_token_revoke_all` (admin-only) — plus the rest of the spine-registered `fuz_auth` standard bundle and protocol specs (see the workspace-layout section above) +- 5 zzz-domain `remote_notification` actions: `workspace_changed` (broadcast on open/close), `filer_change` (`FilerManager` with `notify` crate — recursive watching, 80ms debounced broadcasts with immediate index updates, per-watcher ignore config, in-memory file index; ignores `.git`/`node_modules`/`.svelte-kit`/`target`/`dist` globally plus zzz dir name for workspace/scoped_dir watchers; startup filers on `zzz_dir` and `scoped_dirs`, per-workspace filers with dedup and lifetime tracking), `terminal_data` (PTY stdout broadcast), `terminal_exited` (process exit broadcast), `completion_progress` (streaming completion chunks to requesting WS connection); the spine's role-grant-offer bundle carries its own notification set (`role_grant_offer_received` / `_retracted` / `_accepted` / `_declined` / `_supersede`) +- AI providers: Anthropic, OpenAI, and Gemini all fully implemented (non-streaming + SSE streaming) +- No batch request support (JSON arrays) +- `/api/account/signup` is mounted via `fuz_auth::signup_routes`. Invite-gated by default (`app_settings.open_signup=false`); admins flip the setting via `app_settings_update` to enable open signup. The cross-process test binary opts into `open_signup: true` at startup via `app_settings_patch` so per-test `mint_account` can sign up without invites. `app_settings` is loaded per-request today; a cached `Arc>` shared with the future admin `app_settings_update` handler is planned. +- Token management is JSON-RPC only (`account_token_create` / `account_token_list` / `account_token_revoke`) — no REST token routes +- Admin audit-log SSE broadcast is live at `GET /api/admin/audit/stream` — the shared `fuz_realtime::audit_stream_router`, wired to the spine `AuditEmitter` via `fuz_realtime::register_audit_sse_listener` alongside the WS socket-revocation listeners. Wire shape matches fuz_app's `audit_log_sse`; the `sse.cross.test.ts` suite verifies it. Close-on-revoke keys on the union of access-invalidation events, matching fuz_app's guard: `session_revoke` (session-hash-scoped) / `session_revoke_all` / `token_revoke_all` / `password_change` / `logout` (account-wide) / `role_grant_revoke` (role-matched). The single `token_revoke` is excluded (no SSE stream is keyed by an API token) +- Login/password rate limiting is **always on** (matching `fuz_forge_server` + `mageguild_server` and the fuz defaults): per-IP (5 attempts / 15 min) + per-account (10 / 30 min) sliding windows fire on `/login` and `/password`; 429 carries `{error: 'rate_limit_exceeded', retry_after}` plus a `Retry-After` header. Per-IP key is the resolved client IP from `proxy::client_ip_middleware` — set `ZZZ_TRUSTED_PROXIES` when running behind a reverse proxy so the bucket keys on the originating client rather than the proxy. The `testing_zzz_server` binary disables it via `RunAppOptions::disable_login_rate_limit` so the cross-backend auth suite's repeated logins don't trip the bucket +- Request bodies are capped at `fuz_http::DEFAULT_BODY_LIMIT_BYTES` (1 MiB) on `/api/rpc` + the account/bootstrap/signup routers (the shared fuz default, same as the other spine consumers). `diskfile_update` content rides the RPC body, so a single write is bounded to 1 MiB; a streaming content-addressed route is the deferred path for larger / binary blobs. The WS upgrade and static fallback are not body-capped + +## Design Decisions + +- **DB**: `tokio-postgres` + `deadpool-postgres` pool in `App`. Required at + startup — server fails fast if `DATABASE_URL` is missing or unreachable. + Migrations run on every startup (CREATE TABLE IF NOT EXISTS). +- **Cookie signing**: Pure Rust HMAC-SHA256 via `hmac`/`sha2` crates. + Compatible with fuz_app's keyring format (same `value.base64(signature)`). +- **Session hashing**: `blake3` crate for token → storage key hashing. + Compatible with fuz_app's `hash_blake3` (same hex output). +- **Password hashing**: Argon2id via `argon2` crate (bootstrap, login, password change), + offloaded to `tokio::task::spawn_blocking` to avoid blocking the async runtime. +- **Dispatch is async**: filesystem handlers (`diskfile_update`, etc.) use + `tokio::fs` async I/O. Workspace handlers remain sync (no await points). +- **`parking_lot::RwLock`** for sync handlers (workspaces, scoped-fs); no + poisoning. Async handlers (filer, pty, providers) use `tokio::sync::RwLock` + where a guard is held across an await — scope sync guards before await points. +- **Session touch**: fire-and-forget via `tokio::spawn` — doesn't block + the request pipeline. +- **PTY terminals**: `fuz_pty` as a native crate dependency (no FFI + indirection). `PtyManager` in `App` manages spawned processes with async + read loops via `tokio::spawn`. Each terminal gets a `CancellationToken` so + `terminal_close` can stop the read loop before killing the process. 10ms + poll interval, 50ms wait after kill before waitpid, silent returns for + missing terminal IDs. +- **Provider system**: Enum-dispatched (`Provider` enum, not trait objects) — + 3 providers known at compile time, exhaustive matching. Provider state behind + `tokio::sync::RwLock` for async `set_api_key`. `complete()` clones the + `reqwest::Client` (internally `Arc`'d) and releases the lock before HTTP + calls, so `set_api_key` is never blocked by long-running streaming responses. + SSE parsing is manual with `\r\n` normalization per RFC 8895. +- **Dispatcher transaction wrap**: `fuz_actions::perform_action` wraps + `side_effects: true` actions in a `tokio_postgres` transaction (commit on + `Ok`, rollback on `Err`) and drains post-commit pending effects, so paired + writes commit atomically and read-only actions skip the pool entirely. + zzz's `handlers` functions receive the `ActionContext` DB handle and + stay transaction-agnostic — the wrap is the spine's concern. + +## What's Next + +**Spine consumption — complete.** The spine crates (`fuz_db`, +`fuz_auth`, `fuz_http`, `fuz_realtime`, `fuz_actions`) own auth, HTTP, +realtime, and dispatch. A single `/api/rpc` + `/api/ws` (via +`fuz_actions::create_rpc_router` / `register_action_ws`) serves the +boot-compiled `ActionRegistry`; account / bootstrap / signup REST come +from fuz_auth's routers; the admin audit-log SSE stream +(`GET /api/admin/audit/stream`) comes from +`fuz_realtime::audit_stream_router` + `register_audit_sse_listener`. +`handlers/` holds only `App` state + a `broadcast` shim over +`App.realtime`. + +**AI providers** (Anthropic, OpenAI, and Gemini all complete): + +- [x] Provider system: enum-dispatched `Provider` with `ProviderManager`, `ProviderStatus`, `CompletionOptions` +- [x] Anthropic provider: full implementation with `reqwest` HTTP client, SSE streaming, message format conversion +- [x] `provider_load_status` handler (all 3 providers report status) +- [x] `provider_update_api_key` handler (keeper-only, runtime API key updates) +- [x] `completion_create` handler with `completion_progress` streaming notifications (targeted to requesting WS connection) +- [x] `session_load` returns real provider status from all providers +- [x] OpenAI provider: full completion implementation (Chat Completions API, non-streaming + SSE streaming) +- [x] Gemini provider: full completion implementation (Generative Language API, non-streaming + SSE streaming) + +**Other remaining work**: + +1. Codegen from Zod specs (action input/output types) + +- [x] Trusted-proxy client-IP resolution (XFF + CIDR + strict-IP + validation), Origin allowlist (Origin-only, no Referer fallback), and + login-username canonicalization — all now provided by the spine + (`fuz_http` proxy/origin + `fuz_auth`); zzz wires them via config + (`ZZZ_TRUSTED_PROXIES`, `FUZ_ALLOWED_ORIGINS`). diff --git a/crates/testing_zzz_server/Cargo.toml b/crates/testing_zzz_server/Cargo.toml new file mode 100644 index 000000000..f6111fba3 --- /dev/null +++ b/crates/testing_zzz_server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "testing_zzz_server" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "Test-mode zzz_server binary — wires fuz_testing's TestingArgon2idHasher for fast cross-process integration tests. NEVER ship in a release." + +[[bin]] +name = "testing_zzzd" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +# Production zzz_server library — the test binary reuses the whole +# server lifecycle. The only swap is the password hasher passed into +# `zzz_server::run_app`. +zzz_server = { path = "../zzz_server" } + +# Spine — same crates the production binary uses, plus fuz_testing for +# the swap fixture. fuz_testing is `path_dep` only here; it is +# *deliberately absent* from `zzz_server`'s production dep graph so the +# `cargo xtask check-release` sanity check holds. +fuz_actions.workspace = true +fuz_auth.workspace = true +# `logging` feature → non-blocking stdout subscriber init. +fuz_sys = { workspace = true, features = ["logging"] } +fuz_db.workspace = true +fuz_http.workspace = true +fuz_testing.workspace = true + +# Async runtime + tracing — same shape as `zzz_server`'s production +# `main.rs`. +tokio.workspace = true +tracing.workspace = true diff --git a/crates/testing_zzz_server/src/main.rs b/crates/testing_zzz_server/src/main.rs new file mode 100644 index 000000000..dd6ba4485 --- /dev/null +++ b/crates/testing_zzz_server/src/main.rs @@ -0,0 +1,163 @@ +//! `testing_zzz_server` — test-mode `zzz_server` binary. +//! +//! **NEVER ship this in a release.** The binary wires +//! [`fuz_testing::TestingArgon2idHasher`] in place of +//! [`fuz_auth::Argon2idHasher`] so cross-process integration tests get +//! ~1-5 ms argon2 feedback instead of production's ~30-50 ms, and +//! registers the `_testing_reset` RPC action from +//! [`fuz_testing::create_testing_reset_action_spec`] so per-test fixtures +//! can opt into a fresh auth-table + domain-state reset between cases. +//! Three layered guardrails keep this from leaking into production: +//! +//! 1. The `testing_` prefix is enforced by `fuz_release`'s manifest +//! filter (`is_test_binary_name` rejects any binary name starting +//! with `testing_`). +//! 2. `cargo xtask check-release` walks `cargo metadata` and asserts +//! no production binary's package depends on `fuz_testing`. The +//! `testing_zzz_server` crate is a separate package, so the +//! sibling `zzz_server` package stays clean of `fuz_testing`. +//! 3. `TestingArgon2idHasher::new` logs `WARN: test-mode argon2 hasher +//! active` at construction — a sentinel for log-scraping audits. +//! +//! Otherwise structurally identical to `zzz_server`'s production +//! `main.rs`: same tracing init, same lifecycle, same shutdown. +//! Production defaults to `127.0.0.1:4460` (`zzz_server::DEFAULT_ADDR`); +//! this binary defaults to `127.0.0.1:4462` so a developer running both +//! locally doesn't collide. Override the port via `ZZZ_PORT` or `--port` +//! exactly as the production binary supports. + +use std::net::SocketAddr; +use std::sync::Arc; + +use fuz_auth::PasswordHasher; +use fuz_testing::{ + ResetStateFn, TestingArgon2idHasher, TestingResetOptions, create_testing_reset_action_spec, +}; + +/// Default loopback bind address for the testing binary (port 4462). +/// Distinct from the production default (4460) so both binaries can run +/// side-by-side on `localhost` during local development. `ZZZ_PORT` or +/// `--port` override the port; the host stays loopback. +const TESTING_DEFAULT_ADDR: SocketAddr = + SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 4462); + +#[tokio::main] +async fn main() { + // Non-blocking stdout logging so a stalled stdout consumer can't starve + // the async runtime. `_log_guard` must stay live for the whole process. + let _log_guard = + fuz_sys::logging::init_non_blocking_stdout("info,zzz_server=info,testing_zzzd=info"); + + let password_hasher: Arc = Arc::new(TestingArgon2idHasher::new()); + + // Always-visible startup sentinel — survives any RUST_LOG filter that + // would suppress `info!`. The sister `WARN: test-mode argon2 hasher + // active` line is emitted by `TestingArgon2idHasher::new` itself. + eprintln!("testing_zzzd starting (test-mode argon2 active)"); + + // The `_testing_reset` factory closes over `Arc` so the + // domain-state reset closure can clear zzz workspaces + terminals + + // the optional scratch dir. + let extra_specs_factory: zzz_server::ExtraActionSpecsFactory = Box::new(|app, runtime| { + let app_for_reset = Arc::clone(&app); + // zzz's domain state is in-memory (workspaces + terminals + + // scratch dir), so the in-tx `ActionDb` handed in by the spine + // reset is ignored here — only DB-domain consumers (fuz_forge) + // use it. + let reset_state: ResetStateFn = Arc::new(move |_db| { + let app = Arc::clone(&app_for_reset); + Box::pin(async move { + // Clear every open workspace. The Rust App stores workspaces + // as a plain HashMap (no per-path close hook like the TS + // Backend), so a wholesale clear is the right shape — file + // watchers attached at boot for `zzz_dir` + `scoped_dirs` + // stay running (Permanent lifetime). `parking_lot::RwLock` + // is sync — no await. + // + // Cross-impl note: the TS reset closure + // (`testing_server_core.ts:workspace_close`) fires a + // `workspace_changed` notification per path as a + // side-effect of borrowing the production close path. This + // wholesale `.clear()` does not — cross-process tests + // don't share WS clients across resets, so the divergence + // isn't observable. Revisit (add a per-path `close_workspace` + // hook with notification fanout) when a UI consumer closes + // individual workspaces from the client, OR a subsystem + // (search index, semantic analysis) needs per-path + // cleanup cycles. + app.workspaces.write().clear(); + + // Kill every active terminal. `kill_all()` drains the + // terminal map and waitpids each entry; the manager + // itself stays usable for the next test's + // `terminal_create` calls. + app.pty_manager.kill_all().await; + + // Optional scoped-FS scratch root: tests that allocate + // per-case scratch dirs under `ZZZ_TESTING_SCRATCH_DIR` + // get a clean slate. Unset → no-op. I/O failures here + // surface as a JSON-RPC error so the per-test fixture + // sees the reset short-circuit instead of silently + // running against a half-wiped scratch tree. + if let Ok(scratch_dir) = std::env::var("ZZZ_TESTING_SCRATCH_DIR") + && tokio::fs::metadata(&scratch_dir).await.is_ok() + { + tokio::fs::remove_dir_all(&scratch_dir).await.map_err(|e| { + fuz_http::internal_error_with_source("remove scratch dir", &e) + })?; + } + + Ok(()) + }) + }); + let daemon_token_state = runtime.daemon_token_state.expect( + "testing_zzz_server requires daemon-token rotation to be wired \ + (it provides keeper credentials for _testing_reset)", + ); + vec![create_testing_reset_action_spec(TestingResetOptions { + password_hasher: runtime.password_hasher, + keyring: runtime.keyring, + daemon_token_state, + session_cookie_name: runtime.session_cookie_name, + reset_state: Some(reset_state), + })] + }); + + // Pre-migration hook — wipes the auth-namespace schema when + // `FUZ_TESTING_RESET_DB_ON_STARTUP=true`, then migrations replay + // from nothing. Lifts the manual `psql DROP TABLE...` step out of + // the cross-backend test harness so projects can re-run against a + // shared PG without operator intervention between sessions. + // No-op when the env var is unset/false. + let pre_migration_hook: zzz_server::PreMigrationHook = Box::new(|pool: &fuz_db::Pool| { + Box::pin(async move { + let client = pool.get().await.map_err(|e| { + zzz_server::ServerError::Database(format!( + "failed to get client for startup reset: {e}" + )) + })?; + fuz_testing::reset_db_on_startup_if_env_set(&client) + .await + .map_err(|e| { + zzz_server::ServerError::Database(format!("startup DB reset failed: {e}")) + })?; + Ok(()) + }) + }); + + if let Err(e) = zzz_server::run_app(zzz_server::RunAppOptions { + password_hasher, + default_addr: TESTING_DEFAULT_ADDR, + drain_timeout: fuz_http::DEFAULT_DRAIN_TIMEOUT, + force_test_actions: true, + disable_login_rate_limit: true, + extra_action_specs_factory: Some(extra_specs_factory), + pre_migration_hook: Some(pre_migration_hook), + }) + .await + { + tracing::error!(error = %e, "fatal"); + eprintln!("error: {e}"); + std::process::exit(1); + } +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 000000000..378156bed --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +fuz_audit.workspace = true + +[lints] +workspace = true + +# Dev tooling — exempt from `fuz_audit`'s production-binary scan so +# `cargo xtask check-release` can itself depend on `fuz_audit`. +[package.metadata.fuz_audit] +dev_only = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 000000000..cafa49904 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,232 @@ +//! `cargo xtask` — dev and build automation for the zzz workspace. +//! +//! Subcommands: +//! - `dev` — build `zzz_server`, then run it alongside the Vite frontend +//! (the dev backend binds `4461`; Vite serves `5173` and proxies `/api` to it). +//! - `dev-setup` — generate `.env.development` from `.env.development.example`. +//! - `prod-setup` — generate `.env.production` from `.env.production.example`. +//! - `check-release` — the dep-graph audit (sanity check #2 of the test-binary +//! pattern); its work is delegated to [`fuz_audit::run_check_release_cli`]. +//! - no args / `help` / `-h` / `--help` — print the full subcommand list. +//! - any other subcommand — error to stderr + usage, non-zero exit. +//! +//! Dispatch and the usage text live here (not in `fuz_audit`) so bare +//! `cargo xtask` advertises zzz's own commands, not just `check-release`. +//! +//! Replaces the former Deno orchestration (`scripts/*.ts` + `deno.json`): the +//! workspace builds and runs entirely on `cargo` + `npm`/`npx`, no Deno. + +use std::collections::BTreeMap; +use std::error::Error; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs; +use std::io::Read as _; +use std::net::TcpStream; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::process::{Command, ExitCode}; +use std::time::{Duration, Instant}; + +type Result = std::result::Result>; + +/// Dev backend port. Vite (`5173`) proxies `/api` here; see `vite.config.ts`. +const DEV_BACKEND_PORT: u16 = 4461; +const DEV_ENV_FILE: &str = ".env.development"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("dev") => finish(run_dev()), + Some("dev-setup") => finish(setup_env(DEV_ENV_FILE, ".env.development.example")), + Some("prod-setup") => finish(setup_env(".env.production", ".env.production.example")), + // The dep-graph audit is fuz_audit's; everything else (dispatch, help) is ours. + Some("check-release") => fuz_audit::run_check_release_cli(), + None | Some("help" | "-h" | "--help") => { + print_usage(); + ExitCode::SUCCESS + } + Some(other) => { + eprintln!("[xtask] error: unknown subcommand `{other}`\n"); + print_usage(); + ExitCode::FAILURE + } + } +} + +/// Collapse a subcommand's [`Result`] into a process exit code, printing the +/// error to stderr on failure. +fn finish(outcome: Result<()>) -> ExitCode { + match outcome { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("[xtask] error: {err}"); + ExitCode::FAILURE + } + } +} + +/// Print the full subcommand list. Bare `cargo xtask`, `help`, and `-h`/`--help` +/// land here so every command is discoverable — not just `check-release`. +fn print_usage() { + println!( + "cargo xtask — dev and build automation for the zzz workspace + +usage: cargo xtask + +commands: + dev build zzz_server (port {DEV_BACKEND_PORT}), then run it alongside the Vite frontend + dev-setup create {DEV_ENV_FILE} from {DEV_ENV_FILE}.example + prod-setup create .env.production from .env.production.example + check-release audit that no production binary depends on fuz_testing / fuz_audit" + ); +} + +/// Copy `example` → `target` (mode `0600`) when `target` is absent. Idempotent. +fn setup_env(target: &str, example: &str) -> Result<()> { + if Path::new(target).exists() { + println!("[xtask] {target} already exists — skipping"); + return Ok(()); + } + if !Path::new(example).exists() { + return Err(format!("template not found: {example}").into()); + } + fs::copy(example, target)?; + fs::set_permissions(target, fs::Permissions::from_mode(0o600))?; + println!("[xtask] created {target} (chmod 600) — edit it to set keys/secrets"); + Ok(()) +} + +/// Build `zzz_server`, run it, wait for it to listen, then run the Vite dev +/// server. Blocks until either child exits, then tears the other down. +fn run_dev() -> Result<()> { + if !Path::new(DEV_ENV_FILE).exists() { + return Err(format!("{DEV_ENV_FILE} not found — run: cargo xtask dev-setup").into()); + } + println!("[xtask] loading {DEV_ENV_FILE}"); + let mut env = parse_env_file(DEV_ENV_FILE)?; + + if let Some(token_path) = env.get("FUZ_BOOTSTRAP_TOKEN_PATH") { + ensure_bootstrap_token(token_path)?; + } + + // Bind the backend where the Vite proxy expects it, regardless of the + // file's values (so a stale `.env.development` still works in dev). + let port = DEV_BACKEND_PORT.to_string(); + env.insert("PORT".to_owned(), port.clone()); + env.insert("PUBLIC_ZZZ_SERVER_PROXIED_PORT".to_owned(), port.clone()); + env.insert( + "PUBLIC_ZZZ_WEBSOCKET_URL".to_owned(), + format!("ws://localhost:{DEV_BACKEND_PORT}/api/ws"), + ); + + // Child env = inherited process env, overlaid with the `.env` values. + let mut child_env: BTreeMap = std::env::vars_os().collect(); + for (key, value) in &env { + child_env.insert(OsString::from(key), OsString::from(value)); + } + + println!("[xtask] building zzz_server..."); + if !Command::new("cargo") + .args(["build", "-p", "zzz_server"]) + .status()? + .success() + { + return Err("cargo build -p zzz_server failed".into()); + } + println!("[xtask] build complete"); + + println!("[xtask] starting zzz_server on port {DEV_BACKEND_PORT}..."); + let mut server = Command::new("./target/debug/zzzd") + .args(["--port", &port]) + .envs(&child_env) + .spawn()?; + + if !wait_for_port(DEV_BACKEND_PORT, Duration::from_secs(30)) { + let _ = server.kill(); + return Err(format!("zzz_server never listened on {DEV_BACKEND_PORT}").into()); + } + println!("[xtask] zzz_server healthy"); + + println!("[xtask] starting vite dev server..."); + let mut vite = Command::new("npx") + .args(["vite", "dev"]) + .envs(&child_env) + .spawn()?; + + // Ctrl+C reaches both children directly (shared process group), so they + // shut themselves down; this loop reaps them and cleans up if one crashes. + loop { + if let Some(status) = server.try_wait()? { + println!("[xtask] zzz_server exited ({status}); stopping vite"); + let _ = vite.kill(); + let _ = vite.wait(); + break; + } + if let Some(status) = vite.try_wait()? { + println!("[xtask] vite exited ({status}); stopping zzz_server"); + let _ = server.kill(); + let _ = server.wait(); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + Ok(()) +} + +/// Parse a dotenv-style file into key/value pairs. Skips blank lines and `#` +/// comments; strips one layer of surrounding single or double quotes. +fn parse_env_file(path: &str) -> Result> { + let contents = fs::read_to_string(path)?; + let mut map = BTreeMap::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + map.insert(key.trim().to_owned(), value.to_owned()); + } + Ok(map) +} + +/// Create a 32-byte hex bootstrap token at `path` (mode `0600`) if absent. +fn ensure_bootstrap_token(path: &str) -> Result<()> { + if Path::new(path).exists() { + return Ok(()); + } + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent)?; + } + let mut bytes = [0u8; 32]; + fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + let mut token = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(token, "{byte:02x}"); + } + fs::write(path, &token)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + println!("[xtask] created bootstrap token at {path}"); + Ok(()) +} + +/// Poll a localhost TCP port until something accepts, or `timeout` elapses. +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let addr = format!("127.0.0.1:{port}"); + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(&addr).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + false +} diff --git a/crates/zzz/Cargo.toml b/crates/zzz/Cargo.toml new file mode 100644 index 000000000..f2c18df1a --- /dev/null +++ b/crates/zzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "zzz" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "zzz CLI — daemon manager and workspace launcher" + +[[bin]] +name = "zzz" +path = "src/main.rs" + +[dependencies] +argh.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["process", "time"] } +reqwest.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +# Shared daemon-lifecycle primitives — PID liveness (`/proc`), signal +# forwarding, RFC-3339 timestamps, and crash-safe atomic writes. The CLI keeps +# its own `DaemonInfo` (the fuz_app TS v1 wire shape), but routes the OS-level +# plumbing through fuz_sys rather than re-deriving it. +fuz_sys = { workspace = true, features = ["tls"] } + +[dev-dependencies] +# `io-util` for the in-module health-probe test's mock HTTP responder +# (kept out of the production binary's tokio feature set). +tokio = { workspace = true, features = ["io-util", "rt-multi-thread", "macros", "net", "time"] } + +[lints] +workspace = true diff --git a/crates/zzz/src/cli/commands/daemon.rs b/crates/zzz/src/cli/commands/daemon.rs new file mode 100644 index 000000000..82986b80a --- /dev/null +++ b/crates/zzz/src/cli/commands/daemon.rs @@ -0,0 +1,172 @@ +//! `zzz daemon` — manage the zzz daemon lifecycle. +//! +//! Three subcommands: `start` (foreground), `stop`, `status`. The shared +//! plumbing (binary discovery, health probe, `daemon.json` I/O, PID ops) +//! lives in `crate::daemon_lifecycle`; these handlers orchestrate it. + +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use argh::FromArgs; +use tokio::signal::unix::{SignalKind, signal}; + +use crate::CliError; +use crate::daemon_lifecycle as dl; + +/// Manage the zzz daemon. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "daemon")] +pub struct Daemon { + #[argh(subcommand)] + pub nested: DaemonSub, +} + +#[derive(FromArgs, Debug)] +#[argh(subcommand)] +pub enum DaemonSub { + Start(DaemonStart), + Stop(DaemonStop), + Status(DaemonStatus), +} + +/// Start the zzz daemon (foreground). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "start")] +pub struct DaemonStart { + /// daemon port (overrides `PORT` env and config; default 4460) + #[argh(option)] + pub port: Option, + + /// bind host (accepted for parity; `zzz_server` binds loopback only, so + /// this is currently a no-op) + #[argh(option)] + #[allow(dead_code)] // parity placeholder — zzz_server has no host flag + pub host: Option, +} + +/// Stop the running daemon. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "stop")] +pub struct DaemonStop {} + +/// Show daemon status. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "status")] +pub struct DaemonStatus { + /// machine-readable JSON output + #[argh(switch)] + pub json: bool, +} + +/// Handle `zzz daemon …` subcommands. +pub async fn cmd_daemon(args: Daemon) -> Result<(), CliError> { + match args.nested { + DaemonSub::Start(opts) => cmd_daemon_start(&opts).await, + DaemonSub::Stop(opts) => cmd_daemon_stop(&opts).await, + DaemonSub::Status(opts) => cmd_daemon_status(&opts).await, + } +} + +/// Spawn `zzz_server`, wait for it to report healthy, record `daemon.json`, +/// then block until it exits — forwarding SIGINT/SIGTERM to the child and +/// cleaning up `daemon.json` on exit. +async fn cmd_daemon_start(args: &DaemonStart) -> Result<(), CliError> { + let port = dl::resolve_port(args.port); + let child_env = dl::build_child_env(); + + // Warn (don't block) on a leftover daemon.json from a prior crash. + if let Some(stale) = dl::read_daemon_info() + && !dl::is_pid_alive(stale.pid) + { + eprintln!( + "warning: stale daemon.json (pid {} not running), replacing", + stale.pid + ); + } + + let bin = dl::resolve_server_bin(); + let mut child = tokio::process::Command::new(&bin) + .args(["--port", &port.to_string()]) + .envs(&child_env) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| CliError::Daemon(format!("failed to spawn {}: {e}", bin.display())))?; + let pid = child + .id() + .ok_or_else(|| CliError::Daemon("spawned child has no pid".to_string()))?; + + // Poll /health until the server is up or the timeout elapses. + let deadline = Instant::now() + Duration::from_millis(dl::HEALTH_TIMEOUT_MS); + let mut healthy = false; + while Instant::now() < deadline { + if dl::check_health(port).await { + healthy = true; + break; + } + tokio::time::sleep(Duration::from_millis(dl::HEALTH_POLL_INTERVAL_MS)).await; + } + if !healthy { + let _ = dl::send_sigterm(pid); + let _ = child.wait().await; + return Err(CliError::ServerNotHealthy { + port, + ms: dl::HEALTH_TIMEOUT_MS, + }); + } + + dl::write_daemon_info(&dl::DaemonInfo { + version: 1, + pid, + port, + started: fuz_sys::rfc3339_now(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + })?; + println!("daemon running on http://localhost:{port}"); + + // Foreground lifecycle: a signal forwards SIGTERM to the child (using + // the copied pid, so no borrow conflict with the `child.wait()` arm), + // then the next loop iteration reaps it. + let mut sigint = signal(SignalKind::interrupt())?; + let mut sigterm = signal(SignalKind::terminate())?; + loop { + tokio::select! { + _ = child.wait() => break, + _ = sigint.recv() => { let _ = dl::send_sigterm(pid); } + _ = sigterm.recv() => { let _ = dl::send_sigterm(pid); } + } + } + let _ = dl::remove_daemon_info(); + Ok(()) +} + +/// Stop the running daemon: SIGTERM, wait for exit, clean up `daemon.json`. +async fn cmd_daemon_stop(_args: &DaemonStop) -> Result<(), CliError> { + let Some(info) = dl::read_daemon_info() else { + println!("no daemon running (no daemon.json)"); + return Ok(()); + }; + if !dl::is_pid_alive(info.pid) { + dl::remove_daemon_info()?; + println!("removed stale daemon.json (pid {} not running)", info.pid); + return Ok(()); + } + dl::send_sigterm(info.pid)?; + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && dl::is_pid_alive(info.pid) { + tokio::time::sleep(Duration::from_millis(100)).await; + } + let still_alive = dl::is_pid_alive(info.pid); + dl::remove_daemon_info()?; + if still_alive { + println!("sent SIGTERM to pid {} (still running after 10s)", info.pid); + } else { + println!("daemon stopped (pid {})", info.pid); + } + Ok(()) +} + +/// Show daemon status — shares the report with `zzz status`. +async fn cmd_daemon_status(args: &DaemonStatus) -> Result<(), CliError> { + crate::cli::commands::status::report_status(args.json).await +} diff --git a/crates/zzz/src/cli/commands/init.rs b/crates/zzz/src/cli/commands/init.rs new file mode 100644 index 000000000..33a0d02fa --- /dev/null +++ b/crates/zzz/src/cli/commands/init.rs @@ -0,0 +1,59 @@ +//! `zzz init` — initialize `~/.zzz/`. +//! +//! State directory layout (from `zzz/CLAUDE.md` §"Zzz App Directory"): +//! ```text +//! ~/.zzz/ +//! config.json — daemon config (port) +//! state/ — persistent data (completions, workspaces.json) +//! cache/ — regenerable data, safe to delete +//! run/daemon.json — PID, port, version (ephemeral) +//! ``` +//! Idempotent: it `mkdir -p`s the subdirectories and only writes +//! `config.json` when absent, so re-runs are safe and never clobber a +//! configured port. + +use std::fs; + +use argh::FromArgs; + +use crate::CliError; +use crate::daemon_lifecycle as dl; + +/// State subdirectories created under `~/.zzz/`. `run/` also holds the +/// ephemeral `daemon.json`; `daemon start` creates it on demand, but +/// `init` makes it up front so the layout is complete after a fresh init. +const STATE_SUBDIRS: &[&str] = &["state", "cache", "run"]; + +/// Initialize zzz configuration (`~/.zzz/`). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "init")] +pub struct Init { + /// daemon port to record in `config.json` (default 4460) + #[argh(option)] + pub port: Option, +} + +/// Handle `zzz init`. +pub fn cmd_init(args: &Init) -> Result<(), CliError> { + let zzz_dir = dl::zzz_dir()?; + for sub in STATE_SUBDIRS { + fs::create_dir_all(zzz_dir.join(sub))?; + } + + let config_path = dl::config_path()?; + if config_path.exists() { + println!("zzz already initialized at {}", zzz_dir.display()); + println!(" config: {}", config_path.display()); + } else { + let port = args.port.unwrap_or(dl::DEFAULT_PORT); + let config = serde_json::json!({ "zzz_config_port": port }); + let mut content = + serde_json::to_string_pretty(&config).map_err(|e| CliError::Daemon(e.to_string()))?; + content.push('\n'); + fs::write(&config_path, content)?; + println!("created {}", config_path.display()); + } + + println!("zzz is ready. Run `zzz` to auto-start the daemon and open the browser."); + Ok(()) +} diff --git a/crates/zzz/src/cli/commands/mod.rs b/crates/zzz/src/cli/commands/mod.rs new file mode 100644 index 000000000..ace8fe8f7 --- /dev/null +++ b/crates/zzz/src/cli/commands/mod.rs @@ -0,0 +1,11 @@ +//! Subcommand implementations. +//! +//! Each module declares one argh `FromArgs` struct (or a parent + nested +//! enum for commands with subcommands, like `daemon`) and a `cmd_*` handler +//! function that the top-level dispatch in `main.rs` calls. + +pub mod daemon; +pub mod init; +pub mod open; +pub mod status; +pub mod version; diff --git a/crates/zzz/src/cli/commands/open.rs b/crates/zzz/src/cli/commands/open.rs new file mode 100644 index 000000000..1abca8533 --- /dev/null +++ b/crates/zzz/src/cli/commands/open.rs @@ -0,0 +1,341 @@ +//! `zzz open` — default command. +//! +//! Opens the zzz browser UI, auto-starting the daemon if needed. +//! Handles `zzz`, `zzz `, `zzz `. +//! +//! The flow: +//! 1. Require init — `~/.zzz` must exist (`zzz init`). +//! 2. Daemon discovery — read `~/.zzz/run/daemon.json`, verify the pid is +//! alive and `/health` responds; clean up a stale record otherwise. +//! 3. Auto-start if not running — spawn `zzzd` **detached** (new process +//! group, log-file stdio), poll `/health`, record `daemon.json`. This +//! differs from `daemon start`, which runs the server in the foreground +//! and forwards signals to it. +//! 4. For a directory arg, a best-effort `workspace_open` JSON-RPC call. +//! `workspace_open` requires an authenticated account, which the CLI +//! can't supply (the daemon token is a different credential axis), so +//! this call warn-fails under auth — the authenticated browser does the +//! real open via the `?workspace=` query param below. +//! 5. Browser launch (`xdg-open` / `open` / `start`), with the +//! `?workspace=` param when a path was given. + +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use argh::FromArgs; + +use crate::CliError; +use crate::daemon_lifecycle::{self as dl, DaemonInfo, DaemonState}; + +/// Open file or directory in browser (default command). +/// +/// Accepts at most one positional path. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "open")] +pub struct Open { + /// path to open (file or directory) + #[argh(positional)] + pub path: Option, +} + +/// Handle `zzz open` (and the implicit no-subcommand default). +pub async fn cmd_open(args: &Open) -> Result<(), CliError> { + let zzz_dir = dl::zzz_dir()?; + if !zzz_dir.exists() { + return Err(CliError::NotInitialized); + } + + let info = match discover_running_daemon().await { + Some(info) => info, + None => start_daemon_detached().await?, + }; + let port = info.port; + + let target = resolve_path(args.path.as_deref()); + + if let Some(target) = target.as_deref() { + let workspace_path = if target.ends_with('/') { + target.to_string() + } else { + format!("{target}/") + }; + open_workspace_best_effort(port, &workspace_path).await; + } + + let mut url = format!("http://localhost:{port}"); + if let Some(target) = target.as_deref() { + url.push_str("/workspaces?workspace="); + url.push_str(&encode_uri_component(target)); + } + + println!("opening {url}"); + open_browser(&url).await; + Ok(()) +} + +/// Read `daemon.json` and return it only when the daemon is actually +/// running and answering `/health`. A stale or unresponsive record is +/// cleaned up so the caller can auto-start a fresh daemon. +async fn discover_running_daemon() -> Option { + match dl::get_daemon_state().await { + DaemonState::Running(info) => Some(info), + DaemonState::Stopped => None, + DaemonState::Stale(_) => { + let _ = dl::remove_daemon_info(); + None + } + DaemonState::Wedged(info) => { + // Alive but not answering `/health` — terminate it (mirroring + // `daemon stop`) so the auto-start below can rebind the port, then + // drop the stale record. Without this, the restart would collide + // with the old process still holding the port. + eprintln!( + "warning: daemon pid {} on port {} is not responding; restarting", + info.pid, info.port + ); + let _ = dl::send_sigterm(info.pid); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && dl::is_pid_alive(info.pid) { + tokio::time::sleep(Duration::from_millis(100)).await; + } + let _ = dl::remove_daemon_info(); + None + } + } +} + +/// Spawn `zzzd` detached and wait for it to report healthy, then record +/// `daemon.json`. +/// +/// Detached = a new process group (`process_group(0)`, so the daemon ignores +/// the launching terminal's Ctrl-C) with its stdio captured to +/// `~/.zzz/run/daemon.log`, and the child is never awaited (a dropped +/// `std::process::Child` is not killed) — so the daemon outlives this CLI +/// invocation. It is process-group-detached, not session-detached: a true +/// `setsid` needs `unsafe`, which the workspace forbids, so the daemon relies +/// on orphaning-to-init plus the separate process group to survive. Contrast +/// `daemon start`, which keeps the server in the foreground with inherited +/// stdio. +/// +/// The poll loop also watches for an early child exit (e.g. a bind failure +/// when a wedged old daemon still holds the port) so a failed start surfaces +/// in seconds — pointing at the log — instead of blocking the full health +/// timeout, and the captured log makes the cause diagnosable after exit. +async fn start_daemon_detached() -> Result { + use std::os::unix::process::CommandExt as _; + + let port = dl::resolve_port(None); + let child_env = dl::build_child_env(); + let bin = dl::resolve_server_bin(); + + // Capture the detached daemon's stdout/stderr to a log file — it has no + // terminal once this CLI exits, so this is what makes a boot failure + // diagnosable. Truncated on each fresh start. + let run_dir = dl::zzz_dir()?.join("run"); + std::fs::create_dir_all(&run_dir)?; + let log_path = run_dir.join("daemon.log"); + let log = std::fs::File::create(&log_path)?; + let log_err = log.try_clone()?; + + println!("starting daemon on port {port}..."); + + let mut child = std::process::Command::new(&bin) + .args(["--port", &port.to_string()]) + .envs(&child_env) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)) + .process_group(0) + .spawn() + .map_err(|e| CliError::Daemon(format!("failed to spawn {}: {e}", bin.display())))?; + let pid = child.id(); + + let startup_failed = || CliError::DaemonStartupFailed { + port, + log_path: log_path.display().to_string(), + }; + + let deadline = Instant::now() + Duration::from_millis(dl::HEALTH_TIMEOUT_MS); + let mut healthy = false; + while Instant::now() < deadline { + if dl::check_health(port).await { + healthy = true; + break; + } + // Bail out the moment the daemon exits (e.g. a bind failure) rather + // than waiting out the whole health timeout. + if child.try_wait()?.is_some() { + return Err(startup_failed()); + } + tokio::time::sleep(Duration::from_millis(dl::HEALTH_POLL_INTERVAL_MS)).await; + } + if !healthy { + let _ = dl::send_sigterm(pid); + return Err(startup_failed()); + } + + // Healthy — release the child handle (std does not kill on drop) so the + // daemon keeps running after this CLI process exits. + drop(child); + + let info = DaemonInfo { + version: 1, + pid, + port, + started: fuz_sys::rfc3339_now(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + }; + dl::write_daemon_info(&info)?; + println!( + "daemon running on http://localhost:{port} (logs: {})", + log_path.display() + ); + Ok(info) +} + +/// Best-effort `workspace_open` JSON-RPC call. Failures (including the +/// `-32001 unauthenticated` the auth-gated handler returns to the +/// credential-less CLI) only warn — the browser opens the workspace via the +/// `?workspace=` query param. +async fn open_workspace_best_effort(port: u16, workspace_path: &str) { + // reqwest uses `rustls-no-provider`; install the `ring` provider first. + fuz_sys::tls::ensure_crypto_provider(); + let client = match reqwest::Client::builder() + .timeout(Duration::from_millis(dl::HEALTH_REQUEST_TIMEOUT_MS)) + .build() + { + Ok(client) => client, + Err(e) => { + eprintln!("warning: could not build http client: {e}"); + return; + } + }; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "workspace_open", + "params": { "path": workspace_path }, + }); + let url = format!("http://localhost:{port}/api/rpc"); + match client.post(&url).json(&body).send().await { + Ok(resp) if !resp.status().is_success() => { + eprintln!("warning: workspace_open request failed: {}", resp.status()); + } + Ok(resp) => match resp.json::().await { + Ok(value) => { + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error"); + eprintln!("warning: workspace_open error: {message}"); + } else { + println!("workspace opened: {workspace_path}"); + } + } + Err(e) => eprintln!("warning: could not parse workspace_open response: {e}"), + }, + Err(e) => eprintln!("warning: failed to contact daemon: {e}"), + } +} + +/// Resolve the target path to an absolute path. Absolute (`/…`) and +/// home-relative (`~/…`) paths pass through unchanged — the daemon expands +/// `~` — and everything else is joined onto the current directory. +fn resolve_path(path: Option<&str>) -> Option { + let path = path?; + if path.starts_with('/') || path.starts_with('~') { + return Some(path.to_string()); + } + Some(std::env::current_dir().map_or_else( + |_| path.to_string(), + |cwd| format!("{}/{path}", cwd.display()), + )) +} + +/// Percent-encode a string the way JavaScript's `encodeURIComponent` does: +/// every byte except the unreserved set `A-Za-z0-9-_.!~*'()` is escaped. +fn encode_uri_component(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => out.push(byte as char), + _ => { + use std::fmt::Write as _; + // Writing to a String is infallible. + let _ = write!(out, "%{byte:02X}"); + } + } + } + out +} + +/// Open `url` in the user's browser, trying the platform openers in turn and +/// falling back to printing the URL. +async fn open_browser(url: &str) { + for opener in ["xdg-open", "open", "start"] { + let status = tokio::process::Command::new(opener) + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + if matches!(status, Ok(s) if s.success()) { + return; + } + } + println!("open in browser: {url}"); +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests panic on assertion failure by design" +)] +mod tests { + use super::*; + + #[test] + fn resolve_path_passes_through_absolute_and_home() { + assert_eq!( + resolve_path(Some("/abs/path")).as_deref(), + Some("/abs/path") + ); + assert_eq!(resolve_path(Some("~/dev")).as_deref(), Some("~/dev")); + } + + #[test] + fn resolve_path_none_is_none() { + assert_eq!(resolve_path(None), None); + } + + #[test] + fn resolve_path_relative_is_joined_onto_cwd() { + let cwd = std::env::current_dir().unwrap(); + let resolved = resolve_path(Some("foo")).unwrap(); + assert_eq!(resolved, format!("{}/foo", cwd.display())); + } + + #[test] + fn encode_uri_component_matches_js_semantics() { + // Unreserved set passes through untouched. + assert_eq!(encode_uri_component("aZ09-_.!~*'()"), "aZ09-_.!~*'()"); + // Path separators, spaces, and other bytes are percent-escaped. + assert_eq!(encode_uri_component("/home/a b"), "%2Fhome%2Fa%20b"); + assert_eq!(encode_uri_component("~/dev/"), "~%2Fdev%2F"); + } +} diff --git a/crates/zzz/src/cli/commands/status.rs b/crates/zzz/src/cli/commands/status.rs new file mode 100644 index 000000000..32c1a4f55 --- /dev/null +++ b/crates/zzz/src/cli/commands/status.rs @@ -0,0 +1,85 @@ +//! `zzz status` — show current system state. + +use argh::FromArgs; + +use crate::CliError; +use crate::daemon_lifecycle::{self as dl, DaemonInfo, DaemonState}; + +/// Show current system state (daemon, loaded workspaces, watched repos). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "status")] +pub struct Status { + /// machine-readable JSON output + #[argh(switch)] + pub json: bool, +} + +/// Handle `zzz status`. +/// +/// Reports daemon status (the same report as `zzz daemon status`). A +/// fuller summary (open workspaces, watcher state) is follow-on work. +pub async fn cmd_status(args: &Status) -> Result<(), CliError> { + report_status(args.json).await +} + +/// Read `daemon.json`, probe PID liveness + `/health`, and print a summary. +/// Cleans up a stale `daemon.json` when the recorded pid is gone. +pub async fn report_status(json: bool) -> Result<(), CliError> { + let state = dl::get_daemon_state().await; + if json { + println!("{}", status_json(&state)); + return Ok(()); + } + match state { + DaemonState::Stopped => println!("no daemon running"), + DaemonState::Running(info) => { + println!("daemon running"); + println!(" pid: {}", info.pid); + println!(" port: {}", info.port); + println!(" version: {}", info.app_version); + println!(" started: {}", info.started); + println!(" url: http://localhost:{}", info.port); + } + DaemonState::Wedged(info) => { + println!( + "daemon process alive but not responding on port {}", + info.port + ); + println!(" pid: {}", info.pid); + println!(" port: {} (not listening)", info.port); + } + DaemonState::Stale(info) => { + println!( + "stale daemon.json (pid {} not running) — cleaning up", + info.pid + ); + dl::remove_daemon_info()?; + } + } + Ok(()) +} + +/// The machine-readable status snapshot. `Stopped` is the bare `{running: +/// false}`; the others carry the recorded `DaemonInfo` plus the +/// `running`/`healthy` pair derived from the variant (the wire shape +/// `fuz_app`'s CLI expects). +fn status_json(state: &DaemonState) -> serde_json::Value { + match state { + DaemonState::Stopped => serde_json::json!({ "running": false }), + DaemonState::Running(info) => status_json_info(info, true, true), + DaemonState::Wedged(info) => status_json_info(info, true, false), + DaemonState::Stale(info) => status_json_info(info, false, false), + } +} + +fn status_json_info(info: &DaemonInfo, running: bool, healthy: bool) -> serde_json::Value { + serde_json::json!({ + "running": running, + "healthy": healthy, + "version": info.version, + "pid": info.pid, + "port": info.port, + "started": info.started, + "app_version": info.app_version, + }) +} diff --git a/crates/zzz/src/cli/commands/version.rs b/crates/zzz/src/cli/commands/version.rs new file mode 100644 index 000000000..3d9cfa7f0 --- /dev/null +++ b/crates/zzz/src/cli/commands/version.rs @@ -0,0 +1,28 @@ +//! `zzz version` — print version info. + +use argh::FromArgs; + +use crate::CliError; + +/// Show version information. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "version")] +pub struct Version {} + +/// Print `zzz v{VERSION}` to stdout. +/// +/// Shared by the `version` subcommand and the top-level `--version`/`-v` +/// switch. `CARGO_PKG_VERSION` is the `crates/zzz` package version. +pub fn print_version() { + println!("zzz v{}", env!("CARGO_PKG_VERSION")); +} + +/// Handle `zzz version`. +#[allow( + clippy::unnecessary_wraps, + reason = "uniform Result signature for the argh dispatch in main.rs" +)] +pub fn cmd_version(_args: &Version) -> Result<(), CliError> { + print_version(); + Ok(()) +} diff --git a/crates/zzz/src/cli/mod.rs b/crates/zzz/src/cli/mod.rs new file mode 100644 index 000000000..705e20e6b --- /dev/null +++ b/crates/zzz/src/cli/mod.rs @@ -0,0 +1,6 @@ +//! CLI module — argh-derived subcommand surface. +//! +//! The `TopLevel` + `Subcommand` enum live in `main.rs`; each command's +//! `FromArgs` struct and `cmd_*` handler live under `commands/`. + +pub mod commands; diff --git a/crates/zzz/src/daemon_lifecycle.rs b/crates/zzz/src/daemon_lifecycle.rs new file mode 100644 index 000000000..eb585c811 --- /dev/null +++ b/crates/zzz/src/daemon_lifecycle.rs @@ -0,0 +1,399 @@ +//! Daemon-lifecycle primitives for the zzz CLI. +//! +//! The CLI is a thin client that manages the long-running `zzz_server` +//! daemon: it spawns the binary, polls its `/health` endpoint, records a +//! `daemon.json` for discovery, and reads that file back for `status` / +//! `stop`. This module owns the pure-ish plumbing those handlers share. +//! +//! The on-disk contract mirrors `fuz_app`'s `cli/daemon.js` +//! (`~/.zzz/run/daemon.json`, `{version, pid, port, started, app_version}`) +//! so the file stays readable across the CLI's Deno-to-Rust transition. +//! It keeps a **local** `DaemonInfo`: `fuz_sys`'s daemon helpers model a +//! socket-based v2 schema (no `port`) over a UDS transport, whereas +//! `zzz_server` is HTTP/port-based and this file mirrors `fuz_app`'s v1 +//! `{port, started, app_version}` wire shape. The OS-level plumbing — PID +//! liveness, signal forwarding, RFC-3339 timestamps, crash-safe atomic +//! writes — does route through `fuz_sys` rather than being re-derived here. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::CliError; + +/// Production daemon binary name. The CLI spawns and discovers this. +/// +/// The single source of truth for the daemon binary name (the `[[bin]]` +/// target of the `zzz_server` crate), named `zzzd` for fuz/fuzd-style +/// symmetry; nothing else in the CLI hardcodes the name. +pub const DAEMON_BIN: &str = "zzzd"; + +/// Default daemon port when neither `--port`, `PORT`, nor config supplies one. +pub const DEFAULT_PORT: u16 = 4460; + +/// How long `daemon start` waits for the server to report healthy. +pub const HEALTH_TIMEOUT_MS: u64 = 30_000; + +/// Poll interval while waiting for health. +pub const HEALTH_POLL_INTERVAL_MS: u64 = 200; + +/// Per-request timeout for a single `/health` probe. +pub const HEALTH_REQUEST_TIMEOUT_MS: u64 = 2_000; + +/// On-disk daemon record at `~/.zzz/run/daemon.json`. +/// +/// Matches `fuz_app`'s `DaemonInfo` shape so the file is interchangeable +/// across the CLI transition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonInfo { + /// Schema version (currently `1`). + pub version: u32, + /// Daemon process id. + pub pid: u32, + /// HTTP port the daemon listens on. + pub port: u16, + /// ISO-8601 timestamp when the daemon started. + pub started: String, + /// `zzz` version that started the daemon. + pub app_version: String, +} + +/// `~/.zzz` — the CLI state directory. Errors if `$HOME` is unset. +pub fn zzz_dir() -> Result { + let home = std::env::var_os("HOME").ok_or(CliError::HomeUnset)?; + Ok(PathBuf::from(home).join(".zzz")) +} + +/// `~/.zzz/run/daemon.json`. +pub fn daemon_info_path() -> Result { + Ok(zzz_dir()?.join("run").join("daemon.json")) +} + +/// `~/.zzz/config.json`. +pub fn config_path() -> Result { + Ok(zzz_dir()?.join("config.json")) +} + +/// Read and parse `daemon.json`. Returns `None` when absent or unparseable. +#[must_use] +pub fn read_daemon_info() -> Option { + let path = daemon_info_path().ok()?; + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Atomically write `daemon.json`, creating `run/`. Crash-safe temp → fsync → +/// rename → parent fsync via [`fuz_sys::fs::write_atomic`]; mode `0o644` +/// (the record — pid / port / version — is not secret). +pub fn write_daemon_info(info: &DaemonInfo) -> Result<(), CliError> { + let run_dir = zzz_dir()?.join("run"); + fs::create_dir_all(&run_dir)?; + let path = run_dir.join("daemon.json"); + let mut content = + serde_json::to_string_pretty(info).map_err(|e| CliError::Daemon(e.to_string()))?; + content.push('\n'); + fuz_sys::fs::write_atomic(&path, content.as_bytes(), 0o644)?; + Ok(()) +} + +/// Remove `daemon.json` if present. Missing-file is not an error. +pub fn remove_daemon_info() -> Result<(), CliError> { + let path = daemon_info_path()?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// CLI config at `~/.zzz/config.json`. Only the daemon port today. +#[derive(Debug, Deserialize)] +struct CliConfig { + #[serde(default = "default_port")] + zzz_config_port: u16, +} + +const fn default_port() -> u16 { + DEFAULT_PORT +} + +/// Configured daemon port, or [`DEFAULT_PORT`] when unset/unreadable. +fn config_port() -> u16 { + config_path() + .ok() + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|c| serde_json::from_str::(&c).ok()) + .map_or(DEFAULT_PORT, |c| c.zzz_config_port) +} + +/// Resolve the daemon port: `--port` flag > `PORT` env > config > default. +#[must_use] +pub fn resolve_port(flag: Option) -> u16 { + if let Some(p) = flag { + return p; + } + if let Ok(s) = std::env::var("PORT") + && let Ok(p) = s.parse::() + { + return p; + } + config_port() +} + +/// Locate the `zzz_server` binary to spawn. +/// +/// `ZZZ_SERVER_BIN` override > beside the CLI exe > `~/.zzz/bin/` > +/// `./target/debug/` (dev) > bare name on `$PATH`. +#[must_use] +pub fn resolve_server_bin() -> PathBuf { + if let Some(p) = std::env::var_os("ZZZ_SERVER_BIN") { + return PathBuf::from(p); + } + let mut candidates: Vec = Vec::new(); + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + candidates.push(dir.join(DAEMON_BIN)); + } + if let Some(home) = std::env::var_os("HOME") { + candidates.push( + PathBuf::from(home) + .join(".zzz") + .join("bin") + .join(DAEMON_BIN), + ); + } + candidates.push(PathBuf::from("./target/debug").join(DAEMON_BIN)); + candidates + .into_iter() + .find(|c| c.exists()) + .unwrap_or_else(|| PathBuf::from(DAEMON_BIN)) +} + +/// Whether `pid` names a live process. +/// +/// Adapts the wire-shape `u32` pid to the `i32` [`fuz_sys::is_pid_alive`] +/// takes (a `/proc/{pid}` existence probe on Linux). A pid that overflows +/// `i32` can't name a real process, so it reads as not-alive. +#[must_use] +pub fn is_pid_alive(pid: u32) -> bool { + i32::try_from(pid).is_ok_and(fuz_sys::is_pid_alive) +} + +/// Send `SIGTERM` to `pid` via [`fuz_sys::send_signal`]. +/// +/// A process that's already gone is not an error — `stop` is idempotent; +/// only a failure to *issue* the signal surfaces. +pub fn send_sigterm(pid: u32) -> Result<(), CliError> { + let raw = i32::try_from(pid).map_err(|_| CliError::Daemon(format!("invalid pid {pid}")))?; + fuz_sys::send_signal(raw, fuz_sys::Signal::SIGTERM) + .map(|_| ()) + .map_err(|e| CliError::Daemon(format!("failed to signal pid {pid}: {e}"))) +} + +/// Probe `http://localhost:{port}/health`; `true` on a 2xx within timeout. +pub async fn check_health(port: u16) -> bool { + // reqwest uses `rustls-no-provider`; install the `ring` provider first. + fuz_sys::tls::ensure_crypto_provider(); + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_millis(HEALTH_REQUEST_TIMEOUT_MS)) + .build() + else { + return false; + }; + let url = format!("http://localhost:{port}/health"); + matches!(client.get(&url).send().await, Ok(r) if r.status().is_success()) +} + +/// Resolved liveness of the daemon described by `daemon.json`. +/// +/// Collapses the read-record → probe-pid → probe-`/health` sequence into one +/// value so each command branches on a single state instead of re-deriving a +/// `pid_alive` + `healthy` boolean pair (which drifts apart per command). The +/// payload carries the `DaemonInfo` for the cases that have one. +#[derive(Debug)] +pub enum DaemonState { + /// No `daemon.json` — nothing is recorded as running. + Stopped, + /// `daemon.json` records a pid that is no longer alive. + Stale(DaemonInfo), + /// Process is alive but not answering `/health` (wedged, or still binding). + Wedged(DaemonInfo), + /// Process is alive and answering `/health`. + Running(DaemonInfo), +} + +/// Classify the recorded daemon's liveness. Probes `/health` only when the +/// recorded pid is alive, so a stale record costs no network round-trip. +pub async fn get_daemon_state() -> DaemonState { + let Some(info) = read_daemon_info() else { + return DaemonState::Stopped; + }; + if !is_pid_alive(info.pid) { + return DaemonState::Stale(info); + } + if check_health(info.port).await { + DaemonState::Running(info) + } else { + DaemonState::Wedged(info) + } +} + +/// Parse a `.env` file into key/value pairs. Missing file → empty. +/// +/// Minimal `KEY=VALUE` parsing: blank lines and `#` comments skipped, +/// surrounding matching quotes stripped. Sufficient for passing +/// `DATABASE_URL` / `SECRET_FUZ_COOKIE_KEYS` / etc. through to the daemon. +#[must_use] +pub fn load_env_file(path: &Path) -> Vec<(String, String)> { + let Ok(content) = fs::read_to_string(path) else { + return Vec::new(); + }; + content.lines().filter_map(parse_env_line).collect() +} + +fn parse_env_line(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + let key = key.trim(); + if key.is_empty() { + return None; + } + let value = value.trim(); + let value = value + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(value); + Some((key.to_string(), value.to_string())) +} + +/// Build the child env for the daemon: inherited env, then non-overriding +/// values from the active `.env` file, then a `PUBLIC_ZZZ_DIR` default. +/// +/// `.env` is chosen when `NODE_ENV=production`, else `.env.development` +/// (matching the Deno CLI). `zzz_server` reads its config straight from env +/// (no dotenv loader of its own), so the CLI supplies it here. +#[must_use] +pub fn build_child_env() -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + let env_file = if env.get("NODE_ENV").map(String::as_str) == Some("production") { + ".env" + } else { + ".env.development" + }; + for (key, value) in load_env_file(Path::new(env_file)) { + env.entry(key).or_insert(value); + } + let home = env.get("HOME").cloned().unwrap_or_else(|| ".".to_string()); + env.entry("PUBLIC_ZZZ_DIR".to_string()) + .or_insert_with(|| format!("{home}/.zzz")); + env +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests panic on assertion failure by design" +)] +mod tests { + use super::*; + + #[test] + fn daemon_info_round_trips_and_matches_wire_shape() { + let info = DaemonInfo { + version: 1, + pid: 4242, + port: 4460, + started: "2026-05-30T12:00:00Z".to_string(), + app_version: "0.0.1".to_string(), + }; + let json = serde_json::to_value(&info).unwrap(); + // Keys must match fuz_app's `DaemonInfo` contract exactly. + assert_eq!(json["version"], 1); + assert_eq!(json["pid"], 4242); + assert_eq!(json["port"], 4460); + assert_eq!(json["started"], "2026-05-30T12:00:00Z"); + assert_eq!(json["app_version"], "0.0.1"); + let back: DaemonInfo = serde_json::from_value(json).unwrap(); + assert_eq!(back.pid, info.pid); + assert_eq!(back.port, info.port); + } + + #[test] + fn is_pid_alive_for_self_and_not_for_bogus() { + let me = std::process::id(); + assert!(is_pid_alive(me)); + // A pid this large should not exist on a normal system. + assert!(!is_pid_alive(4_000_000_000)); + } + + #[test] + fn resolve_port_prefers_flag() { + assert_eq!(resolve_port(Some(9999)), 9999); + } + + #[test] + fn parse_env_line_handles_comments_blanks_and_quotes() { + assert_eq!(parse_env_line("# comment"), None); + assert_eq!(parse_env_line(" "), None); + assert_eq!(parse_env_line("=novalue"), None); + assert_eq!( + parse_env_line("DATABASE_URL=postgres://localhost/zzz"), + Some(( + "DATABASE_URL".to_string(), + "postgres://localhost/zzz".to_string() + )) + ); + assert_eq!( + parse_env_line("KEY = \"quoted value\""), + Some(("KEY".to_string(), "quoted value".to_string())) + ); + } + + #[tokio::test] + async fn check_health_true_for_200_false_for_closed() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // A one-shot mock that answers the first connection with 200 /health. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + let mut buf = [0_u8; 1024]; + let _ = sock.read(&mut buf).await; + let body = b"{\"status\":\"ok\"}"; + let head = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + let _ = sock.write_all(head.as_bytes()).await; + let _ = sock.write_all(body).await; + let _ = sock.flush().await; + } + }); + assert!( + check_health(port).await, + "expected healthy against a 200 /health" + ); + let _ = server.await; + + // Bind then drop to obtain a port with nothing listening. + let probe = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let closed_port = probe.local_addr().unwrap().port(); + drop(probe); + assert!( + !check_health(closed_port).await, + "expected unhealthy against a closed port" + ); + } +} diff --git a/crates/zzz/src/error.rs b/crates/zzz/src/error.rs new file mode 100644 index 000000000..7f2111cde --- /dev/null +++ b/crates/zzz/src/error.rs @@ -0,0 +1,73 @@ +//! CLI error types. +//! +//! Typed variants grow per handler, carrying enough structure for +//! per-variant exit codes and user-facing hints. + +use thiserror::Error; + +/// Errors that can occur during CLI operations. +#[derive(Debug, Error)] +pub enum CliError { + /// I/O operation failed. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + /// `$HOME` is unset, so the `~/.zzz` state directory can't be resolved. + #[error("$HOME is not set")] + HomeUnset, + /// `~/.zzz` does not exist yet — the user hasn't run `zzz init`. + #[error("zzz is not initialized")] + NotInitialized, + /// The spawned daemon never reported healthy within the timeout. + #[error("zzz_server did not become healthy within {ms}ms (port {port})")] + ServerNotHealthy { + /// Port the daemon was expected to listen on. + port: u16, + /// Health-wait timeout in milliseconds. + ms: u64, + }, + /// An auto-started (detached) daemon exited or never came up. Its stdio + /// is captured to a log file (named in the message) rather than the + /// terminal, so the failure is diagnosable after the CLI exits. + #[error("daemon failed to start on port {port}; see {log_path} for details")] + DaemonStartupFailed { + /// Port the daemon was expected to listen on. + port: u16, + /// Path to the captured daemon log. + log_path: String, + }, + /// A daemon-lifecycle operation failed (spawn, signal, serialize). + #[error("daemon error: {0}")] + Daemon(String), +} + +impl CliError { + /// Process exit code for this error (for `ExitCode::from`). + #[must_use] + pub const fn exit_code(&self) -> u8 { + match self { + // Config/environment problems use 2 (matches fuz); everything + // else is a generic 1. + Self::HomeUnset | Self::NotInitialized => 2, + Self::Io(_) + | Self::ServerNotHealthy { .. } + | Self::DaemonStartupFailed { .. } + | Self::Daemon(_) => 1, + } + } + + /// User-facing hint shown after the error message, when one helps. + #[must_use] + pub const fn hint(&self) -> Option<&'static str> { + match self { + Self::HomeUnset => Some("set the $HOME environment variable"), + Self::NotInitialized => Some("run `zzz init` first"), + Self::ServerNotHealthy { .. } => Some( + "check the daemon output above; ensure DATABASE_URL and SECRET_FUZ_COOKIE_KEYS are set", + ), + Self::DaemonStartupFailed { .. } => { + Some("ensure DATABASE_URL, SECRET_FUZ_COOKIE_KEYS, and FUZ_ALLOWED_ORIGINS are set") + } + Self::Io(_) | Self::Daemon(_) => None, + } + } +} diff --git a/crates/zzz/src/main.rs b/crates/zzz/src/main.rs new file mode 100644 index 000000000..32515e8c3 --- /dev/null +++ b/crates/zzz/src/main.rs @@ -0,0 +1,191 @@ +//! zzz CLI +//! +//! Command-line client for the zzz daemon: starts/discovers it, opens the +//! browser UI, and manages its lifecycle. +//! +//! Runs on a `tokio` runtime: the daemon-lifecycle and status handlers do +//! network I/O (spawn `zzzd`, poll `/health`) and signal handling, so +//! `main` is `#[tokio::main]`. The runtime spins for sync subcommands +//! (`version`/`init`) too, which is cheap enough not to special-case. +//! +//! Arg parsing is argh, with a pre-parse argv rewrite for path-as-command +//! (`zzz ~/dev/` ⇒ `zzz open ~/dev/`). + +mod cli; +mod daemon_lifecycle; +mod error; + +use argh::FromArgs; +use std::process::ExitCode; + +pub use error::CliError; + +use crate::cli::commands::{ + daemon::{Daemon, cmd_daemon}, + init::{Init, cmd_init}, + open::{Open, cmd_open}, + status::{Status, cmd_status}, + version::{Version, cmd_version, print_version}, +}; + +/// Known subcommand names. Used by `rewrite_argv_for_path_as_command` to +/// decide whether the first positional should be treated as a path +/// argument to `open` (rewrite) or left alone for argh's subcommand +/// matcher to dispatch. +/// +/// Includes argh's own `help` token so `zzz help` is left to argh. +const KNOWN_SUBCOMMANDS: &[&str] = &["open", "init", "daemon", "status", "version", "help"]; + +/// zzz — local-first forge for power users and devs. +#[derive(FromArgs, Debug)] +struct TopLevel { + /// print version information and exit + #[argh(switch, short = 'v')] + version: bool, + + #[argh(subcommand)] + nested: Option, +} + +#[derive(FromArgs, Debug)] +#[argh(subcommand)] +enum Subcommand { + Open(Open), + Init(Init), + Daemon(Daemon), + Status(Status), + Version(Version), +} + +#[tokio::main] +async fn main() -> ExitCode { + let Err(e) = run().await else { + return ExitCode::SUCCESS; + }; + eprintln!("error: {e}"); + if let Some(hint) = e.hint() { + eprintln!("{hint}"); + } + ExitCode::from(e.exit_code()) +} + +async fn run() -> Result<(), CliError> { + let argv: Vec = std::env::args().collect(); + let cmd = parse_argv(argv); + // `--version` / `-v` short-circuits before any subcommand dispatch (and + // before the no-subcommand `open` default). + if cmd.version { + print_version(); + return Ok(()); + } + // No subcommand → default to `open` with no path, matching the Deno CLI. + let Some(sub) = cmd.nested else { + return cmd_open(&Open { path: None }).await; + }; + match sub { + Subcommand::Open(args) => cmd_open(&args).await, + Subcommand::Init(args) => cmd_init(&args), + Subcommand::Daemon(args) => cmd_daemon(args).await, + Subcommand::Status(args) => cmd_status(&args).await, + Subcommand::Version(args) => cmd_version(&args), + } +} + +/// Parse argv into `TopLevel`, applying the path-as-command rewrite. +/// +/// If the first positional isn't a known subcommand (and isn't a flag), +/// inject `open` so argh routes it to the open handler with the original +/// token as a positional argument. This lets `zzz ~/dev/` behave like +/// `zzz open ~/dev/`. +fn parse_argv(argv: Vec) -> TopLevel { + let rewritten = rewrite_argv_for_path_as_command(argv); + let arg_strs: Vec<&str> = rewritten.iter().map(String::as_str).collect(); + let (cmd_name, args) = arg_strs.split_at(1); + match TopLevel::from_args(cmd_name, args) { + Ok(cmd) => cmd, + Err(early_exit) => { + // argh signals --help / --version success via Ok(()); parse + // errors via Err(()). Match argh::from_env's exit behavior. + let code = if early_exit.status.is_ok() { + println!("{}", early_exit.output); + 0 + } else { + eprintln!("{}", early_exit.output); + 1 + }; + std::process::exit(code); + } + } +} + +/// If argv[1] looks like a path rather than a known subcommand, inject +/// `open` at position 1 so argh dispatches via the `Open` handler. +/// +/// Leaves `--flag`-style tokens alone (argh handles `--help` / `-h` +/// natively, and the `--version` / `-v` switch on `TopLevel` is matched by +/// argh and short-circuited in `run`) and leaves `help` alone (argh's +/// built-in help keyword). +fn rewrite_argv_for_path_as_command(mut argv: Vec) -> Vec { + let needs_rewrite = argv.get(1).is_some_and(|first| { + !first.starts_with('-') && !KNOWN_SUBCOMMANDS.contains(&first.as_str()) + }); + if needs_rewrite { + argv.insert(1, "open".to_string()); + } + argv +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(args: &[&str]) -> Vec { + std::iter::once("zzz") + .chain(args.iter().copied()) + .map(String::from) + .collect() + } + + #[test] + fn no_rewrite_when_argv_is_bare() { + assert_eq!(rewrite_argv_for_path_as_command(argv(&[])), argv(&[])); + } + + #[test] + fn no_rewrite_when_first_is_known_subcommand() { + for sub in KNOWN_SUBCOMMANDS { + let input = argv(&[sub]); + assert_eq!(rewrite_argv_for_path_as_command(input.clone()), input); + } + } + + #[test] + fn no_rewrite_when_first_starts_with_dash() { + for flag in ["--help", "-h", "--version", "-v"] { + let input = argv(&[flag]); + assert_eq!(rewrite_argv_for_path_as_command(input.clone()), input); + } + } + + #[test] + fn rewrites_path_to_open() { + assert_eq!( + rewrite_argv_for_path_as_command(argv(&["~/dev/"])), + argv(&["open", "~/dev/"]), + ); + assert_eq!( + rewrite_argv_for_path_as_command(argv(&["./foo.ts"])), + argv(&["open", "./foo.ts"]), + ); + } + + #[test] + fn rewrites_only_first_positional() { + // Subsequent positionals are passed through verbatim — the rewrite + // only injects `open` once at position 1. + assert_eq!( + rewrite_argv_for_path_as_command(argv(&["./foo", "bar"])), + argv(&["open", "./foo", "bar"]), + ); + } +} diff --git a/crates/zzz/tests/cli_daemon.rs b/crates/zzz/tests/cli_daemon.rs new file mode 100644 index 000000000..7c08c02a2 --- /dev/null +++ b/crates/zzz/tests/cli_daemon.rs @@ -0,0 +1,94 @@ +//! Binary-level integration tests for `zzz daemon` / `zzz status`. +//! +//! `zzz` is a bin-only crate, so integration tests can't import its +//! modules — they drive the compiled binary (`CARGO_BIN_EXE_zzz`) against +//! an isolated temp `$HOME`. These cover the status read-back paths +//! (no-daemon, stale-daemon cleanup) without needing a real `zzz_server` +//! or a database. The full `daemon start` ↔ live-server e2e (which needs +//! Postgres) is left to a later, infra-gated test. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "integration test: panics are assertion failures by design" +)] + +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const fn zzz_bin() -> &'static str { + env!("CARGO_BIN_EXE_zzz") +} + +/// A unique temp dir to use as `$HOME` (no `tempfile` dep in this crate). +fn temp_home(tag: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir().join(format!("zzz_cli_it_{}_{tag}_{nonce}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn daemon_status_json_reports_not_running_when_no_daemon() { + let home = temp_home("none"); + let out = Command::new(zzz_bin()) + .args(["daemon", "status", "--json"]) + .env("HOME", &home) + .output() + .expect("run zzz daemon status"); + + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("\"running\":false"), + "expected running:false, got: {stdout}" + ); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn daemon_status_cleans_up_stale_daemon_json() { + let home = temp_home("stale"); + let run_dir = home.join(".zzz").join("run"); + fs::create_dir_all(&run_dir).unwrap(); + let daemon_json = run_dir.join("daemon.json"); + // A valid-i32 pid that is not a running process. + fs::write( + &daemon_json, + r#"{"version":1,"pid":2000000000,"port":59999,"started":"2026-05-30T00:00:00Z","app_version":"test"}"#, + ) + .unwrap(); + + let out = Command::new(zzz_bin()) + .args(["daemon", "status"]) // non-json path performs stale cleanup + .env("HOME", &home) + .output() + .expect("run zzz daemon status"); + + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("stale"), + "expected a stale notice, got: {stdout}" + ); + assert!( + !daemon_json.exists(), + "stale daemon.json should have been cleaned up" + ); + + let _ = fs::remove_dir_all(&home); +} diff --git a/crates/zzz/tests/cli_e2e.rs b/crates/zzz/tests/cli_e2e.rs new file mode 100644 index 000000000..db11b93b3 --- /dev/null +++ b/crates/zzz/tests/cli_e2e.rs @@ -0,0 +1,158 @@ +//! End-to-end test: `zzz daemon start` ↔ a live `testing_zzz_server`. +//! +//! Unlike `cli_daemon.rs` (which only exercises the daemon.json read-back +//! paths with no real server), this drives the full lifecycle against a +//! spawned backend: `daemon start` → poll `daemon status --json` until +//! healthy → `daemon stop` → assert `daemon.json` is gone. +//! +//! Infra-gated like the cross-backend vitest projects: it self-skips unless +//! `ZZZ_TEST_E2E=1`. When opted in, it needs: +//! - a built `testing_zzzd` binary (the fast-argon2 test binary; +//! `cargo build -p testing_zzz_server`), found beside the `zzz` test +//! binary or via `ZZZ_SERVER_BIN`; +//! - a reachable `PostgreSQL` DB (`DATABASE_URL`, default +//! `postgres://localhost/zzz_test`). +//! +//! Run it with: +//! ```bash +//! cargo build -p testing_zzz_server +//! ZZZ_TEST_E2E=1 cargo test -p zzz --test cli_e2e -- --nocapture +//! ``` + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "integration test: panics are assertion failures by design" +)] + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const fn zzz_bin() -> &'static str { + env!("CARGO_BIN_EXE_zzz") +} + +/// The `testing_zzzd` binary: `ZZZ_SERVER_BIN` override, else beside the +/// `zzz` test binary (same `target//` dir). +fn testing_server_bin() -> PathBuf { + if let Some(path) = std::env::var_os("ZZZ_SERVER_BIN") { + return PathBuf::from(path); + } + PathBuf::from(zzz_bin()) + .parent() + .expect("zzz bin has a parent dir") + .join("testing_zzzd") +} + +/// A unique temp dir to use as `$HOME` (no `tempfile` dep in this crate). +fn temp_home(tag: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let dir = + std::env::temp_dir().join(format!("zzz_cli_e2e_{}_{tag}_{nonce}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn daemon_start_status_stop_against_live_server() { + if std::env::var_os("ZZZ_TEST_E2E").is_none() { + eprintln!("skipping cli_e2e: set ZZZ_TEST_E2E=1 (needs Postgres + testing_zzz_server)"); + return; + } + + let server_bin = testing_server_bin(); + assert!( + server_bin.exists(), + "testing_zzzd not found at {} — run `cargo build -p testing_zzz_server` or set ZZZ_SERVER_BIN", + server_bin.display() + ); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://localhost/zzz_test".to_string()); + let home = temp_home("lifecycle"); + // Take a free port from the OS (bind :0, read it, release) so concurrent + // runs don't collide on a pid-derived guess. A small TOCTOU window remains + // before the daemon rebinds it, which is acceptable for a gated e2e. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral port"); + listener.local_addr().expect("read local_addr").port() + }; + + // `zzz daemon start` runs the server in the foreground, so spawn it as a + // child and drive `status` / `stop` against it from this test process. + let mut start = Command::new(zzz_bin()) + .args(["daemon", "start", "--port", &port.to_string()]) + .env("HOME", &home) + .env("ZZZ_SERVER_BIN", &server_bin) + .env("DATABASE_URL", &database_url) + .env( + "SECRET_FUZ_COOKIE_KEYS", + "dev-only-not-for-production-use-000", + ) + .env("FUZ_ALLOWED_ORIGINS", "http://localhost:*") + .env_remove("PORT") // ensure --port wins over any inherited PORT + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn `zzz daemon start`"); + + // Poll `daemon status --json` until the server is up (or give up). + let deadline = Instant::now() + Duration::from_secs(40); + let mut healthy = false; + while Instant::now() < deadline { + if let Some(code) = start.try_wait().expect("try_wait on start child") { + panic!("`zzz daemon start` exited early with {code} before becoming healthy"); + } + let out = Command::new(zzz_bin()) + .args(["daemon", "status", "--json"]) + .env("HOME", &home) + .output() + .expect("run `zzz daemon status --json`"); + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("\"running\":true") && stdout.contains("\"healthy\":true") { + healthy = true; + break; + } + std::thread::sleep(Duration::from_millis(300)); + } + assert!(healthy, "daemon never reported healthy within the timeout"); + + // Stop it via the CLI and confirm a clean shutdown report. + let stop = Command::new(zzz_bin()) + .args(["daemon", "stop"]) + .env("HOME", &home) + .output() + .expect("run `zzz daemon stop`"); + assert!( + stop.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&stop.stderr) + ); + let stop_stdout = String::from_utf8_lossy(&stop.stdout); + assert!( + stop_stdout.contains("stopped") || stop_stdout.contains("SIGTERM"), + "unexpected stop output: {stop_stdout}" + ); + + // `daemon.json` should be cleaned up once the server exits (by `stop` + // and/or the `daemon start` foreground loop's own cleanup). + let daemon_json = home.join(".zzz").join("run").join("daemon.json"); + let gone_deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < gone_deadline && daemon_json.exists() { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !daemon_json.exists(), + "daemon.json should be removed after stop" + ); + + // Reap the foreground `start` process (it exits when its child dies). + let _ = start.kill(); + let _ = start.wait(); + let _ = fs::remove_dir_all(&home); +} diff --git a/crates/zzz_server/Cargo.toml b/crates/zzz_server/Cargo.toml new file mode 100644 index 000000000..6ec1658c7 --- /dev/null +++ b/crates/zzz_server/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "zzz_server" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "zzz_server" +path = "src/lib.rs" + +[[bin]] +name = "zzzd" +path = "src/main.rs" + +[dependencies] +fuz_sys = { workspace = true, features = ["logging", "tls"] } +fuz_db.workspace = true +fuz_auth.workspace = true +fuz_http.workspace = true +fuz_realtime.workspace = true +fuz_actions.workspace = true +tokio.workspace = true +axum.workspace = true +axum-extra.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +futures-util.workspace = true +tokio-util.workspace = true +parking_lot.workspace = true +tokio-postgres.workspace = true +deadpool-postgres.workspace = true +hmac.workspace = true +sha2.workspace = true +blake3.workspace = true +base64.workspace = true +uuid.workspace = true +argon2.workspace = true +notify.workspace = true +fuz_pty.workspace = true +reqwest.workspace = true +libc = "0.2" + +[lints] +workspace = true diff --git a/crates/zzz_server/src/error.rs b/crates/zzz_server/src/error.rs new file mode 100644 index 000000000..7bbff20bc --- /dev/null +++ b/crates/zzz_server/src/error.rs @@ -0,0 +1,18 @@ +use std::net::SocketAddr; + +/// Server-level errors for startup and runtime. +#[derive(Debug, thiserror::Error)] +pub enum ServerError { + #[error("failed to bind to {addr}")] + Bind { + addr: SocketAddr, + #[source] + source: std::io::Error, + }, + #[error("server error")] + Serve(#[source] std::io::Error), + #[error("database error: {0}")] + Database(String), + #[error("configuration error: {0}")] + Config(String), +} diff --git a/crates/zzz_server/src/filer.rs b/crates/zzz_server/src/filer.rs new file mode 100644 index 000000000..64d294576 --- /dev/null +++ b/crates/zzz_server/src/filer.rs @@ -0,0 +1,750 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{Stream, StreamExt, stream}; +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use serde_json::Value; +use tokio::sync::{RwLock, mpsc}; +use tokio::time::Instant; + +use crate::handlers::App; + +// -- Indexing limits ---------------------------------------------------------- + +/// Max bytes of file content held in the in-memory index. Anything above +/// this skips `read_to_string` and stores `contents: None`. Protects RSS +/// against lockfiles, generated artifacts, or large binaries that the +/// watcher otherwise would happily pull into memory. +/// +/// TODO @parity: align with `fuz_app`'s equivalent cap when it lands. +const MAX_INDEXED_FILE_SIZE: u64 = 4 * 1024 * 1024; + +/// Cap on concurrent `read_to_string` calls during a directory scan. +/// File reads block on disk + utf-8 validation; without a cap a large +/// tree would unbound the in-flight set and exhaust fd budgets on small +/// workstations. +const MAX_CONCURRENT_FILE_READS: usize = 32; + +// -- Notification params ------------------------------------------------------ + +/// Params for `filer_change` `remote_notification`. +/// +/// Matches the TypeScript `filer_change_action_spec` input schema: +/// `{ change: DiskfileChange, disknode: SerializableDisknode }`. +#[derive(Serialize)] +struct FilerChangeParams { + change: DiskfileChange, + disknode: SerializableDisknode, +} + +/// Matches `DiskfileChange` from `diskfile_types.ts`. +#[derive(Serialize, Clone)] +struct DiskfileChange { + #[serde(rename = "type")] + change_type: String, + path: String, +} + +/// Matches `SerializableDisknode` from `diskfile_types.ts`. +/// +/// Simplified — `dependents` and `dependencies` are always empty (no +/// dependency tracking in the Rust backend). +#[derive(Serialize, Clone)] +pub struct SerializableDisknode { + pub id: String, + pub source_dir: String, + pub contents: Option, + pub ctime: Option, + pub mtime: Option, + pub dependents: Vec, + pub dependencies: Vec, +} + +// -- Default ignored directories ---------------------------------------------- + +/// Directories always ignored by all watchers. Individual filers +/// can add extra ignores on top of these via `FilerConfig`. +const DEFAULT_IGNORED_DIRS: &[&str] = &[".git", "node_modules", ".svelte-kit", "target", "dist"]; + +/// Check if a single directory name is in the ignore lists. +fn is_ignored_name(name: &str, extra_ignores: &[String]) -> bool { + DEFAULT_IGNORED_DIRS.contains(&name) || extra_ignores.iter().any(|ig| ig == name) +} + +/// Check if a path contains any ignored directory component below `source_dir`. +/// +/// Only checks components after the `source_dir` prefix — root path segments +/// like `/`, `home`, `user` can never match ignored names and are skipped. +fn is_ignored(path: &Path, source_dir: &Path, extra_ignores: &[String]) -> bool { + let suffix = path.strip_prefix(source_dir).unwrap_or(path); + suffix.components().any(|c| { + let s = c.as_os_str().to_str().unwrap_or(""); + is_ignored_name(s, extra_ignores) + }) +} + +// -- File metadata helpers ---------------------------------------------------- + +/// Convert a `SystemTime` to milliseconds since epoch (matching JS `Date` format). +fn system_time_to_ms(t: std::time::SystemTime) -> Option { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs_f64() * 1000.0) +} + +/// Construct a `SerializableDisknode` from pre-read components. +fn make_disknode( + id: String, + source_dir: &str, + contents: Option, + ctime: Option, + mtime: Option, +) -> SerializableDisknode { + SerializableDisknode { + id, + source_dir: source_dir.to_owned(), + contents, + ctime, + mtime, + dependents: vec![], + dependencies: vec![], + } +} + +/// Build a `SerializableDisknode` for a watcher event, reading metadata and +/// contents on blocking threads (never blocks the tokio runtime). +/// +/// Honours [`MAX_INDEXED_FILE_SIZE`]: oversized files keep their metadata +/// but store `contents: None`, matching the size-cap behavior of the +/// initial scan path. +async fn build_disknode( + file_path: &Path, + source_dir: &str, + is_delete: bool, +) -> SerializableDisknode { + let path_str = file_path.to_string_lossy().to_string(); + + if is_delete { + return make_disknode(path_str, source_dir, None, None, None); + } + + let path_owned = file_path.to_path_buf(); + // Read metadata first so we can short-circuit oversized files instead + // of pulling them through `read_to_string`. + let meta = tokio::task::spawn_blocking({ + let p = path_owned.clone(); + move || std::fs::metadata(&p).ok() + }) + .await + .ok() + .flatten(); + + let ctime = meta + .as_ref() + .and_then(|m| m.created().ok()) + .and_then(system_time_to_ms); + let mtime = meta + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(system_time_to_ms); + let is_dir = meta.as_ref().is_some_and(std::fs::Metadata::is_dir); + let size = meta.as_ref().map_or(0, std::fs::Metadata::len); + + let contents = if is_dir || size > MAX_INDEXED_FILE_SIZE { + None + } else { + tokio::task::spawn_blocking(move || std::fs::read_to_string(&path_owned).ok()) + .await + .ok() + .flatten() + }; + + make_disknode(path_str, source_dir, contents, ctime, mtime) +} + +// -- Event → notification mapping --------------------------------------------- + +/// Map a notify `EventKind` to a `DiskfileChangeType` string. +/// +/// Returns `None` for events we don't care about (access, other). +const fn event_kind_to_change_type(kind: EventKind) -> Option<&'static str> { + match kind { + EventKind::Create(_) => Some("add"), + EventKind::Modify(_) => Some("change"), + EventKind::Remove(_) => Some("delete"), + _ => None, + } +} + +// -- Debouncing --------------------------------------------------------------- + +/// Window for coalescing rapid events on the same path. +const DEBOUNCE_DURATION: Duration = Duration::from_millis(80); + +/// A pending debounced notification (broadcast only — index updates are immediate). +struct PendingNotification { + change_type: &'static str, + deadline: Instant, + disknode: SerializableDisknode, +} + +// -- Filer configuration ------------------------------------------------------ + +/// Per-filer configuration controlling which directories to ignore. +pub struct FilerConfig { + /// Extra directory names to ignore beyond the defaults. + /// For workspace watchers this includes `.zzz`; for the `zzz_dir` + /// watcher this is empty so it can see its own files. + pub extra_ignores: Vec, +} + +impl FilerConfig { + /// Config for the `zzz_dir` watcher — no extra ignores, since it needs + /// to see files inside the zzz directory. + pub const fn zzz_dir() -> Self { + Self { + extra_ignores: vec![], + } + } + + /// Config for workspace and `scoped_dir` watchers — ignores the zzz + /// directory name to avoid duplicate events when `zzz_dir` is nested + /// under a watched directory. + /// + /// Derives the ignore name from the actual `zzz_dir` path (e.g. `.zzz` + /// from `/home/user/.zzz/`) so it works with custom `PUBLIC_ZZZ_DIR`. + pub fn workspace(zzz_dir: &str) -> Self { + let zzz_dir_name = Path::new(zzz_dir.trim_end_matches('/')) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(".zzz") + .to_owned(); + Self { + extra_ignores: vec![zzz_dir_name], + } + } +} + +// -- Filer (replaces WorkspaceWatcher) ---------------------------------------- + +/// Watches a directory for file changes, maintains an in-memory file index, +/// and broadcasts `filer_change` notifications to WebSocket clients. +/// +/// Dropped when the filer is stopped (notify cleans up on Drop, +/// the tokio task is aborted). +pub struct Filer { + /// Held to keep the notify watcher alive — dropped when the filer stops. + _watcher: RecommendedWatcher, + /// Background task processing watcher events. + task: tokio::task::JoinHandle<()>, + /// In-memory file index — path → disknode. Updated by watcher events + /// and initial scan. Read by `session_load`. + pub files: Arc>>, + /// Watched directory path — retained so `rescan` can re-walk the tree. + source_dir: String, + /// Ignored directory names — retained for `rescan`. + extra_ignores: Vec, +} + +impl Drop for Filer { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Start watching a directory, perform an initial file scan, and return a `Filer`. +/// +/// The initial scan populates the file index before returning, so callers +/// can immediately read from `filer.files`. The background task then +/// keeps the index updated and broadcasts changes. +pub async fn start_filer( + path: &str, + app: Arc, + config: FilerConfig, +) -> Result { + let (tx, rx) = mpsc::channel::(256); + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.try_send(event); + } + }, + notify::Config::default(), + )?; + + watcher.watch(Path::new(path), RecursiveMode::Recursive)?; + + let source_dir = path.to_owned(); + let files: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + + // Initial scan — populate the file index + let mut initial_files = HashMap::new(); + scan_directory( + &source_dir, + &source_dir, + &config.extra_ignores, + &mut initial_files, + ) + .await; + { + let mut index = files.write().await; + *index = initial_files; + } + + let files_clone = Arc::clone(&files); + let extra_ignores = config.extra_ignores; + let task = tokio::spawn(filer_event_loop( + rx, + source_dir.clone(), + extra_ignores.clone(), + files_clone, + app, + )); + + Ok(Filer { + _watcher: watcher, + task, + files, + source_dir, + extra_ignores, + }) +} + +/// One file discovered by the walk phase — input to the read phase. +struct FileJob { + path: PathBuf, + path_str: String, + ctime: Option, + mtime: Option, + size: u64, +} + +/// In-progress directory walk state for [`walk_files`]. +/// +/// `current` holds the open readdir handle for the directory currently +/// being drained; `dir_stack` holds the not-yet-visited directories. +/// Subdirectories discovered while draining `current` are pushed onto +/// the stack so traversal stays depth-first (matches the previous +/// `Box::pin` recursion order — important for deterministic file +/// ordering on the cold-start path). +struct WalkState { + dir_stack: Vec, + current: Option, + extra_ignores: Vec, +} + +/// Stream of file jobs discovered by walking `root` recursively. +/// +/// Streaming (vs. pre-collecting into `Vec`) keeps peak memory +/// flat in tree size: only the active readdir handle + dir-stack + +/// in-flight `buffer_unordered` futures are resident at any moment. +/// For a 100k-file tree the pre-collected list was ~10 MB of `FileJob`s +/// resident before any read fired; this version caps at +/// `MAX_CONCURRENT_FILE_READS` (32) in-flight jobs. +/// +/// Implementation uses `stream::unfold` rather than spawning a producer +/// task — no `mpsc` channel, no extra `tokio::spawn`, and the walker +/// runs on the same task as the consumer so cancellation propagates +/// naturally when the consumer drops the stream. +fn walk_files(root: String, extra_ignores: Vec) -> impl Stream { + let state = WalkState { + dir_stack: vec![root], + current: None, + extra_ignores, + }; + stream::unfold(state, |mut state| async move { + loop { + // Ensure a current readdir handle. Pop dirs off the stack + // until one opens successfully or the stack is empty. + while state.current.is_none() { + let Some(dir) = state.dir_stack.pop() else { + return None; + }; + state.current = tokio::fs::read_dir(&dir).await.ok(); + } + + // Pull the next entry. Scope the &mut borrow so we can + // mutate `state.dir_stack` on the dir-discovery branch. + let entry_result = match state.current.as_mut() { + Some(entries) => entries.next_entry().await, + None => continue, + }; + + match entry_result { + Ok(Some(entry)) => { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && is_ignored_name(name, &state.extra_ignores) + { + continue; + } + let Ok(meta) = tokio::fs::metadata(&path).await else { + continue; + }; + if meta.is_dir() { + let mut dir_path = path.to_string_lossy().into_owned(); + if !dir_path.ends_with('/') { + dir_path.push('/'); + } + state.dir_stack.push(dir_path); + continue; + } + let path_str = path.to_string_lossy().into_owned(); + let ctime = meta.created().ok().and_then(system_time_to_ms); + let mtime = meta.modified().ok().and_then(system_time_to_ms); + let job = FileJob { + path, + path_str, + ctime, + mtime, + size: meta.len(), + }; + return Some((job, state)); + } + Ok(None) => { + // Directory exhausted — drop handle, pop next on + // the outer loop iteration. + state.current = None; + } + Err(_) => { + // Per-entry read failure (e.g. permission denied on + // a single dirent); skip and keep draining. + } + } + } + }) +} + +/// Recursively scan a directory and populate the file map. +/// +/// Walks the tree and reads files concurrently in a single pipeline: +/// [`walk_files`] streams `FileJob`s as directories are discovered, and +/// `buffer_unordered` fans out up to [`MAX_CONCURRENT_FILE_READS`] +/// `read_to_string` calls at a time. Files over +/// [`MAX_INDEXED_FILE_SIZE`] skip the read and store `contents: None`. +/// +/// Streaming the walker (vs. pre-collecting into a `Vec`) +/// keeps peak memory flat in tree size — the reader starts firing +/// while the walker is still discovering files, instead of waiting +/// for the entire tree to be enumerated. Matters on the hot path: +/// `FilerManager::rescan_all` runs on every `session_load`. +/// +/// Called by `start_filer` (cold path) and `FilerManager::rescan_all` +/// (hot path). +async fn scan_directory( + dir: &str, + source_dir: &str, + extra_ignores: &[String], + files: &mut HashMap, +) { + let source_dir_owned = source_dir.to_owned(); + let walker = walk_files(dir.to_owned(), extra_ignores.to_owned()); + let stream = walker + .map(|job| { + let source_dir = source_dir_owned.clone(); + async move { + let contents = if job.size > MAX_INDEXED_FILE_SIZE { + None + } else { + tokio::fs::read_to_string(&job.path).await.ok() + }; + let disknode = make_disknode( + job.path_str.clone(), + &source_dir, + contents, + job.ctime, + job.mtime, + ); + (job.path_str, disknode) + } + }) + .buffer_unordered(MAX_CONCURRENT_FILE_READS); + // `stream::unfold`'s state future is `!Unpin`, so the composed + // stream needs pinning before `.next()`. `std::pin::pin!` puts it + // on the local stack — no heap allocation. + let mut stream = std::pin::pin!(stream); + + while let Some((path_str, disknode)) = stream.next().await { + files.insert(path_str, disknode); + } +} + +/// Background event loop: receives notify events, debounces them, updates +/// the file index, and broadcasts `filer_change` notifications. +async fn filer_event_loop( + mut rx: mpsc::Receiver, + source_dir: String, + extra_ignores: Vec, + files: Arc>>, + app: Arc, +) { + let source_dir_path = Path::new(&source_dir); + // Pending notifications — index updates happen immediately, but + // filer_change broadcasts are debounced to avoid flooding clients. + let mut pending: HashMap = HashMap::new(); + + loop { + // If we have pending notifications, wait until the nearest deadline or a new event + let timeout = pending + .values() + .map(|p| p.deadline) + .min() + .map(|deadline| deadline.saturating_duration_since(Instant::now())); + + let event = if let Some(timeout) = timeout { + tokio::select! { + biased; + e = rx.recv() => e, + () = tokio::time::sleep(timeout) => None, + } + } else { + rx.recv().await + }; + + match event { + Some(event) => { + let Some(change_type) = event_kind_to_change_type(event.kind) else { + continue; + }; + + for file_path in event.paths { + if is_ignored(&file_path, source_dir_path, &extra_ignores) { + continue; + } + + let is_delete = change_type == "delete"; + + // Skip directory events — we only index files. + if !is_delete + && let Ok(meta) = tokio::fs::metadata(&file_path).await + && meta.is_dir() + { + continue; + } + + let disknode = build_disknode(&file_path, &source_dir, is_delete).await; + + // Update the file index immediately so reads always + // see the latest state (no debounce on the index). + { + let mut index = files.write().await; + if is_delete { + index.remove(&disknode.id); + } else { + index.insert(disknode.id.clone(), disknode.clone()); + } + } + + // Debounce the notification broadcast + let deadline = Instant::now() + DEBOUNCE_DURATION; + pending + .entry(file_path) + .and_modify(|p| { + // Extend the deadline but preserve "add" — a Create + // followed by Modify should still be seen as "add" + // by clients (the file is new). + p.deadline = deadline; + p.disknode = disknode.clone(); + if p.change_type != "add" { + p.change_type = change_type; + } + }) + .or_insert(PendingNotification { + change_type, + deadline, + disknode, + }); + } + } + None => { + // Channel closed or timeout fired — flush ready notifications + if pending.is_empty() { + // Channel truly closed (no pending, no new events) + break; + } + } + } + + // Flush notifications whose deadline has passed + let now = Instant::now(); + let ready: Vec<(PathBuf, PendingNotification)> = + pending.extract_if(|_, p| p.deadline <= now).collect(); + + for (_, event) in ready { + let params = FilerChangeParams { + change: DiskfileChange { + change_type: event.change_type.to_owned(), + path: event.disknode.id.clone(), + }, + disknode: event.disknode, + }; + let notification = fuz_http::notification("filer_change", ¶ms); + app.broadcast(¬ification); + } + } +} + +// -- FilerManager ------------------------------------------------------------- + +/// Whether a filer was started at server startup (permanent) or via +/// `workspace_open` (can be stopped on `workspace_close`). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FilerLifetime { + /// Started at server startup for `zzz_dir` or `scoped_dirs` — never stopped. + Permanent, + /// Started via `workspace_open` — stopped on `workspace_close`. + Workspace, +} + +/// Entry in the filer manager. +pub struct FilerEntry { + pub filer: Filer, + pub lifetime: FilerLifetime, +} + +/// Manages all active filers with deduplication and lifetime tracking. +/// +/// One filer per unique directory path. Permanent filers (`zzz_dir`, `scoped_dirs`) +/// survive `workspace_close`. Workspace filers are stopped on close. +pub struct FilerManager { + filers: RwLock>, +} + +impl FilerManager { + pub fn new() -> Self { + Self { + filers: RwLock::new(HashMap::new()), + } + } + + /// Start a filer for the given directory path. Returns `Ok(true)` if a new + /// filer was created, `Ok(false)` if one already existed for this path. + /// + /// If a filer already exists, its lifetime is upgraded to `Permanent` if + /// the new request is `Permanent` (but never downgraded). + pub async fn start_filer( + &self, + path: &str, + app: Arc, + config: FilerConfig, + lifetime: FilerLifetime, + ) -> Result { + debug_assert!( + path.ends_with('/'), + "FilerManager paths must have trailing slash: {path}" + ); + + // Fast path — already watching + { + let filers = self.filers.read().await; + if let Some(entry) = filers.get(path) { + // Upgrade lifetime if needed (workspace → permanent) + if lifetime == FilerLifetime::Permanent + && entry.lifetime == FilerLifetime::Workspace + { + drop(filers); + let mut filers = self.filers.write().await; + if let Some(entry) = filers.get_mut(path) { + entry.lifetime = FilerLifetime::Permanent; + } + } + return Ok(false); + } + } + + // Create new filer + let filer = start_filer(path, app, config).await?; + + let mut filers = self.filers.write().await; + // Double-check in case another task raced us + if filers.contains_key(path) { + // Filer was created by another task between our read and write + return Ok(false); + } + filers.insert(path.to_owned(), FilerEntry { filer, lifetime }); + Ok(true) + } + + /// Stop and remove a filer for the given path. Only stops workspace-scoped + /// filers — permanent filers are preserved. + /// + /// Returns `true` if the filer was actually stopped. + pub async fn stop_filer(&self, path: &str) -> bool { + debug_assert!( + path.ends_with('/'), + "FilerManager paths must have trailing slash: {path}" + ); + let mut filers = self.filers.write().await; + if let Some(entry) = filers.get(path) { + if entry.lifetime == FilerLifetime::Permanent { + return false; + } + filers.remove(path); + true + } else { + false + } + } + + /// Rescan every active filer's watched directory and replace its index. + /// + /// Called by `session_load` before `collect_all_files` to guarantee a + /// consistent snapshot — notify events are eventually consistent, so a + /// just-written file may not yet be in the index when the event loop is + /// still draining. A direct filesystem walk sidesteps that race. + pub async fn rescan_all(&self) { + // Snapshot what we need under the outer lock, then rescan without + // holding it — the outer lock blocks start_filer/stop_filer. + type FilerRescanEntry = ( + String, + Vec, + Arc>>, + ); + let entries: Vec = { + let filers = self.filers.read().await; + filers + .values() + .map(|e| { + ( + e.filer.source_dir.clone(), + e.filer.extra_ignores.clone(), + Arc::clone(&e.filer.files), + ) + }) + .collect() + }; + for (source_dir, extra_ignores, files) in entries { + let mut fresh = HashMap::new(); + scan_directory(&source_dir, &source_dir, &extra_ignores, &mut fresh).await; + let mut index = files.write().await; + *index = fresh; + } + } + + /// Collect all files from all filers into a single Vec. + /// Used by `session_load` to return the complete file listing. + pub async fn collect_all_files(&self) -> Vec { + // Collect Arc handles under the outer lock, then release it before + // awaiting the inner per-filer locks — avoids holding the manager + // lock across await points (which would block start_filer/stop_filer). + let file_maps: Vec>>> = { + let filers = self.filers.read().await; + filers + .values() + .map(|e| Arc::clone(&e.filer.files)) + .collect() + }; + + let mut all_files = Vec::new(); + for files in &file_maps { + let index = files.read().await; + all_files.extend(index.values().cloned()); + } + all_files + } +} diff --git a/crates/zzz_server/src/handlers/core.rs b/crates/zzz_server/src/handlers/core.rs new file mode 100644 index 000000000..640d4b838 --- /dev/null +++ b/crates/zzz_server/src/handlers/core.rs @@ -0,0 +1,132 @@ +//! Core handlers — `ping` (public health check) and `session_load` +//! (authenticated initial-state load). +//! +//! Spine-backed signature: `(Value, ActionContext<'_>, Arc) -> +//! Result`. `ping` is public (no auth); `session_load` +//! is authenticated and returns the initial state envelope (open workspaces, +//! zzz_dir contents, scoped_dirs, provider status). + +use std::sync::Arc; + +use fuz_actions::ActionContext; +use fuz_http::{JsonrpcError, internal_error_with_source, invalid_params}; +use serde::Serialize; +use serde_json::Value; + +use crate::handlers::{App, WorkspaceInfo}; + +#[derive(Serialize)] +struct PingResult { + ping_id: Value, +} + +#[derive(Serialize)] +struct TestingEmitNotificationsResult { + count: u64, +} + +#[derive(Serialize)] +struct SessionLoadData { + files: Vec, + zzz_dir: String, + scoped_dirs: Vec, + provider_status: Vec, + workspaces: Vec, +} + +#[derive(Serialize)] +struct SessionLoadResult { + data: SessionLoadData, +} + +/// `ping` — public health check. Echoes the request id back as `ping_id`. +/// +/// `ActionContext.request_id` carries the parsed envelope's id; mirrors +/// the legacy `handle_ping` shape. +#[allow( + clippy::unused_async, + reason = "ActionHandler signature requires async" +)] +pub async fn ping( + _params: Value, + ctx: ActionContext<'_>, + _app: Arc, +) -> Result { + let result = PingResult { + ping_id: ctx.request_id.clone(), + }; + serde_json::to_value(result).map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +/// `session_load` — authenticated initial-state load. +/// +/// Returns the cross-domain envelope the frontend needs at boot: +/// open workspaces, zzz_dir file tree (rescanned for consistency), +/// scoped_dirs, provider status. Mirrors the legacy +/// `handle_session_load` body verbatim. +pub async fn session_load( + _params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let workspaces: Vec = { + let ws = app.workspaces.read(); + ws.values().cloned().collect() + }; + + // Rescan each watched directory before reading the index — notify events + // are eventually consistent, so a file written immediately before + // session_load may not yet be in the in-memory index. A fresh walk + // guarantees a consistent snapshot and removes a flaky race in integration + // tests (`session_load_returns_nested_files`) where the filer event loop + // hadn't yet drained the inotify event. + app.filer_manager.rescan_all().await; + let files = app.filer_manager.collect_all_files().await; + + let mut provider_status = Vec::new(); + for p in app.provider_manager.all() { + let status = p.load_status(false).await; + if let Ok(v) = serde_json::to_value(&status) { + provider_status.push(v); + } + } + + let result = SessionLoadResult { + data: SessionLoadData { + files, + zzz_dir: app.zzz_dir.clone(), + scoped_dirs: app.scoped_dirs.clone(), + provider_status, + workspaces, + }, + }; + serde_json::to_value(result).map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +/// `_testing_emit_notifications` — test-only action used by the integration +/// suite to verify `ctx.notify` socket-scoped routing without a real AI +/// provider. Emits `count` `_testing_notification` frames through +/// `ctx.notify`, then returns `{count}`. Gated at registry-compile time +/// by `App.enable_test_actions` (`ZZZ_ENABLE_TEST_ACTIONS=1`). +#[allow( + clippy::unused_async, + reason = "ActionHandler signature requires async" +)] +pub async fn testing_emit_notifications( + params: Value, + ctx: ActionContext<'_>, + _app: Arc, +) -> Result { + let count = params + .get("count") + .and_then(Value::as_u64) + .ok_or_else(|| invalid_params("missing or invalid 'count' parameter", None))?; + if count > 100 { + return Err(invalid_params("count must be <= 100", None)); + } + for i in 0..count { + (ctx.notify)("_testing_notification", &serde_json::json!({"index": i})); + } + serde_json::to_value(TestingEmitNotificationsResult { count }) + .map_err(|e| internal_error_with_source("serialization failed", &e)) +} diff --git a/crates/zzz_server/src/handlers/filesystem.rs b/crates/zzz_server/src/handlers/filesystem.rs new file mode 100644 index 000000000..2fa4a81a6 --- /dev/null +++ b/crates/zzz_server/src/handlers/filesystem.rs @@ -0,0 +1,79 @@ +//! Filesystem handlers. +//! +//! Spine signature `(Value, ActionContext<'_>, Arc)`; the +//! closure-captured `Arc` provides the `ScopedFs` reach-through. + +use std::sync::Arc; + +use fuz_actions::ActionContext; +use fuz_http::{JsonrpcError, internal_error, invalid_params}; +use serde_json::Value; + +use crate::handlers::App; + +pub async fn diskfile_update( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'path' parameter", None))?; + if !path.starts_with('/') { + return Err(invalid_params("path must be absolute", None)); + } + let content = params + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'content' parameter", None))?; + + app.scoped_fs + .write_file(path, content) + .await + .map_err(|e| internal_error(&format!("failed to write file: {e}")))?; + + Ok(Value::Null) +} + +pub async fn diskfile_delete( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'path' parameter", None))?; + if !path.starts_with('/') { + return Err(invalid_params("path must be absolute", None)); + } + + app.scoped_fs + .rm(path) + .await + .map_err(|e| internal_error(&format!("failed to delete file: {e}")))?; + + Ok(Value::Null) +} + +pub async fn directory_create( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'path' parameter", None))?; + if !path.starts_with('/') { + return Err(invalid_params("path must be absolute", None)); + } + + app.scoped_fs + .mkdir(path) + .await + .map_err(|e| internal_error(&format!("failed to create directory: {e}")))?; + + Ok(Value::Null) +} diff --git a/crates/zzz_server/src/handlers/mod.rs b/crates/zzz_server/src/handlers/mod.rs new file mode 100644 index 000000000..86db80c5f --- /dev/null +++ b/crates/zzz_server/src/handlers/mod.rs @@ -0,0 +1,129 @@ +//! Long-lived `App` server state, the per-domain RPC handlers, and the +//! `App.broadcast` / `close_sockets_for_*` connection shims. +//! +//! The per-domain handler modules (`core`, `filesystem`, `provider`, +//! `terminal`, `workspace`) hold the zzz-specific RPC handlers in the spine +//! signature `(params: Value, ctx: ActionContext<'_>, app: Arc) -> +//! Result`. They are registered into +//! `App.action_registry` via `crate::zzz_action_specs::build_*_specs`, which +//! own the `Arc` and clone it into each per-spec handler closure — the +//! source of the zzz-specific deps (`PtyManager`, `FilerManager`, +//! `ProviderManager`, the workspaces map) that don't live on `ActionContext`. +//! +//! Auth, dispatch, and the JSON-RPC / WS routes come from the spine +//! (`fuz_actions::perform_action` plus the route states built in `main.rs`). +//! `App.realtime` is the sole connection-tracking surface; it drives the +//! `broadcast` / `close_sockets_for_*` shims called from `filer.rs`, +//! `pty_manager.rs`, and `workspace.rs`. `WorkspaceInfo` is the value type +//! consumed by `workspace`. + +pub mod core; +pub mod filesystem; +pub mod provider; +pub mod terminal; +pub mod workspace; + +use std::collections::HashMap; +use std::sync::Arc; + +use deadpool_postgres::Pool; +use parking_lot::RwLock; +use serde::Serialize; + +use crate::filer::FilerManager; +use crate::provider::{CompletionOptions, ProviderManager}; +use crate::pty_manager::PtyManager; +use crate::scoped_fs::ScopedFs; + +use fuz_actions::ActionRegistry; +use fuz_realtime::ConnectionRegistry; + +// -- App state (long-lived, shared via Arc) ----------------------------------- + +/// Server state shared across all requests. +/// +/// Constructed once in `main`, wrapped in `Arc`, passed into the spec +/// builders + the spine RPC / WS route states. +pub struct App { + pub workspaces: RwLock>, + pub db_pool: Pool, + pub scoped_fs: ScopedFs, + pub zzz_dir: String, + pub scoped_dirs: Vec, + /// Active file watchers — one per unique directory path, with lifetime + /// tracking. + pub filer_manager: FilerManager, + /// PTY terminal manager. + pub pty_manager: PtyManager, + /// AI provider manager (Anthropic, `OpenAI`, Gemini). + pub provider_manager: ProviderManager, + /// Default completion options. + pub completion_options: CompletionOptions, + /// Register `_testing_*` actions on live dispatchers. Set by integration + /// tests via `ZZZ_ENABLE_TEST_ACTIONS=1`; production must leave false. + /// Read in `main.rs` at registry-compile time to conditionally + /// extend the spec set via `zzz_action_specs::build_testing_specs`. + pub enable_test_actions: bool, + /// `Arc` — the spine's connection-tracking + /// registry. Sole connection store on `App`; drives the + /// `broadcast` / `close_sockets_for_*` shims below. + pub realtime: Arc, + /// Compiled spine action registry. Holds protocol + + /// `auth_adapter::build_account_specs` + `build_admin_specs` plus the + /// zzz-specific specs from `zzz_action_specs::build_*_specs`. + /// + /// **Wrapped in `OnceLock`** so it can be set after `Arc` is + /// constructed — the spec builders close over `Arc`, so the + /// registry can't be built until the App `Arc` exists. + pub action_registry: std::sync::OnceLock>, +} + +impl App { + pub fn new( + db_pool: Pool, + scoped_fs: ScopedFs, + zzz_dir: String, + scoped_dirs: Vec, + provider_manager: ProviderManager, + enable_test_actions: bool, + realtime: Arc, + ) -> Self { + Self { + workspaces: RwLock::new(HashMap::new()), + db_pool, + scoped_fs, + zzz_dir, + scoped_dirs, + filer_manager: FilerManager::new(), + pty_manager: PtyManager::new(), + provider_manager, + completion_options: CompletionOptions::default(), + enable_test_actions, + realtime, + action_registry: std::sync::OnceLock::new(), + } + } + + /// Broadcast a message to all connected clients. + /// + /// Shim over `App.realtime`. The spine WS handler registers + /// connections in `App.realtime` (`Arc`); + /// call sites (`filer::broadcast_filer_change`, `pty_manager` terminal + /// data / exited, `workspace::workspace_*`) broadcast through this shim. + pub fn broadcast(&self, message: &str) { + let _ = self.realtime.broadcast(message); + } +} + +// -- Domain types ------------------------------------------------------------- + +/// Metadata for an open workspace directory. +/// +/// Matches the TypeScript `WorkspaceInfoJson` schema: +/// `{ path: string, name: string, opened_at: string }`. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceInfo { + pub path: String, + pub name: String, + pub opened_at: String, +} diff --git a/crates/zzz_server/src/handlers/provider.rs b/crates/zzz_server/src/handlers/provider.rs new file mode 100644 index 000000000..b555a4ea1 --- /dev/null +++ b/crates/zzz_server/src/handlers/provider.rs @@ -0,0 +1,177 @@ +//! AI provider handlers. +//! +//! `provider_load_status`, `provider_update_api_key`, and `completion_create`. +//! `completion_create` routes streaming progress notifications via +//! `Arc::send_to(conn_id, …)` — `ctx.connection_id` +//! carries the per-socket route on WS, `None` on HTTP. HTTP callers omit the +//! progress token (or pass one with no WS counterpart) and receive the full +//! result without intermediate chunks. + +use std::sync::Arc; + +use fuz_actions::ActionContext; +use fuz_http::{JsonrpcError, internal_error_with_source, invalid_params, notification}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::handlers::App; +use crate::provider::{self, CompletionHandlerOptions, ProviderName}; + +/// Strongly-typed view of the `completion_request` param object. +/// +/// Deserialized in one pass from `&Value` (zero JSON-tree cloning). +/// Mirrors the legacy `handlers::provider::CompletionRequestInput`. +#[derive(Deserialize)] +struct CompletionRequestInput { + provider_name: String, + model: String, + prompt: String, + #[serde(default)] + completion_messages: Option>, +} + +#[derive(Serialize)] +struct ProviderStatusResult { + status: provider::ProviderStatus, +} + +pub async fn provider_load_status( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let name_str = params + .get("provider_name") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'provider_name' parameter", None))?; + + let provider_name = ProviderName::parse(name_str) + .ok_or_else(|| invalid_params(&format!("unknown provider: {name_str}"), None))?; + + let reload = params + .get("reload") + .and_then(Value::as_bool) + .unwrap_or(false); + + let provider = app.provider_manager.require(provider_name)?; + let status = provider.load_status(reload).await; + + serde_json::to_value(ProviderStatusResult { status }) + .map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +pub async fn provider_update_api_key( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let name_str = params + .get("provider_name") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'provider_name' parameter", None))?; + + let provider_name = ProviderName::parse(name_str) + .ok_or_else(|| invalid_params(&format!("unknown provider: {name_str}"), None))?; + + let api_key = params + .get("api_key") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'api_key' parameter", None))?; + + let provider = app.provider_manager.require(provider_name)?; + provider.set_api_key(Some(api_key.to_owned())).await; + let status = provider.load_status(true).await; + + serde_json::to_value(ProviderStatusResult { status }) + .map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +/// Start an AI completion, streaming progress to the originating WS socket. +/// +/// On WS: when the caller passes a `_meta.progressToken`, builds a +/// `ProgressSender` closure capturing `Arc` + the +/// per-socket `ConnectionId` from `ctx.connection_id`. Each provider chunk +/// is wrapped in a `completion_progress` JSON-RPC notification and routed +/// via `ConnectionRegistry::send_to(conn_id, …)`. The closure is `'static` +/// (the borrowed spine `notify` shape can't be captured into a +/// `ProgressSender: 'static` directly — the per-connection registry route +/// is the right wire for streaming). +/// +/// On HTTP: `ctx.connection_id` is `None`, so no streaming. The caller +/// still receives the full result envelope. +/// +/// Cancellation: passes `ctx.signal` through to the provider — per-socket +/// on WS (cancelled on disconnect or audit-driven revocation), fresh +/// per-request on HTTP. +pub async fn completion_create( + params: Value, + ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let request_value = params + .get("completion_request") + .ok_or_else(|| invalid_params("missing 'completion_request' parameter", None))?; + + let request = CompletionRequestInput::deserialize(request_value) + .map_err(|e| invalid_params(&format!("invalid completion_request: {e}"), None))?; + + let provider_name = ProviderName::parse(&request.provider_name).ok_or_else(|| { + invalid_params( + &format!("unknown provider: {}", request.provider_name), + None, + ) + })?; + + let progress_token = params + .get("_meta") + .and_then(|m| m.get("progressToken")) + .and_then(Value::as_str) + .map(String::from); + + let completion_options = app.completion_options.clone(); + + let handler_options = CompletionHandlerOptions { + model: request.model, + completion_options, + completion_messages: request.completion_messages, + prompt: request.prompt, + }; + + // Build the per-request `ProgressSender` only when both a progress + // token and a WS connection are available. On HTTP (`connection_id = + // None`) or when the caller omitted `_meta.progressToken`, the + // sender is `None` and the provider runs in non-streaming mode. + let progress_sender: Option = + match (progress_token.as_ref(), ctx.connection_id) { + (Some(token), Some(conn_id)) => { + let realtime = Arc::clone(&app.realtime); + let token = token.clone(); + let sender: provider::ProgressSender = Box::new(move |chunk: Value| { + let payload = serde_json::json!({ + "chunk": chunk, + "_meta": { "progressToken": token }, + }); + let wire = notification("completion_progress", &payload); + realtime.send_to(conn_id, &wire); + }); + Some(sender) + } + _ => None, + }; + + let provider = app.provider_manager.require(provider_name)?; + let mut result = provider + .complete(&handler_options, progress_sender.as_ref(), ctx.signal) + .await?; + + if let Some(token) = &progress_token + && let Some(obj) = result.as_object_mut() + { + obj.insert( + "_meta".to_owned(), + serde_json::json!({"progressToken": token}), + ); + } + + Ok(result) +} diff --git a/crates/zzz_server/src/handlers/terminal.rs b/crates/zzz_server/src/handlers/terminal.rs new file mode 100644 index 000000000..c770c33a9 --- /dev/null +++ b/crates/zzz_server/src/handlers/terminal.rs @@ -0,0 +1,138 @@ +//! Terminal handlers. +//! +//! Spine signature `(Value, ActionContext<'_>, Arc)`; the +//! closure-captured `Arc` provides the `PtyManager` reach-through. + +use std::sync::Arc; + +use fuz_actions::ActionContext; +use fuz_http::{JsonrpcError, internal_error, internal_error_with_source, invalid_params}; +use serde::Serialize; +use serde_json::Value; + +use crate::handlers::App; + +#[derive(Serialize)] +struct TerminalCreateResult { + terminal_id: String, +} + +#[derive(Serialize)] +struct TerminalCloseResult { + exit_code: Option, +} + +pub async fn terminal_create( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let command = params + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'command' parameter", None))?; + + let args: Vec = match params.get("args") { + Some(Value::Array(arr)) => arr + .iter() + .map(|v| { + v.as_str() + .map(String::from) + .ok_or_else(|| invalid_params("args must be an array of strings", None)) + }) + .collect::, _>>()?, + Some(Value::Null) | None => vec![], + _ => return Err(invalid_params("args must be an array of strings", None)), + }; + + let cwd = params.get("cwd").and_then(Value::as_str); + + let terminal_id = uuid::Uuid::new_v4().to_string(); + + app.pty_manager + .spawn(&terminal_id, command, &args, cwd, Arc::clone(&app)) + .await + .map_err(|e| internal_error(&format!("failed to create terminal: {e}")))?; + + serde_json::to_value(TerminalCreateResult { terminal_id }) + .map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +pub async fn terminal_data_send( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let terminal_id = params + .get("terminal_id") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'terminal_id' parameter", None))?; + + let data = params + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'data' parameter", None))?; + + app.pty_manager.write(terminal_id, data).await; + + Ok(Value::Null) +} + +pub async fn terminal_resize( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let terminal_id = params + .get("terminal_id") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'terminal_id' parameter", None))?; + + let cols = params + .get("cols") + .and_then(Value::as_u64) + .ok_or_else(|| invalid_params("missing or invalid 'cols' parameter", None))?; + + let rows = params + .get("rows") + .and_then(Value::as_u64) + .ok_or_else(|| invalid_params("missing or invalid 'rows' parameter", None))?; + + #[expect( + clippy::cast_possible_truncation, + reason = "terminal dimensions fit u16" + )] + { + app.pty_manager + .resize(terminal_id, cols as u16, rows as u16) + .await; + } + + Ok(Value::Null) +} + +pub async fn terminal_close( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let terminal_id = params + .get("terminal_id") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'terminal_id' parameter", None))?; + + let signal_str = params + .get("signal") + .and_then(Value::as_str) + .unwrap_or("SIGTERM"); + + let signal = match signal_str { + "SIGKILL" => libc::SIGKILL, + _ => libc::SIGTERM, + }; + + let exit_code = app.pty_manager.kill(terminal_id, signal).await.flatten(); + + serde_json::to_value(TerminalCloseResult { exit_code }) + .map_err(|e| internal_error_with_source("serialization failed", &e)) +} diff --git a/crates/zzz_server/src/handlers/workspace.rs b/crates/zzz_server/src/handlers/workspace.rs new file mode 100644 index 000000000..96a113aeb --- /dev/null +++ b/crates/zzz_server/src/handlers/workspace.rs @@ -0,0 +1,217 @@ +//! Workspace handlers. +//! +//! Spine signature `(Value, ActionContext<'_>, Arc)`; the +//! closure-captured `Arc` carries the zzz-specific deps the spine +//! `ActionContext` doesn't (`workspaces` map, `FilerManager`, `ScopedFs`, +//! `ConnectionRegistry`-backed broadcast). + +use std::path::Path; +use std::sync::Arc; + +use fuz_actions::ActionContext; +use fuz_http::{JsonrpcError, internal_error, internal_error_with_source, invalid_params}; +use fuz_realtime::notify_to_string; +use serde::Serialize; +use serde_json::Value; + +use crate::filer::{FilerConfig, FilerLifetime}; +use crate::handlers::{App, WorkspaceInfo}; + +// -- Notification params ----------------------------------------------------- + +#[derive(Serialize)] +struct WorkspaceChangedParams<'a> { + #[serde(rename = "type")] + change_type: &'a str, + workspace: &'a WorkspaceInfo, +} + +// -- Typed response structs -------------------------------------------------- + +#[derive(Serialize)] +struct WorkspaceListResult { + workspaces: Vec, +} + +#[derive(Serialize)] +struct WorkspaceOpenResult { + workspace: WorkspaceInfo, + files: Vec, +} + +// -- Helpers ----------------------------------------------------------------- + +fn to_normalized_dir(path: &Path) -> Result { + let mut s = path + .to_str() + .ok_or_else(|| internal_error("path is not valid UTF-8"))? + .to_owned(); + if !s.ends_with('/') { + s.push('/'); + } + Ok(s) +} + +// -- Handlers ---------------------------------------------------------------- + +/// `workspace_list` — read-only snapshot of open workspaces. +/// +/// Spine signature: `(Value, ActionContext<'_>)`. `params` is unused +/// (`workspace_list` takes no input); kept in the signature for +/// `ActionHandler` shape uniformity. `async` is required by the +/// `ActionHandler` future-returning shape even though the body has +/// no `.await` points. +#[allow( + clippy::unused_async, + reason = "ActionHandler signature requires async" +)] +pub async fn workspace_list( + _params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let list: Vec = { + let workspaces = app.workspaces.read(); + workspaces.values().cloned().collect() + }; + let result = WorkspaceListResult { workspaces: list }; + serde_json::to_value(result).map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +/// `workspace_open` — open a workspace directory. +/// +/// Side-effects: registers a filer watcher, adds the path to `ScopedFs`, +/// inserts a `WorkspaceInfo` into the in-memory map, broadcasts a +/// `workspace_changed` notification to all connections. +pub async fn workspace_open( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'path' parameter", None))?; + + let canonical = Path::new(path).canonicalize().map_err(|_| { + let suffix = if path.ends_with('/') { "" } else { "/" }; + internal_error(&format!( + "failed to open workspace: directory does not exist: {path}{suffix}" + )) + })?; + + if !canonical.is_dir() { + let suffix = if path.ends_with('/') { "" } else { "/" }; + return Err(internal_error(&format!( + "failed to open workspace: not a directory: {path}{suffix}" + ))); + } + + let normalized = to_normalized_dir(&canonical)?; + + let existing = { + let workspaces = app.workspaces.read(); + workspaces.get(&normalized).cloned() + }; + + if let Some(workspace) = existing { + let result = WorkspaceOpenResult { + workspace, + files: vec![], + }; + return serde_json::to_value(result) + .map_err(|e| internal_error_with_source("serialization failed", &e)); + } + + let name = canonical + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + + let info = WorkspaceInfo { + path: normalized.clone(), + name, + opened_at: fuz_sys::rfc3339_now(), + }; + + let workspace = { + let mut workspaces = app.workspaces.write(); + workspaces.entry(normalized).or_insert(info).clone() + }; + + app.scoped_fs.add_path(Path::new(&workspace.path)); + + if let Err(e) = app + .filer_manager + .start_filer( + &workspace.path, + Arc::clone(&app), + FilerConfig::workspace(&app.zzz_dir), + FilerLifetime::Workspace, + ) + .await + { + tracing::warn!(path = %workspace.path, error = %e, "failed to start file watcher"); + } + + // Broadcast the workspace_changed notification. Uses the spine + // `notify_to_string` builder + the legacy `App.broadcast` path — + // the spine `ConnectionRegistry` broadcast swap lands in a later + // batch that retires the legacy `connections` map. + let params_value = serde_json::to_value(WorkspaceChangedParams { + change_type: "open", + workspace: &workspace, + }) + .map_err(|e| internal_error_with_source("notification params serialize failed", &e))?; + let notification = notify_to_string("workspace_changed", ¶ms_value); + app.broadcast(¬ification); + + let result = WorkspaceOpenResult { + workspace, + files: vec![], + }; + serde_json::to_value(result).map_err(|e| internal_error_with_source("serialization failed", &e)) +} + +/// `workspace_close` — close a workspace directory. +pub async fn workspace_close( + params: Value, + _ctx: ActionContext<'_>, + app: Arc, +) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_params("missing or invalid 'path' parameter", None))?; + + let mut key = path.to_owned(); + if !key.ends_with('/') { + key.push('/'); + } + + let removed = { + let mut workspaces = app.workspaces.write(); + workspaces.remove(&key) + }; + + let Some(workspace) = removed else { + return Err(invalid_params(&format!("workspace not open: {path}"), None)); + }; + + let is_initial_scoped_dir = app.scoped_dirs.contains(&key); + if !is_initial_scoped_dir { + app.filer_manager.stop_filer(&key).await; + app.scoped_fs.remove_path(Path::new(&key)); + } + + let params_value = serde_json::to_value(WorkspaceChangedParams { + change_type: "close", + workspace: &workspace, + }) + .map_err(|e| internal_error_with_source("notification params serialize failed", &e))?; + let notification = notify_to_string("workspace_changed", ¶ms_value); + app.broadcast(¬ification); + + Ok(Value::Null) +} diff --git a/crates/zzz_server/src/lib.rs b/crates/zzz_server/src/lib.rs new file mode 100644 index 000000000..2d0667036 --- /dev/null +++ b/crates/zzz_server/src/lib.rs @@ -0,0 +1,839 @@ +//! `zzz_server` — Rust backend for zzz. +//! +//! The library entry point [`run_app`] owns the full server lifecycle: +//! env loading, DB pool + migrations, spine state construction, +//! `ActionRegistry` compile, file watchers, daemon-token rotation, +//! route composition, signal handling, and graceful shutdown. +//! +//! The `password_hasher` parameter is the swap point for the +//! test-binary pattern (fast argon2 via a pluggable hasher): +//! +//! - Production wires [`fuz_auth::Argon2idHasher`] from `src/main.rs`. +//! - `testing_zzz_server`'s `main.rs` wires +//! `fuz_testing::TestingArgon2idHasher` for ~1-5 ms argon2 in +//! cross-process integration tests. +//! +//! Keeping the lifecycle in the library shrinks each binary's +//! `main.rs` to the hasher selection plus `run_app(...)`. + +pub mod error; +pub mod filer; +pub mod handlers; +pub mod provider; +pub mod pty_manager; +pub mod scoped_fs; +pub mod zzz_action_specs; + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; +use tokio::net::TcpListener; +use tower_http::services::ServeDir; + +pub use error::ServerError; + +/// Default loopback bind address (port 4460). `--port` or `ZZZ_PORT` +/// override the port; the host stays loopback. The port matches the `zzz` +/// CLI's daemon default so a directly-run `zzzd` and a CLI-spawned daemon +/// bind the same port. +pub const DEFAULT_ADDR: SocketAddr = + SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 4460); + +/// zzz's concrete instantiation of [`fuz_actions::ExtraActionSpecsFactory`] +/// over [`handlers::App`] — extra specs folded in after the standard zzz set. +/// +/// Production passes `None`; the `testing_zzz_server` binary passes `Some(_)` +/// to inject `_testing_reset`, closing over `fuz_testing` types in its own +/// process so the production graph stays clean. +pub type ExtraActionSpecsFactory = fuz_actions::ExtraActionSpecsFactory; + +/// zzz's concrete instantiation of [`fuz_actions::PreMigrationHook`]. +/// +/// Production passes `None`; the `testing_zzz_server` binary passes `Some(_)` +/// to fire `fuz_testing::reset_db_on_startup_if_env_set` (env-gated schema +/// wipe). Errors surface as [`ServerError::Database`]. +pub type PreMigrationHook = fuz_actions::PreMigrationHook; + +/// Options for [`run_app`]. +/// +/// Named fields rather than positional params so future swap points +/// can land additively without churning every call site. The +/// `extra_action_specs_factory` slot already follows that pattern — +/// adding a second factory or a config override (e.g., a test-only +/// notify decorator) would be one new named field plus a `Default` +/// fallback. +pub struct RunAppOptions { + /// Production-vs-test password hasher swap point. + /// Production: [`fuz_auth::Argon2idHasher`]. Test binary: + /// `fuz_testing::TestingArgon2idHasher`. + pub password_hasher: Arc, + /// Default bind address when neither `--port` nor `ZZZ_PORT` supplies a + /// port. The host stays this address's host (loopback); only the port is + /// overridable. Production: [`DEFAULT_ADDR`] (`127.0.0.1:4460`). Test + /// binary: `127.0.0.1:4462` so the two can run side-by-side. + pub default_addr: SocketAddr, + /// Bounds the graceful-shutdown connection drain. Pass + /// [`fuz_http::DEFAULT_DRAIN_TIMEOUT`] unless a consumer needs a different + /// bound — the other spine consumers all pass that shared default. + pub drain_timeout: std::time::Duration, + /// Override the `ZZZ_ENABLE_TEST_ACTIONS` env-parsed flag. + /// Production: `false`. Test binary: `true` so the `_testing_*` + /// registry branch fires regardless of operator env. + pub force_test_actions: bool, + /// Disable the per-IP + per-account `/login` + `/password` rate limiters. + /// Production: `false` — login rate limiting is always on, matching + /// `fuz_forge_server` + `mageguild_server` and the fuz defaults. Test + /// binary: `true` so the cross-backend auth suite's repeated logins don't + /// trip the bucket. + pub disable_login_rate_limit: bool, + /// Factory injecting extra action specs after the standard zzz set. + /// Production: `None`. Test binary: `Some(_)` so + /// `fuz_testing::create_testing_reset_action_spec` can register + /// without dragging `fuz_testing` into the production dep graph + /// (the `cargo xtask check-release` audit blocks that). + pub extra_action_specs_factory: Option, + /// Hook fired after pool creation, **before** migrations run. + /// Production: `None`. Test binary: `Some(_)` to wire + /// `fuz_testing::reset_db_on_startup_if_env_set` so per-process + /// startup can wipe the auth-namespace schema and let migrations + /// replay from nothing. + pub pre_migration_hook: Option, +} + +/// Run the `zzz_server` lifecycle to completion. +/// +/// Parses CLI args + env, opens the DB pool, runs migrations, builds +/// every spine subsystem, mounts the routes, binds the listener, and +/// blocks on graceful shutdown (Ctrl-C / SIGTERM). Returns once all +/// connections have drained and PTYs are torn down. +/// +/// Every configuration knob lives on [`RunAppOptions`]; everything +/// not explicitly named there flows from CLI args or the process +/// environment. +/// +/// # Errors +/// +/// Returns [`ServerError`] for env/config validation failures, DB +/// connectivity / migration failures, listener bind failures, and +/// `axum::serve` errors. +pub async fn run_app(options: RunAppOptions) -> Result<(), ServerError> { + let RunAppOptions { + password_hasher, + default_addr, + drain_timeout, + force_test_actions, + disable_login_rate_limit, + extra_action_specs_factory, + pre_migration_hook, + } = options; + let mut config = parse_config(default_addr)?; + if force_test_actions { + config.enable_test_actions = true; + } + + // Database — required. Spine `fuz_db::create_pool` builds the + // deadpool-postgres pool; `fuz_db::run_migrations` runs the auth DDL + // tracked under the reserved `fuz_auth` namespace. + let pool = fuz_db::create_pool(&config.database_url) + .map_err(|e| ServerError::Database(format!("failed to create pool: {e}")))?; + // Pre-migration hook — test binary uses this slot for the env-gated + // `fuz_testing::reset_db_on_startup_if_env_set` schema wipe so the + // migration chain below sees a clean DB. Production passes `None`. + if let Some(hook) = pre_migration_hook { + hook(&pool).await?; + } + fuz_db::run_migrations(&pool, &[fuz_auth::AUTH_MIGRATIONS]) + .await + .map_err(|e| ServerError::Database(format!("migration failed: {e}")))?; + + // Validate the cookie keys env early; the spine `fuz_auth::Keyring` + // (constructed below as `spine_keyring`) is the sole keyring on `App`. + let errors = fuz_auth::Keyring::validate(&config.secret_cookie_keys); + if !errors.is_empty() { + return Err(ServerError::Config(format!( + "SECRET_FUZ_COOKIE_KEYS validation failed: {}", + errors.join(", ") + ))); + } + + // Bootstrap availability check — drives the `bootstrap_available_atomic` + // shared by the spine account router (returned on `/status` 401) and + // the bootstrap router (gate on `/bootstrap`). + let bootstrap_available = + fuz_auth::is_bootstrap_available(&pool, config.bootstrap_token_path.as_deref()).await; + + let scoped_dir_strings: Vec = + config.scoped_dirs.iter().map(|p| resolve_dir(p)).collect(); + + // Include zzz_dir first (like Deno: `new ScopedFs([this.zzz_dir, ...this.scoped_dirs])`) + // Use canonicalized paths, not raw config paths + let mut scoped_fs_paths: Vec = Vec::with_capacity(1 + scoped_dir_strings.len()); + scoped_fs_paths.push(PathBuf::from(&config.zzz_dir)); + scoped_fs_paths.extend(scoped_dir_strings.iter().map(PathBuf::from)); + let scoped_fs = scoped_fs::ScopedFs::new(scoped_fs_paths); + + // AI providers — read API keys from env, construct ProviderManager + let mut provider_manager = provider::ProviderManager::new(); + provider_manager.add(provider::Provider::Anthropic( + provider::anthropic::AnthropicProvider::new(std::env::var("SECRET_ANTHROPIC_API_KEY").ok()), + )); + provider_manager.add(provider::Provider::OpenAi( + provider::openai::OpenAiProvider::new(std::env::var("SECRET_OPENAI_API_KEY").ok()), + )); + provider_manager.add(provider::Provider::Gemini( + provider::gemini::GeminiProvider::new(std::env::var("SECRET_GOOGLE_API_KEY").ok()), + )); + + if config.enable_test_actions { + tracing::info!( + "test actions enabled — `_testing_*` methods registered on live dispatchers" + ); + } + + // Per-IP + per-account rate limiters on `/login` and `/password`, always + // on in production — matching `fuz_forge_server` + `mageguild_server` and + // the fuz defaults (`DEFAULT_LOGIN_IP_RATE_LIMIT` 5/15min, + // `DEFAULT_LOGIN_ACCOUNT_RATE_LIMIT` 10/30min). The test binary sets + // `disable_login_rate_limit` so the cross-backend auth suite's repeated + // logins don't trip the bucket. Spine `fuz_auth::RateLimiter` + // (parking_lot, sync). + let (login_ip_rate_limiter, login_account_rate_limiter) = if disable_login_rate_limit { + (None, None) + } else { + tracing::info!("login rate limiting enabled (5/15min per-IP, 10/30min per-account)"); + ( + Some(Arc::new(fuz_auth::RateLimiter::new( + fuz_auth::DEFAULT_LOGIN_IP_RATE_LIMIT, + ))), + Some(Arc::new(fuz_auth::RateLimiter::new( + fuz_auth::DEFAULT_LOGIN_ACCOUNT_RATE_LIMIT, + ))), + ) + }; + + // Per-account rate limiter shared across admin RPC methods and the + // role-grant-offer surface. Mirrors fuz_app's + // `default_action_account_rate_limit` (1200 / 15min per actor) — + // bounds paginated admin-side scraping pressure per the TS posture + // at `admin_action_specs.ts:262..400` (every admin spec carries + // `rate_limit: 'account'`) and offer-spam / account-existence-oracle + // pressure on `role_grant_offer_create` per + // `role_grant_offer_action_specs.ts:211..228`. Always-on (no env + // gate); the production cap sits far above the cross-backend test + // suite's request volume. + let action_account_rate_limiter: Option> = Some(Arc::new( + fuz_auth::RateLimiter::new(fuz_auth::DEFAULT_ACTION_ACCOUNT_RATE_LIMIT), + )); + // IP-axis action limiter unwired today — TS shape `rate_limit: 'account'` + // doesn't gate on IP. Leave `None`; lift to a real limiter when a + // consumer files a need (e.g. a deployment fronted by a CDN where + // per-account scraping flows from one IP). + let action_ip_rate_limiter: Option> = None; + + // Spine connection registry + audit emitter — wired into `App` and + // mounted into the spine RPC + WS dispatchers below. Listener + // registration (audit-event → socket revocation) happens after + // `Arc` is constructed so the socket-revoker capability is + // available. + let realtime = Arc::new(fuz_realtime::ConnectionRegistry::new()); + let spine_audit_emitter = Arc::new(fuz_auth::AuditEmitter::new(pool.clone())); + // SSE half of the realtime surface — the registry of open + // `GET /api/admin/audit/stream` subscriptions. The audit listener wired + // alongside the socket-revocation listeners below fans every audit row to + // these streams and closes account-keyed streams on revocation. + let audit_sse = Arc::new(fuz_realtime::SseRegistry::new()); + let spine_keyring = Arc::new( + fuz_auth::Keyring::new(&config.secret_cookie_keys).ok_or_else(|| { + ServerError::Config( + "SECRET_FUZ_COOKIE_KEYS is required for spine keyring (no valid keys found)" + .to_owned(), + ) + })?, + ); + let spine_password_hasher: Arc = password_hasher; + // Parse `ZZZ_TRUSTED_PROXIES` into the spine `fuz_http::ParsedProxy` + // type. Empty/unset → empty vec → middleware treats every connection + // as untrusted (XFF ignored, `client_ip` = TCP peer). Misconfiguration + // fails fast so the operator sees the error instead of silently + // leaving a hole. Sole trusted-proxy state on `App`. + let spine_trusted_proxies: Arc> = + Arc::new(match config.trusted_proxies.as_deref() { + None => Vec::new(), + Some(raw) => fuz_http::parse_proxy_list(raw) + .map_err(|e| ServerError::Config(format!("ZZZ_TRUSTED_PROXIES: {e}")))?, + }); + if !spine_trusted_proxies.is_empty() { + tracing::info!( + count = spine_trusted_proxies.len(), + "trusted proxies configured — XFF resolution enabled" + ); + } + // Refuse to boot (fail loud) on an absent / all-empty allowlist — an empty + // list silently fails *open* (allow-all), disabling the Origin gate on + // every REST + RPC + WS handler; see `fuz_http::require_non_empty_origins`. + // Mirrors the TS `validate_server_env` contract. + let spine_allowed_origins = Arc::new( + fuz_http::require_non_empty_origins(config.allowed_origins.as_deref()) + .map_err(|e| ServerError::Config(e.to_string()))?, + ); + let bootstrap_available_atomic = + Arc::new(std::sync::atomic::AtomicBool::new(bootstrap_available)); + let socket_revoker: Arc = + Arc::clone(&realtime).into_socket_revoker(); + // Spine daemon-token state — sole daemon-token state on `App`. Init + // failure degrades to `None` so the server still serves cookie + bearer auth. + let spine_daemon_token: Option = + match fuz_auth::init_daemon_token(Path::new(&config.zzz_dir)).await { + Ok(state) => { + fuz_auth::resolve_keeper_into(&state, &pool).await; + Some(state) + } + Err(e) => { + tracing::warn!(error = %e, "daemon token init failed — running without daemon token auth"); + None + } + }; + let account_route_state = fuz_auth::AccountRouteState { + pool: pool.clone(), + keyring: Arc::clone(&spine_keyring), + password_hasher: Arc::clone(&spine_password_hasher), + audit: Arc::clone(&spine_audit_emitter), + socket_revoker: Arc::clone(&socket_revoker), + allowed_origins: Arc::clone(&spine_allowed_origins), + bootstrap_available: Arc::clone(&bootstrap_available_atomic), + login_ip_rate_limiter, + login_account_rate_limiter, + daemon_token_state: spine_daemon_token.clone(), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + }; + let bootstrap_route_state = fuz_auth::BootstrapRouteState { + options: Arc::new(fuz_auth::BootstrapOptions { + pool: pool.clone(), + password_hasher: Arc::clone(&spine_password_hasher), + audit: Arc::clone(&spine_audit_emitter), + bootstrap_available: Arc::clone(&bootstrap_available_atomic), + token_store: config.bootstrap_token_path.as_ref().map(|p| { + let store: Arc = + Arc::new(fuz_auth::FileBootstrapTokenStore::new(PathBuf::from(p))); + store + }), + on_keeper_resolved: spine_daemon_token.as_ref().map(|state| { + let cb: Arc = + Arc::new(fuz_auth::DaemonTokenKeeperResolved::new(Arc::clone(state))); + cb + }), + }), + keyring: Arc::clone(&spine_keyring), + allowed_origins: Arc::clone(&spine_allowed_origins), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + }; + + // Signup route: mounted on the production server so the + // cross-process integration harness (testing_zzz_server reuses + // run_app) can mint per-test accounts through production RPC. + // Open_signup defaults to false in app_settings, so the route is + // invite-gated at runtime unless an admin flips the flag. The + // signup handler loads app_settings per request; switch to a + // cached Arc> shared with the future admin + // update handler when that lands on Rust. + let signup_route_state = fuz_auth::SignupRouteState { + options: Arc::new(fuz_auth::SignupOptions { + pool: pool.clone(), + password_hasher: Arc::clone(&spine_password_hasher), + audit: Arc::clone(&spine_audit_emitter), + signup_ip_rate_limiter: None, + signup_account_rate_limiter: None, + signup_fail_floor_ms: fuz_auth::DEFAULT_SIGNUP_FAIL_FLOOR_MS, + signup_fail_jitter_ms: fuz_auth::DEFAULT_SIGNUP_FAIL_JITTER_MS, + }), + keyring: Arc::clone(&spine_keyring), + allowed_origins: Arc::clone(&spine_allowed_origins), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + }; + + let app_state = Arc::new(handlers::App::new( + pool, + scoped_fs, + config.zzz_dir, + scoped_dir_strings, + provider_manager, + config.enable_test_actions, + Arc::clone(&realtime), + )); + + // Register audit-event → WebSocket socket-revocation listeners on + // the spine `AuditEmitter`. Mirrors `fuz_app`'s + // `create_ws_auth_guard` + `create_ws_logout_closer` composition. + // + // One listener per event type — keeps matching logic explicit and + // avoids a per-event match cascade in a single closure. Failure + // outcomes never trigger socket close: a failed `session_revoke` row + // carries the caller-submitted `session_id` (attacker-controlled + // metadata), so reacting to it would let an authenticated user + // disconnect another user by guessing a session hash. + // + // ## Layering with eager handler-side close + // + // Revocation-emitting RPC handlers (`account_session_revoke`, + // `account_session_revoke_all`, `account_token_revoke`) and REST + // handlers (`/api/account/logout`, `/api/account/password`) call + // `close_sockets_for_*` synchronously before emitting the audit row. + // That eager call is the actual revocation guarantee — it lands on + // the live WS even if the audit INSERT later fails. The listeners + // run on the materialized row and call the same idempotent + // `close_sockets_for_*` a second time; the duplication is + // intentional defense-in-depth. + fuz_auth::register_socket_revocation_listeners(&spine_audit_emitter, &socket_revoker); + + // SSE half of the audit fan-out — every audit row becomes one `data:` + // frame on each open `/api/admin/audit/stream` subscription, and a + // successful account-wide revocation drops that account's streams. Mirrors + // `fuz_app`'s `create_audit_log_sse`; the socket-revocation listeners above + // are the WS half. + fuz_realtime::register_audit_sse_listener(&spine_audit_emitter, &audit_sse); + + // Compile the spine action registry — must run after `Arc` is + // constructed because the zzz-specific spec builders capture + // `Arc::clone(&app_state)` into per-spec handler closures. + // + // Composition order: protocol (heartbeat + cancel), then + // `fuz_auth` placeholder adapters (account + admin self-service), + // then zzz-specific specs (`core`, `workspace`, `filesystem`, + // `terminal`, `provider`). + let mut all_specs: Vec = fuz_actions::PROTOCOL_ACTION_SPECS(); + all_specs.extend(fuz_actions::auth_adapter::build_auth_spec_set( + Arc::clone(&spine_audit_emitter), + Arc::clone(&socket_revoker), + action_account_rate_limiter.clone(), + action_ip_rate_limiter.clone(), + Arc::new(fuz_auth::AdminOfferAuthorize), + Arc::new(fuz_auth::RoleRegistry::default()), + )); + all_specs.extend(zzz_action_specs::build_core_specs(Arc::clone(&app_state))); + all_specs.extend(zzz_action_specs::build_workspace_specs(Arc::clone( + &app_state, + ))); + all_specs.extend(zzz_action_specs::build_filesystem_specs(Arc::clone( + &app_state, + ))); + all_specs.extend(zzz_action_specs::build_terminal_specs(Arc::clone( + &app_state, + ))); + all_specs.extend(zzz_action_specs::build_provider_specs(Arc::clone( + &app_state, + ))); + if app_state.enable_test_actions { + all_specs.extend(zzz_action_specs::build_testing_specs(Arc::clone( + &app_state, + ))); + } + if let Some(factory) = extra_action_specs_factory { + let runtime = fuz_actions::ExtraActionSpecsRuntime { + password_hasher: Arc::clone(&spine_password_hasher), + keyring: Arc::clone(&spine_keyring), + daemon_token_state: spine_daemon_token.clone(), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + }; + all_specs.extend(factory(Arc::clone(&app_state), runtime)); + } + let action_registry = Arc::new( + fuz_actions::ActionRegistry::compile(all_specs) + .map_err(|e| ServerError::Config(format!("ActionRegistry::compile failed: {e}")))?, + ); + // Set the action_registry on App via OnceLock. The set call returns + // Err only if the cell is already populated, which is impossible + // here because we just constructed the Arc. + if app_state.action_registry.set(action_registry).is_err() { + return Err(ServerError::Config( + "action_registry was already set — unexpected double init".to_owned(), + )); + } + tracing::info!( + spec_count = app_state.action_registry.get().map_or(0, |r| r.len()), + "spine action registry compiled" + ); + + // Start file watchers at startup (matches Deno's Backend constructor + // which calls `this.#start_filer(this.zzz_dir)` then iterates scoped_dirs). + // zzz_dir uses FilerConfig::zzz_dir() (no .zzz ignore); scoped_dirs use workspace config. + match app_state + .filer_manager + .start_filer( + &app_state.zzz_dir, + Arc::clone(&app_state), + filer::FilerConfig::zzz_dir(), + filer::FilerLifetime::Permanent, + ) + .await + { + Ok(_) => tracing::info!(path = %app_state.zzz_dir, "started zzz_dir filer"), + Err(e) => { + tracing::warn!(path = %app_state.zzz_dir, error = %e, "failed to start zzz_dir filer"); + } + } + + for dir in &app_state.scoped_dirs { + if *dir == app_state.zzz_dir { + continue; + } + match app_state + .filer_manager + .start_filer( + dir, + Arc::clone(&app_state), + filer::FilerConfig::workspace(&app_state.zzz_dir), + filer::FilerLifetime::Permanent, + ) + .await + { + Ok(_) => tracing::info!(path = %dir, "started scoped_dir filer"), + Err(e) => tracing::warn!(path = %dir, error = %e, "failed to start scoped_dir filer"), + } + } + + // Spawn daemon-token rotation task on the spine state (matches + // `fuz_app`'s rotation cadence; the spine `spawn_rotation_task` uses + // `parking_lot::RwLock` so no async runtime hop per rotation). + let rotation_handle = spine_daemon_token + .as_ref() + .map(|state| fuz_auth::spawn_rotation_task(Arc::clone(state))); + + let app_state_for_shutdown = Arc::clone(&app_state); + + // -- Spine RPC + WS routes ------------------------------------- + // + // The spine `ActionRegistry` dispatcher is mounted at `/api/rpc` + // and the spine WS handler at `/api/ws` — the single namespace + // per the ecosystem's pre-stable posture (no `/v2` suffix, no + // compat shim, no deprecation period). The boot-compiled + // `ActionRegistry` (protocol + auth_adapter + zzz-specific + // specs) is the sole dispatcher for `/api/rpc` and `/api/ws` + // traffic. + // + // `app.broadcast` is shimmed onto `App.realtime` + // (see `handlers/mod.rs`). + // + // Middleware: each spine router carries its own + // `fuz_http::client_ip_middleware` layer over + // `spine_trusted_proxies`. The outer router (`/api/account/*` REST + // + `/api/account/bootstrap`) also reads `Extension`; + // a separate `fuz_http::client_ip_middleware` + // layer below covers the outer scope. + let registry_for_rpc = Arc::clone(app_state.action_registry.get().ok_or_else(|| { + ServerError::Config("action_registry must be set before mounting /api/rpc".to_owned()) + })?); + let spine_rpc_state = fuz_actions::RpcRouteState { + pool: app_state.db_pool.clone(), + keyring: Arc::clone(&spine_keyring), + daemon_token_state: spine_daemon_token.clone(), + allowed_origins: Arc::clone(&spine_allowed_origins), + registry: registry_for_rpc, + audit: Arc::clone(&spine_audit_emitter), + socket_revoker: Arc::clone(&socket_revoker), + // Same `ConnectionRegistry` the WS endpoint populates, so a + // notification emitted on the HTTP dispatch path reaches the + // live sockets rather than an empty registry. + notification_sender: Arc::clone(&realtime).into_notification_sender(), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + account_rate_limiter: None, + ip_rate_limiter: None, + }; + let spine_rpc_router = fuz_actions::create_rpc_router(spine_rpc_state) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&spine_trusted_proxies), + fuz_http::client_ip_middleware, + )) + // 1 MiB request-body cap — the shared `DEFAULT_BODY_LIMIT_BYTES`, same + // as fuz_forge_server + mageguild_server. diskfile content rides the + // RPC body, so edits are bounded to 1 MiB over RPC; a streaming + // content-addressed route is the deferred path for larger / binary blobs. + .layer(fuz_http::body_limit_layer( + fuz_http::DEFAULT_BODY_LIMIT_BYTES, + )); + + let registry_for_ws = Arc::clone(app_state.action_registry.get().ok_or_else(|| { + ServerError::Config("action_registry must be set before mounting /api/ws".to_owned()) + })?); + let spine_ws_state = fuz_actions::WsRouteState { + pool: app_state.db_pool.clone(), + keyring: Arc::clone(&spine_keyring), + daemon_token_state: spine_daemon_token.clone(), + allowed_origins: Arc::clone(&spine_allowed_origins), + registry: registry_for_ws, + audit: Arc::clone(&spine_audit_emitter), + socket_revoker: Arc::clone(&socket_revoker), + notification_sender: Arc::clone(&realtime).into_notification_sender(), + connection_registry: Arc::clone(&realtime), + session_cookie_name: fuz_auth::SESSION_COOKIE_NAME, + account_rate_limiter: None, + ip_rate_limiter: None, + }; + let spine_ws_router = fuz_actions::register_action_ws(spine_ws_state).layer( + axum::middleware::from_fn_with_state( + Arc::clone(&spine_trusted_proxies), + fuz_http::client_ip_middleware, + ), + ); + + // Spine account REST router: mounts `/status`, `/login`, `/logout`, + // `/password` under `/api/account`. + // `fuz_http::client_ip_middleware` is wrapped on the router so + // `Extension` is populated for every account + // route (rate-limit keys + audit_log.ip). + let spine_account_router = fuz_auth::account_router(account_route_state) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&spine_trusted_proxies), + fuz_http::client_ip_middleware, + )) + .layer(fuz_http::body_limit_layer( + fuz_http::DEFAULT_BODY_LIMIT_BYTES, + )); + + // Spine bootstrap router: mounts `/bootstrap` at the router root, so + // nesting under `/api/account` produces `/api/account/bootstrap`. + // Replaces the legacy `crate::bootstrap::bootstrap_handler`. + let spine_bootstrap_router = + fuz_auth::bootstrap_routes::bootstrap_router(bootstrap_route_state) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&spine_trusted_proxies), + fuz_http::client_ip_middleware, + )) + .layer(fuz_http::body_limit_layer( + fuz_http::DEFAULT_BODY_LIMIT_BYTES, + )); + + // Spine signup router: mounts `/signup` at the router root, so + // nesting under `/api/account` produces `/api/account/signup`. + // Same client_ip_middleware layer so audit_log.ip on success + + // failure rows reflects the resolved client IP rather than the + // proxy peer. + let spine_signup_router = fuz_auth::signup_routes::signup_router(signup_route_state) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&spine_trusted_proxies), + fuz_http::client_ip_middleware, + )) + .layer(fuz_http::body_limit_layer( + fuz_http::DEFAULT_BODY_LIMIT_BYTES, + )); + + // Spine audit-log SSE stream: `GET /api/admin/audit/stream` — the shared + // `fuz_realtime::audit_stream_router` (admin-gated, account-keyed close on + // revocation), wired to the `audit_sse` registry the listener above fans + // rows into. Carries its own `origin_layer` so the origin allowlist gates + // it like every other zzz handler; it resolves auth itself and writes no + // `audit_log.ip`, so no `client_ip` layer is needed. + let spine_audit_stream_router = + fuz_realtime::audit_stream_router(fuz_realtime::AuditStreamRouteState::new( + app_state.db_pool.clone(), + Arc::clone(&spine_keyring), + spine_daemon_token.clone(), + Arc::clone(&audit_sse), + )) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&spine_allowed_origins), + fuz_http::origin_layer, + )); + + let mut app = Router::new() + .route("/health", get(health_handler)) + // Spine REST routers — account REST + bootstrap. The order of + // `.nest("/api/account", ...)` calls doesn't matter because the + // bootstrap router only exposes `/bootstrap` and account exposes + // the four other paths. axum merges nests at the same prefix. + .nest("/api/account", spine_account_router) + .nest("/api/account", spine_bootstrap_router) + .nest("/api/account", spine_signup_router) + // Spine RPC + WS — single canonical mount. `create_rpc_router` + // exposes `/rpc` and `register_action_ws` exposes `/ws`, so + // nesting at `/api` produces `/api/rpc` and `/api/ws`. Both + // nested routers carry their own state (`RpcRouteState` / + // `WsRouteState`) + middleware stack. + .nest("/api", spine_rpc_router) + .nest("/api", spine_ws_router) + // Admin-gated audit-log SSE stream — absolute path, so merge (not nest). + .merge(spine_audit_stream_router); + + if let Some(ref dir) = config.static_dir { + tracing::info!(dir = %dir.display(), "serving static files"); + app = app.fallback_service(ServeDir::new(dir)); + } + + let addr = config.bind_addr; + let listener = TcpListener::bind(addr) + .await + .map_err(|source| ServerError::Bind { addr, source })?; + + tracing::info!("zzz_server listening on {addr}"); + + // Signal handling + graceful drain come from the spine + // (`fuz_http::lifecycle`) — the SIGINT/SIGTERM → `CancellationToken` + // → drain dance is shared with the other spine consumers. zzz's own + // teardown (rotation-task abort, PTY cleanup) runs after the drain + // returns. + let shutdown = fuz_http::shutdown_token(); + fuz_http::serve_with_shutdown(listener, app, shutdown, drain_timeout) + .await + .map_err(ServerError::Serve)?; + + // Stop daemon token rotation + if let Some(handle) = rotation_handle { + handle.abort(); + } + + // Clean up spawned terminal processes before exiting + app_state_for_shutdown.pty_manager.kill_all().await; + + tracing::info!("server shutdown complete"); + Ok(()) +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +async fn health_handler() -> Json { + Json(HealthResponse { status: "ok" }) +} + +// -- Config ------------------------------------------------------------------- + +/// Validated config built from CLI args + env vars. +pub struct Config { + pub bind_addr: SocketAddr, + pub static_dir: Option, + pub database_url: String, + pub secret_cookie_keys: String, + pub bootstrap_token_path: Option, + pub allowed_origins: Option, + pub scoped_dirs: Vec, + pub zzz_dir: String, + /// Register `_testing_*` actions on live dispatchers. Set by integration + /// tests via `ZZZ_ENABLE_TEST_ACTIONS=1`; production must leave unset. + pub enable_test_actions: bool, + /// Comma-separated trusted-proxy entries (IPs and CIDR ranges). + /// Unset/empty → no XFF trust → `client_ip` falls back to the TCP + /// peer IP on every request. Set when running behind a reverse + /// proxy so login rate-limit keys and `audit_log.ip` reflect the + /// originating client. Parsed eagerly in `run()`; invalid entries + /// fail startup. + pub trusted_proxies: Option, +} + +/// Read a Zod-`stringbool()`-shaped env var via the spine parser +/// ([`fuz_sys::env::parse_stringbool`]): case-insensitive truthy +/// (`true`/`1`/`yes`/`on`/`y`/`enabled`) / falsy +/// (`false`/`0`/`no`/`off`/`n`/`disabled`). Unset → `false`; unknown +/// values error so a typo doesn't silently disable the feature. +fn parse_stringbool_env(name: &str) -> Result { + let Ok(v) = std::env::var(name) else { + return Ok(false); + }; + fuz_sys::env::parse_stringbool(&v).map_err(|e| ServerError::Config(format!("{name}: {e}"))) +} + +/// Resolve a path to an absolute, canonical, normalized directory string +/// with trailing `/`. Tries `canonicalize` (resolves symlinks, requires path +/// to exist), falls back to `absolute` (no I/O), falls back to the raw path. +fn resolve_dir(path: &Path) -> String { + let mut s = std::fs::canonicalize(path) + .unwrap_or_else(|_| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())) + .to_string_lossy() + .into_owned(); + if !s.ends_with('/') { + s.push('/'); + } + s +} + +fn parse_config(default_addr: SocketAddr) -> Result { + let mut port: Option = None; + let mut static_dir: Option = None; + + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--port" => { + i += 1; + if let Some(val) = args.get(i) { + if let Ok(p) = val.parse() { + port = Some(p); + } else { + tracing::warn!(value = val.as_str(), "invalid --port value, ignoring"); + } + } + } + "--static-dir" => { + i += 1; + if let Some(val) = args.get(i) { + static_dir = Some(PathBuf::from(val)); + } + } + _ => {} + } + i += 1; + } + + // Fall back to env vars for port/static_dir + if port.is_none() + && let Ok(val) = std::env::var("ZZZ_PORT") + { + if let Ok(p) = val.parse() { + port = Some(p); + } else { + tracing::warn!(value = val.as_str(), "invalid ZZZ_PORT value, ignoring"); + } + } + if static_dir.is_none() + && let Ok(val) = std::env::var("ZZZ_STATIC_DIR") + { + static_dir = Some(PathBuf::from(val)); + } + + // Required env vars + let database_url = std::env::var("DATABASE_URL") + .map_err(|_| ServerError::Config("DATABASE_URL is required".to_owned()))?; + + let secret_cookie_keys = std::env::var("SECRET_FUZ_COOKIE_KEYS") + .map_err(|_| ServerError::Config("SECRET_FUZ_COOKIE_KEYS is required".to_owned()))?; + + let bootstrap_token_path = std::env::var("FUZ_BOOTSTRAP_TOKEN_PATH").ok(); + let allowed_origins = std::env::var("FUZ_ALLOWED_ORIGINS").ok(); + + let scoped_dirs = std::env::var("PUBLIC_ZZZ_SCOPED_DIRS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + + let zzz_dir = { + let raw = std::env::var("PUBLIC_ZZZ_DIR").unwrap_or_else(|_| ".zzz/".to_owned()); + resolve_dir(Path::new(&raw)) + }; + + let enable_test_actions = parse_stringbool_env("ZZZ_ENABLE_TEST_ACTIONS")?; + let trusted_proxies = std::env::var("ZZZ_TRUSTED_PROXIES").ok(); + + Ok(Config { + bind_addr: SocketAddr::new( + default_addr.ip(), + port.unwrap_or_else(|| default_addr.port()), + ), + static_dir, + database_url, + secret_cookie_keys, + bootstrap_token_path, + allowed_origins, + scoped_dirs, + zzz_dir, + enable_test_actions, + trusted_proxies, + }) +} diff --git a/crates/zzz_server/src/main.rs b/crates/zzz_server/src/main.rs new file mode 100644 index 000000000..3b04015e8 --- /dev/null +++ b/crates/zzz_server/src/main.rs @@ -0,0 +1,38 @@ +//! `zzz_server` production binary. +//! +//! Thin entry point — wires [`fuz_auth::Argon2idHasher`] (production +//! argon2 params, ~30-50 ms per hash) and hands off to +//! [`zzz_server::run_app`] which owns the full server lifecycle. +//! +//! The `testing_zzz_server` binary in `crates/testing_zzz_server/` +//! ships the same lifecycle with `fuz_testing::TestingArgon2idHasher` +//! swapped in for ~1-5 ms argon2 during cross-process integration +//! tests. + +use std::sync::Arc; + +use fuz_auth::PasswordHasher; + +#[tokio::main] +async fn main() { + // Non-blocking stdout logging so a stalled stdout consumer can't starve + // the async runtime. `_log_guard` must stay live for the whole process. + let _log_guard = fuz_sys::logging::init_non_blocking_stdout("info"); + + let password_hasher: Arc = Arc::new(fuz_auth::Argon2idHasher::new()); + + if let Err(e) = zzz_server::run_app(zzz_server::RunAppOptions { + password_hasher, + default_addr: zzz_server::DEFAULT_ADDR, + drain_timeout: fuz_http::DEFAULT_DRAIN_TIMEOUT, + force_test_actions: false, + disable_login_rate_limit: false, + extra_action_specs_factory: None, + pre_migration_hook: None, + }) + .await + { + tracing::error!(error = %e, "fatal"); + std::process::exit(1); + } +} diff --git a/crates/zzz_server/src/provider/anthropic.rs b/crates/zzz_server/src/provider/anthropic.rs new file mode 100644 index 000000000..a4894aa2e --- /dev/null +++ b/crates/zzz_server/src/provider/anthropic.rs @@ -0,0 +1,393 @@ +use std::ops::ControlFlow; + +use fuz_http::JsonrpcError; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use super::{ + CompletionHandlerOptions, CompletionMessage, PROVIDER_ERROR_NEEDS_API_KEY, ProgressSender, + ProviderName, ProviderStatus, ai_provider_error, common, sse, +}; + +const API_URL: &str = "https://api.anthropic.com/v1/messages"; +const API_VERSION: &str = "2023-06-01"; +const PROVIDER: ProviderName = ProviderName::Claude; +/// The `&str` form (derived from `PROVIDER`) for the error/SSE plumbing. +const PROVIDER_NAME: &str = PROVIDER.as_str(); + +// -- Provider state ----------------------------------------------------------- + +struct AnthropicState { + client: Option, + cached_status: Option, +} + +/// Anthropic/Claude AI provider. +/// +/// Uses the Messages API with optional SSE streaming. +/// State is behind `tokio::sync::RwLock` because: +/// - `set_api_key` writes from keeper RPC handlers +/// - `load_status` reads and caches status +pub struct AnthropicProvider { + state: RwLock, +} + +impl AnthropicProvider { + pub fn new(api_key: Option) -> Self { + let client = api_key.map(|key| build_client(&key)); + Self { + state: RwLock::new(AnthropicState { + client, + cached_status: None, + }), + } + } + + pub async fn load_status(&self, reload: bool) -> ProviderStatus { + let state = self.state.read().await; + if !reload && let Some(ref status) = state.cached_status { + return status.clone(); + } + // Drop read lock before acquiring write lock + let has_client = state.client.is_some(); + drop(state); + + let status = if has_client { + ProviderStatus::available(PROVIDER) + } else { + ProviderStatus::unavailable(PROVIDER, PROVIDER_ERROR_NEEDS_API_KEY) + }; + + let mut state = self.state.write().await; + state.cached_status = Some(status.clone()); + status + } + + pub async fn set_api_key(&self, key: Option) { + let mut state = self.state.write().await; + state.client = key.as_deref().map(build_client); + state.cached_status = None; + } + + pub async fn complete( + &self, + options: &CompletionHandlerOptions, + progress_sender: Option<&ProgressSender>, + signal: &CancellationToken, + ) -> Result { + // Clone the client (cheap — internally Arc'd) and release the lock + // before the HTTP call. This avoids blocking set_api_key for the + // duration of a potentially long-running streaming response. + let client = { + let state = self.state.read().await; + state + .client + .clone() + .ok_or_else(|| ai_provider_error(PROVIDER_NAME, PROVIDER_ERROR_NEEDS_API_KEY))? + }; + + let streaming = progress_sender.is_some(); + let body = build_request_body(options, streaming); + + let response: reqwest::Response = client + .post(API_URL) + .json(&body) + .send() + .await + .map_err(|e: reqwest::Error| ai_provider_error(PROVIDER_NAME, &e.to_string()))?; + + let response = + common::check_response_status(response, PROVIDER_NAME, parse_api_error).await?; + + if let (true, Some(sender)) = (streaming, progress_sender) { + handle_streaming_response(response, options, sender, signal).await + } else { + handle_non_streaming_response(response, options).await + } + } +} + +async fn handle_non_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, +) -> Result { + let api_response: Value = response + .json::() + .await + .map_err(|e: reqwest::Error| { + ai_provider_error(PROVIDER_NAME, &format!("failed to parse response: {e}")) + })?; + + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &api_response, + )) +} + +async fn handle_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, + progress_sender: &ProgressSender, + signal: &CancellationToken, +) -> Result { + let mut accumulated_content = String::new(); + let mut message_id = String::new(); + let mut final_usage: Option = None; + let mut stop_reason = String::from("end_turn"); + + sse::consume_sse_stream(response, PROVIDER_NAME, signal, |event| { + let Some(event_type) = event.event_type.as_deref() else { + return ControlFlow::Continue(()); + }; + let Ok(data) = serde_json::from_str::(&event.data) else { + return ControlFlow::Continue(()); + }; + match event_type { + "message_start" => { + if let Some(id) = data + .get("message") + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + { + id.clone_into(&mut message_id); + } + } + "content_block_delta" => { + if let Some(text) = data + .get("delta") + .and_then(|d| d.get("text")) + .and_then(Value::as_str) + { + accumulated_content.push_str(text); + progress_sender(common::build_text_progress_chunk(text)); + } + } + "message_delta" => { + if let Some(sr) = data + .get("delta") + .and_then(|d| d.get("stop_reason")) + .and_then(Value::as_str) + { + sr.clone_into(&mut stop_reason); + } + if let Some(usage) = data.get("usage") { + final_usage = Some(usage.clone()); + } + } + _ => {} + } + ControlFlow::Continue(()) + }) + .await?; + + let api_response = json!({ + "id": message_id, + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": accumulated_content}], + "model": options.model, + "stop_reason": stop_reason, + "stop_sequence": null, + "usage": final_usage, + }); + + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &api_response, + )) +} + +// -- Request building --------------------------------------------------------- + +fn build_request_body(options: &CompletionHandlerOptions, stream: bool) -> Value { + let messages = build_messages(options.completion_messages.as_deref(), &options.prompt); + let opts = &options.completion_options; + + let mut body = json!({ + "model": options.model, + "max_tokens": opts.output_token_max, + "stream": stream, + "messages": messages, + }); + + let obj = body.as_object_mut().unwrap_or_else(|| unreachable!()); + + if !opts.system_message.is_empty() { + obj.insert("system".to_owned(), json!(opts.system_message)); + } + if let Some(t) = opts.temperature { + obj.insert("temperature".to_owned(), json!(t)); + } + if let Some(k) = opts.top_k { + obj.insert("top_k".to_owned(), json!(k)); + } + if let Some(p) = opts.top_p { + obj.insert("top_p".to_owned(), json!(p)); + } + if let Some(ref seqs) = opts.stop_sequences + && !seqs.is_empty() + { + obj.insert("stop_sequences".to_owned(), json!(seqs)); + } + + body +} + +/// Convert `CompletionMessage[]` + prompt into the Anthropic messages format. +/// +/// Filters out system role messages (system is passed as a separate field). +/// Appends the prompt as a final user message. +fn build_messages(completion_messages: Option<&[CompletionMessage]>, prompt: &str) -> Vec { + let capacity = completion_messages.map_or(0, <[_]>::len) + 1; // +1 for prompt + let mut messages: Vec = Vec::with_capacity(capacity); + + if let Some(msgs) = completion_messages { + for msg in msgs { + if msg.role == "system" { + continue; + } + messages.push(json!({ + "role": msg.role, + "content": [{"type": "text", "text": msg.content}], + })); + } + } + + messages.push(json!({ + "role": "user", + "content": [{"type": "text", "text": prompt}], + })); + + messages +} + +// -- HTTP client -------------------------------------------------------------- + +fn build_client(api_key: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(val) = reqwest::header::HeaderValue::from_str(api_key) { + headers.insert("x-api-key", val); + } + headers.insert( + "anthropic-version", + reqwest::header::HeaderValue::from_static(API_VERSION), + ); + common::build_client_with_headers(headers) +} + +// -- Error parsing ------------------------------------------------------------ + +/// Parse an Anthropic API error response body. +/// +/// Anthropic errors look like: `{"type":"error","error":{"type":"...","message":"..."}}` +fn parse_api_error(body: &str) -> Option { + let v: Value = serde_json::from_str(body).ok()?; + v.get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(String::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::CompletionOptions; + + fn opts() -> CompletionHandlerOptions { + CompletionHandlerOptions { + model: "claude-3-haiku".to_owned(), + completion_options: CompletionOptions::default(), + completion_messages: None, + prompt: "hi".to_owned(), + } + } + + #[test] + fn request_body_includes_stream_flag() { + let body = build_request_body(&opts(), true); + assert_eq!(body["stream"], true); + let body = build_request_body(&opts(), false); + assert_eq!(body["stream"], false); + } + + #[test] + fn request_body_omits_optional_fields_when_none() { + let body = build_request_body(&opts(), false); + assert!(body.get("temperature").is_none()); + assert!(body.get("top_k").is_none()); + assert!(body.get("top_p").is_none()); + assert!(body.get("stop_sequences").is_none()); + // system_message defaults to "" — skipped + assert!(body.get("system").is_none()); + } + + #[test] + fn request_body_includes_optional_fields_when_set() { + let mut o = opts(); + o.completion_options.temperature = Some(0.5); + o.completion_options.top_p = Some(0.9); + o.completion_options.top_k = Some(40); + o.completion_options.system_message = "be concise".to_owned(); + o.completion_options.stop_sequences = Some(vec!["END".to_owned()]); + let body = build_request_body(&o, false); + assert_eq!(body["temperature"], 0.5); + assert_eq!(body["top_p"], 0.9); + assert_eq!(body["top_k"], 40); + assert_eq!(body["system"], "be concise"); + assert_eq!(body["stop_sequences"], json!(["END"])); + } + + #[test] + fn request_body_omits_empty_stop_sequences() { + let mut o = opts(); + o.completion_options.stop_sequences = Some(vec![]); + let body = build_request_body(&o, false); + assert!(body.get("stop_sequences").is_none()); + } + + #[test] + fn messages_appends_prompt_as_user() { + let m = build_messages(None, "hello"); + assert_eq!(m.len(), 1); + assert_eq!(m[0]["role"], "user"); + assert_eq!(m[0]["content"][0]["text"], "hello"); + assert_eq!(m[0]["content"][0]["type"], "text"); + } + + #[test] + fn messages_filters_system_role() { + let history = vec![ + CompletionMessage { + role: "system".to_owned(), + content: "ignored".to_owned(), + }, + CompletionMessage { + role: "assistant".to_owned(), + content: "prior".to_owned(), + }, + ]; + let m = build_messages(Some(&history), "now"); + // assistant kept + prompt appended; system dropped + assert_eq!(m.len(), 2); + assert_eq!(m[0]["role"], "assistant"); + assert_eq!(m[0]["content"][0]["text"], "prior"); + assert_eq!(m[1]["role"], "user"); + assert_eq!(m[1]["content"][0]["text"], "now"); + } + + #[test] + fn parse_api_error_extracts_message() { + let body = r#"{"type":"error","error":{"type":"x","message":"key invalid"}}"#; + assert_eq!(parse_api_error(body).as_deref(), Some("key invalid")); + } + + #[test] + fn parse_api_error_returns_none_on_malformed_input() { + assert!(parse_api_error("not json").is_none()); + assert!(parse_api_error(r#"{"no":"error"}"#).is_none()); + assert!(parse_api_error(r#"{"error":{"type":"x"}}"#).is_none()); + } +} diff --git a/crates/zzz_server/src/provider/common.rs b/crates/zzz_server/src/provider/common.rs new file mode 100644 index 000000000..7ac1881af --- /dev/null +++ b/crates/zzz_server/src/provider/common.rs @@ -0,0 +1,120 @@ +//! Shared helpers used by every provider implementation. +//! +//! `build_completion_response` wraps a provider's final payload in the +//! discriminated-union envelope the frontend expects. +//! `build_text_progress_chunk` produces the uniform streaming-chunk shape +//! `{message: {role, content}}` that the text-streaming providers emit on +//! every delta. + +use fuz_http::JsonrpcError; +use reqwest::header::HeaderMap; +use serde_json::{Value, json}; + +use super::ai_provider_error; + +/// Wrap a provider-native response in the `completion_response` envelope. +/// +/// `provider_name` doubles as the `data.type` discriminator — per the TS +/// `ProviderData` schema the two always match. +pub fn build_completion_response(provider_name: &str, model: &str, data_value: &Value) -> Value { + json!({ + "completion_response": { + "created": fuz_sys::rfc3339_now(), + "provider_name": provider_name, + "model": model, + "data": { + "type": provider_name, + "value": data_value, + }, + }, + }) +} + +/// Uniform streaming-chunk shape for text-only providers. +/// +/// Matches the TS `CompletionProgressInput.chunk` schema's text-delta +/// shape: `{message: {role: 'assistant', content}}`. +pub fn build_text_progress_chunk(content: &str) -> Value { + json!({ + "message": { + "role": "assistant", + "content": content, + } + }) +} + +/// Build a `reqwest::Client` with the given default headers, falling back +/// to a bare client if header construction fails. +pub fn build_client_with_headers(headers: HeaderMap) -> reqwest::Client { + // reqwest uses `rustls-no-provider`; install the `ring` provider first. + fuz_sys::tls::ensure_crypto_provider(); + reqwest::Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Pass `response` through on success; on non-2xx, read the body, run +/// `parse_api_error` over it, and return a provider-tagged JSON-RPC +/// error. Each provider's wire format for errors differs (Anthropic, +/// `OpenAI`, Gemini wrap under `error.message`), so the parser is supplied +/// per call. +pub async fn check_response_status( + response: reqwest::Response, + provider_name: &str, + parse_api_error: F, +) -> Result +where + F: FnOnce(&str) -> Option, +{ + if response.status().is_success() { + return Ok(response); + } + let error_body = response + .text() + .await + .unwrap_or_else(|_| String::from("unknown error")); + let error_msg = parse_api_error(&error_body).unwrap_or(error_body); + Err(ai_provider_error(provider_name, &error_msg)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_response_envelope() { + let value = build_completion_response("claude", "claude-3", &json!({"x": 1})); + let resp = &value["completion_response"]; + assert_eq!(resp["provider_name"], "claude"); + assert_eq!(resp["model"], "claude-3"); + assert_eq!(resp["data"]["type"], "claude"); + assert_eq!(resp["data"]["value"], json!({"x": 1})); + assert!( + resp["created"].is_string(), + "created should be RFC3339 string" + ); + } + + #[test] + fn completion_response_data_type_matches_provider_name() { + for name in ["claude", "chatgpt", "gemini"] { + let value = build_completion_response(name, "m", &Value::Null); + assert_eq!(value["completion_response"]["provider_name"], name); + assert_eq!(value["completion_response"]["data"]["type"], name); + } + } + + #[test] + fn text_progress_chunk_shape() { + let chunk = build_text_progress_chunk("hello"); + assert_eq!(chunk["message"]["role"], "assistant"); + assert_eq!(chunk["message"]["content"], "hello"); + } + + #[test] + fn text_progress_chunk_empty_content_preserved() { + let chunk = build_text_progress_chunk(""); + assert_eq!(chunk["message"]["content"], ""); + } +} diff --git a/crates/zzz_server/src/provider/gemini.rs b/crates/zzz_server/src/provider/gemini.rs new file mode 100644 index 000000000..729def74b --- /dev/null +++ b/crates/zzz_server/src/provider/gemini.rs @@ -0,0 +1,524 @@ +use std::ops::ControlFlow; + +use fuz_http::JsonrpcError; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use super::{ + CompletionHandlerOptions, CompletionMessage, PROVIDER_ERROR_NEEDS_API_KEY, ProgressSender, + ProviderName, ProviderStatus, ai_provider_error, common, sse, +}; + +const API_BASE: &str = "https://generativelanguage.googleapis.com/v1beta/models"; +const PROVIDER: ProviderName = ProviderName::Gemini; +/// The `&str` form (derived from `PROVIDER`) for the error/SSE plumbing. +const PROVIDER_NAME: &str = PROVIDER.as_str(); + +struct GeminiState { + api_key: Option, + cached_status: Option, +} + +/// Google Gemini AI provider. +/// +/// Uses the Generative Language REST API directly. SDK is not used to +/// avoid the dependency; the REST surface mirrors the SDK closely. +pub struct GeminiProvider { + /// Client carries no auth headers (Gemini puts the key in the URL), + /// so it's stable for the provider's lifetime and lives outside the + /// `RwLock` to avoid serializing clone-out behind status writes. + client: reqwest::Client, + state: RwLock, +} + +impl GeminiProvider { + pub fn new(api_key: Option) -> Self { + Self { + client: build_client(), + state: RwLock::new(GeminiState { + api_key, + cached_status: None, + }), + } + } + + pub async fn load_status(&self, reload: bool) -> ProviderStatus { + let state = self.state.read().await; + if !reload && let Some(ref status) = state.cached_status { + return status.clone(); + } + let has_key = state.api_key.is_some(); + drop(state); + + let status = if has_key { + ProviderStatus::available(PROVIDER) + } else { + ProviderStatus::unavailable(PROVIDER, PROVIDER_ERROR_NEEDS_API_KEY) + }; + + let mut state = self.state.write().await; + state.cached_status = Some(status.clone()); + status + } + + pub async fn set_api_key(&self, key: Option) { + let mut state = self.state.write().await; + state.api_key = key; + state.cached_status = None; + } + + pub async fn complete( + &self, + options: &CompletionHandlerOptions, + progress_sender: Option<&ProgressSender>, + signal: &CancellationToken, + ) -> Result { + let api_key = { + let state = self.state.read().await; + state + .api_key + .clone() + .ok_or_else(|| ai_provider_error(PROVIDER_NAME, PROVIDER_ERROR_NEEDS_API_KEY))? + }; + let client = self.client.clone(); + + let streaming = progress_sender.is_some(); + let url = build_url(&options.model, &api_key, streaming); + let body = build_request_body(options); + + let response = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| ai_provider_error(PROVIDER_NAME, &e.to_string()))?; + + let response = + common::check_response_status(response, PROVIDER_NAME, parse_api_error).await?; + + if let (true, Some(sender)) = (streaming, progress_sender) { + handle_streaming_response(response, options, sender, signal).await + } else { + handle_non_streaming_response(response, options).await + } + } +} + +async fn handle_non_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, +) -> Result { + let api_response: Value = response + .json::() + .await + .map_err(|e| ai_provider_error(PROVIDER_NAME, &format!("failed to parse response: {e}")))?; + let value = build_gemini_value(&api_response); + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &value, + )) +} + +async fn handle_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, + progress_sender: &ProgressSender, + signal: &CancellationToken, +) -> Result { + let mut accumulated_content = String::new(); + let mut last_response: Option = None; + let mut usage_metadata: Option = None; + + sse::consume_sse_stream(response, PROVIDER_NAME, signal, |event| { + let Ok(data) = serde_json::from_str::(&event.data) else { + return ControlFlow::Continue(()); + }; + + let chunk_text = extract_text(&data); + if !chunk_text.is_empty() { + accumulated_content.push_str(&chunk_text); + progress_sender(common::build_text_progress_chunk(&chunk_text)); + } + + if let Some(usage) = data.get("usageMetadata") { + usage_metadata = Some(usage.clone()); + } + last_response = Some(data); + + ControlFlow::Continue(()) + }) + .await?; + + let candidates = last_response + .as_ref() + .and_then(|r| r.get("candidates")) + .cloned() + .unwrap_or(Value::Null); + let function_calls = last_response + .as_ref() + .and_then(extract_function_calls) + .unwrap_or(Value::Null); + let prompt_feedback = last_response + .as_ref() + .and_then(|r| r.get("promptFeedback")) + .cloned() + .unwrap_or(Value::Null); + + let value = json!({ + "text": accumulated_content, + "candidates": candidates, + "function_calls": function_calls, + "prompt_feedback": prompt_feedback, + "usage_metadata": usage_metadata.unwrap_or(Value::Null), + }); + + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &value, + )) +} + +// -- Response extraction ------------------------------------------------------ + +/// Build the `value` payload for the discriminated union — Gemini is the +/// only provider with strictly-typed `value` fields (`text`, +/// `candidates`, `function_calls`, `prompt_feedback`, `usage_metadata`). +fn build_gemini_value(api_response: &Value) -> Value { + let text = extract_text(api_response); + let candidates = api_response + .get("candidates") + .cloned() + .unwrap_or(Value::Null); + let function_calls = extract_function_calls(api_response).unwrap_or(Value::Null); + let prompt_feedback = api_response + .get("promptFeedback") + .cloned() + .unwrap_or(Value::Null); + let usage_metadata = api_response + .get("usageMetadata") + .cloned() + .unwrap_or(Value::Null); + + json!({ + "text": text, + "candidates": candidates, + "function_calls": function_calls, + "prompt_feedback": prompt_feedback, + "usage_metadata": usage_metadata, + }) +} + +/// Concatenate text across all parts of the first candidate. +fn extract_text(response: &Value) -> String { + let Some(parts) = response + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("content")) + .and_then(|c| c.get("parts")) + .and_then(Value::as_array) + else { + return String::new(); + }; + let mut out = String::new(); + for part in parts { + if let Some(text) = part.get("text").and_then(Value::as_str) { + out.push_str(text); + } + } + out +} + +/// Extract function-call parts across all candidates. Returns `None` if +/// no `functionCall` parts are present (lets caller substitute `Null`). +fn extract_function_calls(response: &Value) -> Option { + let candidates = response.get("candidates").and_then(Value::as_array)?; + let mut calls: Vec = Vec::new(); + for candidate in candidates { + let Some(parts) = candidate + .get("content") + .and_then(|c| c.get("parts")) + .and_then(Value::as_array) + else { + continue; + }; + for part in parts { + if let Some(fc) = part.get("functionCall") { + calls.push(fc.clone()); + } + } + } + if calls.is_empty() { + None + } else { + Some(Value::Array(calls)) + } +} + +// -- Request building --------------------------------------------------------- + +fn build_url(model: &str, api_key: &str, streaming: bool) -> String { + let method = if streaming { + "streamGenerateContent" + } else { + "generateContent" + }; + if streaming { + format!("{API_BASE}/{model}:{method}?alt=sse&key={api_key}") + } else { + format!("{API_BASE}/{model}:{method}?key={api_key}") + } +} + +fn build_request_body(options: &CompletionHandlerOptions) -> Value { + let contents = build_contents(options.completion_messages.as_deref(), &options.prompt); + let opts = &options.completion_options; + + let mut generation_config = serde_json::Map::new(); + generation_config.insert("maxOutputTokens".to_owned(), json!(opts.output_token_max)); + if let Some(t) = opts.temperature { + generation_config.insert("temperature".to_owned(), json!(t)); + } + if let Some(k) = opts.top_k { + generation_config.insert("topK".to_owned(), json!(k)); + } + if let Some(p) = opts.top_p { + generation_config.insert("topP".to_owned(), json!(p)); + } + if let Some(f) = opts.frequency_penalty { + generation_config.insert("frequencyPenalty".to_owned(), json!(f)); + } + if let Some(p) = opts.presence_penalty { + generation_config.insert("presencePenalty".to_owned(), json!(p)); + } + if let Some(ref seqs) = opts.stop_sequences + && !seqs.is_empty() + { + generation_config.insert("stopSequences".to_owned(), json!(seqs)); + } + + let mut body = json!({ + "contents": contents, + "generationConfig": Value::Object(generation_config), + }); + + if !opts.system_message.is_empty() { + let obj = body.as_object_mut().unwrap_or_else(|| unreachable!()); + obj.insert( + "systemInstruction".to_owned(), + json!({ + "parts": [{"text": opts.system_message}], + }), + ); + } + + body +} + +fn build_contents(completion_messages: Option<&[CompletionMessage]>, prompt: &str) -> Vec { + let capacity = completion_messages.map_or(0, <[_]>::len) + 1; + let mut contents = Vec::with_capacity(capacity); + + if let Some(msgs) = completion_messages { + for msg in msgs { + let role = if msg.role == "user" { "user" } else { "model" }; + contents.push(json!({ + "role": role, + "parts": [{"text": msg.content}], + })); + } + } + + contents.push(json!({ + "role": "user", + "parts": [{"text": prompt}], + })); + + contents +} + +// -- HTTP client -------------------------------------------------------------- + +fn build_client() -> reqwest::Client { + common::build_client_with_headers(reqwest::header::HeaderMap::new()) +} + +// -- Error parsing ------------------------------------------------------------ + +/// Parse a Gemini API error response body. +/// +/// Gemini errors look like: `{"error":{"code":...,"message":"...","status":"..."}}` +fn parse_api_error(body: &str) -> Option { + let v: Value = serde_json::from_str(body).ok()?; + v.get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(String::from) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests panic on assertion failure by design" +)] +mod tests { + use super::*; + use crate::provider::CompletionOptions; + + fn opts() -> CompletionHandlerOptions { + CompletionHandlerOptions { + model: "gemini-1.5-flash".to_owned(), + completion_options: CompletionOptions::default(), + completion_messages: None, + prompt: "hi".to_owned(), + } + } + + #[test] + fn url_streaming_uses_sse_endpoint() { + let url = build_url("gemini-1.5-flash", "KEY", true); + assert!(url.contains(":streamGenerateContent")); + assert!(url.contains("alt=sse")); + assert!(url.contains("key=KEY")); + } + + #[test] + fn url_non_streaming_uses_basic_endpoint() { + let url = build_url("gemini-1.5-flash", "KEY", false); + assert!(url.contains(":generateContent")); + assert!(!url.contains("streamGenerateContent")); + assert!(!url.contains("alt=sse")); + } + + #[test] + fn request_body_uses_camelcase_generation_config() { + let mut o = opts(); + o.completion_options.temperature = Some(0.5); + o.completion_options.top_k = Some(20); + o.completion_options.top_p = Some(0.9); + o.completion_options.frequency_penalty = Some(0.1); + o.completion_options.presence_penalty = Some(0.2); + o.completion_options.stop_sequences = Some(vec!["X".to_owned()]); + let body = build_request_body(&o); + let cfg = &body["generationConfig"]; + assert!(cfg["maxOutputTokens"].is_number()); + assert_eq!(cfg["temperature"], 0.5); + assert_eq!(cfg["topK"], 20); + assert_eq!(cfg["topP"], 0.9); + assert_eq!(cfg["frequencyPenalty"], 0.1); + assert_eq!(cfg["presencePenalty"], 0.2); + assert_eq!(cfg["stopSequences"], json!(["X"])); + } + + #[test] + fn request_body_includes_system_instruction_when_present() { + let mut o = opts(); + o.completion_options.system_message = "be terse".to_owned(); + let body = build_request_body(&o); + assert_eq!(body["systemInstruction"]["parts"][0]["text"], "be terse"); + } + + #[test] + fn request_body_omits_system_instruction_when_empty() { + let body = build_request_body(&opts()); + assert!(body.get("systemInstruction").is_none()); + } + + #[test] + fn contents_maps_assistant_to_model() { + let history = vec![ + CompletionMessage { + role: "user".to_owned(), + content: "q".to_owned(), + }, + CompletionMessage { + role: "assistant".to_owned(), + content: "a".to_owned(), + }, + ]; + let c = build_contents(Some(&history), "now"); + assert_eq!(c[0]["role"], "user"); + assert_eq!(c[1]["role"], "model"); + assert_eq!(c[1]["parts"][0]["text"], "a"); + assert_eq!(c[2]["role"], "user"); + assert_eq!(c[2]["parts"][0]["text"], "now"); + } + + #[test] + fn extract_text_joins_parts() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [ + {"text": "hello "}, + {"text": "world"}, + ] + } + }] + }); + assert_eq!(extract_text(&response), "hello world"); + } + + #[test] + fn extract_text_returns_empty_when_missing() { + assert_eq!(extract_text(&json!({})), ""); + assert_eq!(extract_text(&json!({"candidates": []})), ""); + } + + #[test] + fn extract_function_calls_returns_some_when_present() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [ + {"text": "calling"}, + {"functionCall": {"name": "foo", "args": {}}}, + ] + } + }] + }); + let calls = extract_function_calls(&response).expect("expected calls"); + assert_eq!(calls.as_array().unwrap().len(), 1); + assert_eq!(calls[0]["name"], "foo"); + } + + #[test] + fn extract_function_calls_returns_none_when_absent() { + let response = json!({"candidates": [{"content": {"parts": [{"text": "hi"}]}}]}); + assert!(extract_function_calls(&response).is_none()); + } + + #[test] + fn gemini_value_uses_snake_case_fields() { + let api_response = json!({ + "candidates": [{ + "content": {"parts": [{"text": "out"}]} + }], + "promptFeedback": {"blocked": false}, + "usageMetadata": {"totalTokenCount": 10}, + }); + let value = build_gemini_value(&api_response); + assert_eq!(value["text"], "out"); + assert!(value["candidates"].is_array()); + assert_eq!(value["prompt_feedback"]["blocked"], false); + assert_eq!(value["usage_metadata"]["totalTokenCount"], 10); + // Absent → Null, not omitted. + assert!(value["function_calls"].is_null()); + } + + #[test] + fn parse_api_error_extracts_message() { + let body = r#"{"error":{"code":400,"message":"bad model","status":"INVALID_ARGUMENT"}}"#; + assert_eq!(parse_api_error(body).as_deref(), Some("bad model")); + } + + #[test] + fn parse_api_error_returns_none_on_malformed_input() { + assert!(parse_api_error("xxx").is_none()); + assert!(parse_api_error(r#"{"error":{"code":500}}"#).is_none()); + } +} diff --git a/crates/zzz_server/src/provider/mod.rs b/crates/zzz_server/src/provider/mod.rs new file mode 100644 index 000000000..6fd622f12 --- /dev/null +++ b/crates/zzz_server/src/provider/mod.rs @@ -0,0 +1,357 @@ +pub mod anthropic; +pub mod common; +pub mod gemini; +pub mod openai; +pub mod sse; + +use std::collections::HashMap; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fuz_http::{JsonrpcError, internal_error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +// -- Provider name enum ------------------------------------------------------- + +/// Known AI provider names. +/// +/// Matches the TypeScript `ProviderName = 'claude' | 'chatgpt' | 'gemini'`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderName { + Claude, + Chatgpt, + Gemini, +} + +impl ProviderName { + #[allow(dead_code)] + pub const ALL: [Self; 3] = [Self::Claude, Self::Chatgpt, Self::Gemini]; + + /// Parse a wire-format provider name (lowercase) without going through + /// `serde_json` — avoids allocating a `Value::String` per request. + pub fn parse(s: &str) -> Option { + match s { + "claude" => Some(Self::Claude), + "chatgpt" => Some(Self::Chatgpt), + "gemini" => Some(Self::Gemini), + _ => None, + } + } + + /// The lowercase wire name — the single home for these literals. + /// `Display`, the serde rename, and each provider's `PROVIDER_NAME` + /// `&str` all derive from this (the inverse of `parse`). + pub const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Chatgpt => "chatgpt", + Self::Gemini => "gemini", + } + } +} + +impl fmt::Display for ProviderName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +// -- Provider status ---------------------------------------------------------- + +/// Status of an AI provider. +/// +/// Mirrors the TypeScript `ProviderStatus` discriminated union — exactly one of +/// `{name, available: true, checked_at}` or +/// `{name, available: false, error, checked_at}`. Modeling it as an enum makes +/// the impossible combinations (available-with-error, unavailable-without-error) +/// unrepresentable rather than holding them off with private constructors; the +/// custom `Serialize` emits the flat wire shape `available`/`error` describe. +#[derive(Debug, Clone)] +pub enum ProviderStatus { + Available { + name: ProviderName, + checked_at: u64, + }, + Unavailable { + name: ProviderName, + checked_at: u64, + error: String, + }, +} + +impl ProviderStatus { + pub fn available(name: ProviderName) -> Self { + Self::Available { + name, + checked_at: now_millis(), + } + } + + pub fn unavailable(name: ProviderName, error: &str) -> Self { + Self::Unavailable { + name, + checked_at: now_millis(), + error: error.to_owned(), + } + } +} + +impl Serialize for ProviderStatus { + /// Flat wire shape: `{name, available, checked_at}` for the available case, + /// plus `error` for the unavailable one. Field order matches the prior + /// struct so the serialized bytes are unchanged. + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + match self { + Self::Available { name, checked_at } => { + let mut s = serializer.serialize_struct("ProviderStatus", 3)?; + s.serialize_field("name", name)?; + s.serialize_field("available", &true)?; + s.serialize_field("checked_at", checked_at)?; + s.end() + } + Self::Unavailable { + name, + checked_at, + error, + } => { + let mut s = serializer.serialize_struct("ProviderStatus", 4)?; + s.serialize_field("name", name)?; + s.serialize_field("available", &false)?; + s.serialize_field("checked_at", checked_at)?; + s.serialize_field("error", error)?; + s.end() + } + } + } +} + +// -- Completion types --------------------------------------------------------- + +/// Options controlling completion generation. +/// +/// Server-level defaults (stored on `App`, cloned per-request). +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct CompletionOptions { + pub frequency_penalty: Option, + pub output_token_max: u32, + pub presence_penalty: Option, + pub seed: Option, + pub stop_sequences: Option>, + pub system_message: String, + pub temperature: Option, + pub top_k: Option, + pub top_p: Option, +} + +impl Default for CompletionOptions { + fn default() -> Self { + Self { + output_token_max: 8192, + system_message: String::new(), + frequency_penalty: None, + presence_penalty: None, + seed: None, + stop_sequences: None, + temperature: None, + top_k: None, + top_p: None, + } + } +} + +/// A single message in a completion conversation. +/// +/// Matches the TypeScript `CompletionMessage = {role: string, content: string}`. +#[derive(Debug, Clone, Deserialize)] +pub struct CompletionMessage { + pub role: String, + pub content: String, +} + +/// Options passed to a provider's complete method. +/// +/// Streaming is keyed off the presence of a `ProgressSender` argument +/// to `complete`, not a field here — the handler builds the sender from +/// the request's `progressToken` and only constructs one when streaming +/// is requested. +pub struct CompletionHandlerOptions { + pub model: String, + pub completion_options: CompletionOptions, + pub completion_messages: Option>, + pub prompt: String, +} + +/// Callback for sending streaming progress notifications. +/// +/// Built by the handler from `ctx.notify` + `progress_token` — providers +/// invoke it with each chunk and never see the underlying transport. +pub type ProgressSender = Box; + +// -- Provider enum ------------------------------------------------------------ + +/// Enum-dispatched AI provider. +/// +/// Uses enum instead of trait objects: exactly 3 providers, known at compile +/// time. Gives exhaustive matching, no heap indirection, simpler lifetimes. +pub enum Provider { + Anthropic(anthropic::AnthropicProvider), + OpenAi(openai::OpenAiProvider), + Gemini(gemini::GeminiProvider), +} + +impl fmt::Debug for Provider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Provider({})", self.name()) + } +} + +impl Provider { + pub const fn name(&self) -> ProviderName { + match self { + Self::Anthropic(_) => ProviderName::Claude, + Self::OpenAi(_) => ProviderName::Chatgpt, + Self::Gemini(_) => ProviderName::Gemini, + } + } + + pub async fn load_status(&self, reload: bool) -> ProviderStatus { + match self { + Self::Anthropic(p) => p.load_status(reload).await, + Self::OpenAi(p) => p.load_status(reload).await, + Self::Gemini(p) => p.load_status(reload).await, + } + } + + pub async fn set_api_key(&self, key: Option) { + match self { + Self::Anthropic(p) => p.set_api_key(key).await, + Self::OpenAi(p) => p.set_api_key(key).await, + Self::Gemini(p) => p.set_api_key(key).await, + } + } + + pub async fn complete( + &self, + options: &CompletionHandlerOptions, + progress_sender: Option<&ProgressSender>, + signal: &CancellationToken, + ) -> Result { + match self { + Self::Anthropic(p) => p.complete(options, progress_sender, signal).await, + Self::OpenAi(p) => p.complete(options, progress_sender, signal).await, + Self::Gemini(p) => p.complete(options, progress_sender, signal).await, + } + } +} + +// -- Provider manager --------------------------------------------------------- + +/// Manages all AI providers. +/// +/// Constructed once in `main`, stored in `App`. +pub struct ProviderManager { + providers: HashMap, +} + +impl fmt::Debug for ProviderManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProviderManager") + .field("providers", &self.providers.keys().collect::>()) + .finish() + } +} + +impl ProviderManager { + pub fn new() -> Self { + Self { + providers: HashMap::new(), + } + } + + pub fn add(&mut self, provider: Provider) { + self.providers.insert(provider.name(), provider); + } + + pub fn get(&self, name: ProviderName) -> Option<&Provider> { + self.providers.get(&name) + } + + /// Get a provider or return a `method_not_found`-style error. + pub fn require(&self, name: ProviderName) -> Result<&Provider, JsonrpcError> { + self.get(name) + .ok_or_else(|| internal_error(&format!("provider not found: {name}"))) + } + + /// Iterate all providers (for `session_load` status collection). + pub fn all(&self) -> impl Iterator { + self.providers.values() + } +} + +// -- Error helpers ------------------------------------------------------------ + +pub const PROVIDER_ERROR_NEEDS_API_KEY: &str = "needs API key"; +pub const PROVIDER_ERROR_NOT_INSTALLED: &str = "not installed"; + +pub fn ai_provider_error(provider_name: &str, message: &str) -> JsonrpcError { + internal_error(&format!("{provider_name}: {message}")) +} + +// -- Helpers ------------------------------------------------------------------ + +#[expect( + clippy::cast_possible_truncation, + reason = "millis won't exceed u64 for centuries" +)] +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + reason = "tests panic on assertion failure by design" +)] +mod tests { + use super::*; + + #[test] + fn available_serializes_to_flat_wire_shape() { + let status = ProviderStatus::Available { + name: ProviderName::Claude, + checked_at: 42, + }; + let value = serde_json::to_value(&status).unwrap(); + assert_eq!( + value, + serde_json::json!({"name": "claude", "available": true, "checked_at": 42}), + ); + } + + #[test] + fn unavailable_serializes_with_error() { + let status = ProviderStatus::Unavailable { + name: ProviderName::Claude, + checked_at: 7, + error: "needs API key".to_owned(), + }; + let value = serde_json::to_value(&status).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "name": "claude", + "available": false, + "checked_at": 7, + "error": "needs API key", + }), + ); + } +} diff --git a/crates/zzz_server/src/provider/openai.rs b/crates/zzz_server/src/provider/openai.rs new file mode 100644 index 000000000..d0b484833 --- /dev/null +++ b/crates/zzz_server/src/provider/openai.rs @@ -0,0 +1,399 @@ +use std::ops::ControlFlow; + +use fuz_http::JsonrpcError; +use serde_json::{Value, json}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use super::{ + CompletionHandlerOptions, CompletionMessage, PROVIDER_ERROR_NEEDS_API_KEY, ProgressSender, + ProviderName, ProviderStatus, ai_provider_error, common, sse, +}; + +const API_URL: &str = "https://api.openai.com/v1/chat/completions"; +const PROVIDER: ProviderName = ProviderName::Chatgpt; +/// The `&str` form (derived from `PROVIDER`) for the error/SSE plumbing. +const PROVIDER_NAME: &str = PROVIDER.as_str(); +const SSE_DONE_MARKER: &str = "[DONE]"; + +struct OpenAiState { + client: Option, + cached_status: Option, +} + +/// OpenAI/ChatGPT AI provider. +/// +/// Uses the Chat Completions API with optional SSE streaming. +pub struct OpenAiProvider { + state: RwLock, +} + +impl OpenAiProvider { + pub fn new(api_key: Option) -> Self { + let client = api_key.map(|key| build_client(&key)); + Self { + state: RwLock::new(OpenAiState { + client, + cached_status: None, + }), + } + } + + pub async fn load_status(&self, reload: bool) -> ProviderStatus { + let state = self.state.read().await; + if !reload && let Some(ref status) = state.cached_status { + return status.clone(); + } + let has_client = state.client.is_some(); + drop(state); + + let status = if has_client { + ProviderStatus::available(PROVIDER) + } else { + ProviderStatus::unavailable(PROVIDER, PROVIDER_ERROR_NEEDS_API_KEY) + }; + + let mut state = self.state.write().await; + state.cached_status = Some(status.clone()); + status + } + + pub async fn set_api_key(&self, key: Option) { + let mut state = self.state.write().await; + state.client = key.as_deref().map(build_client); + state.cached_status = None; + } + + pub async fn complete( + &self, + options: &CompletionHandlerOptions, + progress_sender: Option<&ProgressSender>, + signal: &CancellationToken, + ) -> Result { + let client = { + let state = self.state.read().await; + state + .client + .clone() + .ok_or_else(|| ai_provider_error(PROVIDER_NAME, PROVIDER_ERROR_NEEDS_API_KEY))? + }; + + let streaming = progress_sender.is_some(); + let body = build_request_body(options, streaming); + + let response = client + .post(API_URL) + .json(&body) + .send() + .await + .map_err(|e| ai_provider_error(PROVIDER_NAME, &e.to_string()))?; + + let response = + common::check_response_status(response, PROVIDER_NAME, parse_api_error).await?; + + if let (true, Some(sender)) = (streaming, progress_sender) { + handle_streaming_response(response, options, sender, signal).await + } else { + handle_non_streaming_response(response, options).await + } + } +} + +async fn handle_non_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, +) -> Result { + let api_response: Value = response + .json::() + .await + .map_err(|e| ai_provider_error(PROVIDER_NAME, &format!("failed to parse response: {e}")))?; + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &api_response, + )) +} + +async fn handle_streaming_response( + response: reqwest::Response, + options: &CompletionHandlerOptions, + progress_sender: &ProgressSender, + signal: &CancellationToken, +) -> Result { + let mut accumulated_content = String::new(); + let mut completion_id = String::new(); + let mut finish_reason: Option = None; + let mut final_usage: Option = None; + + sse::consume_sse_stream(response, PROVIDER_NAME, signal, |event| { + // OpenAI signals the end of the stream with `data: [DONE]` — not + // valid JSON, so detect it before parsing. + if event.data.trim() == SSE_DONE_MARKER { + return ControlFlow::Break(()); + } + let Ok(data) = serde_json::from_str::(&event.data) else { + return ControlFlow::Continue(()); + }; + + if completion_id.is_empty() + && let Some(id) = data.get("id").and_then(Value::as_str) + { + id.clone_into(&mut completion_id); + } + + let choice = data.get("choices").and_then(|c| c.get(0)); + + if let Some(content) = choice + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("content")) + .and_then(Value::as_str) + && !content.is_empty() + { + accumulated_content.push_str(content); + progress_sender(common::build_text_progress_chunk(content)); + } + + if let Some(reason) = choice + .and_then(|c| c.get("finish_reason")) + .and_then(Value::as_str) + { + finish_reason = Some(reason.to_owned()); + } + + if let Some(usage) = data.get("usage") + && !usage.is_null() + { + final_usage = Some(usage.clone()); + } + + ControlFlow::Continue(()) + }) + .await?; + + let api_response = json!({ + "id": completion_id, + "object": "chat.completion", + "created": fuz_sys::rfc3339_now(), + "model": options.model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": accumulated_content, + }, + "finish_reason": finish_reason.unwrap_or_else(|| "stop".to_owned()), + }], + "usage": final_usage, + }); + + Ok(common::build_completion_response( + PROVIDER_NAME, + &options.model, + &api_response, + )) +} + +// -- Request building --------------------------------------------------------- + +fn build_request_body(options: &CompletionHandlerOptions, stream: bool) -> Value { + let messages = build_messages( + &options.completion_options.system_message, + options.completion_messages.as_deref(), + &options.prompt, + &options.model, + ); + let opts = &options.completion_options; + + let mut body = json!({ + "model": options.model, + "stream": stream, + "max_completion_tokens": opts.output_token_max, + "messages": messages, + }); + + let obj = body.as_object_mut().unwrap_or_else(|| unreachable!()); + + if let Some(t) = opts.temperature { + obj.insert("temperature".to_owned(), json!(t)); + } + if let Some(p) = opts.top_p { + obj.insert("top_p".to_owned(), json!(p)); + } + if let Some(s) = opts.seed { + obj.insert("seed".to_owned(), json!(s)); + } + if let Some(f) = opts.frequency_penalty { + obj.insert("frequency_penalty".to_owned(), json!(f)); + } + if let Some(p) = opts.presence_penalty { + obj.insert("presence_penalty".to_owned(), json!(p)); + } + if let Some(ref seqs) = opts.stop_sequences + && !seqs.is_empty() + { + obj.insert("stop".to_owned(), json!(seqs)); + } + + body +} + +fn build_messages( + system_message: &str, + completion_messages: Option<&[CompletionMessage]>, + prompt: &str, + model: &str, +) -> Vec { + let capacity = completion_messages.map_or(0, <[_]>::len) + 2; + let mut messages = Vec::with_capacity(capacity); + + // Some legacy reasoning models (e.g. o1-mini) reject system messages. + // TS reference handles this with the same gate. + if model != "o1-mini" { + messages.push(json!({ + "role": "system", + "content": system_message, + })); + } + + if let Some(msgs) = completion_messages { + for msg in msgs { + messages.push(json!({ + "role": msg.role, + "content": msg.content, + })); + } + } + + messages.push(json!({ + "role": "user", + "content": prompt, + })); + + messages +} + +// -- HTTP client -------------------------------------------------------------- + +fn build_client(api_key: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) { + headers.insert(reqwest::header::AUTHORIZATION, val); + } + common::build_client_with_headers(headers) +} + +// -- Error parsing ------------------------------------------------------------ + +/// Parse an `OpenAI` API error response body. +/// +/// `OpenAI` errors look like: `{"error":{"message":"...","type":"...","code":"..."}}` +fn parse_api_error(body: &str) -> Option { + let v: Value = serde_json::from_str(body).ok()?; + v.get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(String::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::CompletionOptions; + + fn opts() -> CompletionHandlerOptions { + CompletionHandlerOptions { + model: "gpt-4o-mini".to_owned(), + completion_options: CompletionOptions::default(), + completion_messages: None, + prompt: "hi".to_owned(), + } + } + + #[test] + fn request_body_includes_stream_flag() { + let body = build_request_body(&opts(), true); + assert_eq!(body["stream"], true); + let body = build_request_body(&opts(), false); + assert_eq!(body["stream"], false); + } + + #[test] + fn request_body_omits_optional_fields_when_none() { + let body = build_request_body(&opts(), false); + assert!(body.get("temperature").is_none()); + assert!(body.get("top_p").is_none()); + assert!(body.get("seed").is_none()); + assert!(body.get("frequency_penalty").is_none()); + assert!(body.get("presence_penalty").is_none()); + assert!(body.get("stop").is_none()); + } + + #[test] + fn request_body_includes_optional_fields_when_set() { + let mut o = opts(); + o.completion_options.temperature = Some(0.7); + o.completion_options.top_p = Some(0.95); + o.completion_options.seed = Some(42); + o.completion_options.frequency_penalty = Some(0.1); + o.completion_options.presence_penalty = Some(-0.2); + o.completion_options.stop_sequences = Some(vec!["STOP".to_owned()]); + let body = build_request_body(&o, false); + assert_eq!(body["temperature"], 0.7); + assert_eq!(body["top_p"], 0.95); + assert_eq!(body["seed"], 42); + assert_eq!(body["frequency_penalty"], 0.1); + assert_eq!(body["presence_penalty"], -0.2); + assert_eq!(body["stop"], json!(["STOP"])); + } + + #[test] + fn messages_default_includes_system_then_prompt() { + let m = build_messages("be brief", None, "hi", "gpt-4o"); + assert_eq!(m.len(), 2); + assert_eq!(m[0]["role"], "system"); + assert_eq!(m[0]["content"], "be brief"); + assert_eq!(m[1]["role"], "user"); + assert_eq!(m[1]["content"], "hi"); + } + + #[test] + fn messages_omits_system_for_o1_mini() { + let m = build_messages("ignored", None, "hi", "o1-mini"); + assert_eq!(m.len(), 1); + assert_eq!(m[0]["role"], "user"); + } + + #[test] + fn messages_passes_history_through() { + let history = vec![ + CompletionMessage { + role: "user".to_owned(), + content: "prior q".to_owned(), + }, + CompletionMessage { + role: "assistant".to_owned(), + content: "prior a".to_owned(), + }, + ]; + let m = build_messages("sys", Some(&history), "now", "gpt-4o"); + assert_eq!(m.len(), 4); + assert_eq!(m[0]["role"], "system"); + assert_eq!(m[1]["role"], "user"); + assert_eq!(m[1]["content"], "prior q"); + assert_eq!(m[2]["role"], "assistant"); + assert_eq!(m[2]["content"], "prior a"); + assert_eq!(m[3]["content"], "now"); + } + + #[test] + fn parse_api_error_extracts_message() { + let body = r#"{"error":{"message":"bad key","type":"invalid_request_error"}}"#; + assert_eq!(parse_api_error(body).as_deref(), Some("bad key")); + } + + #[test] + fn parse_api_error_returns_none_on_malformed_input() { + assert!(parse_api_error("garbage").is_none()); + assert!(parse_api_error(r#"{"error":"string-not-object"}"#).is_none()); + } +} diff --git a/crates/zzz_server/src/provider/sse.rs b/crates/zzz_server/src/provider/sse.rs new file mode 100644 index 000000000..552a1ae6b --- /dev/null +++ b/crates/zzz_server/src/provider/sse.rs @@ -0,0 +1,244 @@ +//! Shared SSE stream consumer. +//! +//! Anthropic, `OpenAI`, and Gemini all stream JSON via Server-Sent +//! Events. The wire formats differ (Anthropic carries `event:` +//! discriminators, `OpenAI` uses `data: [DONE]` as a terminator, Gemini +//! just ends the stream) so the helper hands the callback raw event +//! records and lets each provider decide how to parse + dispatch. + +use std::ops::ControlFlow; + +use futures_util::StreamExt; +use fuz_http::JsonrpcError; +use tokio_util::sync::CancellationToken; + +use super::ai_provider_error; + +/// One parsed SSE event block. +/// +/// `data` is the multi-line `data:` payload joined with `\n`. Callers +/// JSON-decode it themselves so they can also handle non-JSON +/// terminators like `OpenAI`'s `[DONE]`. +pub struct SseEvent { + pub event_type: Option, + pub data: String, +} + +/// Consume an SSE response stream, invoking `on_event` for each event +/// block. Stops when the stream ends, when `signal` is cancelled, or +/// when `on_event` returns `ControlFlow::Break`. +pub async fn consume_sse_stream( + response: reqwest::Response, + provider_name: &str, + signal: &CancellationToken, + mut on_event: F, +) -> Result<(), JsonrpcError> +where + F: FnMut(SseEvent) -> ControlFlow<()>, +{ + let mut stream = response.bytes_stream(); + // Raw bytes not yet decoded — may end mid-UTF-8-sequence when a chunk + // boundary splits a multibyte code point. + let mut raw: Vec = Vec::new(); + // Decoded, line-ending-normalized text awaiting event boundaries. + let mut buffer = String::new(); + + loop { + // Select over cancellation and the next chunk so an idle or hung + // upstream stream stays cancellable — polling `is_cancelled` only + // after a chunk arrived would block forever on a stalled stream. + let chunk = tokio::select! { + () = signal.cancelled() => break, + next = stream.next() => match next { + Some(chunk) => chunk.map_err(|e| { + ai_provider_error(provider_name, &format!("stream read error: {e}")) + })?, + None => break, + }, + }; + + raw.extend_from_slice(&chunk); + drain_decoded(&mut raw, &mut buffer); + + while let Some(boundary) = buffer.find("\n\n") { + // Parse from a borrow of the buffer head; SseEvent owns its + // String fields so the borrow is dropped before drain. + let parsed = parse_sse_event(&buffer[..boundary]); + // Drop event + delimiter without copying the buffer tail — + // a stream with N events would otherwise be O(N^2) in the + // remaining buffered bytes per event. + let _ = buffer.drain(..boundary + 2); + + if let Some(event) = parsed + && on_event(event) == ControlFlow::Break(()) + { + return Ok(()); + } + } + } + + Ok(()) +} + +/// Move the longest complete-UTF-8 prefix of `raw` into `buffer`, +/// normalizing line endings along the way. +/// +/// Only bytes that form whole UTF-8 code points are decoded; a multibyte +/// sequence split across chunk boundaries stays buffered in `raw` until +/// its continuation bytes arrive, instead of being mangled into +/// replacement characters by a per-chunk lossy decode. +fn drain_decoded(raw: &mut Vec, buffer: &mut String) { + let consumed = match std::str::from_utf8(raw) { + Ok(text) => { + push_normalized(buffer, text); + raw.len() + } + Err(e) => { + let valid_up_to = e.valid_up_to(); + // Bytes `[..valid_up_to]` are valid UTF-8 by `Utf8Error`'s + // contract; the `Err` arm is unreachable but keeps us off + // `unwrap`/`unsafe`. + if let Ok(text) = std::str::from_utf8(&raw[..valid_up_to]) { + push_normalized(buffer, text); + } + valid_up_to + } + }; + raw.drain(..consumed); +} + +/// Append `text` to `buffer`, normalizing line endings per the SSE spec +/// (RFC 8895 §9.2): `\r\n` → `\n`, then a lone `\r` → `\n`. +fn push_normalized(buffer: &mut String, text: &str) { + if text.contains('\r') { + buffer.push_str(&text.replace("\r\n", "\n").replace('\r', "\n")); + } else { + buffer.push_str(text); + } +} + +fn parse_sse_event(event_text: &str) -> Option { + let mut event_type: Option = None; + let mut data_lines: Vec<&str> = Vec::new(); + + for line in event_text.lines() { + if let Some(rest) = strip_field_prefix(line, "event") { + event_type = Some(rest.trim().to_owned()); + } else if let Some(rest) = strip_field_prefix(line, "data") { + data_lines.push(rest); + } + } + + if data_lines.is_empty() { + return None; + } + + Some(SseEvent { + event_type, + data: data_lines.join("\n"), + }) +} + +/// Strip `"{field}: "` or `"{field}:"` from the start of a line. +/// +/// The SSE spec allows a single optional space after the colon; some +/// servers (notably the Anthropic API) emit `data: ...` while others +/// emit `data:...`. +fn strip_field_prefix<'a>(line: &'a str, field: &str) -> Option<&'a str> { + let rest = line.strip_prefix(field)?.strip_prefix(':')?; + Some(rest.strip_prefix(' ').unwrap_or(rest)) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests panic on assertion failure by design" +)] +mod tests { + use super::*; + + #[test] + fn parses_event_and_data() { + let event = parse_sse_event("event: message_start\ndata: {\"id\":\"1\"}").unwrap(); + assert_eq!(event.event_type.as_deref(), Some("message_start")); + assert_eq!(event.data, "{\"id\":\"1\"}"); + } + + #[test] + fn parses_data_without_event_type() { + let event = parse_sse_event("data: {\"x\":1}").unwrap(); + assert!(event.event_type.is_none()); + assert_eq!(event.data, "{\"x\":1}"); + } + + #[test] + fn rejects_event_without_data() { + assert!(parse_sse_event("event: ping").is_none()); + } + + #[test] + fn rejects_empty_input() { + assert!(parse_sse_event("").is_none()); + } + + #[test] + fn joins_multi_line_data_with_newlines() { + let event = parse_sse_event("data: line one\ndata: line two\ndata: line three").unwrap(); + assert_eq!(event.data, "line one\nline two\nline three"); + } + + #[test] + fn tolerates_no_space_after_colon() { + let event = parse_sse_event("event:foo\ndata:{\"x\":1}").unwrap(); + assert_eq!(event.event_type.as_deref(), Some("foo")); + assert_eq!(event.data, "{\"x\":1}"); + } + + #[test] + fn passes_through_non_json_data() { + // OpenAI's `[DONE]` terminator — callers detect this; the parser + // doesn't try to JSON-decode. + let event = parse_sse_event("data: [DONE]").unwrap(); + assert_eq!(event.data, "[DONE]"); + } + + #[test] + fn ignores_unrecognized_fields() { + // SSE allows `id:`, `retry:`, and bare comments — we drop them + // and key only on event + data. + let event = parse_sse_event("id: 42\nevent: foo\ndata: bar\nretry: 100").unwrap(); + assert_eq!(event.event_type.as_deref(), Some("foo")); + assert_eq!(event.data, "bar"); + } + + #[test] + fn drain_decoded_holds_back_split_multibyte() { + // "é" is 0xC3 0xA9. Feed the lead byte first: it must NOT be + // decoded yet (a lossy decode would emit U+FFFD and corrupt it). + let mut raw = vec![0xC3]; + let mut buffer = String::new(); + drain_decoded(&mut raw, &mut buffer); + assert!( + buffer.is_empty(), + "incomplete code point must stay buffered" + ); + assert_eq!(raw, vec![0xC3], "lead byte retained for next chunk"); + + // Continuation byte arrives — now the full "é" decodes intact. + raw.push(0xA9); + drain_decoded(&mut raw, &mut buffer); + assert_eq!(buffer, "é"); + assert!(raw.is_empty()); + } + + #[test] + fn drain_decoded_normalizes_line_endings() { + let mut raw = b"a\r\nb\rc\nd".to_vec(); + let mut buffer = String::new(); + drain_decoded(&mut raw, &mut buffer); + assert_eq!(buffer, "a\nb\nc\nd"); + assert!(raw.is_empty()); + } +} diff --git a/crates/zzz_server/src/pty_manager.rs b/crates/zzz_server/src/pty_manager.rs new file mode 100644 index 000000000..8ec7fa526 --- /dev/null +++ b/crates/zzz_server/src/pty_manager.rs @@ -0,0 +1,231 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use fuz_pty::{Pty, ReadResult, WaitResult}; +use serde::Serialize; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::handlers::App; + +// -- Notification params ------------------------------------------------------ + +#[derive(Serialize)] +struct TerminalDataParams<'a> { + terminal_id: &'a str, + data: &'a str, +} + +#[derive(Serialize)] +struct TerminalExitedParams<'a> { + terminal_id: &'a str, + exit_code: Option, +} + +// -- Per-terminal state ------------------------------------------------------- + +/// State for a single spawned terminal. +struct TerminalEntry { + pty: Pty, + /// Cancel the async read loop before killing the process. + cancel: CancellationToken, +} + +// -- PtyManager --------------------------------------------------------------- + +/// Manages spawned PTY processes keyed by `terminal_id` (UUID string). +/// +/// Held in `App`, shared via `Arc`. Each terminal has an async read loop +/// that broadcasts `terminal_data` notifications and sends `terminal_exited` +/// when the process exits. +pub struct PtyManager { + terminals: RwLock>, +} + +impl PtyManager { + pub fn new() -> Self { + Self { + terminals: RwLock::new(HashMap::new()), + } + } + + /// Spawn a new PTY process and start its async read loop. + pub async fn spawn( + &self, + terminal_id: &str, + command: &str, + args: &[String], + cwd: Option<&str>, + app: Arc, + ) -> Result<(), String> { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let pty = Pty::spawn(command, &arg_refs, cwd, 80, 24).map_err(|e| e.to_string())?; + + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + let tid = terminal_id.to_owned(); + + // Capture fd and pid for the read loop — it uses raw values, not a + // Pty struct, because the TerminalEntry owns the Pty (and its close). + let read_fd = pty.master_fd; + let read_pid = pty.pid; + + { + let mut terminals = self.terminals.write().await; + terminals.insert(terminal_id.to_owned(), TerminalEntry { pty, cancel }); + } + + tokio::spawn(async move { + read_loop(read_fd, read_pid, &tid, cancel_clone, app).await; + }); + + Ok(()) + } + + /// Write data to a terminal's stdin. Silently no-ops if terminal not found. + pub async fn write(&self, terminal_id: &str, data: &str) { + let terminals = self.terminals.read().await; + if let Some(entry) = terminals.get(terminal_id) { + let _ = entry.pty.write(data.as_bytes()); + } + } + + /// Resize a terminal's PTY window. Silently no-ops if terminal not found. + pub async fn resize(&self, terminal_id: &str, cols: u16, rows: u16) { + let terminals = self.terminals.read().await; + if let Some(entry) = terminals.get(terminal_id) { + let _ = entry.pty.resize(cols, rows); + } + } + + /// Kill a terminal process and return its exit code. + /// + /// Returns `None` if the `terminal_id` doesn't exist. + pub async fn kill(&self, terminal_id: &str, signal: i32) -> Option> { + let entry = { + let mut terminals = self.terminals.write().await; + terminals.remove(terminal_id)? + }; + + // Cancel the read loop first — it checks cancellation before each read, + // so it will exit before we close the fd below. + entry.cancel.cancel(); + + // Send signal (process may already be dead) + let _ = entry.pty.kill(signal); + + // Give process time to exit (matching Deno's 50ms wait) + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let exit_code = match entry.pty.waitpid() { + WaitResult::Exited(code) => Some(code), + WaitResult::StillRunning => None, + }; + + let _ = entry.pty.close(); + + Some(exit_code) + } + + /// Kill every active terminal process without destroying the manager. + /// + /// The terminal map is drained and each entry waitpid'd; the manager + /// itself stays usable for new `terminal_create` calls. Used by the + /// test binary's `_testing_reset` `reset_state` callback to clear + /// cross-test terminal pollution without tearing the binary down, and + /// at shutdown to free PTY fds. + pub async fn kill_all(&self) { + let entries: Vec<(String, TerminalEntry)> = { + let mut terminals = self.terminals.write().await; + terminals.drain().collect() + }; + + for (tid, entry) in entries { + tracing::info!(terminal_id = %tid, "killing terminal"); + entry.cancel.cancel(); + let _ = entry.pty.kill(libc::SIGTERM); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _ = entry.pty.waitpid(); + let _ = entry.pty.close(); + } + } +} + +impl std::fmt::Debug for PtyManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PtyManager").finish_non_exhaustive() + } +} + +// -- Async read loop ---------------------------------------------------------- + +/// Poll the PTY master fd for output and broadcast to WebSocket clients. +/// +/// Uses raw fd/pid values — does NOT own the fd. The `TerminalEntry` in the +/// map owns the `Pty` and is responsible for `close()`. On natural exit (EOF), +/// this loop removes the entry from the map and closes it. On cancellation +/// (from `kill`), the caller already removed the entry — this loop just exits. +async fn read_loop( + master_fd: i32, + pid: i32, + terminal_id: &str, + cancel: CancellationToken, + app: Arc, +) { + let read_pty = Pty { master_fd, pid }; + let mut buf = [0u8; 8192]; + + loop { + if cancel.is_cancelled() { + return; + } + + match read_pty.read(&mut buf) { + ReadResult::Data(n) => { + let data = String::from_utf8_lossy(&buf[..n]); + if !data.is_empty() { + let notification = fuz_http::notification( + "terminal_data", + &TerminalDataParams { + terminal_id, + data: &data, + }, + ); + app.broadcast(¬ification); + } + } + ReadResult::WouldBlock => { + // No data — yield and retry after 10ms (matching Deno behavior) + tokio::select! { + () = cancel.cancelled() => return, + () = tokio::time::sleep(std::time::Duration::from_millis(10)) => {}, + } + } + ReadResult::Eof => { + tracing::info!(terminal_id, "terminal EOF"); + let exit_code = match read_pty.waitpid() { + WaitResult::Exited(code) => Some(code), + WaitResult::StillRunning => None, + }; + + let notification = fuz_http::notification( + "terminal_exited", + &TerminalExitedParams { + terminal_id, + exit_code, + }, + ); + app.broadcast(¬ification); + + // Remove and close the terminal entry (natural exit cleanup). + // If kill() already removed it, this is a no-op. + let removed = app.pty_manager.terminals.write().await.remove(terminal_id); + if let Some(entry) = removed { + let _ = entry.pty.close(); + } + + return; + } + } + } +} diff --git a/crates/zzz_server/src/scoped_fs.rs b/crates/zzz_server/src/scoped_fs.rs new file mode 100644 index 000000000..f8b88eff8 --- /dev/null +++ b/crates/zzz_server/src/scoped_fs.rs @@ -0,0 +1,230 @@ +use std::path::{Component, Path, PathBuf}; + +use parking_lot::RwLock; + +/// Normalize a path to a UTF-8 string with a single trailing slash. +/// +/// Centralized so [`ScopedFs::new`], [`ScopedFs::add_path`], and +/// [`ScopedFs::remove_path`] all share the same shape — every entry in +/// `allowed_paths` is `/`. +fn to_normalized_string(path: &Path) -> String { + let mut s = path.to_string_lossy().into_owned(); + if !s.ends_with('/') { + s.push('/'); + } + s +} + +// -- Errors ------------------------------------------------------------------- + +/// Errors from scoped filesystem operations. +#[derive(Debug, thiserror::Error)] +pub enum ScopedFsError { + #[error("Path is not allowed: {0}")] + PathNotAllowed(String), + #[error("Path is a symlink which is not allowed: {0}")] + SymlinkNotAllowed(String), + #[error("{0}")] + Io(#[from] std::io::Error), +} + +// -- ScopedFs ----------------------------------------------------------------- + +/// Scoping wrapper around filesystem operations. +/// +/// Restricts all operations to the currently-allowed directories. Rejects +/// relative paths, path traversal, and symlinks. +/// +/// **Not a confinement boundary against an authenticated caller.** The allowed +/// set is *mutable at runtime* via [`Self::add_path`], and `workspace_open` +/// (gated only at `AuthSpec::authenticated()`) calls it with a caller-supplied +/// directory without consulting the existing allowlist. So any authenticated +/// caller can widen the scope to an arbitrary existing directory and then write +/// beneath it. Read this type as a guard against accidental or buggy writes +/// outside the open workspaces — not as a sandbox. That is consistent with zzz's +/// posture (an authenticated zzz credential carries local-user authority +/// regardless — see the `terminal_*` actions), but don't mistake the type for a +/// stronger guarantee than it makes. +/// +/// NOTE: There is an inherent TOCTOU gap between the symlink check (`lstat`) +/// and the caller's subsequent filesystem operation. A symlink could be +/// created after validation. +pub struct ScopedFs { + /// Allowed directory roots, each normalized with a trailing `/`. + /// + /// Stored as `String` (not `PathBuf`) so `is_path_allowed` doesn't + /// re-run `to_string_lossy()` on every allowed entry on every fs + /// operation — the lossy conversion happens once at insert time. + allowed_paths: RwLock>, +} + +impl ScopedFs { + /// Create a new `ScopedFs` with the given allowed directory paths. + /// + /// Each path is normalized with a trailing `/` and must be absolute. + pub fn new(paths: Vec) -> Self { + // `into_iter` keeps ownership consumption parity with the previous + // shape so the public `Vec` API stays the same and + // `clippy::needless_pass_by_value` is satisfied. + let allowed_paths = paths + .into_iter() + .map(|p| to_normalized_string(&p)) + .collect(); + Self { + allowed_paths: RwLock::new(allowed_paths), + } + } + + /// Add a path to the allowed set. No-op if already present. + pub fn add_path(&self, path: &Path) -> bool { + let normalized = to_normalized_string(path); + let mut paths = self.allowed_paths.write(); + if paths.iter().any(|p| p == &normalized) { + return false; + } + paths.push(normalized); + true + } + + /// Remove a path from the allowed set. + pub fn remove_path(&self, path: &Path) -> bool { + let normalized = to_normalized_string(path); + let mut paths = self.allowed_paths.write(); + if let Some(index) = paths.iter().position(|p| p == &normalized) { + paths.remove(index); + true + } else { + false + } + } + + /// Check if a path falls under one of the allowed directories. + fn is_path_allowed(&self, path: &Path) -> bool { + let path_str = path.to_string_lossy(); + let paths = self.allowed_paths.read(); + for allowed in paths.iter() { + // `allowed` always ends in `/` (normalized at insert). + // `starts_with` covers files/subdirs; the bare-dir case + // matches when the request path equals `allowed` minus its + // trailing slash. + if path_str.starts_with(allowed.as_str()) + || allowed + .strip_suffix('/') + .is_some_and(|trimmed| path_str == trimmed) + { + return true; + } + } + false + } + + /// Validate and normalize a path for safe filesystem access. + /// + /// - Rejects relative paths and null bytes + /// - Normalizes path components (resolves `.` and `..`) + /// - Checks against allowed directories + /// - Rejects symlinks (target and all parent directories) + async fn ensure_safe_path(&self, path: &str) -> Result { + // Reject null bytes + if path.contains('\0') { + return Err(ScopedFsError::PathNotAllowed(path.to_owned())); + } + + // Must be absolute + let raw = Path::new(path); + if !raw.is_absolute() { + return Err(ScopedFsError::PathNotAllowed(path.to_owned())); + } + + // Normalize path (resolve . and .. without touching the filesystem) + let normalized = normalize_path(raw); + + // Check against allowed paths + if !self.is_path_allowed(&normalized) { + return Err(ScopedFsError::PathNotAllowed( + normalized.to_string_lossy().into_owned(), + )); + } + + // Check the target path for symlinks if it exists + match tokio::fs::symlink_metadata(&normalized).await { + Ok(meta) => { + if meta.file_type().is_symlink() { + return Err(ScopedFsError::SymlinkNotAllowed( + normalized.to_string_lossy().into_owned(), + )); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // File doesn't exist yet — that's fine for write/mkdir + } + Err(e) => return Err(ScopedFsError::Io(e)), + } + + // Check all parent directories for symlinks + let mut current = normalized.as_path(); + while let Some(parent) = current.parent() { + if parent == Path::new("/") || parent == current { + break; + } + match tokio::fs::symlink_metadata(parent).await { + Ok(meta) => { + if meta.file_type().is_symlink() { + return Err(ScopedFsError::SymlinkNotAllowed( + parent.to_string_lossy().into_owned(), + )); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Parent doesn't exist — will fail at the actual operation + } + Err(e) => return Err(ScopedFsError::Io(e)), + } + current = parent; + } + + Ok(normalized) + } + + /// Write content to a file (creates parent directories if needed). + pub async fn write_file(&self, path: &str, content: &str) -> Result<(), ScopedFsError> { + let safe_path = self.ensure_safe_path(path).await?; + if let Some(parent) = safe_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&safe_path, content).await?; + Ok(()) + } + + /// Remove a file. + pub async fn rm(&self, path: &str) -> Result<(), ScopedFsError> { + let safe_path = self.ensure_safe_path(path).await?; + tokio::fs::remove_file(&safe_path).await?; + Ok(()) + } + + /// Create a directory (recursive). + pub async fn mkdir(&self, path: &str) -> Result<(), ScopedFsError> { + let safe_path = self.ensure_safe_path(path).await?; + tokio::fs::create_dir_all(&safe_path).await?; + Ok(()) + } +} + +/// Normalize a path by resolving `.` and `..` components without filesystem access. +fn normalize_path(path: &Path) -> PathBuf { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} // skip . + Component::ParentDir => { + // Pop the last normal component (don't go above root) + if let Some(Component::Normal(_)) = components.last() { + components.pop(); + } + } + c => components.push(c), + } + } + components.iter().collect() +} diff --git a/crates/zzz_server/src/zzz_action_specs/core.rs b/crates/zzz_server/src/zzz_action_specs/core.rs new file mode 100644 index 000000000..40b990964 --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/core.rs @@ -0,0 +1,59 @@ +//! Core `ActionSpec` builders — `ping` (public) and `session_load` +//! (authenticated), plus the test-only `_testing_emit_notifications` +//! builder gated on `App.enable_test_actions`. +//! +//! Both methods are zzz-namespaced (not in `fuz_actions::PROTOCOL_ACTION_SPECS` +//! / `auth_adapter`), so they ship here. Wire shape matches the legacy +//! `handlers::handle_ping` / `handle_session_load` byte-for-byte. + +use std::sync::Arc; + +use fuz_actions::{ActionContext, ActionHandler, ActionSpec}; +use fuz_auth::AuthSpec; +use serde_json::Value; + +use crate::handlers::App; +use crate::handlers::core; + +/// Build the core action specs (`ping`, `session_load`). +#[must_use] +pub fn build_core_specs(app: Arc) -> Vec { + vec![ping_spec(Arc::clone(&app)), session_load_spec(app)] +} + +fn ping_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { core::ping(params, ctx, app).await }) + }); + ActionSpec::read_only("ping", AuthSpec::public(), handler) +} + +fn session_load_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { core::session_load(params, ctx, app).await }) + }); + ActionSpec::read_only("session_load", AuthSpec::authenticated(), handler) +} + +/// Build the test-only action specs (`_testing_emit_notifications`). Caller +/// extends the registry input with these only when `enable_test_actions` +/// is set — the gating happens at registry-compile time so production +/// boots never carry the test surface at all. +#[must_use] +pub fn build_testing_specs(app: Arc) -> Vec { + vec![testing_emit_notifications_spec(app)] +} + +fn testing_emit_notifications_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { core::testing_emit_notifications(params, ctx, app).await }) + }); + ActionSpec::read_only( + "_testing_emit_notifications", + AuthSpec::authenticated(), + handler, + ) +} diff --git a/crates/zzz_server/src/zzz_action_specs/filesystem.rs b/crates/zzz_server/src/zzz_action_specs/filesystem.rs new file mode 100644 index 000000000..c8385f5f0 --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/filesystem.rs @@ -0,0 +1,49 @@ +//! `ActionSpec` builders for filesystem methods. +//! +//! Mirrors `src/lib/action_specs.ts`'s `diskfile_update`, +//! `diskfile_delete`, `directory_create` (all `side_effects: true`, +//! `authenticated`). None touch the DB today — `side_effects` is set +//! to match the spec and to keep the transactional-audit boundary +//! intact when filesystem audit emission lands. + +use std::sync::Arc; + +use fuz_actions::{ActionContext, ActionHandler, ActionSpec}; +use fuz_auth::AuthSpec; +use serde_json::Value; + +use crate::handlers::App; +use crate::handlers::filesystem; + +#[must_use] +pub fn build_filesystem_specs(app: Arc) -> Vec { + vec![ + diskfile_update_spec(Arc::clone(&app)), + diskfile_delete_spec(Arc::clone(&app)), + directory_create_spec(app), + ] +} + +fn diskfile_update_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { filesystem::diskfile_update(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("diskfile_update", AuthSpec::authenticated(), handler) +} + +fn diskfile_delete_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { filesystem::diskfile_delete(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("diskfile_delete", AuthSpec::authenticated(), handler) +} + +fn directory_create_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { filesystem::directory_create(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("directory_create", AuthSpec::authenticated(), handler) +} diff --git a/crates/zzz_server/src/zzz_action_specs/mod.rs b/crates/zzz_server/src/zzz_action_specs/mod.rs new file mode 100644 index 000000000..ab05350cf --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/mod.rs @@ -0,0 +1,22 @@ +//! Per-domain `ActionSpec` builders for zzz's spine-backed dispatch. +//! +//! Each builder takes the consumer-side deps (`Arc`, plus optional +//! spine deps like `Arc` when the handler emits audit +//! rows), produces a `Vec` that `main.rs` concatenates into +//! the `ActionRegistry::compile(...)` input. +//! +//! Each builder registers the zzz-specific handlers in `crate::handlers::*` +//! (`core`, `workspace`, `filesystem`, `terminal`, `provider`) into the +//! `ActionRegistry`. + +pub mod core; +pub mod filesystem; +pub mod provider; +pub mod terminal; +pub mod workspace; + +pub use core::{build_core_specs, build_testing_specs}; +pub use filesystem::build_filesystem_specs; +pub use provider::build_provider_specs; +pub use terminal::build_terminal_specs; +pub use workspace::build_workspace_specs; diff --git a/crates/zzz_server/src/zzz_action_specs/provider.rs b/crates/zzz_server/src/zzz_action_specs/provider.rs new file mode 100644 index 000000000..b707b834d --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/provider.rs @@ -0,0 +1,55 @@ +//! `ActionSpec` builders for AI provider methods. +//! +//! - `provider_load_status` — authenticated read. +//! - `provider_update_api_key` — keeper (`daemon_token` + keeper role), +//! side-effects. +//! - `completion_create` — authenticated write. The notify routing lives in +//! `handlers::provider::completion_create`: `Arc::send_to(conn_id, …)` +//! routes streaming `completion_progress` notifications to the originating +//! WS socket via `ctx.connection_id`. + +use std::sync::Arc; + +use fuz_actions::{ActionContext, ActionHandler, ActionSpec}; +use fuz_auth::{AuthSpec, CredentialType}; +use serde_json::Value; + +use crate::handlers::App; +use crate::handlers::provider; + +const DAEMON_TOKEN_ONLY: &[CredentialType] = &[CredentialType::DaemonToken]; +const KEEPER_ROLE: &[&str] = &["keeper"]; + +#[must_use] +pub fn build_provider_specs(app: Arc) -> Vec { + vec![ + provider_load_status_spec(Arc::clone(&app)), + provider_update_api_key_spec(Arc::clone(&app)), + completion_create_spec(app), + ] +} + +fn provider_load_status_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { provider::provider_load_status(params, ctx, app).await }) + }); + ActionSpec::read_only("provider_load_status", AuthSpec::authenticated(), handler) +} + +fn provider_update_api_key_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { provider::provider_update_api_key(params, ctx, app).await }) + }); + let keeper_auth = AuthSpec::authenticated_gated(Some(DAEMON_TOKEN_ONLY), Some(KEEPER_ROLE)); + ActionSpec::with_side_effects("provider_update_api_key", keeper_auth, handler) +} + +fn completion_create_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { provider::completion_create(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("completion_create", AuthSpec::authenticated(), handler) +} diff --git a/crates/zzz_server/src/zzz_action_specs/terminal.rs b/crates/zzz_server/src/zzz_action_specs/terminal.rs new file mode 100644 index 000000000..5126b24b4 --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/terminal.rs @@ -0,0 +1,52 @@ +//! `ActionSpec` builders for terminal methods. + +use std::sync::Arc; + +use fuz_actions::{ActionContext, ActionHandler, ActionSpec}; +use fuz_auth::AuthSpec; +use serde_json::Value; + +use crate::handlers::App; +use crate::handlers::terminal; + +#[must_use] +pub fn build_terminal_specs(app: Arc) -> Vec { + vec![ + terminal_create_spec(Arc::clone(&app)), + terminal_data_send_spec(Arc::clone(&app)), + terminal_resize_spec(Arc::clone(&app)), + terminal_close_spec(app), + ] +} + +fn terminal_create_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { terminal::terminal_create(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("terminal_create", AuthSpec::authenticated(), handler) +} + +fn terminal_data_send_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { terminal::terminal_data_send(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("terminal_data_send", AuthSpec::authenticated(), handler) +} + +fn terminal_resize_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { terminal::terminal_resize(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("terminal_resize", AuthSpec::authenticated(), handler) +} + +fn terminal_close_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { terminal::terminal_close(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("terminal_close", AuthSpec::authenticated(), handler) +} diff --git a/crates/zzz_server/src/zzz_action_specs/workspace.rs b/crates/zzz_server/src/zzz_action_specs/workspace.rs new file mode 100644 index 000000000..6fbd921f6 --- /dev/null +++ b/crates/zzz_server/src/zzz_action_specs/workspace.rs @@ -0,0 +1,68 @@ +//! `ActionSpec` builders for workspace methods. +//! +//! Mirrors the three workspace specs in `src/lib/action_specs.ts`: +//! `workspace_list` (read-only, authenticated), `workspace_open` +//! (side-effects, authenticated), `workspace_close` (side-effects, +//! authenticated). +//! +//! ## Side-effects flag +//! +//! `workspace_open` / `workspace_close` carry `side_effects: true` +//! matching `crate::handlers::method_spec`. They don't touch the DB today +//! (workspace state is in-memory) — the flag is preserved for parity +//! with the Deno reference and to keep the eventual transactional-audit +//! boundary intact when audit emission lands on workspace mutations. + +use std::sync::Arc; + +use fuz_actions::{ActionContext, ActionHandler, ActionSpec}; +use fuz_auth::AuthSpec; +use fuz_http::JsonrpcError; +use serde_json::Value; + +use crate::handlers::App; +use crate::handlers::workspace; + +/// Build the workspace action specs. +/// +/// `app` is the long-lived `Arc` from the composition root — +/// cloned once per spec into each handler closure so the handler can +/// reach through to zzz-specific deps (`workspaces`, `FilerManager`, +/// `ScopedFs`) that `ActionContext` doesn't carry. +#[must_use] +pub fn build_workspace_specs(app: Arc) -> Vec { + vec![ + workspace_list_spec(Arc::clone(&app)), + workspace_open_spec(Arc::clone(&app)), + workspace_close_spec(app), + ] +} + +fn workspace_list_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { workspace::workspace_list(params, ctx, app).await }) + }); + ActionSpec::read_only("workspace_list", AuthSpec::authenticated(), handler) +} + +fn workspace_open_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { workspace::workspace_open(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("workspace_open", AuthSpec::authenticated(), handler) +} + +fn workspace_close_spec(app: Arc) -> ActionSpec { + let handler: ActionHandler = Arc::new(move |params: Value, ctx: ActionContext<'_>| { + let app = Arc::clone(&app); + Box::pin(async move { workspace::workspace_close(params, ctx, app).await }) + }); + ActionSpec::with_side_effects("workspace_close", AuthSpec::authenticated(), handler) +} + +// Silence unused-import on JsonrpcError until other zzz_action_specs +// modules land that propagate it through their builder signatures. +#[doc(hidden)] +const _: fn() -> Option = || None; diff --git a/docs/architecture.md b/docs/architecture.md index 9a3b059a6..5e69599b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,10 +1,10 @@ # Architecture -Core systems: actions, cells, content model, data flow, indexed collections, filesystem. +Core systems: actions, cells, content model, data flow, terminals, indexed collections, filesystem, file editing, spaces and workspaces, capabilities. ## Action System -Symmetric peer-to-peer JSON-RPC 2.0. The same `ActionPeer` code runs on frontend and backend. +Symmetric peer-to-peer JSON-RPC 2.0 — by design either end can initiate. The frontend runs the TypeScript `ActionDispatcher` (from `@fuzdev/fuz_app`); the Rust `zzz_server` backend implements the same spec + wire contract via `fuz_actions`. ### Action Spec @@ -12,52 +12,60 @@ Every action is a plain object with Zod schemas. Defined in `src/lib/action_spec ```typescript export const completion_create_action_spec = { - method: 'completion_create', - kind: 'request_response', - initiator: 'frontend', - auth: 'public', - side_effects: true, - input: z.strictObject({ - completion_request: CompletionRequest, - _meta: z.looseObject({progressToken: Uuid.optional()}).optional(), - }), - output: z.strictObject({ - completion_response: CompletionResponse, - _meta: z.looseObject({progressToken: Uuid.optional()}).optional(), - }), - async: true, + method: 'completion_create', + kind: 'request_response', + initiator: 'frontend', + auth: { account: 'required', actor: 'none' }, + side_effects: true, + input: z.strictObject({ + completion_request: CompletionRequest, + _meta: z.looseObject({ progressToken: Uuid.optional() }).optional() + }), + output: z.strictObject({ + completion_response: CompletionResponse, + _meta: z.looseObject({ progressToken: Uuid.optional() }).optional() + }), + async: true } satisfies ActionSpecUnion; ``` ### Action Kinds -| Kind | Phases | Transport | Use | -|------|--------|-----------|-----| -| `request_response` | `send_request` → `receive_request` → `send_response` → `receive_response` | HTTP or WebSocket | Standard RPC | -| `remote_notification` | `send` → `receive` | WebSocket only | Streaming progress (backend → frontend) | -| `local_call` | `execute` | None | Frontend-only UI actions | +- `request_response` — Standard RPC. Phases: `send_request` → `receive_request` → `send_response` → `receive_response`. Transport: HTTP or WebSocket +- `remote_notification` — Backend → frontend push (progress, broadcast). Phases: `send` → `receive`. Transport: WebSocket only +- `local_call` — Frontend-only UI actions. Phases: `execute`. Transport: None + +`remote_notification` actions have two routing paths on the backend: + +- **Request-scoped** (`ctx.notify(method, params)` from a handler) — delivered + only to the originating socket. Used for progress streams tied to an + in-flight request (`completion_progress`). Specs that use + this pattern set `streams: ''` to name the companion + notification. +- **Broadcast** (`backend.api.(input)`) — fanned out to all connected + sockets. Used for server-wide events that every client needs + (`filer_change`, `workspace_changed`, `terminal_data`, `terminal_exited`). ### Action Spec Fields -| Field | Type | Values | -|-------|------|--------| -| `method` | `string` | Action name (e.g. `'completion_create'`) | -| `kind` | `ActionKind` | `'request_response'` \| `'remote_notification'` \| `'local_call'` | -| `initiator` | `ActionInitiator` | `'frontend'` \| `'backend'` \| `'both'` | -| `auth` | `ActionAuth \| null` | `'public'` \| `'authorize'` \| `null` | -| `side_effects` | `boolean \| null` | Whether action mutates state | -| `input` | `z.ZodType` | Zod schema for request params | -| `output` | `z.ZodType` | Zod schema for response | -| `async` | `boolean` | Whether handler is async | +- `method` (`string`) — Action name (e.g. `'completion_create'`) +- `kind` (`ActionKind`) — `'request_response'` | `'remote_notification'` | `'local_call'` +- `initiator` (`ActionInitiator`) — `'frontend'` | `'backend'` | `'both'` +- `auth` (`RouteAuth | null`) — `{account, actor, roles?, credential_types?}` | `null` (four-axis flat record) +- `side_effects` (`boolean | null`) — Whether action mutates state +- `input` (`z.ZodType`) — Zod schema for request params +- `output` (`z.ZodType`) — Zod schema for response +- `async` (`boolean`) — Whether handler is async +- `streams` (`string` (optional)) — Name of companion `remote_notification` method this action emits via `ctx.notify` (e.g. `'completion_progress'`) ### Core Components -| Component | File | Purpose | -|-----------|------|---------| -| `ActionSpec` | `action_spec.ts` | Action metadata schema | -| `ActionEvent` | `action_event.ts` | Lifecycle state machine (initial → parsed → handling → handled/failed) | -| `ActionPeer` | `action_peer.ts` | Send/receive on both sides | -| `ActionRegistry` | `action_registry.ts` | Type-safe action lookup | +- `ActionSpec` (`action_spec.ts`) — Action metadata schema +- `ActionEvent` (`action_event.ts`) — Lifecycle state machine (initial → parsed → handling → handled/failed) +- `ActionDispatcher` (`action_dispatcher.ts`) — Send/receive on both sides +- `ActionRegistry` (`action_registry.ts`) — Type-safe action lookup + +These live in `@fuzdev/fuz_app/actions/` — the SAES runtime is extracted to fuz_app; zzz imports them. Cell patterns (the `Cell` base class, `IndexedCollection`) remain in zzz. ### Action Event Lifecycle @@ -76,55 +84,50 @@ Frontend and backend register handlers per action per phase: ```typescript // Frontend (frontend_action_handlers.ts) -export const frontend_action_handlers: FrontendActionHandlers = { - completion_create: { - send_request: ({data: {input}}) => { - console.log('sending prompt:', input.completion_request.prompt); - }, - receive_response: ({app, data: {input, output}}) => { - const progress_token = input._meta?.progressToken; - if (progress_token) { - const turn = app.cell_registry.all.get(progress_token); - if (turn instanceof Turn) { - turn.content = to_completion_response_text(output.completion_response) || ''; - turn.response = output.completion_response; - } - } - }, - receive_error: ({data: {error}}) => { - console.error('completion failed:', error); - }, - }, -}; - -// Backend (server/backend_action_handlers.ts) -export const backend_action_handlers: BackendActionHandlers = { - completion_create: { - receive_request: async ({backend, data: {input}}) => { - const {prompt, provider_name, model, completion_messages} = input.completion_request; - const progress_token = input._meta?.progressToken; - const provider = backend.lookup_provider(provider_name); - const handler = provider.get_handler(!!progress_token); - return await handler({model, prompt, completion_messages, completion_options, progress_token}); - }, - }, -}; +// Handlers are built by a factory that closes over the `Frontend` instance: +export const create_frontend_action_handlers = (frontend: Frontend): FrontendActionHandlers => ({ + completion_create: { + send_request: ({ data: { input } }) => { + console.log('sending prompt:', input.completion_request.prompt); + }, + receive_response: ({ data: { input, output } }) => { + const progress_token = input._meta?.progressToken; + if (progress_token) { + const turn = frontend.cell_registry.all.get(progress_token); + if (turn instanceof Turn) { + turn.content = to_completion_response_text(output.completion_response) || ''; + turn.response = output.completion_response; + } + } + }, + receive_error: ({ data: { error } }) => { + console.error('completion failed:', error); + } + } +}); + +// The matching backend handler lives in +// the Rust `zzz_server` (`crates/zzz_server/src/handlers/`), registered into +// the spine `ActionRegistry`. It receives `(params, ActionContext, Arc)`, +// looks up the provider, and streams `completion_progress` chunks to the +// originating socket via `ConnectionRegistry::send_to(ctx.connection_id, …)`. +// See ../crates/CLAUDE.md for the backend handler patterns. ``` ### Transport Layer -Actions are transport-agnostic via the `Transport` interface (`transports.ts`): +Actions are transport-agnostic via the `Transport` interface (from `@fuzdev/fuz_app/actions/`): ```typescript interface Transport { - transport_name: TransportName; - send(message: JsonrpcRequest): Promise; - send(message: JsonrpcNotification): Promise; - is_ready: () => boolean; + transport_name: TransportName; + send(message: JsonrpcRequest): Promise; + send(message: JsonrpcNotification): Promise; + is_ready: () => boolean; } ``` -Implementations: `FrontendHttpTransport`, `FrontendWebsocketTransport`, `BackendWebsocketTransport`. +Frontend implementations: `FrontendHttpTransport`, `FrontendWebsocketTransport`. The Rust backend serves the matching `/api/rpc` + `/api/ws` endpoints directly. ### JSON-RPC 2.0 @@ -137,30 +140,21 @@ MCP-compatible subset, no batching: // Notification (no id): { jsonrpc: "2.0", method: "completion_progress", params: {...} } ``` -### All 20 Actions - -| Method | Kind | Initiator | Purpose | -|--------|------|-----------|---------| -| `ping` | `request_response` | `both` | Health check | -| `session_load` | `request_response` | `frontend` | Load initial session data | -| `filer_change` | `remote_notification` | `backend` | File system change notification | -| `diskfile_update` | `request_response` | `frontend` | Write file content | -| `diskfile_delete` | `request_response` | `frontend` | Delete a file | -| `directory_create` | `request_response` | `frontend` | Create a directory | -| `completion_create` | `request_response` | `frontend` | Start AI completion | -| `completion_progress` | `remote_notification` | `backend` | Stream completion chunks | -| `ollama_progress` | `remote_notification` | `backend` | Model operation progress | -| `toggle_main_menu` | `local_call` | `frontend` | Toggle main menu UI | -| `ollama_list` | `request_response` | `frontend` | List local models | -| `ollama_ps` | `request_response` | `frontend` | List running models | -| `ollama_show` | `request_response` | `frontend` | Show model details | -| `ollama_pull` | `request_response` | `frontend` | Pull model | -| `ollama_delete` | `request_response` | `frontend` | Delete model | -| `ollama_copy` | `request_response` | `frontend` | Copy model | -| `ollama_create` | `request_response` | `frontend` | Create model | -| `ollama_unload` | `request_response` | `frontend` | Unload model from memory | -| `provider_load_status` | `request_response` | `frontend` | Check provider availability | -| `provider_update_api_key` | `request_response` | `frontend` | Update provider API key | +### Actions + +Defined in `src/lib/action_specs.ts`. A representative subset below — the `terminal_*` and `workspace_*` families are omitted here; see [reference.md](./reference.md) (generated from the specs) for the full list: + +- `ping` — Health check. Kind: `request_response`. Initiator: `both` +- `session_load` — Load initial session data. Kind: `request_response`. Initiator: `frontend` +- `filer_change` — File system change notification. Kind: `remote_notification`. Initiator: `backend` +- `diskfile_update` — Write file content. Kind: `request_response`. Initiator: `frontend` +- `diskfile_delete` — Delete a file. Kind: `request_response`. Initiator: `frontend` +- `directory_create` — Create a directory. Kind: `request_response`. Initiator: `frontend` +- `completion_create` — Start AI completion. Kind: `request_response`. Initiator: `frontend` +- `completion_progress` — Stream completion chunks. Kind: `remote_notification`. Initiator: `backend` +- `toggle_main_menu` — Toggle main menu UI. Kind: `local_call`. Initiator: `frontend` +- `provider_load_status` — Check provider availability. Kind: `request_response`. Initiator: `frontend` +- `provider_update_api_key` — Update provider API key. Kind: `request_response`. Initiator: `frontend` ## Cell System @@ -174,10 +168,10 @@ From `cell.svelte.ts`: export abstract class Cell implements CellJson { readonly cid = ++global_cell_count; // monotonic client-side ordering - // Base properties from CellJson - id: Uuid = $state()!; - created: Datetime = $state()!; - updated: Datetime = $state()!; + // Base properties from CellJson — $state.raw() by default + id: Uuid = $state.raw()!; + created: Datetime = $state.raw()!; + updated: Datetime = $state.raw()!; readonly schema!: TSchema; readonly schema_keys: Array> = $derived(...); @@ -201,8 +195,8 @@ export abstract class Cell implements Cel ```typescript interface CellOptions { - app: Frontend; // Root app state reference - json?: z.input; // Initial JSON data (parsed by schema) + app: Frontend; // Root app state reference + json?: z.input; // Initial JSON data (parsed by schema) } ``` @@ -213,36 +207,36 @@ Real example from `chat.svelte.ts`: ```typescript // 1. Schema with CellJson base — every field has .default() export const ChatJson = CellJson.extend({ - name: z.string().default(''), - thread_ids: z.array(Uuid).default(() => []), - main_input: z.string().default(''), - view_mode: z.enum(['simple', 'multi']).default('simple'), - selected_thread_id: Uuid.nullable().default(null), -}).meta({cell_class_name: 'Chat'}); - -// 2. Class: $state for schema fields, $derived for computed + name: z.string().default(''), + thread_ids: z.array(Uuid).default(() => []), + main_input: z.string().default(''), + view_mode: z.enum(['simple', 'multi']).default('simple'), + selected_thread_id: Uuid.nullable().default(null) +}).meta({ cell_class_name: 'Chat' }); + +// 2. Class: $state.raw by default, $state only for in-place-mutated arrays export class Chat extends Cell { - name: string = $state()!; - thread_ids: Array = $state()!; - main_input: string = $state()!; - view_mode: ChatViewMode = $state()!; - selected_thread_id: Uuid | null = $state()!; - - readonly threads: Array = $derived.by(() => { - const result: Array = []; - for (const id of this.thread_ids) { - const thread = this.app.threads.items.by_id.get(id); - if (thread) result.push(thread); - } - return result; - }); - - readonly enabled_threads = $derived(this.threads.filter((t) => t.enabled)); - - constructor(options: ChatOptions) { - super(ChatJson, options); - this.init(); // Must call at end - } + name: string = $state.raw()!; + thread_ids: Array = $state()!; // $state because push/splice used + main_input: string = $state.raw()!; + view_mode: ChatViewMode = $state.raw()!; + selected_thread_id: Uuid | null = $state.raw()!; + + readonly threads: Array = $derived.by(() => { + const result: Array = []; + for (const id of this.thread_ids) { + const thread = this.app.threads.items.by_id.get(id); + if (thread) result.push(thread); + } + return result; + }); + + readonly enabled_threads = $derived(this.threads.filter((t) => t.enabled)); + + constructor(options: ChatOptions) { + super(ChatJson, options); + this.init(); // Must call at end + } } ``` @@ -277,12 +271,17 @@ All cell classes are registered in `cell_classes.ts`. Frontend iterates and regi ```typescript // cell_classes.ts — add new classes here export const cell_classes = { - Parts, Chat, Chats, Thread, Threads, Turn, /* ... 26 total */ + Parts, + Chat, + Chats, + Thread, + Threads, + Turn /* ... 31 total */ } satisfies Record>; // frontend.svelte.ts — auto-registers all classes for (const constructor of Object.values(cell_classes)) { - this.cell_registry.register(constructor); + this.cell_registry.register(constructor); } // Lookup by ID at runtime @@ -303,10 +302,8 @@ Prompt → parts: Array (reusable content templates) ### Parts -| Type | Class | Content source | -|------|-------|----------------| -| Text | `TextPart` | `content: string` stored directly | -| Diskfile | `DiskfilePart` | `path: DiskfilePath` → reads from disk or editor state | +- Text (`TextPart`) — `content: string` stored directly +- Diskfile (`DiskfilePart`) — `path: DiskfilePath` → reads from disk or editor state ### Turns @@ -314,17 +311,32 @@ Conversation messages with role: ```typescript class Turn extends Cell { - part_ids: Array = $state()!; - role: CompletionRole = $state()!; // 'user' | 'assistant' | 'system' - request: CompletionRequest | undefined = $state.raw(); - response: CompletionResponse | undefined = $state.raw(); - - readonly content: string = $derived( - this.parts.map(p => p.content).filter(Boolean).join('\n\n') - ); - readonly pending: boolean = $derived( - this.role === 'assistant' && this.is_content_empty && !this.response - ); + part_ids: Array = $state()!; // $state because push/splice used + role: CompletionRole = $state.raw()!; // 'user' | 'assistant' | 'system' + request: CompletionRequest | undefined = $state.raw(); + response: CompletionResponse | undefined = $state.raw(); + + // mutable by design — streaming handlers assign to it; + // the getter joins part contents, the setter writes the first part + get content(): string { + return this.parts + .map((part) => part.content) + .filter((c) => c != null) + .join('\n\n'); + } + set content(value: string | null | undefined) { + if (value != null && this.parts[0]) { + this.parts[0].content = value; + } + } + + readonly pending: boolean = $derived( + this.role === 'assistant' && + this.is_content_loaded && + this.is_content_empty && + !this.response && + !this.error_message + ); } ``` @@ -334,9 +346,9 @@ Linear conversation with one model. Sends messages via the action system: ```typescript class Thread extends Cell { - model_name: string = $state()!; + model_name: string = $state.raw()!; readonly turns: IndexedCollection = new IndexedCollection(); - enabled: boolean = $state()!; + enabled: boolean = $state.raw()!; async send_message(content: string): Promise { const user_turn = this.add_user_turn(content); @@ -367,35 +379,82 @@ User types message in Chat UI → app.api.completion_create(request) → ActionEvent send_request phase → Transport.send(JSON-RPC request) - → Backend.peer.receive(message) - → ActionEvent receive_request phase - → backend_action_handlers.completion_create.receive_request() - → backend.lookup_provider(provider_name) - → provider.get_handler(!!progress_token) - → handler({model, prompt, ...}) - → For each chunk: backend.api.completion_progress({token, chunk}) - → Return {completion_response} - → ActionEvent send_response phase - → JSON-RPC response via Transport - → Frontend receive_response phase - → turn.content = response_text - → turn.response = completion_response - → Svelte reactivity updates UI + → POST /api/rpc or /api/ws → Rust spine dispatch (spec lookup, auth check, schema validation) + → handlers::provider::completion_create(params, ctx, app) + → ProviderManager looks up the provider by name + → provider streams the completion (stream = true when a progress token is present) + → For each text chunk: + → completion_progress notification to the originating WS connection (ctx.connection_id) + → Return {completion_response} + → JSON-RPC response via WebSocket + → Frontend receive_response phase + → turn.content = response_text + → turn.response = completion_response + → Svelte reactivity updates UI ``` ### Streaming Progress ``` -Backend provider iterates chunks from SDK - → provider.send_streaming_progress(progress_token, chunk) - → backend.api.completion_progress({progressToken, chunk}) - → WebSocket notification (no id, no response expected) +Rust provider parses the SSE stream from the API — the shared `provider/sse.rs` +hands raw SSE events to the provider, which parses its own event vocabulary +(Anthropic's `content_block_delta` in `provider/anthropic.rs`; OpenAI and +Gemini use their own event shapes) + → for each text chunk + → ConnectionRegistry::send_to(ctx.connection_id, completion_progress notification) + → WebSocket notification to the originating socket (no id, no response) → frontend_action_handlers.completion_progress.receive() → Find turn by progressToken in cell_registry → Append chunk to turn content → UI re-renders incrementally ``` +Streaming progress (`completion_progress`) is +**socket-scoped** — it routes only to the client that initiated the request, +never broadcast. On HTTP transport `ctx.notify` is a no-op (with a DEV warn). +`backend.api.*` is reserved for genuine broadcasts (`filer_change`, +`terminal_data`, `terminal_exited`, `workspace_changed`). + +## Terminals + +PTY terminals rendered by xterm.js, spawned and managed by the Rust backend's +`PtyManager` (`crates/zzz_server/src/pty_manager.rs`, using the native +`fuz_pty` crate — [development.md](./development.md) covers the build story). + +Actions: `terminal_create` (→ `{terminal_id}`), `terminal_data_send` (stdin), +`terminal_resize`, and `terminal_close` (→ `{exit_code}`) are +`request_response`; `terminal_data` (output chunks) and `terminal_exited` are +**broadcast** `remote_notification`s — like `filer_change`, fanned out to all +connected sockets, not socket-scoped like `completion_progress`. + +``` +User types in xterm.js (TerminalView.svelte) + → term.onData → app.api.terminal_data_send({terminal_id, data}) + → handlers::terminal → PtyManager::write (raw write to the PTY master) + → child process output → PtyManager read_loop (10ms poll) + → terminal_data broadcast to all sockets + → frontend_action_handlers.terminal_data.receive + → frontend.terminal_writers.get(terminal_id)?.(data) + → the mounted TerminalView writes the chunk into its xterm buffer +``` + +The live frontend path is callback maps, not Cells: `TerminalView` registers +write/exit callbacks in `Frontend.terminal_writers` / +`terminal_exit_handlers`, and the notification handlers dispatch by +`terminal_id`. (The `Terminal` Cell class is registered but not part of this +flow today — `TerminalRunner.svelte` tracks runs as plain objects.) +`TerminalRunner` always spawns a shell (`terminal_create({command: 'sh'})`) +and sends the actual command line via `terminal_data_send`; presets +(`TerminalPreset`) are component-local state seeded from defaults, not +persisted. Restart closes the old terminal (tolerating failure if it already +exited) and spawns a fresh one with a new `terminal_id`. + +On natural process exit the backend's read loop broadcasts `terminal_exited` +and cleans up its entry; an explicit `terminal_close` cancels the read loop, +signals the process (SIGTERM by default), and returns the exit code in the +RPC response. Terminals are pure in-memory process state — no persistence, +no reconnect-to-running across server restarts. + ## IndexedCollection Queryable reactive collections with multiple index types. From `indexed_collection.svelte.ts`. @@ -404,31 +463,29 @@ Queryable reactive collections with multiple index types. From `indexed_collecti ```typescript class IndexedCollection { - readonly by_id: SvelteMap = new SvelteMap(); - readonly values: Array = $derived(Array.from(this.by_id.values())); - readonly size: number = $derived(this.by_id.size); + readonly by_id: SvelteMap = new SvelteMap(); + readonly values: Array = $derived(Array.from(this.by_id.values())); + readonly size: number = $derived(this.by_id.size); } ``` ### Index Types -| Type | Cardinality | Example | -|------|-------------|---------| -| `single` | One key → one item | `by('name', 'gpt-5')` | -| `multi` | One key → many items | `where('provider_name', 'ollama')` | -| `derived` | Computed sorted array | `derived_index('ordered_by_name')` | -| `dynamic` | Runtime-computed | Custom queries | +- `single` — One key → one item. Example: `by('name', 'gpt-5')` +- `multi` — One key → many items. Example: `where('provider_name', 'claude')` +- `derived` — Computed sorted array. Example: `derived_index('ordered_by_name')` +- `dynamic` — Runtime-computed. Example: Custom queries ### Index Definition ```typescript interface IndexDefinition { - key: string; - type?: 'single' | 'multi' | 'derived' | 'dynamic'; - extractor?: (item: T) => any; - compute: (collection: IndexedCollection) => TResult; - onadd?: (result: TResult, item: T, collection: IndexedCollection) => TResult; - onremove?: (result: TResult, item: T, collection: IndexedCollection) => TResult; + key: string; + type?: 'single' | 'multi' | 'derived' | 'dynamic'; + extractor?: (item: T) => any; + compute: (collection: IndexedCollection) => TResult; + onadd?: (result: TResult, item: T, collection: IndexedCollection) => TResult; + onremove?: (result: TResult, item: T, collection: IndexedCollection) => TResult; } ``` @@ -437,36 +494,126 @@ interface IndexDefinition { ```typescript // Create with indexes const items = new IndexedCollection({ - indexes: [ - create_single_index({key: 'name', extractor: m => m.name}), - create_multi_index({key: 'provider_name', extractor: m => m.provider_name}), - create_derived_index({key: 'ordered_by_name', sort: (a, b) => a.name.localeCompare(b.name)}), - ], + indexes: [ + create_single_index({ key: 'name', extractor: (m) => m.name }), + create_multi_index({ key: 'provider_name', extractor: (m) => m.provider_name }), + create_derived_index({ key: 'ordered_by_name', sort: (a, b) => a.name.localeCompare(b.name) }) + ] }); // Query -items.by('name', 'gpt-5'); // single → Model | undefined -items.where('provider_name', 'ollama'); // multi → Array -items.derived_index('ordered_by_name'); // derived → Array +items.by('name', 'gpt-5'); // single → Model | undefined +items.where('provider_name', 'claude'); // multi → Array +items.derived_index('ordered_by_name'); // derived → Array ``` ## Filesystem Two separate concerns: -| Concern | Env Var | Purpose | -|---------|---------|---------| -| App directory | `PUBLIC_ZZZ_DIR` | Zzz's own data (`.zzz/state/`, `.zzz/cache/`, `.zzz/run/`) | -| Scoped dirs | `PUBLIC_ZZZ_SCOPED_DIRS` | User file access (comma-separated paths) | +- App directory (`PUBLIC_ZZZ_DIR`) — Zzz's own data (`.zzz/state/`, `.zzz/cache/`, `.zzz/run/`) +- Scoped dirs (`PUBLIC_ZZZ_SCOPED_DIRS`) — User file access (comma-separated paths) ### ScopedFs -All filesystem operations go through `ScopedFs` (`server/scoped_fs.ts`). Security: paths validated against allowed roots, symlinks rejected, absolute paths required, parent directories checked recursively. +All filesystem operations go through `ScopedFs` (Rust: `crates/zzz_server/src/scoped_fs.rs`). Security: paths validated against allowed roots, symlinks rejected, absolute paths required, parent directories checked recursively. ### Filer -Each scoped directory gets a `Filer` watcher. File changes are broadcast to clients via `filer_change` notifications over WebSocket. +`FilerManager` starts one `Filer` watcher per unique directory — the app dir, each scoped dir, and each open workspace dir. File changes are broadcast to clients via `filer_change` notifications over WebSocket. + +### Daemon Info -### Server Info +`~/.zzz/run/daemon.json` tracks the running daemon (PID, port, version). The Rust CLI (`crates/zzz/src/daemon_lifecycle.rs`) writes it atomically when spawning `zzzd`, reads it back for discovery and `status`, removes it on `daemon stop`, and cleans it up when the recorded PID turns out dead (stale detection via PID liveness). + +## File Editing + +The frontend file pipeline is five Cells plus a per-file editor-session class: + +- `Diskfiles` — `IndexedCollection` (`by_path` single index, + `by_extension` multi index); its `handle_change` is the `filer_change` + dispatch point +- `Diskfile` — one file: `{path, source_dir, content}`; the Cell `id` is + client-side identity, `path` is the disk identity used for backend + correlation +- `DiskfilesEditor` → `DiskfileTabs` → `DiskfileTab` — VS-Code-style tabs: + single-click opens a reusable _preview_ tab, editing or an explicit open + promotes it to permanent; tab order, recent-tab history, and + reopen-closed-tab state live on `DiskfileTabs` +- `DiskfileHistory` — per-path edit history (disk changes, unsaved edits, + original state; max 100 entries), held in `Frontend.diskfile_histories` — + in-memory only, lost on reload +- `DiskfileEditorState` (plain class, not a Cell) — one open file's editing + session; routes `current_content` writes through the history and owns + `save_changes()` + +Save round trip: + +``` +User edits → DiskfileEditorState.current_content setter + → unsaved-edit entry in DiskfileHistory +Save → save_changes() → app.api.diskfile_update({path, content}) + → ScopedFs::write_file (response is null — no content echo) +→ notify watcher fires → Filer updates its index immediately + → debounced (80ms) filer_change broadcast to all sockets + → Diskfiles.handle_change → existing Diskfile.set_json(...) + → editor sees diskfile.content change → disk-change history entry +``` -`run/server.json` tracks the running server (PID, port, version). Written atomically on startup, removed on clean shutdown (SIGINT/SIGTERM). Stale detection via `process.kill(pid, 0)`. +The confirmation is the broadcast, not the RPC response — a save and an +external edit look identical to the frontend. The initial file listing comes +from `session_load` (the backend rescans and flattens every active filer's +index); `workspace_open` currently returns `files: []`, so a newly opened +workspace populates only via subsequent `filer_change` events or a reload. +Tabs, history, and editor state are UI-session-only — a reload restores only +what `session_load` provides. + +## Spaces and Workspaces + +Two layers of directory scoping on top of the Filesystem section's "two +separate concerns": + +- **Workspace** (backend-tracked) — an open directory the server watches and + serves. `workspace_open` validates the path, adds it to `ScopedFs`, starts + a workspace-lifetime `Filer`, and broadcasts `workspace_changed`; + `workspace_close` reverses that (unless the path is one of the boot-time + `PUBLIC_ZZZ_SCOPED_DIRS`, whose permanent filers are never torn down). + Backend state is an in-memory map — a restart forgets all workspaces. + Scoped dirs never appear as workspaces: they're the operator-configured + always-on layer; workspaces are the user-opened runtime layer. +- **Space** (frontend-only) — a named grouping of directory paths + (`Space.directory_paths`) with no backend counterpart (no `space_*` + actions). `active_directory_paths` derives to only the paths that resolve + to a currently open workspace. `Spaces` auto-creates and protects a + `scratchpad` space. Space state is in-memory only today (DB persistence is + planned). + +The two meet in `DeskMenu.svelte`: toggling a directory into the active Space +first ensures its workspace is open. Opening brand-new directories happens on +`/workspaces` (path input → `workspace_open`; the `?workspace=` query +param auto-opens — this is how the CLI's `zzz ` lands the browser on a +workspace). `workspace_changed` broadcasts keep every connected client's +`Workspaces` collection in sync. + +## Capabilities + +`Capabilities` (`capabilities.svelte.ts`) is a single Cell aggregating +hardcoded (deliberately non-extensible) `Capability` statuses. The +`/capabilities` route is zzz's diagnostics + settings page: verify the +backend is reachable, see filesystem scope, configure and test provider API +keys, and control the WebSocket transport. In the static-only build (no +backend) every capability reads as unavailable — the "diminished +capabilities" deploy. + +Population, per capability: + +- `backend` — driven by `ping` (the ping action's frontend handlers forward + to `capabilities.handle_ping_*`); keeps a rolling round-trip-time history +- `websocket` — `$derived` off the `Socket` wrapper's connection state; its + panel is also a live control surface (connect/disconnect, heartbeat and + reconnect tuning) +- `filesystem` — `$derived` off `zzz_dir`/`scoped_dirs` from `session_load`, + gated on backend status +- `providers` — one `ProviderCapability` per provider, `$derived` off + `Frontend.provider_status`, populated by `session_load` and refreshed via + `provider_load_status` (and after `provider_update_api_key`) diff --git a/docs/development.md b/docs/development.md index d5493e104..639cc79c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,69 +4,119 @@ Development workflow, extension points, and common patterns. ## Setup +Requires Node (>=24.14), a Rust toolchain, PostgreSQL, and the sibling fuz +Rust workspace checked out alongside this repo (path deps — including `fuz_pty`). + ```bash git clone https://github.com/fuzdev/zzz.git && cd zzz -cp src/lib/server/.env.development.example .env.development -npm install +createdb zzz # PostgreSQL database the backend connects to +cargo xtask dev-setup # generate .env.development (idempotent) +npm install # Node dependencies +cargo xtask dev # build the Rust backend + run it with the Vite frontend ``` -Optionally add API keys to `.env.development` for remote providers (Anthropic, OpenAI, Google). Ollama requires no key. +`cargo xtask dev` rebuilds `zzz_server` (`cargo build -p zzz_server`) on every run, +binds the backend on `4461`, and serves the Vite frontend on `5173` (proxying +`/api` → `4461`). Browse to `localhost:5173`. + +Optionally add API keys to `.env.development` for remote providers (Anthropic, +OpenAI, Google), or set them at runtime on `/capabilities`. + +### PTY terminals + +Terminal integration uses the `fuz_pty` Rust crate, a native dependency of +the `zzz_server` backend (no FFI indirection). Building the backend +(`cargo build -p zzz_server`) pulls it in. `fuz_pty` lives in a sibling +Rust workspace, which must be checked out alongside this repo. ## Commands -| Command | Purpose | -|---------|---------| -| `gro check` | All checks (typecheck, test, gen, format, lint) | -| `gro typecheck` | Type checking only (faster iteration) | -| `gro test` | Run Vitest tests | -| `gro test -- --watch` | Tests in watch mode | -| `gro gen` | Regenerate `*.gen.ts` files | -| `gro format` | Format with Prettier | -| `gro lint` | ESLint checking | -| `gro build` | Production build | -| `gro deploy` | Deploy to production | +- `gro check` — All checks (typecheck, test, gen, format, lint) +- `gro typecheck` — Type checking only (faster iteration) +- `gro test` — Run Vitest tests +- `gro test -- --watch` — Tests in watch mode +- `gro gen` — Run `*.gen.ts` generators (regenerate their outputs) +- `gro format` — Format with tsv +- `gro lint` — ESLint checking +- `gro build` — Production build +- `gro deploy` — Deploy to production + +`cargo xtask dev` runs the dev server (Rust backend + Vite frontend) — the +user manages it; don't start it yourself. + +Three task runners, by role: **`gro`** for checks, build, gen, and tests +(`gro check` / `build` / `gen` / `test`); **`cargo xtask`** for the dev server and +env setup (`dev`, `dev-setup`, `prod-setup`); **`npm run`** for the package +scripts, notably `test:cross` (the Rust cross-process suites). `npm run dev`, +`npm run dev:setup`, and `npm run prod:setup` alias the matching `cargo xtask` +commands. + +## Production build + +There are two production targets: -Never run `gro dev` — the user manages the dev server. +- **Static-only** — `gro build` prerenders the SPA into `build/`, and `gro deploy` + publishes it to a static host (zzz.software). No Rust backend, so the + filesystem, terminals, AI, and auth are unavailable — the "diminished + capabilities" build. +- **Full self-hosted** — the same `build/` SPA served by the Rust `zzz_server` + daemon, which also serves `/api`. This is the complete app, and needs **both** + a frontend build and a backend build (`gro build` only does the SPA). + +Build and run the full self-hosted server: + +```bash +cargo xtask prod-setup # writes .env.production — edit its secrets +gro build # frontend → build/ +cargo build -p zzz_server --release # backend → target/release/zzzd +./target/release/zzzd --static-dir build # serve SPA + /api (port 4460; --port/ZZZ_PORT overrides) +``` + +`zzzd` reads its config from the **process environment** — it does _not_ load +`.env.production` itself (unlike `cargo xtask dev`, which loads `.env.development` +and injects it into the child processes). Supply the env via your process manager, a systemd +`EnvironmentFile`, or, in a shell, `set -a && . ./.env.production && set +a` before +running. It requires `DATABASE_URL`, `SECRET_FUZ_COOKIE_KEYS`, and a non-empty +`FUZ_ALLOWED_ORIGINS` (it hard-fails at boot otherwise). `--static-dir` (or +`ZZZ_STATIC_DIR`) points it at the built frontend; CLI flags win over env. In +production the SPA and API share one origin, so `.env.production` sets every +`PUBLIC_ZZZ_SERVER_*` port to the backend port (4460). ## Code Generation -`gro gen` regenerates these files from action specs. Never edit them manually: +The `*.gen.ts` files are hand-written generators; `gro gen` runs them and writes +their outputs. Edit the generators, never the outputs (each output carries a +`DO NOT EDIT` banner): -| Generated file | Source | -|---------------|--------| -| `src/lib/action_metatypes.gen.ts` | Action method types | -| `src/lib/action_collections.gen.ts` | Action spec collections | -| `src/lib/frontend_action_types.gen.ts` | Frontend handler types | -| `src/lib/server/backend_action_types.gen.ts` | Backend handler types | -| `src/routes/library.gen.ts` | Route metadata | +- `action_collections.gen.ts` (`src/lib/action_collections.ts`) — Action spec collections, input/output type maps +- `action_metatypes.gen.ts` (`src/lib/action_metatypes.ts`) — Action method types, handler enums +- `frontend_action_types.gen.ts` (`src/lib/frontend_action_types.ts`) — Frontend handler types +- `reference.gen.ts` (`docs/reference.md`) — Action-spec + cell-class reference lists -Run `gro gen` after changing `action_specs.ts`. +Run `gro gen` after changing `action_specs.ts` (or `cell_classes.ts`). `gro check` +fails if any output is stale. ## File Naming -| Pattern | Purpose | Example | -|---------|---------|---------| -| `snake_case.ts` | TypeScript modules | `helpers.ts`, `action_peer.ts` | -| `snake_case.svelte.ts` | Svelte 5 reactive state | `chat.svelte.ts` | -| `PascalCase.svelte` | Svelte components | `ChatView.svelte` | -| `snake_case.test.ts` | Test files (in `src/test/`) | `cell.svelte.base.test.ts` | -| `*_types.ts` | Type definitions | `action_types.ts` | -| `*_helpers.ts` | Utility functions | `jsonrpc_helpers.ts` | +- `snake_case.ts` — TypeScript modules. Example: `helpers.ts`, `action_dispatcher.ts` +- `snake_case.svelte.ts` — Svelte 5 reactive state. Example: `chat.svelte.ts` +- `PascalCase.svelte` — Svelte components. Example: `ChatView.svelte` +- `snake_case.test.ts` — Test files (in `src/test/`). Example: `cell.svelte.base.test.ts` +- `*_types.ts` — Type definitions. Example: `action_types.ts` +- `*_helpers.ts` — Utility functions. Example: `jsonrpc_helpers.ts` ### Component Naming Components use `PascalCase` with domain prefixes: -| Prefix | Domain | Examples | -|--------|--------|----------| -| `Chat` | Chat UI | `ChatView`, `ChatListitem` | -| `Diskfile` | File editor | `DiskfileEditorView`, `DiskfileExplorer` | -| `Model` | Model management | `ModelListitem`, `ModelPickerDialog` | -| `Ollama` | Ollama-specific | `OllamaManager`, `OllamaPullModel` | -| `Part` | Content parts | `PartView`, `PartEditorForText` | -| `Prompt` | Prompts | `PromptList`, `PromptPickerDialog` | -| `Thread` | Threads | `ThreadList`, `ThreadContextmenu` | -| `Turn` | Turns | `TurnView`, `TurnListitem` | +- `Chat` — Chat UI. Examples: `ChatView`, `ChatListitem` +- `Diskfile` — File editor. Examples: `DiskfileEditorView`, `DiskfileExplorer` +- `Model` — Model management. Examples: `ModelListitem`, `ModelPickerDialog` +- `Part` — Content parts. Examples: `PartView`, `PartEditorForText` +- `Prompt` — Prompts. Examples: `PromptList`, `PromptPickerDialog` +- `Terminal` — Terminals. Examples: `TerminalRunner`, `TerminalView`, `TerminalContextmenu` +- `Thread` — Threads. Examples: `ThreadList`, `ThreadContextmenu` +- `Turn` — Turns. Examples: `TurnView`, `TurnListitem` ## Extension Points @@ -76,24 +126,24 @@ Components use `PascalCase` with domain prefixes: ```typescript export const MyThingJson = CellJson.extend({ - name: z.string().default(''), - value: z.number().default(0), -}).meta({cell_class_name: 'MyThing'}); + name: z.string().default(''), + value: z.number().default(0) +}).meta({ cell_class_name: 'MyThing' }); ``` 2. Create the class (`src/lib/my_thing.svelte.ts`): ```typescript export class MyThing extends Cell { - name: string = $state()!; - value: number = $state()!; + name: string = $state.raw()!; + value: number = $state.raw()!; - readonly doubled = $derived(this.value * 2); + readonly doubled = $derived(this.value * 2); - constructor(options: MyThingOptions) { - super(MyThingJson, options); - this.init(); // Must call at end - } + constructor(options: MyThingOptions) { + super(MyThingJson, options); + this.init(); // Must call at end + } } ``` @@ -101,8 +151,8 @@ export class MyThing extends Cell { ```typescript export const cell_classes = { - // ... existing classes - MyThing, + // ... existing classes + MyThing } satisfies Record>; ``` @@ -112,31 +162,33 @@ export const cell_classes = { ```typescript export const my_action_action_spec = { - method: 'my_action', - kind: 'request_response', - initiator: 'frontend', - auth: 'public', - side_effects: true, - input: z.strictObject({ - message: z.string(), - }), - output: z.strictObject({ - result: z.string(), - }), - async: true, + method: 'my_action', + kind: 'request_response', + initiator: 'frontend', + auth: null, // public; or {account: 'required', actor: 'none'} to require a session + side_effects: true, + input: z.strictObject({ + message: z.string() + }), + output: z.strictObject({ + result: z.string() + }), + async: true } satisfies ActionSpecUnion; ``` 2. Run `gro gen` to regenerate handler types. -3. Add frontend handler (`src/lib/frontend_action_handlers.ts`): +3. Add frontend handler (`src/lib/frontend_action_handlers.ts`) — handlers go + inside `create_frontend_action_handlers(frontend)` and reach app state via the + closed-over `frontend` (the action event itself carries no `app`): ```typescript my_action: { send_request: ({data: {input}}) => { console.log('sending:', input.message); }, - receive_response: ({app, data: {output}}) => { + receive_response: ({data: {output}}) => { console.log('received:', output.result); }, receive_error: ({data: {error}}) => { @@ -145,26 +197,55 @@ my_action: { }, ``` -4. Add backend handler (`src/lib/server/backend_action_handlers.ts`): +4. Add the backend handler in the Rust backend (`crates/zzz_server`): a spec + builder in `zzz_action_specs/` and the handler fn in `handlers/`. Both + HTTP RPC and WebSocket paths dispatch through the same `ActionRegistry`, + so the handler is picked up on both transports. See ../crates/CLAUDE.md. + +### Streaming Handlers + +For actions that push progress notifications back to the requester while +running, pair the `request_response` action with a companion +`remote_notification` action and name the companion in the `streams` field: ```typescript -my_action: { - receive_request: async ({backend, data: {input}}) => { - const {message} = input; - return {result: `Processed: ${message}`}; - }, -}, +export const my_long_job_action_spec = { + method: 'my_long_job', + kind: 'request_response', + initiator: 'frontend', + auth: {account: 'required', actor: 'none'}, + streams: 'my_long_job_progress', // name of the companion notification + input: z.strictObject({...}), + output: z.null(), + async: true, +} satisfies ActionSpecUnion; + +export const my_long_job_progress_action_spec = { + method: 'my_long_job_progress', + kind: 'remote_notification', + initiator: 'backend', + input: z.strictObject({...}), + async: false, +} satisfies ActionSpecUnion; ``` +The backend handler sends progress chunks to the originating socket +(request-scoped) and terminates early when the socket closes; `completion_create` + +- `completion_progress` is the worked example. Broadcasts to all connected + sockets (server-wide events like `filer_change` or `workspace_changed`) go + through the backend's realtime connection registry. See ../crates/CLAUDE.md + for the Rust handler patterns. + ### Adding a New Route Create `src/routes/my_route/+page.svelte`: ```svelte

My Route

@@ -176,10 +257,10 @@ Create `src/routes/my_route/+page.svelte`: ```svelte ``` @@ -187,7 +268,7 @@ Create `src/routes/my_route/+page.svelte`: ```typescript // Add -const chat = app.chats.add({name: 'New Chat'}); +const chat = app.chats.add({ name: 'New Chat' }); // Get by ID const chat = app.chats.items.by_id.get(id); @@ -196,11 +277,11 @@ const chat = app.chats.items.by_id.get(id); const model = app.models.items.by('name', 'gpt-5-2025-08-07'); // Query multi-index -const ollama_models = app.models.items.where('provider_name', 'ollama'); +const claude_models = app.models.items.where('provider_name', 'claude'); // Iterate for (const chat of app.chats.items.values) { - console.log(chat.name); + console.log(chat.name); } ``` @@ -221,22 +302,22 @@ app.api.toggle_main_menu(); ```svelte
-

{title}

- {#if children} - {@render children()} - {/if} +

{title}

+ {#if children} + {@render children()} + {/if}
``` @@ -244,15 +325,15 @@ app.api.toggle_main_menu(); ```svelte - {#snippet entries()} - doSomething()}>Action Label - {/snippet} -
Right-click me
+ {#snippet entries()} + doSomething()}>Action Label + {/snippet} +
Right-click me
``` @@ -271,52 +352,46 @@ gro test -- src/test/cell.svelte.base.test.ts # specific file Uses Vitest with `test` and `expect`: ```typescript -import {test, expect} from 'vitest'; +import { test, expect } from 'vitest'; -import {providers_default, models_default} from '$lib/config_defaults.js'; +import { providers_default, models_default } from '$lib/config_defaults.ts'; test('all model provider_names exist in providers_default', () => { - const model_provider_names = new Set(models_default.map((model) => model.provider_name)); - const provider_names = new Set(providers_default.map((provider) => provider.name)); - - for (const provider_name of model_provider_names) { - expect( - provider_names.has(provider_name), - `Provider "${provider_name}" does not exist in providers_default`, - ).toBe(true); - } + const model_provider_names = new Set(models_default.map((model) => model.provider_name)); + const provider_names = new Set(providers_default.map((provider) => provider.name)); + + for (const provider_name of model_provider_names) { + expect( + provider_names.has(provider_name), + `Provider "${provider_name}" does not exist in providers_default` + ).toBe(true); + } }); ``` ### Test File Naming -| Pattern | Example | -|---------|---------| -| `module.test.ts` | `action_event.test.ts` | -| `module.aspect.test.ts` | `cell.svelte.base.test.ts`, `cell.svelte.decoders.test.ts` | -| `module.aspect.test.ts` | `indexed_collection.svelte.queries.test.ts` | +- `module.test.ts` — `action_event.test.ts` +- `module.aspect.test.ts` — `cell.svelte.base.test.ts`, `cell.svelte.decoders.test.ts` +- `module.aspect.test.ts` — `indexed_collection.svelte.queries.test.ts` ## Code Style ### Naming -| Type | Convention | Example | -|------|-----------|---------| -| Variables/functions | `snake_case` | `send_message`, `user_input` | -| Classes | `PascalCase` | `ChatView`, `ActionPeer` | -| Types/interfaces | `PascalCase` | `ChatOptions`, `ActionSpec` | -| Zod schemas | `PascalCase` | `ChatJson`, `CompletionRequest` | -| Private fields | `#field` | `#internal_state` | +- Variables/functions — `snake_case`. Example: `send_message`, `user_input` +- Classes — `PascalCase`. Example: `ChatView`, `ActionDispatcher` +- Types/interfaces — `PascalCase`. Example: `ChatOptions`, `ActionSpec` +- Zod schemas — `PascalCase`. Example: `ChatJson`, `CompletionRequest` +- Private fields — `#field`. Example: `#internal_state` ### Code Markers -| Marker | Meaning | -|--------|---------| -| `// @slop [Model]` | LLM-generated code needing review | -| `// TODO` | Work item | -| `// TODO @many` | Affects multiple locations | -| `// TODO @api` | API design question | -| `// TODO @db` | Database-related | +- `// @slop [Model]` — LLM-generated code needing review +- `// TODO` — Work item +- `// TODO @many` — Affects multiple locations +- `// TODO @api` — API design question +- `// TODO @db` — Database-related ### Import Order @@ -325,29 +400,31 @@ test('all model provider_names exist in providers_default', () => { 3. Relative imports (`./...`) ```typescript -import {z} from 'zod'; -import {SvelteMap} from 'svelte/reactivity'; +import { z } from 'zod'; +import { SvelteMap } from 'svelte/reactivity'; -import {Cell} from '$lib/cell.svelte.js'; -import type {Frontend} from '$lib/frontend.svelte.js'; +import { Cell } from '$lib/cell.svelte.ts'; +import type { Frontend } from '$lib/frontend.svelte.ts'; -import {helper_function} from './helpers.js'; +import { helper_function } from './helpers.ts'; ``` -All imports use `.js` extensions (ESM convention). +Imports use the real source extension (`.ts` / `.svelte.ts` / `.svelte`) — +library code (`src/lib`) imports relative, while `src/routes` and `src/test` +use the `$lib` alias. ### Svelte 5 Runes in State Classes ```typescript -// Schema fields — $state()! initialized by Cell.init() -name: string = $state()!; +// Schema fields — $state.raw()! by default, initialized by Cell.init() +name: string = $state.raw()!; + +// $state()! only for arrays/objects mutated in place (push, splice, etc.) +thread_ids: Array = $state()!; // Derived values readonly doubled = $derived(this.count * 2); readonly complex = $derived.by(() => expensiveCalculation(this.count)); - -// Raw state (no deep reactivity) — for large objects -response: CompletionResponse = $state.raw(); ``` No `$effect` in Cell classes — effects belong in Svelte components only. @@ -361,6 +438,6 @@ throw jsonrpc_errors.ai_provider_error(provider_name, error_message); // Let ThrownJsonrpcError bubble through if (error instanceof ThrownJsonrpcError) { - throw error; + throw error; } ``` diff --git a/docs/providers.md b/docs/providers.md index f97d27efb..376a791ee 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -4,18 +4,9 @@ Integration guide for AI providers and adding new ones. ## Supported Providers -| Provider | Type | Class | SDK | API Key Env | -|----------|------|-------|-----|-------------| -| Ollama | Local | `BackendProviderOllama` | `ollama` | None required | -| Claude | Remote (BYOK) | `BackendProviderClaude` | `@anthropic-ai/sdk` | `SECRET_ANTHROPIC_API_KEY` | -| ChatGPT | Remote (BYOK) | `BackendProviderChatgpt` | `openai` | `SECRET_OPENAI_API_KEY` | -| Gemini | Remote (BYOK) | `BackendProviderGemini` | `@google/generative-ai` | `SECRET_GOOGLE_API_KEY` | - -### Ollama (Local) - -No API key. Auto-detects available models. Model management UI at `/providers/ollama`. - -Setup: Install Ollama, run `ollama serve`, zzz auto-detects. +- Claude (`provider/anthropic.rs`) — Full (non-streaming + SSE streaming). Type: Remote (BYOK). API Key Env: `SECRET_ANTHROPIC_API_KEY` +- ChatGPT (`provider/openai.rs`) — Full (non-streaming + SSE streaming). Type: Remote (BYOK). API Key Env: `SECRET_OPENAI_API_KEY` +- Gemini (`provider/gemini.rs`) — Full (non-streaming + SSE streaming). Type: Remote (BYOK). API Key Env: `SECRET_GOOGLE_API_KEY` ### Remote Providers (Claude, ChatGPT, Gemini) @@ -29,121 +20,40 @@ SECRET_GOOGLE_API_KEY=AIza... ## Default Models -Defined in `src/lib/config_defaults.ts` (`models_default`): - -### Ollama - -| Model | Tags | -|-------|------| -| `gemma3n:e2b`, `gemma3n:e4b` | small | -| `gemma3:1b`, `gemma3:4b` | small | -| `qwen3:0.6b`, `qwen3:1.7b`, `qwen3:4b`, `qwen3:8b` | small / (none) | -| `deepseek-r1:1.5b`, `deepseek-r1:7b`, `deepseek-r1:8b` | reasoning | -| `llama3.2:1b`, `llama3.2:3b` | small | -| `phi4-mini:3.8b` | (none) | -| `smollm2:135m`, `smollm2:360m`, `smollm2:1.7b` | small | - -### Claude (Anthropic) - -| Model | Tags | -|-------|------| -| `claude-sonnet-4-5-20250929` | smart | -| `claude-opus-4-1-20250805` | smart, smartest | -| `claude-3-5-haiku-20241022` | cheap | - -### ChatGPT (OpenAI) - -| Model | Tags | -|-------|------| -| `gpt-5-2025-08-07` | smart | -| `gpt-5-nano-2025-08-07` | cheap, cheaper | -| `gpt-5-mini-2025-08-07` | cheap | -| `gpt-4.1-2025-04-14` | smart | - -### Gemini (Google) - -| Model | Tags | -|-------|------| -| `gemini-2.5-pro` | smart | -| `gemini-2.5-flash` | cheap | -| `gemini-2.5-flash-lite` | cheap, cheaper | - -### Chat Templates - -Pre-configured model groups in `config_defaults.ts` (`chat_template_defaults`): `frontier`, `cheap frontier`, `local 3-4b`, `local 1-2b`, `local <1b`, `local gemmas`, `quick test`. +The default model catalog is the source of truth in `src/lib/config_defaults.ts` +(`models_default`) — model IDs churn, so it isn't duplicated here. Each entry +carries a `provider_name` (`claude` / `chatgpt` / `gemini`) and `tags` drawn from +`smart`, `smartest`, `cheap`, `cheaper`. Pre-configured model groups live +alongside it in `chat_template_defaults` (`frontier`, `cheap frontier`, +`quick test`). ## Provider Architecture -### Class Hierarchy +Providers live in the Rust backend (`crates/zzz_server/src/provider/`), +enum-dispatched via the `Provider` enum (`provider/mod.rs`) — the providers +are known at compile time and matched exhaustively, no trait objects. +`ProviderManager` owns the set; `set_api_key` recreates the underlying +`reqwest` client; a provider reports an error status when no key is +configured. All three providers — Anthropic (`provider/anthropic.rs`), OpenAI +(`provider/openai.rs`), and Gemini (`provider/gemini.rs`) — are fully +implemented with non-streaming and SSE-streaming completions through the shared +`provider/sse.rs`. See ../crates/CLAUDE.md for the +backend details. -``` -BackendProvider (backend_provider.ts) -├── BackendProviderLocal (for locally-installed services) -│ └── BackendProviderOllama (backend_provider_ollama.ts) -└── BackendProviderRemote (for API-based services, manages API keys) - ├── BackendProviderClaude (backend_provider_claude.ts) - ├── BackendProviderChatgpt (backend_provider_chatgpt.ts) - └── BackendProviderGemini (backend_provider_gemini.ts) -``` +### CompletionOptions -### BackendProvider Base +The per-completion options the backend passes to a provider: -From `server/backend_provider.ts`: - -```typescript -abstract class BackendProvider { - abstract readonly name: string; - protected client: TClient | null = null; - protected provider_status: ProviderStatus | null = null; - - abstract handle_streaming_completion(options: CompletionHandlerOptions): Promise; - abstract handle_non_streaming_completion(options: CompletionHandlerOptions): Promise; - - get_handler(streaming: boolean): CompletionHandler { - return streaming - ? this.handle_streaming_completion.bind(this) - : this.handle_non_streaming_completion.bind(this); - } - - abstract create_client(): void; - abstract get_client(): TClient; - abstract load_status(reload?: boolean): Promise; - - protected validate_streaming_requirements(progress_token?: Uuid): asserts progress_token { ... } - protected async send_streaming_progress(progress_token: Uuid, chunk: ...): Promise { ... } -} ``` - -### BackendProviderRemote - -Adds API key management. `set_api_key()` recreates the client. Returns error status if no key configured. - -### BackendProviderLocal - -Creates client on construction. `load_status()` checks if the service is available locally. - -### CompletionHandlerOptions - -```typescript -interface CompletionHandlerOptions { - model: string; - completion_options: CompletionOptions; - completion_messages: Array | undefined; - prompt: string; - progress_token?: Uuid; // Opts into streaming when provided -} - -interface CompletionOptions { - frequency_penalty?: number; - output_token_max: number; - presence_penalty?: number; - seed?: number; - stop_sequences?: Array; - system_message: string; - temperature?: number; - top_k?: number; - top_p?: number; -} +frequency_penalty?: number +output_token_max: number +presence_penalty?: number +seed?: number +stop_sequences?: Array +system_message: string +temperature?: number +top_k?: number +top_p?: number ``` ### CompletionRequest / CompletionResponse @@ -152,59 +62,31 @@ From `completion_types.ts`: ```typescript const CompletionRequest = z.strictObject({ - created: DatetimeNow, - provider_name: ProviderName, - model: z.string(), - prompt: z.string(), - completion_messages: z.array(CompletionMessage).optional(), + created: DatetimeNow, + provider_name: ProviderName, + model: z.string(), + prompt: z.string(), + completion_messages: z.array(CompletionMessage).optional() }); const CompletionResponse = z.strictObject({ - created: DatetimeNow, - provider_name: ProviderName, - model: z.string(), - data: ProviderDataSchema, + created: DatetimeNow, + provider_name: ProviderName, + model: z.string(), + data: ProviderDataSchema }); ``` ## Real Provider Example -From `server/backend_provider_claude.ts`: - -```typescript -export class BackendProviderClaude extends BackendProviderRemote { - readonly name = 'claude'; - - constructor(options: BackendProviderOptions) { - super({...options, api_key: options.api_key ?? (SECRET_ANTHROPIC_API_KEY || null)}); - } - - protected override create_client(): void { - this.client = this.api_key ? new Anthropic({apiKey: this.api_key}) : null; - } - - async handle_streaming_completion(options: CompletionHandlerOptions): Promise { - const {model, completion_options, completion_messages, prompt, progress_token} = options; - this.validate_streaming_requirements(progress_token); - - const stream = await this.get_client().messages.create( - create_claude_completion_options(model, completion_options, completion_messages, prompt, true), - ); - - let accumulated_content = ''; - for await (const event of stream) { - if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - accumulated_content += event.delta.text; - void this.send_streaming_progress(progress_token, { - message: { role: 'assistant', content: event.delta.text }, - }); - } - } - - return to_completion_result('claude', model, api_response, progress_token); - } -} -``` +The Anthropic provider (`crates/zzz_server/src/provider/anthropic.rs`) calls +the Messages API with a `reqwest` client. For streaming completions it sets +`stream: true`, parses the SSE response (`provider/sse.rs`, manual `\r\n` +normalization), and forwards each `content_block_delta` text chunk to the +originating WebSocket connection as a `completion_progress` notification. The +OpenAI (Chat Completions) and Gemini (Generative Language) providers follow the +same pattern against their respective APIs, sharing `provider/sse.rs`. +See ../crates/CLAUDE.md. ## Completion Flow @@ -213,35 +95,45 @@ User sends message → Thread.send_message(content) → Build CompletionRequest (provider_name, model, prompt, completion_messages) → app.api.completion_create({completion_request, _meta: {progressToken}}) - → Backend: backend.lookup_provider(provider_name) - → provider.get_handler(!!progress_token) - → provider.handle_streaming_completion(options) - → Call provider SDK with stream: true - → For each chunk: - → provider.send_streaming_progress(progress_token, chunk) - → WebSocket notification to frontend - → Turn content updated incrementally - → Return CompletionResult + → WS dispatch → backend completion_create handler + → ProviderManager looks up the provider by name + → provider calls its API (stream: true when a progress token is present) + → For each text chunk: + → completion_progress notification to the originating WS connection + → Turn content updated incrementally + → Return the completion result ``` +Streaming progress is socket-scoped — the chunks go only to the originating +WebSocket connection, never broadcast. Cancellation is supported: +`Thread.cancel_pending()` fires from the client side, the frontend WS client +sends the `cancel` notification and rejects the pending promise with +`request_cancelled` so the UI can distinguish user-initiated cancels from +real provider failures; the backend aborts the in-flight request. + ### Provider Status ```typescript const status = await provider.load_status(); // { name: 'claude', available: true, checked_at: 1234567890 } -// { name: 'claude', available: false, error: 'API key required', checked_at: ... } +// { name: 'claude', available: false, error: 'needs API key', checked_at: ... } ``` -Remote providers: `available` = `true` when API key is set and client created. -Local providers (Ollama): `available` = `true` when service responds. +Remote providers: `available` = `true` when an API key is configured. ## Adding a New Provider -1. Create `src/lib/server/backend_provider_newprovider.ts` extending `BackendProviderRemote` -2. Implement `create_client()`, `handle_streaming_completion()`, `handle_non_streaming_completion()` -3. Register in `src/lib/server/server.ts`: `backend.add_provider(new BackendProviderNewProvider(provider_options))` -4. Add response helper in `src/lib/response_helpers.ts` -5. Add env var to `.env.development.example`: `SECRET_NEWPROVIDER_API_KEY=` -6. Add default models to `src/lib/config_defaults.ts` (`models_default`) +Providers live in the Rust backend (`crates/zzz_server/src/provider/`), enum- +dispatched via the `Provider` enum (no trait objects). To add one: + +1. Add a variant to the `Provider` enum and `ProviderName` in `provider/mod.rs` +2. Create `crates/zzz_server/src/provider/newprovider.rs` implementing the + completion path (status, non-streaming, and SSE streaming via `provider/sse.rs`) +3. Wire it into `ProviderManager` and the exhaustive match arms — construction + and registration happen at boot in `crates/zzz_server/src/lib.rs` + (`provider_manager.add(Provider::...)`, reading the key env var) +4. Add env var to `.env.development.example` and `.env.production.example`: + `SECRET_NEWPROVIDER_API_KEY=` +5. Add default models to `src/lib/config_defaults.ts` (`models_default`) -See [src/lib/server/CLAUDE.md](../src/lib/server/CLAUDE.md) for detailed backend architecture. +See ../crates/CLAUDE.md for the backend architecture. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 000000000..57979c27d --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,37 @@ + + +# Reference + +Generated from `action_specs.ts` and `cell_classes.ts` by `reference.gen.ts`. +Run `gro gen` to refresh it; `gro check` fails if it drifts. Don't edit by hand. + +## Action specs (21) + +The fuz_app protocol actions `heartbeat` and `cancel` are dispatched too but +omitted here — they belong to the shared runtime, not zzz. + +- `completion_create` — Start an AI completion request, optionally with a progress token for streaming. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `completion_progress` — Streams a completion chunk to the frontend during a streaming AI response. Kind: remote_notification. Initiator: backend. Auth: public +- `directory_create` — Create a new directory on disk. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `diskfile_delete` — Delete a file from disk. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `diskfile_update` — Write new content to a file on disk. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `filer_change` — Notifies the frontend of a file system change detected by the watcher. Kind: remote_notification. Initiator: backend. Auth: public +- `ping` — Health check — echoes the request ID back to the caller. Kind: request_response. Initiator: both. Auth: account=none, actor=none +- `provider_load_status` — Check the availability and status of an AI provider. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `provider_update_api_key` — Update the API key for an AI provider. Kind: request_response. Initiator: frontend. Auth: account=required, actor=required, roles=keeper, creds=daemon_token +- `session_load` — Load initial session data including filesystem state and provider status. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `terminal_close` — Kill a terminal process and return the exit code. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `terminal_create` — Spawn a PTY process and return the terminal ID. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `terminal_data` — Stream stdout/stderr bytes from a terminal to the frontend. Kind: remote_notification. Initiator: backend. Auth: public +- `terminal_data_send` — Send stdin bytes to a terminal. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `terminal_exited` — Notify the frontend that a terminal process exited naturally. Kind: remote_notification. Initiator: backend. Auth: public +- `terminal_resize` — Update PTY dimensions for a terminal. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `toggle_main_menu` — Toggle or set the visibility of the main navigation menu. Kind: local_call. Initiator: frontend. Auth: public +- `workspace_changed` — Notifies frontends when a workspace is opened or closed. Kind: remote_notification. Initiator: backend. Auth: public +- `workspace_close` — Close a workspace directory — stops file watching and removes from ScopedFs. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `workspace_list` — List all open workspaces. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none +- `workspace_open` — Open a workspace directory — registers with ScopedFs and starts file watching. Kind: request_response. Initiator: frontend. Auth: account=required, actor=none + +## Cell classes (31) + +`Action`, `Actions`, `Capabilities`, `Chat`, `Chats`, `Diskfile`, `DiskfileHistory`, `DiskfilePart`, `Diskfiles`, `DiskfilesEditor`, `DiskfileTab`, `DiskfileTabs`, `Model`, `Models`, `Parts`, `Prompt`, `Prompts`, `Provider`, `Providers`, `Space`, `Spaces`, `Terminal`, `TerminalPreset`, `TextPart`, `Thread`, `Threads`, `Time`, `Turn`, `Ui`, `Workspace`, `Workspaces` diff --git a/eslint.config.js b/eslint.config.js index ab7e63d69..81daff8b1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,21 @@ -import {configs /*, ts_config*/} from '@ryanatkn/eslint-config'; +import { configs /*, ts_config*/ } from '@ryanatkn/eslint-config'; // ts_config.rules['no-console'] = 1; -export default configs; +export default [ + ...configs, + { + // Rust artifacts — eslint walks `target/doc/**/*.js` (170 generated files + // from `cargo doc`), and each one stalls the typescript-eslint project + // service for ~60s. `crates/` listed defensively even though it has no JS today. + ignores: ['target', 'crates'] + }, + { + // `$bindable()` is a Svelte 5 marker, not a default value, but the rule + // can't tell them apart and flags every bindable required prop. + files: ['**/*.svelte'], + rules: { + '@typescript-eslint/no-useless-default-assignment': 'off' + } + } +]; diff --git a/gro.config.ts b/gro.config.ts new file mode 100644 index 000000000..ce89ae8ac --- /dev/null +++ b/gro.config.ts @@ -0,0 +1,16 @@ +import type { CreateGroConfig } from '@fuzdev/gro'; + +// eslint-disable-next-line @typescript-eslint/require-await +const config: CreateGroConfig = async (base_config) => { + const base_plugins = base_config.plugins; + base_config.plugins = async (ctx) => { + // Drop gro's default dev server — zzz's backend is the Rust `zzzd` + // binary, built with `cargo` and run via `cargo xtask dev` (dev) or + // `zzz daemon start` (prod), not gro's Node server. + return (await base_plugins(ctx)).filter((p) => p.name !== 'gro_plugin_server'); + }; + + return base_config; +}; + +export default config; diff --git a/package-lock.json b/package-lock.json index 6bc070077..6cd8a6c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,104 +9,92 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", - "@fuzdev/gro": "^0.197.1", - "@google/generative-ai": "^0.24.1", - "@hono/node-server": "^1.19.6", - "@hono/node-ws": "^1.2.0", - "date-fns": "^4.1.0", - "esm-env": "^1.2.2", - "hono": "^4.10.7", - "openai": "^6.10.0", - "zod": "^4.3.6" + "@fuzdev/blake3_wasm": "^0.1.1", + "@fuzdev/gro": "^0.209.0", + "@xterm/xterm": "^6.0.0", + "date-fns": "^4.1.0" }, "devDependencies": { "@changesets/changelog-git": "^0.2.1", - "@fuzdev/fuz_code": "^0.45.1", - "@fuzdev/fuz_css": "^0.57.0", - "@fuzdev/fuz_ui": "^0.191.1", - "@fuzdev/fuz_util": "^0.55.0", - "@jridgewell/trace-mapping": "^0.3.31", - "@ryanatkn/eslint-config": "^0.9.0", - "@sveltejs/adapter-node": "^5.4.0", + "@fuzdev/fuz_app": "^0.104.0", + "@fuzdev/fuz_code": "^0.48.0", + "@fuzdev/fuz_css": "^0.63.2", + "@fuzdev/fuz_ui": "^0.206.7", + "@fuzdev/fuz_util": "^0.65.2", + "@fuzdev/mdz": "^0.3.0", + "@ryanatkn/eslint-config": "^0.12.2", + "@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.55.0", + "@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/estree": "^1.0.8", "@types/node": "^24.10.1", + "@types/pg": "^8.15.7", "@webref/css": "^8.2.0", "eslint": "^9.39.1", - "eslint-plugin-svelte": "^3.13.1", + "eslint-plugin-svelte": "^3.17.1", + "esm-env": "^1.2.2", "jsdom": "^27.2.0", "magic-string": "^0.30.21", - "ollama": "^0.6.3", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.4.1", - "svelte": "^5.54.0", - "svelte-check": "^4.4.5", - "svelte2tsx": "^0.7.52", + "pg": "^8.20.0", + "svelte": "^5.56.0", + "svelte-check": "^4.4.8", + "svelte-docinfo": "^0.5.4", + "svelte2tsx": "^0.7.55", "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", + "vite": "^7.3.1", "vitest": "^4.0.15", - "zimmerframe": "^1.1.4" + "zimmerframe": "^1.1.4", + "zod": "^4.3.6" }, "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "funding": { "url": "https://www.ryanatkn.com/funding" }, "peerDependencies": { + "@fuzdev/fuz_app": ">=0.82.0", + "@fuzdev/fuz_ui": "^0.205.1", + "@fuzdev/fuz_util": ">=0.65.1", "@sveltejs/kit": "^2", - "svelte": "^5" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.71.2", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", - "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "esm-env": "^1", + "svelte": "^5", + "zod": "^4" }, "peerDependenciesMeta": { - "zod": { + "esm-env": { "optional": true } } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", - "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "devOptional": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.2" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", - "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -114,7 +102,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -124,15 +112,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@changesets/changelog-git": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", @@ -151,9 +130,9 @@ "license": "MIT" }, "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==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "devOptional": true, "funding": [ { @@ -167,13 +146,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "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==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "devOptional": true, "funding": [ { @@ -187,17 +166,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "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==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "devOptional": true, "funding": [ { @@ -211,21 +190,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "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==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "devOptional": true, "funding": [ { @@ -239,16 +218,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.17.tgz", - "integrity": "sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "devOptional": true, "funding": [ { @@ -261,14 +240,19 @@ } ], "license": "MIT-0", - "engines": { - "node": ">=18" + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, "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==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "devOptional": true, "funding": [ { @@ -282,44 +266,47 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -334,9 +321,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -351,9 +338,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -368,9 +355,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -385,9 +372,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -402,9 +389,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -419,9 +406,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -436,9 +423,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -453,9 +440,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -470,9 +457,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -487,9 +474,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -504,9 +491,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -521,9 +508,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -538,9 +525,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -555,9 +542,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -572,9 +559,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -589,9 +576,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -606,9 +593,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -623,9 +610,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -640,9 +627,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -657,9 +644,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -674,9 +661,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -691,9 +678,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -708,9 +695,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -725,9 +712,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -742,9 +729,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -759,9 +746,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -791,9 +778,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -801,15 +788,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -842,20 +829,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -879,9 +866,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -915,12 +902,29 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fuzdev/blake3_wasm": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@fuzdev/blake3_wasm/-/blake3_wasm-0.1.0.tgz", - "integrity": "sha512-EU5uUcSX55Li3IXi1NiBDoVlxCN8ip9wqAhVZlMBEUa+cFQtLL6Z8GpYjlWy0KosLmxy2Z9WQv49PAkiAzFppg==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fuzdev/blake3_wasm/-/blake3_wasm-0.1.1.tgz", + "integrity": "sha512-JikFOouJEVLKJvsEQ7+fRdo3GElL4nmu2sV8rg+xu2bv+BAMk+GvoO3TOSPYX9fdHeXJ7U4N0IdIP/mNh7WNfw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -928,63 +932,108 @@ "url": "https://www.ryanatkn.com/funding" } }, - "node_modules/@fuzdev/fuz_code": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_code/-/fuz_code-0.45.1.tgz", - "integrity": "sha512-aVWWJHJ3U/bV9ZqooBuZ1XQrFgKdbSgRgs4NQOXDHl20JmmoR0jf7BkxQM/lxhtT/WU5kFJhiaGFYZCSmSgUuw==", + "node_modules/@fuzdev/fuz_app": { + "version": "0.104.0", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_app/-/fuz_app-0.104.0.tgz", + "integrity": "sha512-UiW6/jFPNpCkzPK8kcsQqOX05h3K6ZooxrQfUpxgQsGbuxnyjxd2MrfNjxHMpzoalbJTbSy3xrkdSlZquf+UBQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=22.15" - }, - "funding": { - "url": "https://www.ryanatkn.com/funding" + "node": ">=24.14" }, "peerDependencies": { - "@fuzdev/fuz_css": ">=0.47.0", - "@fuzdev/fuz_util": ">=0.49.2", + "@electric-sql/pglite": ">=0.4", + "@fuzdev/blake3_wasm": ">=0.1.0", + "@fuzdev/fuz_util": ">=0.65.2", + "@hono/node-server": ">=1", + "@hono/node-ws": ">=1", + "@node-rs/argon2": ">=2", + "@sveltejs/kit": "^2", "esm-env": "^1", - "magic-string": "^0.30", + "hono": ">=4", + "pg": ">=8", "svelte": "^5", - "zimmerframe": "^1" + "ws": ">=8", + "zod": "^4" }, "peerDependenciesMeta": { - "@fuzdev/fuz_css": { + "@electric-sql/pglite": { "optional": true }, - "@fuzdev/fuz_util": { + "@hono/node-server": { "optional": true }, - "magic-string": { + "@hono/node-ws": { "optional": true }, - "svelte": { + "@node-rs/argon2": { "optional": true }, - "zimmerframe": { + "esm-env": { + "optional": true + }, + "hono": { + "optional": true + }, + "pg": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/@fuzdev/fuz_code": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_code/-/fuz_code-0.48.0.tgz", + "integrity": "sha512-uzcAkElwu73UULfd2Vij0U9oCL0ySZEYHd8CxYGSglxLZc4JzTIxDpFkY67glPBmAVR36CgyXpdWGGCTW3bF7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.21", + "zimmerframe": "^1.1.4" + }, + "engines": { + "node": ">=24.14" + }, + "funding": { + "url": "https://www.ryanatkn.com/funding" + }, + "peerDependencies": { + "@fuzdev/fuz_css": ">=0.62.0", + "@fuzdev/fuz_util": ">=0.65.1", + "esm-env": "^1", + "svelte": "^5" + }, + "peerDependenciesMeta": { + "@fuzdev/fuz_css": { + "optional": true + }, + "svelte": { "optional": true } } }, "node_modules/@fuzdev/fuz_css": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_css/-/fuz_css-0.57.0.tgz", - "integrity": "sha512-2UGLAG4tfvLEOqTWTdV1j5raitAJ5YzyiM2luXaJfddSCfs0lZGlVP4M8i2DtIfcCBJDJfanjZuu2WSNm8MYjQ==", + "version": "0.63.2", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_css/-/fuz_css-0.63.2.tgz", + "integrity": "sha512-df2j970oRPHPJVGoMIhkHltRI6ZMTrhVoBEPfek8hY8RiFMnJMW8cFq+X6oTSGpdfo1COQpjHbeLZemd74z+mg==", "dev": true, "license": "MIT", "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "funding": { "url": "https://www.ryanatkn.com/funding" }, "peerDependencies": { "@fuzdev/blake3_wasm": "^0.1.0", - "@fuzdev/fuz_util": ">=0.52.0", - "@fuzdev/gro": ">=0.195.0", + "@fuzdev/fuz_util": ">=0.65.1", + "@fuzdev/gro": ">=0.203.0", "@sveltejs/acorn-typescript": "^1", "@webref/css": "^8", "acorn-jsx": "^5", + "svelte": "^5", "zimmerframe": "^1", "zod": "^4" }, @@ -992,9 +1041,6 @@ "@fuzdev/blake3_wasm": { "optional": true }, - "@fuzdev/fuz_util": { - "optional": true - }, "@fuzdev/gro": { "optional": true }, @@ -1007,6 +1053,9 @@ "acorn-jsx": { "optional": true }, + "svelte": { + "optional": true + }, "zimmerframe": { "optional": true }, @@ -1016,68 +1065,53 @@ } }, "node_modules/@fuzdev/fuz_ui": { - "version": "0.191.1", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.191.1.tgz", - "integrity": "sha512-AXrlIcx+ijB98+z6RejsVIyQXeHfr7VfYsXOjS6GNALU6kFbrU1QArUbjv57TciqONEIh5bVywX3tf4fMOL0zQ==", + "version": "0.206.7", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.206.7.tgz", + "integrity": "sha512-UUO+6zVK6Ot9NmZLaKWASJ6odjjLOUDObIWOjSKtoPBnSu62UvzlGYdSGEHYk1hSR58wNj0qndhWNDzmf/WDdg==", "dev": true, "license": "MIT", "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "funding": { "url": "https://www.ryanatkn.com/funding" }, "peerDependencies": { - "@fuzdev/fuz_code": ">=0.45.1", - "@fuzdev/fuz_css": ">=0.53.0", - "@fuzdev/fuz_util": ">=0.52.0", - "@fuzdev/gro": ">=0.195.0", - "@jridgewell/trace-mapping": "^0.3", + "@fuzdev/fuz_code": ">=0.46.0", + "@fuzdev/fuz_css": ">=0.62.0", + "@fuzdev/fuz_util": ">=0.65.1", + "@fuzdev/gro": ">=0.204.0", + "@fuzdev/mdz": ">=0.2.0", "@sveltejs/kit": "^2.47.3", - "@types/estree": "^1", "esm-env": "^1", "svelte": "^5", - "svelte2tsx": "^0.7.45", - "vite": ">=6", "zod": "^4.1.12" }, "peerDependenciesMeta": { "@fuzdev/fuz_code": { "optional": true }, - "@fuzdev/fuz_util": { - "optional": true - }, "@fuzdev/gro": { "optional": true }, - "@jridgewell/trace-mapping": { - "optional": true - }, - "@types/estree": { + "@fuzdev/mdz": { "optional": true }, "esm-env": { "optional": true }, - "svelte2tsx": { - "optional": true - }, - "vite": { - "optional": true - }, "zod": { "optional": true } } }, "node_modules/@fuzdev/fuz_util": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_util/-/fuz_util-0.55.0.tgz", - "integrity": "sha512-nHjwB6RIExT4+n+1OWhy+mlq0KGlkdY/NtABYncHqo6AuD+Pq8+7PDIhhGEWcBWB49NySxroujdaGfyS8xrsBw==", + "version": "0.65.2", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_util/-/fuz_util-0.65.2.tgz", + "integrity": "sha512-VE20HhfZTPOY3SMx4kBELaAZFSRppbskQsIsqy4SvMmGknc0LrxN6jTrnzhp0blooPt3vPparf/yKdzwPUVUCg==", "license": "MIT", "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "funding": { "url": "https://www.ryanatkn.com/funding" @@ -1088,6 +1122,7 @@ "@types/node": "^24", "esm-env": "^1.2.2", "svelte": "^5", + "svelte-docinfo": ">=0.2.0", "zod": "^4.0.14" }, "peerDependenciesMeta": { @@ -1100,10 +1135,10 @@ "@types/node": { "optional": true }, - "esm-env": { + "svelte": { "optional": true }, - "svelte": { + "svelte-docinfo": { "optional": true }, "zod": { @@ -1112,26 +1147,24 @@ } }, "node_modules/@fuzdev/gro": { - "version": "0.197.1", - "resolved": "https://registry.npmjs.org/@fuzdev/gro/-/gro-0.197.1.tgz", - "integrity": "sha512-FAiMQ4Pngbc9+CsF1WFjwD3Q+L+AVMyRXswnjl0/AczTCcYt+kBPQYEsZ4K9E6QcYT9WPuRs+WI1CX9YVGofyw==", + "version": "0.209.0", + "resolved": "https://registry.npmjs.org/@fuzdev/gro/-/gro-0.209.0.tgz", + "integrity": "sha512-i2eViCE4aRoJgcaVEf2Xiq6iqHmrKmYpoyHGDDoFVSs4WeKs34ZacqBbMe8vkmaeUQauFIVUnFcFbudArngAtw==", "license": "MIT", "dependencies": { + "@fuzdev/tsv_wasm": "^0.2.0", "chokidar": "^5.0.0", "dotenv": "^17.2.3", "esm-env": "^1.2.2", "oxc-parser": "^0.99.0", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.5.1", "ts-blank-space": "^0.6.2", - "tslib": "^2.8.1", - "zod": "^4.3.6" + "tslib": "^2.8.1" }, "bin": { "gro": "dist/gro.js" }, "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "funding": { "url": "https://www.ryanatkn.com/funding" @@ -1141,85 +1174,72 @@ }, "peerDependencies": { "@fuzdev/blake3_wasm": "^0.1.0", - "@fuzdev/fuz_util": ">=0.53.0", + "@fuzdev/fuz_util": ">=0.65.2", "@sveltejs/kit": "^2", - "esbuild": "^0.27.0", + "esbuild": "^0.28.0", "svelte": "^5", + "svelte-docinfo": ">=0.4.1", "typescript": "^5", - "vitest": "^3 || ^4" + "vitest": "^3 || ^4", + "zod": "^4" }, "peerDependenciesMeta": { "@sveltejs/kit": { "optional": true }, + "svelte-docinfo": { + "optional": true + }, "vitest": { "optional": true } } }, - "node_modules/@fuzdev/gro/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "node_modules/@fuzdev/mdz": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@fuzdev/mdz/-/mdz-0.3.0.tgz", + "integrity": "sha512-hd6hEWYY/CUqAHc277pYKySkjHbwABy6vY+AOd0GArOcsi6QM6FZ3nTiB3Lzw7NECx0ODPjPuHCFPYkFOhKm8w==", + "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" + "magic-string": "^0.30.21", + "zimmerframe": "^1.1.4" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@fuzdev/gro/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": ">=24.14" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", - "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" + "url": "https://www.ryanatkn.com/funding" }, "peerDependencies": { - "hono": "^4" + "@fuzdev/fuz_util": ">=0.65.1", + "@sveltejs/kit": "^2.47.3", + "@types/estree": "^1", + "esm-env": "^1", + "svelte": "^5" + }, + "peerDependenciesMeta": { + "@types/estree": { + "optional": true + }, + "esm-env": { + "optional": true + } } }, - "node_modules/@hono/node-ws": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.2.0.tgz", - "integrity": "sha512-OBPQ8OSHBw29mj00wT/xGYtB6HY54j0fNSdVZ7gZM3TUeq0So11GXaWtFf1xWxQNfumKIsj0wRuLKWfVsO5GgQ==", + "node_modules/@fuzdev/tsv_wasm": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@fuzdev/tsv_wasm/-/tsv_wasm-0.2.0.tgz", + "integrity": "sha512-mbnFSWnAG8otz0SPY1baF0E6mbyJHhuxPxhJ607aVZ8N7vm6xb2rV9M/+PStgzt1p1KX2fLuQ2WKZcj1S0CZdg==", "license": "MIT", - "dependencies": { - "ws": "^8.17.0" + "bin": { + "tsv": "cli.js" }, "engines": { - "node": ">=18.14.1" + "node": ">=20" }, - "peerDependencies": { - "@hono/node-server": "^1.11.1", - "hono": "^4.6.0" + "funding": { + "url": "https://www.ryanatkn.com/funding" } }, "node_modules/@humanfs/core": { @@ -1320,19 +1340,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-parser/binding-android-arm64": { @@ -1438,6 +1460,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1454,6 +1479,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1470,6 +1498,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1486,6 +1517,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1502,6 +1536,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1518,6 +1555,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1591,130 +1631,10 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.6", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz", - "integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", - "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1725,9 +1645,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", - "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1738,9 +1658,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", - "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1751,9 +1671,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", - "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1764,9 +1684,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", - "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1777,9 +1697,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", - "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1790,9 +1710,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", - "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1803,9 +1723,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", - "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1816,9 +1736,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", - "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1829,9 +1749,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", - "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1841,10 +1761,23 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", - "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1855,9 +1788,22 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", - "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1868,9 +1814,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", - "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1881,9 +1827,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", - "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1894,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", - "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1907,9 +1853,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", - "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1920,9 +1866,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", - "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1932,10 +1878,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", - "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1946,9 +1905,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", - "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1959,9 +1918,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", - "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1971,10 +1930,23 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", - "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1985,9 +1957,9 @@ ] }, "node_modules/@ryanatkn/eslint-config": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@ryanatkn/eslint-config/-/eslint-config-0.9.0.tgz", - "integrity": "sha512-RF42tZfJo2CYE4E3clQRBm9bVHMpL5ErR3HfWaxbiuL1aGraehegsiXMsr1L4BiKpSP55ZO8vvCr1ibUaSRIrQ==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ryanatkn/eslint-config/-/eslint-config-0.12.2.tgz", + "integrity": "sha512-p5yIpW0ka5Gev3VM2a7X8kEnVqORaF8JccU79D7BxEL+erTMlsn0aFWtMaxPXaIxBwL3yqJ+42uFc4AKDIn/aA==", "dev": true, "license": "Unlicense", "dependencies": { @@ -2006,37 +1978,21 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", - "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" } }, - "node_modules/@sveltejs/adapter-node": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz", - "integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/plugin-commonjs": "^28.0.1", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "rollup": "^4.9.5" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0" - } - }, "node_modules/@sveltejs/adapter-static": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", @@ -2048,18 +2004,18 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.55.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", - "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "version": "2.61.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", + "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -2077,7 +2033,7 @@ "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", + "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -2111,13 +2067,13 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", - "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "devOptional": true, "license": "MIT", "dependencies": { - "debug": "^4.4.1" + "obug": "^2.1.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24" @@ -2129,9 +2085,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -2177,21 +2133,26 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } }, "node_modules/@types/trusted-types": { "version": "2.0.7", @@ -2200,21 +2161,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2224,8 +2184,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -2240,17 +2200,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2260,20 +2220,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2287,14 +2247,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2305,9 +2265,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, "license": "MIT", "engines": { @@ -2322,17 +2282,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2342,15 +2302,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", - "dev": true, + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2361,21 +2321,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2388,43 +2348,56 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2434,19 +2407,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2456,18 +2429,31 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", - "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "devOptional": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2475,13 +2461,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", - "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.15", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2490,7 +2476,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2502,9 +2488,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", - "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2515,13 +2501,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", - "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.15", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2529,13 +2515,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", - "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2544,9 +2531,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", - "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "devOptional": true, "license": "MIT", "funding": { @@ -2554,13 +2541,14 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", - "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2568,19 +2556,28 @@ } }, "node_modules/@webref/css": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@webref/css/-/css-8.2.0.tgz", - "integrity": "sha512-BSTwlyJwR2LotmT6GTmO5WIPPORr+4lU39vDBWNVEFnLo9w3XYCuHU4lmmd8OY5Zj9ykadg6pfJ/1cFHxzyr3w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@webref/css/-/css-8.4.1.tgz", + "integrity": "sha512-8DTncc0dhWJ4lVbi9rhLVyMNm+YEYrsFLRbdjgMxPupjNHcAdXiT1s4ZWJXzN4ckUvYQKTjLJKtZWc6tsR4FIQ==", "dev": true, "license": "MIT", "peerDependencies": { - "css-tree": "^3.1.0" + "css-tree": "^3.2.1" } }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2610,9 +2607,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2716,9 +2713,9 @@ } }, "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "devOptional": true, "license": "MIT", "engines": { @@ -2743,16 +2740,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2787,12 +2783,15 @@ "dev": true, "license": "MIT" }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=20" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -2801,6 +2800,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2827,14 +2833,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "devOptional": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -2854,34 +2860,45 @@ } }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "devOptional": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2921,7 +2938,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/deep-is": { @@ -2942,15 +2959,15 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/dotenv": { - "version": "17.2.4", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", - "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2973,16 +2990,16 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "devOptional": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "hasInstallScript": true, "license": "MIT", "peer": true, @@ -2993,32 +3010,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-string-regexp": { @@ -3035,25 +3052,25 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -3072,7 +3089,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3095,9 +3112,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.13.1.tgz", - "integrity": "sha512-Ng+kV/qGS8P/isbNYVE3sJORtubB+yLEcYICMkUWNaDTb0SwZni/JhAYXh/Dz/q2eThUwWY0VMPZ//KYD1n3eQ==", + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.1.tgz", + "integrity": "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3119,7 +3136,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0", + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -3183,9 +3200,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3196,12 +3213,20 @@ } }, "node_modules/esrap": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", - "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/esrecurse": { @@ -3248,9 +3273,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -3341,9 +3366,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -3361,16 +3386,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3385,9 +3400,9 @@ } }, "node_modules/globals": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", - "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -3397,13 +3412,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3414,39 +3422,17 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.10.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.7.tgz", - "integrity": "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "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==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "devOptional": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/http-proxy-agent": { @@ -3474,20 +3460,7 @@ "debug": "4" }, "engines": { - "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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, "node_modules/ignore": { @@ -3527,22 +3500,6 @@ "node": ">=0.8.19" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3566,13 +3523,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, "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", @@ -3597,9 +3547,9 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3610,18 +3560,19 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", @@ -3631,7 +3582,6 @@ "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", @@ -3656,19 +3606,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3764,11 +3701,11 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "devOptional": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -3783,16 +3720,16 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "devOptional": true, "license": "CC0-1.0" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3866,37 +3803,6 @@ ], "license": "MIT" }, - "node_modules/ollama": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", - "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-fetch": "^3.6.20" - } - }, - "node_modules/openai": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.10.0.tgz", - "integrity": "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4025,13 +3931,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4039,6 +3938,103 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4047,9 +4043,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { @@ -4060,9 +4056,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "devOptional": true, "funding": [ { @@ -4183,9 +4179,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -4196,39 +4192,57 @@ "node": ">=4" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">=4" } }, - "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, "engines": { - "node": ">=14" + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/prettier-plugin-svelte": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", - "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + "engines": { + "node": ">= 0.8.0" } }, "node_modules/punycode": { @@ -4242,13 +4256,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -4265,27 +4278,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4297,9 +4289,9 @@ } }, "node_modules/rollup": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", - "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4313,27 +4305,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.50.1", - "@rollup/rollup-android-arm64": "4.50.1", - "@rollup/rollup-darwin-arm64": "4.50.1", - "@rollup/rollup-darwin-x64": "4.50.1", - "@rollup/rollup-freebsd-arm64": "4.50.1", - "@rollup/rollup-freebsd-x64": "4.50.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", - "@rollup/rollup-linux-arm-musleabihf": "4.50.1", - "@rollup/rollup-linux-arm64-gnu": "4.50.1", - "@rollup/rollup-linux-arm64-musl": "4.50.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", - "@rollup/rollup-linux-ppc64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-musl": "4.50.1", - "@rollup/rollup-linux-s390x-gnu": "4.50.1", - "@rollup/rollup-linux-x64-gnu": "4.50.1", - "@rollup/rollup-linux-x64-musl": "4.50.1", - "@rollup/rollup-openharmony-arm64": "4.50.1", - "@rollup/rollup-win32-arm64-msvc": "4.50.1", - "@rollup/rollup-win32-ia32-msvc": "4.50.1", - "@rollup/rollup-win32-x64-msvc": "4.50.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -4350,13 +4346,6 @@ "node": ">=6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4374,13 +4363,13 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4452,6 +4441,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4460,9 +4459,9 @@ "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==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "devOptional": true, "license": "MIT" }, @@ -4492,37 +4491,24 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svelte": { - "version": "5.54.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", - "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4533,9 +4519,9 @@ } }, "node_modules/svelte-check": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", - "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", "dev": true, "license": "MIT", "dependencies": { @@ -4556,10 +4542,63 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-docinfo": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/svelte-docinfo/-/svelte-docinfo-0.5.4.tgz", + "integrity": "sha512-9XONWxu//kRmThbJBpDuNu3jSP6lSPgMy4T9G2GYHWWrHDiowJFn8ov5acvhBvAogBinBATylXk1TsvyQlI0iQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "commander": "^14.0.3", + "es-module-lexer": "^2.0.0", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.15", + "typescript": "^5.9.3" + }, + "bin": { + "svelte-docinfo": "dist/main.js" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "svelte2tsx": "^0.7.30", + "zod": "^4" + } + }, "node_modules/svelte-eslint-parser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", - "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", + "integrity": "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -4568,11 +4607,12 @@ "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.18.3" + "pnpm": "10.30.3" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -4587,10 +4627,10 @@ } }, "node_modules/svelte2tsx": { - "version": "0.7.52", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.52.tgz", - "integrity": "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg==", - "dev": true, + "version": "0.7.55", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.55.tgz", + "integrity": "sha512-JWzgeM3lqySRNfqcsesvVEh8LhTWBxQJ9RMjzJ+VepdmXtVnNd0SbtGctG6+/fbHq0N6mhwSd823gszw9JHeGQ==", + "devOptional": true, "license": "MIT", "dependencies": { "dedent-js": "^1.0.1", @@ -4598,7 +4638,7 @@ }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", - "typescript": "^4.9.4 || ^5.0.0" + "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" } }, "node_modules/symbol-tree": { @@ -4616,9 +4656,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "devOptional": true, "license": "MIT", "engines": { @@ -4643,9 +4683,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "devOptional": true, "license": "MIT", "engines": { @@ -4653,22 +4693,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", "devOptional": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", "devOptional": true, "license": "MIT" }, @@ -4683,9 +4723,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { @@ -4708,16 +4748,10 @@ "node": ">=20" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -4772,16 +4806,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4791,7 +4825,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -4820,13 +4854,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "devOptional": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4895,9 +4929,9 @@ } }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -4911,9 +4945,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -4927,9 +4961,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -4943,9 +4977,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -4959,9 +4993,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -4975,9 +5009,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -4991,9 +5025,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -5007,9 +5041,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -5023,9 +5057,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -5039,9 +5073,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -5055,9 +5089,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -5071,9 +5105,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -5087,9 +5121,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -5103,9 +5137,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -5119,9 +5153,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -5135,9 +5169,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -5151,9 +5185,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -5167,9 +5201,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -5183,9 +5217,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -5199,9 +5233,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -5215,9 +5249,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -5231,9 +5265,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -5247,9 +5281,9 @@ } }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -5263,9 +5297,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -5279,9 +5313,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -5295,9 +5329,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -5311,9 +5345,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "devOptional": true, "hasInstallScript": true, "license": "MIT", @@ -5324,38 +5358,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", "devOptional": true, "license": "MIT", "workspaces": [ @@ -5364,7 +5398,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -5373,31 +5407,31 @@ } }, "node_modules/vitest": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", - "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.15", - "@vitest/mocker": "4.0.15", - "@vitest/pretty-format": "4.0.15", - "@vitest/runner": "4.0.15", - "@vitest/snapshot": "4.0.15", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -5413,12 +5447,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.15", - "@vitest/browser-preview": "4.0.15", - "@vitest/browser-webdriverio": "4.0.15", - "@vitest/ui": "4.0.15", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -5447,6 +5482,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5464,35 +5502,15 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" } }, - "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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-fetch": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", - "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "dev": true, - "license": "MIT" - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -5561,9 +5579,10 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5598,6 +5617,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 13cb758c9..3df8855f4 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@fuzdev/zzz", "version": "0.0.1", - "description": "local-first forge for power users and devs", - "motto": "nice web things for the tired", + "description": "software garage for power users and devs", + "tagline": "nice web things for the tired", "glyph": "💤", "logo": "logo.svg", "logo_alt": "three sleepy z's", @@ -17,83 +17,77 @@ "bugs": "https://github.com/fuzdev/zzz/issues", "funding": "https://www.ryanatkn.com/funding", "scripts": { - "start": "gro dev", - "dev": "gro dev", + "start": "cargo xtask dev", + "dev": "cargo xtask dev", + "dev:setup": "cargo xtask dev-setup", + "prod:setup": "cargo xtask prod-setup", "build": "gro build", "check": "gro check", "typecheck": "gro typecheck", "test": "gro test", + "test:cross": "FUZ_TEST_CROSS_BACKEND=1 vitest run --project cross_backend_rust --project cross_backend_rust_proxy", "preview": "vite preview", "deploy": "gro deploy", - "serve": "gro build && npm run preview & node dist_server/server/server.js" + "serve": "gro build && npm run preview" }, "type": "module", "engines": { - "node": ">=22.15" + "node": ">=24.14" }, "peerDependencies": { + "@fuzdev/fuz_app": ">=0.82.0", + "@fuzdev/fuz_ui": "^0.205.1", + "@fuzdev/fuz_util": ">=0.65.1", "@sveltejs/kit": "^2", - "svelte": "^5" + "esm-env": "^1", + "svelte": "^5", + "zod": "^4" + }, + "peerDependenciesMeta": { + "esm-env": { + "optional": true + } }, "devDependencies": { "@changesets/changelog-git": "^0.2.1", - "@fuzdev/fuz_code": "^0.45.1", - "@fuzdev/fuz_css": "^0.57.0", - "@fuzdev/fuz_ui": "^0.191.1", - "@fuzdev/fuz_util": "^0.55.0", - "@jridgewell/trace-mapping": "^0.3.31", - "@ryanatkn/eslint-config": "^0.9.0", - "@sveltejs/adapter-node": "^5.4.0", + "@fuzdev/fuz_app": "^0.104.0", + "@fuzdev/fuz_code": "^0.48.0", + "@fuzdev/fuz_css": "^0.63.2", + "@fuzdev/fuz_ui": "^0.206.7", + "@fuzdev/fuz_util": "^0.65.2", + "@fuzdev/mdz": "^0.3.0", + "@ryanatkn/eslint-config": "^0.12.2", + "@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.55.0", + "@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/estree": "^1.0.8", "@types/node": "^24.10.1", + "@types/pg": "^8.15.7", "@webref/css": "^8.2.0", "eslint": "^9.39.1", - "eslint-plugin-svelte": "^3.13.1", + "eslint-plugin-svelte": "^3.17.1", + "esm-env": "^1.2.2", "jsdom": "^27.2.0", "magic-string": "^0.30.21", - "ollama": "^0.6.3", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.4.1", - "svelte": "^5.54.0", - "svelte-check": "^4.4.5", - "svelte2tsx": "^0.7.52", + "pg": "^8.20.0", + "svelte": "^5.56.0", + "svelte-check": "^4.4.8", + "svelte-docinfo": "^0.5.4", + "svelte2tsx": "^0.7.55", "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", + "vite": "^7.3.1", "vitest": "^4.0.15", - "zimmerframe": "^1.1.4" - }, - "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", - "@fuzdev/gro": "^0.197.1", - "@google/generative-ai": "^0.24.1", - "@hono/node-server": "^1.19.6", - "@hono/node-ws": "^1.2.0", - "date-fns": "^4.1.0", - "esm-env": "^1.2.2", - "hono": "^4.10.7", - "openai": "^6.10.0", + "zimmerframe": "^1.1.4", "zod": "^4.3.6" }, - "prettier": { - "plugins": [ - "prettier-plugin-svelte" - ], - "useTabs": true, - "printWidth": 100, - "singleQuote": true, - "bracketSpacing": false, - "overrides": [ - { - "files": "package.json", - "options": { - "useTabs": false - } - } - ] + "dependencies": { + "@fuzdev/blake3_wasm": "^0.1.1", + "@fuzdev/gro": "^0.209.0", + "@xterm/xterm": "^6.0.0", + "date-fns": "^4.1.0" }, "sideEffects": [ "**/*.css" diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 000000000..b9e1d38cd --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,12 @@ +// Registers ambient types for the `virtual:svelte-docinfo` module (Vite plugin). +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +declare module 'virtual:fuz.css' { + const css: string; + export default css; +} +declare module 'virtual:pkg.json' { + import type { PkgJson } from '@fuzdev/fuz_util/pkg_json.ts'; + const pkg_json: PkgJson; + export default pkg_json; +} diff --git a/src/app.html b/src/app.html index e3061bc77..06d95cd29 100644 --- a/src/app.html +++ b/src/app.html @@ -11,7 +11,7 @@ ((c) => c === 'dark' || (c !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches) ? 'dark' - : 'light')(localStorage.getItem('fuz:color-scheme')), + : 'light')(localStorage.getItem('fuz:color-scheme')) ); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2c967f85f..f3054f9f7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,9 @@ -import type {Handle} from '@sveltejs/kit'; +import type { Handle } from '@sveltejs/kit'; -export const handle: Handle = async ({event, resolve}) => { +export const handle: Handle = async ({ event, resolve }) => { if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.')) { // console.warn('cmon chrome'); - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } return resolve(event); diff --git a/src/lib/ActionContextmenu.svelte b/src/lib/ActionContextmenu.svelte index f53b21040..e83860dc5 100644 --- a/src/lib/ActionContextmenu.svelte +++ b/src/lib/ActionContextmenu.svelte @@ -1,15 +1,14 @@
@@ -44,6 +49,9 @@ {#each scoped_dirs as dir (dir)} {dir} {/each} + {#each workspace_paths as dir (dir)} + {dir} (workspace) + {/each}
@@ -51,8 +59,11 @@

The backend's filesystem is scoped for security. Symlinks are not followed. Configure with PUBLIC_ZZZ_DIR - and PUBLIC_ZZZ_SCOPED_DIRS. + PUBLIC_ZZZ_DIR + + and PUBLIC_ZZZ_SCOPED_DIRS. Directories added via workspaces + are shown separately.

diff --git a/src/lib/CapabilityProvider.svelte b/src/lib/CapabilityProvider.svelte index 185966ceb..ffa28fe19 100644 --- a/src/lib/CapabilityProvider.svelte +++ b/src/lib/CapabilityProvider.svelte @@ -1,18 +1,13 @@ -{#if provider_name === 'ollama'} - -{:else} - -{/if} + diff --git a/src/lib/CapabilityProviderApi.svelte b/src/lib/CapabilityProviderApi.svelte index 7859cdd56..a80cf390f 100644 --- a/src/lib/CapabilityProviderApi.svelte +++ b/src/lib/CapabilityProviderApi.svelte @@ -1,37 +1,36 @@ - -
-
-
-
-
-
- ollama {capabilities.ollama.status === 'success' - ? 'available' - : capabilities.ollama.status === 'failure' - ? 'unavailable' - : capabilities.ollama.status === 'pending' || checking - ? 'checking' - : 'not checked'} - {#if capabilities.ollama.status === 'pending' || checking} - - {/if} -
- - {#if capabilities.ollama.error_message} - {capabilities.ollama.error_message} - {:else if !capabilities.backend_available} - backend unavailable - {:else} - {ollama.host} - {#if capabilities.ollama.data?.round_trip_time} - {Math.round(capabilities.ollama.data.round_trip_time)}ms{/if} - {/if} - -
-
- -
- -
- Ollama (ollama.com, - GitHub) is a local model - server that forks - llama.cpp. It's one - of Zzz's first integrations and the plan is to support many other local LLM backends - (input/feedback is welcome). See also the Ollama provider page. -
-
- - {#if capabilities.ollama.error_message} -
- {capabilities.ollama.error_message} -
- {/if} - - - {#if capabilities.ollama.status === 'success'} -
- -
- {/if} - - - {#if capabilities.ollama.status !== 'initial' && capabilities.ollama_models.length > 0} -
-
-

- - {capabilities.ollama_models.length} model{plural(capabilities.ollama_models.length)} installed - locally -

-
    - {#each capabilities.ollama_models as ollama_model (ollama_model.name)} - {@const model = app.models.find_by_name(ollama_model.name)} -
  • - {#if model}{:else}{ollama_model.name}{/if} -
    {ollama_model.size} MB
    -
  • - {/each} -
-
-
- {:else if capabilities.ollama.status === 'success' && capabilities.ollama.data?.list_response?.models.length === 0} -
-
-

no models found - for now you can install models using the Ollama CLI

-
-
- {/if} - -

- Full controls are on the Ollama provider page. -

-
diff --git a/src/lib/CapabilitySystem.svelte b/src/lib/CapabilitySystem.svelte index 34f86b476..27304e7fe 100644 --- a/src/lib/CapabilitySystem.svelte +++ b/src/lib/CapabilitySystem.svelte @@ -1,16 +1,16 @@
-

{library.name}@{library.package_json.version}

+

{library.name}@{library.pkg_json.version}

DEV: {DEV + ''}

diff --git a/src/lib/CapabilityWebsocket.svelte b/src/lib/CapabilityWebsocket.svelte index 56b6aeb86..5082ee5b6 100644 --- a/src/lib/CapabilityWebsocket.svelte +++ b/src/lib/CapabilityWebsocket.svelte @@ -1,35 +1,30 @@ {#snippet entries()} - - {#snippet icon()}{/snippet} + chat {#snippet menu()} { show_model_picker = true; }} > - {#snippet icon()}{/snippet} add thread { chat.view_mode = chat.view_mode === 'simple' ? 'multi' : 'simple'; }} > - {#snippet icon()}{/snippet} {chat.view_mode === 'simple' ? 'multi' : 'simple'} view @@ -54,8 +58,7 @@ {#if chat.threads.length} - chat.remove_all_threads()}> - {#snippet icon()}{/snippet} + chat.remove_all_threads()}> remove all threads {/if} @@ -68,11 +71,11 @@ /> { chat.main_input = ''; }} > - {#snippet icon()}{/snippet} clear input {/if} @@ -80,6 +83,7 @@ { // TODO make it have a unique name, and adding threads looks hacky, // maybe add a `chats.duplicate` method const new_chat = app.chats.add_chat(chat.clone()); // TODO hacky for (const thread of chat.threads) { - new_chat.add_thread(thread.model); + if (thread.model) new_chat.add_thread(thread.model); } // Select the new chat await app.chats.navigate_to(new_chat.id); }} > - {#snippet icon()}{/snippet} duplicate chat { // TODO @many better confirmation // eslint-disable-next-line no-alert @@ -119,7 +123,6 @@ } }} > - {#snippet icon()}{/snippet} delete chat {/snippet} diff --git a/src/lib/ChatInitializer.svelte b/src/lib/ChatInitializer.svelte index b3157d962..380e0ad7f 100644 --- a/src/lib/ChatInitializer.svelte +++ b/src/lib/ChatInitializer.svelte @@ -1,10 +1,11 @@ @@ -21,7 +21,7 @@ sort_by_numeric('created_newest', 'created (newest)', 'created', 'desc'), sort_by_numeric('created_oldest', 'created (oldest)', 'created', 'asc'), sort_by_text('name_asc', 'name (a-z)', 'name'), - sort_by_text('name_desc', 'name (z-a)', 'name', 'desc'), + sort_by_text('name_desc', 'name (z-a)', 'name', 'desc') ]} sort_key_default="updated_newest" > diff --git a/src/lib/ChatListitem.svelte b/src/lib/ChatListitem.svelte index 944de2ee4..a5fdd896a 100644 --- a/src/lib/ChatListitem.svelte +++ b/src/lib/ChatListitem.svelte @@ -1,15 +1,15 @@ -
+
- + {#if provider_error} {provider_error}{/if} + icon="logo" + icon_props={{ size: 'var(--font_size_sm)' }} + label="name" + />{#if provider_error} + {provider_error} + {/if} +
@@ -113,23 +105,34 @@ bind:this={content_input} bind:content={input} token_count={input_token_count} - placeholder={GLYPH_PLACEHOLDER} + placeholder={format_placeholder()} show_stats show_actions {focus_key} bind:pending_element_to_focus_key > - - - + {#if thread.pending} + + {:else} + + + + {/if}
@@ -146,15 +149,7 @@
diff --git a/src/lib/ChatThreadAddByModel.svelte b/src/lib/ChatThreadAddByModel.svelte index cab34d222..112017bb9 100644 --- a/src/lib/ChatThreadAddByModel.svelte +++ b/src/lib/ChatThreadAddByModel.svelte @@ -1,15 +1,15 @@ @@ -42,7 +43,9 @@ {#each tags as tag (tag)} - {@const threads_with_tag = chat.threads.filter((t) => t.model.tags.includes(tag))} + {@const threads_with_tag = chat.threads.filter( + (t) => t.model?.tags.includes(tag) ?? false + )} - import {slide} from 'svelte/transition'; + import { slide } from 'svelte/transition'; import Details from '@fuzdev/fuz_ui/Details.svelte'; + import ConfirmButton from '@fuzdev/fuz_app/ui/ConfirmButton.svelte'; - import Glyph from './Glyph.svelte'; - import ConfirmButton from './ConfirmButton.svelte'; - import {Chat} from './chat.svelte.js'; - import {frontend_context} from './frontend.svelte.js'; - import {GLYPH_THREAD, GLYPH_CHAT, GLYPH_DELETE, GLYPH_VIEW} from './glyphs.js'; + import { Chat } from './chat.svelte.ts'; + import { frontend_context } from './frontend.svelte.ts'; + import { icon_chat, icon_delete, icon_thread, icon_view } from '@fuzdev/fuz_ui/icons.ts'; + import Svg from '@fuzdev/fuz_ui/Svg.svelte'; import ThreadList from './ThreadList.svelte'; import ChatViewSimple from './ChatViewSimple.svelte'; import ChatViewMulti from './ChatViewMulti.svelte'; @@ -17,10 +17,10 @@ import EditableText from './EditableText.svelte'; const app = frontend_context.get(); - const {chats} = app; + const { chats } = app; const { - chat, + chat }: { chat: Chat; } = $props(); @@ -34,18 +34,18 @@
-
+
{#if chat} -
+
- +
- created {chat.created_formatted_short_date} + + created {chat.created_formatted_short_date} +
{#if thread_count} - + {/if} chat.id && chats.remove(chat.id)} title="delete chat {'"' + chat.name + '"'}" - class="plain icon_button" + class="plain icon-button" > - - {#snippet popover_button_content()}{/snippet} + + {#snippet popover_button_content()}{/snippet}
@@ -75,12 +75,12 @@ {/if} {#if thread_count && (chat.view_mode !== 'simple' || thread_count > 1)} -
+
- threads{thread_count} + threads{thread_count}
@@ -90,10 +90,10 @@ {#if chat.view_mode === 'multi'}
{#snippet summary()}manage threads{/snippet} -
+
-
+
@@ -101,7 +101,7 @@
{#if !thread_count} -
+
chats.navigate_to(chat_id)} />
{:else if chat.view_mode === 'simple'} diff --git a/src/lib/ChatViewMulti.svelte b/src/lib/ChatViewMulti.svelte index a949f0434..7c9801135 100644 --- a/src/lib/ChatViewMulti.svelte +++ b/src/lib/ChatViewMulti.svelte @@ -1,23 +1,24 @@ -
-
+
+
- to {count} + to {count}
chat.remove_all_threads()} position="right" disabled={!count} - class="plain">  remove all +   remove all +
    {#each chat.threads as thread (thread.id)} @@ -81,8 +84,8 @@ chat.send_to_thread(thread.id, input)} - turns_attrs={{class: 'max_height_sm'}} - attrs={{class: 'p_md'}} + turns_attrs={{ class: 'max-height-sm' }} + attrs={{ class: 'p_md' }} /> {/each} diff --git a/src/lib/ChatViewSimple.svelte b/src/lib/ChatViewSimple.svelte index 40a8ed124..0abd9257e 100644 --- a/src/lib/ChatViewSimple.svelte +++ b/src/lib/ChatViewSimple.svelte @@ -1,13 +1,13 @@ - - - {#snippet popover_content(popover)} - {#if popover_content_prop} - {@render popover_content_prop(popover, () => confirm(popover))} - {:else} - - {/if} - {/snippet} - - -{#snippet children_default(popover: Popover)} - {#if children} - {@render children(popover, () => confirm(popover))} - {:else} - - {/if} -{/snippet} - - diff --git a/src/lib/ContentEditor.svelte b/src/lib/ContentEditor.svelte index 548aa9264..201ed29e9 100644 --- a/src/lib/ContentEditor.svelte +++ b/src/lib/ContentEditor.svelte @@ -1,20 +1,20 @@ - - {#snippet icon()}{/snippet} + {#if children} {@render children()} {:else} diff --git a/src/lib/ContextmenuEntryToggle.svelte b/src/lib/ContextmenuEntryToggle.svelte index 9691fb0b0..c28779b93 100644 --- a/src/lib/ContextmenuEntryToggle.svelte +++ b/src/lib/ContextmenuEntryToggle.svelte @@ -1,11 +1,11 @@ @@ -37,6 +41,5 @@ {/snippet} {#snippet icon_default()} - + {/snippet} diff --git a/src/lib/Dashboard.svelte b/src/lib/Dashboard.svelte index f0021eddb..39cf245fd 100644 --- a/src/lib/Dashboard.svelte +++ b/src/lib/Dashboard.svelte @@ -1,23 +1,30 @@ { - if (e.key === '`' && !is_editable(e.target)) { + if ( + e.key === 'Escape' && + app.ui.show_desk_menu && + !app.ui.desk_pinned && + !is_editable(e.target) + ) { + app.ui.toggle_desk_menu(false); + swallow(e); + } else if (e.key === '~' && !is_editable(e.target)) { + app.ui.toggle_desk_menu(); + swallow(e); + } else if (e.key === '`' && !is_editable(e.target)) { app.ui.toggle_sidebar(); swallow(e); } @@ -83,10 +95,15 @@ /> -
    +
    {@render children()}
    @@ -104,7 +121,7 @@ { if (futureclicks_activated) { // If already activated once, toggle immediately when on root @@ -123,7 +140,7 @@ @@ -139,18 +156,14 @@
    {#snippet children(selected)} - {#if typeof link.icon === 'string'} - {link.label} - {:else} - - - - {link.label} - {/if} + + + + {link.label} {/snippet}
    @@ -163,11 +176,24 @@ + + + {#if !app.ui.show_desk_menu} + + {/if}
    diff --git a/src/lib/DashboardActions.svelte b/src/lib/DashboardActions.svelte index e4c9f5ece..0e6a79f02 100644 --- a/src/lib/DashboardActions.svelte +++ b/src/lib/DashboardActions.svelte @@ -2,26 +2,26 @@ import ActionList from './ActionList.svelte'; import ActionDetail from './ActionDetail.svelte'; import DashboardHeader from './DashboardHeader.svelte'; - import Glyph from './Glyph.svelte'; - import {GLYPH_LOG} from './glyphs.js'; - import type {Action} from './action.svelte.js'; - import {app_context} from './app.svelte.js'; + import { icon_log } from '@fuzdev/fuz_ui/icons.ts'; + import Svg from '@fuzdev/fuz_ui/Svg.svelte'; + import type { Action } from './action.svelte.ts'; + import { app_context } from './app.svelte.ts'; import TimeWidget from './TimeWidget.svelte'; - import {random_item} from '@fuzdev/fuz_util/random.js'; + import { random_item } from '@fuzdev/fuz_util/random.ts'; const app = app_context.get(); - const {actions} = $derived(app); + const { actions } = $derived(app); // TODO could potentially be removed from the collection by some external process, // so having this state be component-local solves some problems but not all - let selected_action: Action | null = $state(null); + let selected_action: Action | null = $state.raw(null);
    {#snippet header()} -

    system actions

    +

    system actions

    {/snippet}
    @@ -49,7 +49,10 @@ style:grid-template-columns="320px 1fr" style:gap="var(--space_md)" > -
    +
    select an action from the list or to view its details + }} + > + go fish + to view its details

    {:else} @@ -79,20 +84,16 @@

    no actions yet, ? + }} + > + do something? + ?

    {/if}
- - diff --git a/src/lib/DashboardCapabilities.svelte b/src/lib/DashboardCapabilities.svelte index 36580142c..cb020285c 100644 --- a/src/lib/DashboardCapabilities.svelte +++ b/src/lib/DashboardCapabilities.svelte @@ -1,11 +1,11 @@ @@ -13,7 +13,7 @@
{#snippet header()} -

system capabilities

+

system capabilities

{/snippet}
diff --git a/src/lib/DashboardChats.svelte b/src/lib/DashboardChats.svelte index 8351ceabb..e043e285a 100644 --- a/src/lib/DashboardChats.svelte +++ b/src/lib/DashboardChats.svelte @@ -1,29 +1,29 @@ - -
+ +
{#if chats.items.size > 1} {/if}
@@ -70,7 +70,9 @@

- {:else if capabilities.backend_available === null || capabilities.backend_available === undefined} + {:else if capabilities.backend_available === null || + capabilities.backend_available === undefined + }
checking backend connection @@ -79,7 +81,7 @@ {/if}
-
+
{#if chats.selected} @@ -89,17 +91,23 @@

select a chat from the list, - , or , or + ? + }} + > + go fish + ?

@@ -107,9 +115,9 @@

no chats yet, - ? + ?

{/if} diff --git a/src/lib/DashboardDiskfiles.svelte b/src/lib/DashboardDiskfiles.svelte index 4f2ab154e..379c70dbd 100644 --- a/src/lib/DashboardDiskfiles.svelte +++ b/src/lib/DashboardDiskfiles.svelte @@ -1,29 +1,29 @@ @@ -22,19 +22,21 @@ href={/* eslint-disable-line svelte/no-navigation-without-resolve */ to_nav_link_href( app, 'chats', - resolve('/chats'), - )}>chats + chats + {#if app.chats.ordered_items.length} @@ -43,7 +45,7 @@
{#if app.prompts.ordered_items.length} @@ -94,10 +98,10 @@
    @@ -105,7 +109,7 @@
  • @@ -117,16 +121,20 @@
    {#each app.models.ordered_by_name as model (model.name)}
  • - +
  • {:else}

    no models available yet

    diff --git a/src/lib/DashboardModels.svelte b/src/lib/DashboardModels.svelte index e4b3a0f9c..b9ae44e61 100644 --- a/src/lib/DashboardModels.svelte +++ b/src/lib/DashboardModels.svelte @@ -1,8 +1,8 @@
    -

    models

    +

    models

    -
    +
    {#each app.models.ordered_by_name as model (model)} {/each} @@ -22,7 +22,7 @@
    diff --git a/src/lib/DiskfileActions.svelte b/src/lib/DiskfileActions.svelte index 91e7f8509..a41e6d2a9 100644 --- a/src/lib/DiskfileActions.svelte +++ b/src/lib/DiskfileActions.svelte @@ -1,21 +1,21 @@ -
    +
    - - diff --git a/src/lib/DiskfileEditorView.svelte b/src/lib/DiskfileEditorView.svelte index 4a0062488..4df89fb7c 100644 --- a/src/lib/DiskfileEditorView.svelte +++ b/src/lib/DiskfileEditorView.svelte @@ -1,24 +1,24 @@ ('font_size_largest', 'largest first', 'content_length', 'desc'), - sort_by_numeric('font_size_smallest', 'smallest first', 'content_length', 'asc'), + sort_by_numeric('font_size_smallest', 'smallest first', 'content_length', 'asc') ]} sort_key_default="path_asc" show_sort_controls diff --git a/src/lib/DiskfilePickerDialog.svelte b/src/lib/DiskfilePickerDialog.svelte index f77e888d5..1bfc94238 100644 --- a/src/lib/DiskfilePickerDialog.svelte +++ b/src/lib/DiskfilePickerDialog.svelte @@ -1,13 +1,13 @@ ('font_size_largest', 'largest first', 'content_length', 'desc'), - sort_by_numeric('font_size_smallest', 'smallest first', 'content_length', 'asc'), + sort_by_numeric('font_size_smallest', 'smallest first', 'content_length', 'asc') ]} sort_key_default="path_asc" show_sort_controls diff --git a/src/lib/DiskfileTabListitem.svelte b/src/lib/DiskfileTabListitem.svelte index dcb1f6c21..c28ecbd2b 100644 --- a/src/lib/DiskfileTabListitem.svelte +++ b/src/lib/DiskfileTabListitem.svelte @@ -1,16 +1,16 @@ - - {#if glyph} - + + {#if icon} + {/if} {@render children()} diff --git a/src/lib/ExternalLink.svelte b/src/lib/ExternalLink.svelte index 3cea0c56f..ee10af3e4 100644 --- a/src/lib/ExternalLink.svelte +++ b/src/lib/ExternalLink.svelte @@ -7,12 +7,13 @@ -[{#if children}{@render children(GLYPH_EXTERNAL_LINK)}{:else}{/if}] + + [{#if children} + {@render children(icon_external_link)} + {:else} + + {/if}] + diff --git a/src/lib/Footer.svelte b/src/lib/Footer.svelte index 4f39f432f..6267e1fed 100644 --- a/src/lib/Footer.svelte +++ b/src/lib/Footer.svelte @@ -1,22 +1,22 @@ - +
    {#if page.url.pathname === resolve('/')} {@render icon()} {:else} - {@render icon()} + {/if}
    diff --git a/src/lib/FrontendRoot.svelte b/src/lib/FrontendRoot.svelte index b4103e1e1..c4c83314c 100644 --- a/src/lib/FrontendRoot.svelte +++ b/src/lib/FrontendRoot.svelte @@ -1,11 +1,12 @@ +
    diff --git a/src/lib/Glyph.svelte b/src/lib/Glyph.svelte deleted file mode 100644 index a6c47678c..000000000 --- a/src/lib/Glyph.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -{glyph} diff --git a/src/lib/MainDialog.svelte b/src/lib/MainDialog.svelte index cdbb4382a..bf6023e7e 100644 --- a/src/lib/MainDialog.svelte +++ b/src/lib/MainDialog.svelte @@ -1,14 +1,15 @@ {#if !disabled && app.ui.show_main_dialog} - app.api.toggle_main_menu({show: false})} layout="page"> -
    -
    -
    -

    work in progress

    -
    -
    - -
    -
    -
    + app.api.toggle_main_menu({ show: false })} align="top"> + +
    +

    work in progress

    +
    +
    + +
    +
    {/if} diff --git a/src/lib/ModelContextmenu.svelte b/src/lib/ModelContextmenu.svelte index eaf419d8a..3066952e3 100644 --- a/src/lib/ModelContextmenu.svelte +++ b/src/lib/ModelContextmenu.svelte @@ -1,21 +1,20 @@ @@ -24,53 +23,40 @@ {#snippet entries()} - - {#snippet icon()}{/snippet} - model + {#if model} + + model - {#snippet menu()} - - {#snippet icon()}{/snippet} - + {#snippet menu()} + - + - model.app.chats.add(undefined, true).add_thread(model)}> - {#snippet icon()}{/snippet} - create new chat - - - {#if model.provider_name === 'ollama'} { - await model.navigate_to_provider_model_view(); - }} + icon={icon_chat} + run={() => model.app.chats.add(undefined, true).add_thread(model)} > - {#snippet icon()}{/snippet} - manage Ollama model + create new chat - {/if} - - + - {/snippet} - + {/snippet} + + {/if} {/snippet} diff --git a/src/lib/ModelDetail.svelte b/src/lib/ModelDetail.svelte index d8eb05263..7ded249e9 100644 --- a/src/lib/ModelDetail.svelte +++ b/src/lib/ModelDetail.svelte @@ -1,23 +1,21 @@ - +
    -
    - +
    +
    {#if at_detail_page} @@ -62,25 +46,16 @@ {/if}
    - + {#if model.provider && !model.provider.available} - - + + {model.provider.status && !model.provider.status.available ? model.provider.status.error : 'unavailable'} {/if}
    - {#if model.downloaded !== undefined} -
    - {#if model.downloaded} - - {:else} - not - {/if} downloaded -
    - {/if} {#if model.tags.length}
      {#each model.tags as tag (tag)} @@ -91,117 +66,84 @@
    - {#if model.provider_name === 'ollama'} - app.api.ollama_show({model: model.name})} - ondelete={async (m) => { - await app.ollama.delete(m.name); - }} + +
    + -
    - - + - {/if} + {#if model.cost_output} +
    output: ${model.cost_output.toFixed(2)} / 1M tokens
    + {/if} + + {/if} + -->
    + {#if icon === 'logo'} +   + {:else if icon} +   + {/if}{model.name} + {/if} + +
    diff --git a/src/lib/ModelListitem.svelte b/src/lib/ModelListitem.svelte index 6b687497d..1a33de472 100644 --- a/src/lib/ModelListitem.svelte +++ b/src/lib/ModelListitem.svelte @@ -1,11 +1,11 @@ - +
    @@ -22,9 +22,10 @@ {model.name}
    - {model.provider_name}{#if model.context_window_formatted}{model.context_window_formatted}{/if} + {model.provider_name}{#if model.context_window_formatted} + {model.context_window_formatted} + {/if}
    diff --git a/src/lib/ModelPicker.svelte b/src/lib/ModelPicker.svelte index 2e51d1bfc..2ae50ab0a 100644 --- a/src/lib/ModelPicker.svelte +++ b/src/lib/ModelPicker.svelte @@ -1,18 +1,18 @@ - - -{@render children(selected, selected_descendent)} + {@render children(selected, selected_descendent)} + diff --git a/src/lib/OllamaActionItem.svelte b/src/lib/OllamaActionItem.svelte deleted file mode 100644 index 49c6b76bc..000000000 --- a/src/lib/OllamaActionItem.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - -
  • -
    -
    -
    - {#if action.pending} - - {:else} - - {/if} - -
    {action.method}
    - {#if model_name} -
    -
    {model_name}
    -
    - {/if} - {#if action.failed && error_message} -
    {error_message}
    - {/if} -
    - - {format_timestamp(action.updated_date.getTime())} - -
    - {#if progress_percent !== null} -
    - -
    - {/if} -
    -
  • diff --git a/src/lib/OllamaActions.svelte b/src/lib/OllamaActions.svelte deleted file mode 100644 index 2371c376c..000000000 --- a/src/lib/OllamaActions.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
    -
    -

    action history

    -
    - - -
    -
    - - {#if ollama.filtered_actions.length === 0} -

    - no action history{ollama.show_read_actions ? '' : ', showing only write actions'} -

    - {:else} -
      - {#each ollama.filtered_actions as action (action.id)} - - {/each} -
    - {/if} -
    diff --git a/src/lib/OllamaConfigure.svelte b/src/lib/OllamaConfigure.svelte deleted file mode 100644 index f69c01549..000000000 --- a/src/lib/OllamaConfigure.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - -
    -
    -

    - configure -

    - {#if last_active_view && onback} - - {/if} -
    - -
    -

    - Ollama is a local LLM provider. {#if !error_message && capabilities.backend_available} - Want to ?{/if} -

    - -
    -
    - -
    - -
    - - - {#if ollama.host !== OLLAMA_URL} -
    - - - {OLLAMA_URL} - -
    - {/if} - -
    - -
    -
    - - {#if error_message} -
    - {error_message} -
    - {/if} -
    - - - - -
    -
    diff --git a/src/lib/OllamaCopyModel.svelte b/src/lib/OllamaCopyModel.svelte deleted file mode 100644 index 5a8160357..000000000 --- a/src/lib/OllamaCopyModel.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - -
    -
    -

    - copy model -

    - -
    - -
    -

    Create a copy of the source model with a new name.

    - -
    - -
    - -
    - -
    - -
    - - -
    - - {#if ollama.copy_is_duplicate_name} - a model with this name already exists - {/if} -
    -
    diff --git a/src/lib/OllamaCreateModel.svelte b/src/lib/OllamaCreateModel.svelte deleted file mode 100644 index 6f0d1a18b..000000000 --- a/src/lib/OllamaCreateModel.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - -
    -
    -

    - create model -

    - -
    - -
    -

    - This creates a new custom modelfile, to download a builtin model see . -

    - -
    - -
    - -
    - -

    Choose a base model to customize, or leave empty for a completely new model

    -
    - -
    - -

    Define the model's behavior and personality

    -
    - -
    - -

    Custom prompt template using Ollama template syntax

    -
    - -
    - - -
    - - {#if ollama.create_is_duplicate_name} - a model with this name already exists - {/if} -
    -
    diff --git a/src/lib/OllamaManager.svelte b/src/lib/OllamaManager.svelte deleted file mode 100644 index f35e335dd..000000000 --- a/src/lib/OllamaManager.svelte +++ /dev/null @@ -1,236 +0,0 @@ - - -
    - -
    -
    - -
    -
    -
    -
    - - Ollama {ollama.available - ? `connected` - : status === 'failure' - ? 'unavailable' - : status === 'pending' - ? 'connecting' - : 'not checked'} - {#if status === 'pending'} - - {/if} - -
    - {#if capabilities.ollama.error_message} - {capabilities.ollama.error_message} - {:else if !capabilities.backend_available} - backend unavailable - {:else} - {ollama.host} - {#if ollama.list_round_trip_time} - → {Math.round(ollama.list_round_trip_time)}ms - {/if} - {/if} -
    -
    -
    -
    - - -
    - - - - - - - -
    -
    - - {#if ollama.models_downloaded.length > 0} - -
    -

    - {ollama.models_downloaded.length} model{plural(ollama.models_downloaded.length)} -

    - - - {#each ollama.models_downloaded as model (model.id)} -
  • - -
  • - {/each} -
    -
    - - {#if ollama.models_not_downloaded.length > 0} -
    -

    - {ollama.models_not_downloaded.length} not downloaded -

    - - - {#each ollama.models_not_downloaded as model (model.id)} -
  • - { - await model.navigate_to_download(); - }} - /> -
  • - {/each} -
    -
    - {/if} - {:else if ollama.available} -
    -

    - no models found, - or install them using the - Ollama CLI -

    -
    - {/if} -
    -
    - - -
    - {#if ollama.manager_selected_view === 'configure'} - ollama.set_manager_view('pull', null)} - onback={() => ollama.manager_back_to_last_view()} - /> - {:else if ollama.manager_selected_view === 'model' && ollama.manager_selected_model} -
    - ollama.app.api.ollama_show({model: m.name})} - onclose={() => ollama.close_form()} - ondelete={(m) => ollama.delete(m.name)} - /> -
    - {:else if ollama.manager_selected_view === 'pull'} - ollama.close_form()} /> - {:else if ollama.manager_selected_view === 'create'} - ollama.close_form()} - onshowpull={() => ollama.set_manager_view('pull', null)} - /> - {:else if ollama.manager_selected_view === 'copy'} - ollama.close_form()} /> - {/if} -
    -
    diff --git a/src/lib/OllamaModelDetail.svelte b/src/lib/OllamaModelDetail.svelte deleted file mode 100644 index cf90762dc..000000000 --- a/src/lib/OllamaModelDetail.svelte +++ /dev/null @@ -1,251 +0,0 @@ - - - - {#if header} - {@render header()} - {:else} -
    -
    -

    - -

    -
    - {#if onclose} - - {/if} -
    - {/if} - -
    - {#if model.downloaded} - - - - - - - - {#if ondelete} - ondelete(model)} - position="right" - class="plain color_c" - title="delete {model.name}" - > -   delete model - - {#snippet popover_content(popover)} - - {/snippet} - - {/if} - {:else} - - {/if} -
    - - {#if !model.downloaded} -
    -

    not downloaded

    -
    - {:else if model.ollama_show_response_loading} -
    - - loading model details... -
    - {:else if model.ollama_show_response_error} -
    -
    - failed to load details: {model.ollama_show_response_error} -
    - -
    - {:else if model.ollama_show_response} -
    - - {#if model.ollama_show_response.details} -
    -
    capabilities:
    - - {model.ollama_show_response.capabilities?.join(', ') || 'none'} - - -
    parameters:
    - - {model.ollama_show_response.details.parameter_size} - - - {#if model.filesize} -
    filesize:
    - {format_gigabytes(model.filesize)} - {/if} - - {#if model.ollama_modified_at} -
    modified:
    - {format_short_date(model.ollama_modified_at)} - {/if} - -
    family:
    - {model.ollama_show_response.details.family} - -
    quantization:
    - - {model.ollama_show_response.details.quantization_level} - - -
    format:
    - {model.ollama_show_response.details.format} - - {#if model.ollama_show_response.details.parent_model} -
    parent:
    - {model.ollama_show_response.details.parent_model} - {/if} -
    - {/if} - - - {#if model.ollama_show_response.model_info && Object.keys(model.ollama_show_response.model_info).length > 0} -
    -
    model info:
    -
    {JSON.stringify(model.ollama_show_response.model_info, null, '\t')}
    -
    - {/if} - - - {#if model.ollama_show_response.template} -
    -
    template:
    -
    {model.ollama_show_response.template}
    -
    - {/if} - - - {#if model.ollama_show_response.parameters} -
    -
    parameters:
    -
    {model.ollama_show_response.parameters}
    -
    - {/if} - - - {#if model.ollama_show_response.system} -
    -
    system:
    -
    {model.ollama_show_response.system}
    -
    - {/if} - - - {#if model.ollama_show_response.license} -
    - {#snippet summary()}
    license:
    {/snippet} -
    {model.ollama_show_response.license}
    -
    - {/if} - - - {#if model.ollama_show_response.modelfile} -
    - {#snippet summary()}
    modelfile:
    {/snippet} -
    {model.ollama_show_response.modelfile}
    -
    - {/if} -
    - {/if} -
    diff --git a/src/lib/OllamaModelListitem.svelte b/src/lib/OllamaModelListitem.svelte deleted file mode 100644 index f86014628..000000000 --- a/src/lib/OllamaModelListitem.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/src/lib/OllamaModelStatus.svelte b/src/lib/OllamaModelStatus.svelte deleted file mode 100644 index a31841ddd..000000000 --- a/src/lib/OllamaModelStatus.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -{#if pulling}{/if} -{#if running} -
    - loaded -
    -{/if} diff --git a/src/lib/OllamaPsStatus.svelte b/src/lib/OllamaPsStatus.svelte deleted file mode 100644 index a26efa120..000000000 --- a/src/lib/OllamaPsStatus.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -
    -
    -

    active models

    -
    - {#if ollama.ps_status === 'pending'} - - {/if} - -
    -
    - - {#if ollama.running_models.length > 0} -
      - {#each ollama.running_models as item (item.name)} - {@const model = ollama.model_by_name.get(item.name)} -
    • - {#if !model} - -
      -
      - - model {item.name} not found -
      -
      - {:else} -
      -
      - - - - {#if item.size_vram > 0} - - VRAM: {format_bytes(item.size_vram)} - - {/if} -
      -
      - - {#if item.expires_at} - {@const expires_at_date = new Date(item.expires_at)} - {@const expires_at_ms = expires_at_date.valueOf()} - - {#if expires_at_ms > ollama.app.time.now_ms} - expires {formatDistance(expires_at_date, ollama.app.time.now_ms, { - addSuffix: true, - })} - {:else} - expired - {/if} - - {/if} - -
      -
      - {/if} -
    • - {/each} -
    - {:else} -

    no models currently loaded

    - {/if} -
    diff --git a/src/lib/OllamaPullModel.svelte b/src/lib/OllamaPullModel.svelte deleted file mode 100644 index f45946e49..000000000 --- a/src/lib/OllamaPullModel.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - -
    -
    -

    - pull model -

    - -
    - -
    -

    This downloads a builtin model so you can use it locally.

    - -
    - -

    - Enter a model name from the Ollama library{#if models_not_downloaded.length > 0} -  or choose from the available models: - {:else}.{/if} -

    - {#if models_not_downloaded.length > 0} -
    - {#each models_not_downloaded as model (model.id)} - - {/each} -
    - {/if} -
    - - - -
    - - {#if oncancel} - - {/if} -
    - - {#if ollama.pull_already_downloaded} - this model is already downloaded - {/if} - - {#if pull_actions.length > 0} -
    -

    pull operations

    -
      - {#each pull_actions as action (action.id)} - - {/each} -
    -
    - {/if} -
    -
    diff --git a/src/lib/PartContextmenu.svelte b/src/lib/PartContextmenu.svelte index 4ad846974..1bfcbf9e8 100644 --- a/src/lib/PartContextmenu.svelte +++ b/src/lib/PartContextmenu.svelte @@ -1,18 +1,19 @@ {#snippet entries()} - - {#snippet icon()}{get_part_type_glyph(part)}{/snippet} + part {#snippet menu()} @@ -48,15 +48,16 @@ { show_editor = true; }} > - {#snippet icon()}{/snippet} edit part { // TODO @many better confirmation // eslint-disable-next-line no-alert @@ -65,7 +66,6 @@ } }} > - {#snippet icon()}{/snippet} delete part {/snippet} @@ -74,9 +74,9 @@ {#if show_editor} (show_editor = false)}> -
    -

    edit part

    + +

    edit part

    -
    +
    {/if} diff --git a/src/lib/PartEditorForDiskfile.svelte b/src/lib/PartEditorForDiskfile.svelte index 61da5481b..603af7f0a 100644 --- a/src/lib/PartEditorForDiskfile.svelte +++ b/src/lib/PartEditorForDiskfile.svelte @@ -1,21 +1,22 @@
    -   +   {part.name}
    diff --git a/src/lib/Picker.svelte b/src/lib/Picker.svelte index 3c8bc139e..3d8a193e0 100644 --- a/src/lib/Picker.svelte +++ b/src/lib/Picker.svelte @@ -1,9 +1,9 @@ @@ -27,7 +28,7 @@ show = false; }} > -
    + { @@ -38,6 +39,6 @@ } }} /> -
    + {/if} diff --git a/src/lib/PingForm.svelte b/src/lib/PingForm.svelte index c8a3c427d..a24914c19 100644 --- a/src/lib/PingForm.svelte +++ b/src/lib/PingForm.svelte @@ -1,25 +1,25 @@ - - -
    - {#if button} - {@render button(popover)} - {:else} - - {/if} - - {#if popover.visible} -
    - {@render popover_content(popover)} -
    - {/if} -
    diff --git a/src/lib/ProgressBar.svelte b/src/lib/ProgressBar.svelte index 0732019b1..f7d91ca58 100644 --- a/src/lib/ProgressBar.svelte +++ b/src/lib/ProgressBar.svelte @@ -1,9 +1,9 @@ {#snippet entries()} - - {#snippet icon()}{/snippet} + prompt {#snippet menu()} @@ -41,19 +45,20 @@ /> { prompt.add_part( Part.create(app, { type: 'text', - content: '', - }), + content: '' + }) ); }} > - {#snippet icon()}{/snippet} add text part { if (!app.diskfiles.items.size) { alert('No files available. Add files first.'); // eslint-disable-line no-alert @@ -63,25 +68,24 @@ show_diskfile_picker = true; }} > - {#snippet icon()}{/snippet} add file part {#if prompt.parts.length} - prompt.remove_all_parts()}> - {#snippet icon()}{/snippet} + prompt.remove_all_parts()}> remove all parts {/if} { // TODO confirm dialog that shows the prompt's summary // TODO @many better confirmation @@ -91,7 +95,6 @@ } }} > - {#snippet icon()}{/snippet} delete prompt {/snippet} @@ -106,8 +109,8 @@ prompt.add_part( Part.create(app, { type: 'diskfile', - path: diskfile.path, - }), + path: diskfile.path + }) ); return true; }} diff --git a/src/lib/PromptList.svelte b/src/lib/PromptList.svelte index 50c042064..8a8780ef8 100644 --- a/src/lib/PromptList.svelte +++ b/src/lib/PromptList.svelte @@ -1,14 +1,14 @@ @@ -23,7 +23,7 @@ sort_by_numeric('created_newest', 'created (newest)', 'created', 'desc'), sort_by_numeric('created_oldest', 'created (oldest)', 'created', 'asc'), sort_by_text('name_asc', 'name (a-z)', 'name'), - sort_by_text('name_desc', 'name (z-a)', 'name', 'desc'), + sort_by_text('name_desc', 'name (z-a)', 'name', 'desc') ]} sort_key_default="updated_newest" no_items="" diff --git a/src/lib/PromptListitem.svelte b/src/lib/PromptListitem.svelte index cd4440710..995ecfc0b 100644 --- a/src/lib/PromptListitem.svelte +++ b/src/lib/PromptListitem.svelte @@ -1,15 +1,15 @@ ('name_asc', 'name (a-z)', 'name'), sort_by_text('name_desc', 'name (z-a)', 'name', 'desc'), sort_by_numeric('created_newest', 'newest first', 'created_date', 'desc'), - sort_by_numeric('created_oldest', 'oldest first', 'created_date', 'asc'), + sort_by_numeric('created_oldest', 'oldest first', 'created_date', 'asc') ]} sort_key_default="name_asc" show_sort_controls diff --git a/src/lib/PromptPickerDialog.svelte b/src/lib/PromptPickerDialog.svelte index 51cf8b846..414e0512c 100644 --- a/src/lib/PromptPickerDialog.svelte +++ b/src/lib/PromptPickerDialog.svelte @@ -1,14 +1,14 @@ ('name_asc', 'name (a-z)', 'name'), sort_by_text('name_desc', 'name (z-a)', 'name', 'desc'), sort_by_numeric('created_newest', 'newest first', 'created_date', 'desc'), - sort_by_numeric('created_oldest', 'oldest first', 'created_date', 'asc'), + sort_by_numeric('created_oldest', 'oldest first', 'created_date', 'asc') ]} sort_key_default="name_asc" show_sort_controls diff --git a/src/lib/PromptStats.svelte b/src/lib/PromptStats.svelte index 3c0f358e4..a5c12d849 100644 --- a/src/lib/PromptStats.svelte +++ b/src/lib/PromptStats.svelte @@ -1,9 +1,9 @@
    @@ -46,7 +39,8 @@ {/if}

    {provider.company}

    - {provider.name} + + {provider.name}

    {format_url(provider.homepage)} @@ -57,16 +51,12 @@
    - {#if provider.name === 'ollama'} - - {:else} -
    - - {#if provider.api_key_url} - get API key - {/if} -
    - {/if} +
    + + {#if provider.api_key_url} + get API key + {/if} +
    diff --git a/src/lib/ProviderLink.svelte b/src/lib/ProviderLink.svelte index 156fbb9f1..5f53275c7 100644 --- a/src/lib/ProviderLink.svelte +++ b/src/lib/ProviderLink.svelte @@ -1,78 +1,71 @@ - + {#if provider} - {#if children} + + {#if children} {@render children()} {:else} - {#if icon === 'glyph'} - - {:else if icon === 'svg'} + {#if icon === 'logo'}   {:else if icon} - {@render icon(provider, GLYPH_PROVIDER)} - {/if}{#if show_name} - {provider.name} - {:else} - {provider.title} - {/if} - {/if} +   + {/if}{label === 'name' ? provider.name : provider.title} + {/if} + {:else if fallback} {@render fallback()} {:else} - missing provider + missing provider {/if} - - diff --git a/src/lib/ProviderLogo.svelte b/src/lib/ProviderLogo.svelte index 8a694eb08..d09ac1ba6 100644 --- a/src/lib/ProviderLogo.svelte +++ b/src/lib/ProviderLogo.svelte @@ -1,11 +1,10 @@ diff --git a/src/lib/ProviderSummary.svelte b/src/lib/ProviderSummary.svelte index a16358364..24b70961b 100644 --- a/src/lib/ProviderSummary.svelte +++ b/src/lib/ProviderSummary.svelte @@ -1,15 +1,15 @@ -
    +
    - + {type}: {queued_messages_count} @@ -135,22 +135,22 @@ {#if socket.connected} {/if} - +
    {/if} @@ -169,14 +169,14 @@ {#if queued_messages_count > 0} -
    +
    {#each queued_messages as message (message.id)} {@const selected = selected_queued_messages.has(message.id)} {@const message_type = message.data?.type || 'unknown'} {@const message_data_serialized = JSON.stringify(message.data, null, 2)}
    0 ? 'border_top border-style:solid border_color_30' : ''}" @@ -207,7 +207,7 @@ {#if copied}
    {message.id}
    {:else} -
    +
    {message.id}
    {/if} @@ -225,7 +225,7 @@ {#if copied}
    {message.data.id}
    {:else} -
    +
    {message.data.id}
    {/if} @@ -239,10 +239,10 @@ - + {#snippet popover_content(popover)}
    message details
    {message_data_serialized}
    + class="shade_10 border_radius_xs border_width border_style border_color_20 font_family_mono font_size_xs white-space:pre-wrap word-break:break-word p_md" + >{message_data_serialized}
    {/snippet} @@ -274,22 +275,22 @@ {#if type === 'queued' || (type === 'failed' && socket.connected)} {/if} remove_message(message.id)} position="center" - popover_button_attrs={{class: 'icon_button color_c font_size_sm'}} - class="icon_button plain font_size_sm" + popover_button_attrs={{ class: 'icon-button palette_c font_size_sm' }} + class="icon-button plain font_size_sm" title="remove message" > - +
    @@ -304,7 +305,7 @@
    Reason: - {failed_message.reason} + {failed_message.reason}
    {/if} @@ -321,21 +322,21 @@
    diff --git a/src/lib/SortableList.svelte b/src/lib/SortableList.svelte index 2e07ca207..662f2b572 100644 --- a/src/lib/SortableList.svelte +++ b/src/lib/SortableList.svelte @@ -1,11 +1,11 @@ + +
    + + +
    + + diff --git a/src/lib/TerminalContextmenu.svelte b/src/lib/TerminalContextmenu.svelte new file mode 100644 index 000000000..c830d1793 --- /dev/null +++ b/src/lib/TerminalContextmenu.svelte @@ -0,0 +1,26 @@ + + + + +{#snippet entries()} + {#if get_terminal_text} + + {/if} + +{/snippet} diff --git a/src/lib/TerminalPresetBar.svelte b/src/lib/TerminalPresetBar.svelte new file mode 100644 index 000000000..43ee58ed7 --- /dev/null +++ b/src/lib/TerminalPresetBar.svelte @@ -0,0 +1,126 @@ + + +
    + {#each presets as preset (preset.id)} + + + {#if ondelete} + + {/if} + + {/each} + + {#if oncreate} + {#if adding} + + + + + + + {:else} + + {/if} + {/if} +
    + + diff --git a/src/lib/TerminalRunItem.svelte b/src/lib/TerminalRunItem.svelte new file mode 100644 index 000000000..3634354d8 --- /dev/null +++ b/src/lib/TerminalRunItem.svelte @@ -0,0 +1,142 @@ + + + +
    +
    + $ {display_command} + + {#if exited} + + exited {exit_code ?? '?'} + + {:else} + running + {/if} + + {#if onrestart} + + {/if} +
    +
    + +
    +
    + + +
    +
    +
    + + diff --git a/src/lib/TerminalRunner.svelte b/src/lib/TerminalRunner.svelte new file mode 100644 index 000000000..9b0f04942 --- /dev/null +++ b/src/lib/TerminalRunner.svelte @@ -0,0 +1,178 @@ + + +
    +
    +
    + {#each runs as run (run.terminal_id)} +
    + +
    + {/each} +
    +
    + + {#if runs.length === 0} +

    no commands run yet — use a preset or type a command below

    + {/if} + + {#if error_message} +

    {error_message}

    + {/if} + +
    + + +
    +
    + + diff --git a/src/lib/TerminalView.svelte b/src/lib/TerminalView.svelte new file mode 100644 index 000000000..6b0ae210f --- /dev/null +++ b/src/lib/TerminalView.svelte @@ -0,0 +1,202 @@ + + +
    +
    + terminal {terminal_id.slice(0, 8)} +
    + + +
    +
    +
    +
    + + diff --git a/src/lib/ThreadContextmenu.svelte b/src/lib/ThreadContextmenu.svelte index 8ea45117d..31ca7286c 100644 --- a/src/lib/ThreadContextmenu.svelte +++ b/src/lib/ThreadContextmenu.svelte @@ -1,17 +1,16 @@ @@ -38,8 +37,7 @@ /> {#snippet entries()} - - {#snippet icon()}{/snippet} + thread {#snippet menu()} {#if thread.content} @@ -52,11 +50,11 @@ {#if thread.turns.size > 0} { thread.remove_all_turns(); }} > - {#snippet icon()}{/snippet} clear conversation {/if} @@ -64,15 +62,16 @@ { show_model_picker = true; }} > - {#snippet icon()}{/snippet} switch model   {thread.model_name} { // TODO @many better confirmation // eslint-disable-next-line no-alert @@ -81,7 +80,6 @@ } }} > - {#snippet icon()}{/snippet} delete thread {/snippet} diff --git a/src/lib/ThreadList.svelte b/src/lib/ThreadList.svelte index 31ce265de..203864c47 100644 --- a/src/lib/ThreadList.svelte +++ b/src/lib/ThreadList.svelte @@ -1,13 +1,13 @@ - + k +
    diff --git a/src/lib/TutorialForDatabase.svelte b/src/lib/TutorialForDatabase.svelte index 951ae5c3e..238fbfbab 100644 --- a/src/lib/TutorialForDatabase.svelte +++ b/src/lib/TutorialForDatabase.svelte @@ -1,32 +1,32 @@ {#if app.ui.tutorial_for_database} -
    -
    -