From 9aead2a1ce552802f53d0483e3491fcbd315ef4e Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 24 Jul 2026 21:15:12 +0500 Subject: [PATCH 01/29] Living UI V2: replace FastAPI/Vite system with PocketBase + vendored-kit platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New standalone living-ui-v2/ workspace: versioned React kit (realtime PB hooks, theme packs, console relay), project blueprint, and `lui` CLI (create/validate/dev/kit-sync/pb/ops/run/data/verify/probe) - Validation gate: types, build, migrations-on-fresh-db, ops manifest, ownership hashes — with source-annotated errors and a same-error breaker - walk_verify sub-agent: drives the running app in a real browser (playwright MCP) and blocks launch on observed defects (PR #388 contract) - Manager/actions rewritten for single-process PB apps; V1 template, importer, and sidecar removed; ZIP import is deterministic code - Creation wizard (layout/theme/reference files), option-chip QnA, session handoff, spec suite under living-ui-v2/spec/ --- .github/workflows/living-ui-v2.yml | 61 + .gitignore | 3 +- agent_core/core/prompts/action.py | 3 +- agent_core/core/prompts/application.py | 117 +- app/agent_base.py | 13 +- app/data/action/browser_probe.py | 95 + app/data/action/end_turn.py | 31 + app/data/action/living_ui_actions.py | 403 +-- app/data/action/send_message.py | 17 +- app/data/action/update_todos.py | 27 +- app/data/agent_file_system_template/AGENT.md | 2 +- .../living_ui_modules/auth/AuthService.ts | 187 -- app/data/living_ui_modules/auth/README.md | 230 -- app/data/living_ui_modules/auth/auth_types.ts | 48 - .../auth/backend/auth_middleware.py | 125 - .../auth/backend/auth_models.py | 164 -- .../auth/backend/auth_routes.py | 344 --- .../auth/backend/auth_service.py | 53 - .../auth/backend/tests/test_auth.py | 247 -- .../auth/frontend/AuthLayout.tsx | 102 - .../auth/frontend/AuthProvider.tsx | 84 - .../auth/frontend/InviteModal.tsx | 141 - .../auth/frontend/LoginPage.tsx | 49 - .../auth/frontend/MemberList.tsx | 116 - .../auth/frontend/ProfilePage.tsx | 117 - .../auth/frontend/RegisterPage.tsx | 63 - .../auth/frontend/UserMenu.tsx | 97 - .../living_ui_modules/auth/requirements.txt | 2 - app/data/living_ui_sidecar/proxy.py | 233 -- app/data/living_ui_sidecar/requirements.txt | 3 - app/data/living_ui_template/.env.example | 10 - app/data/living_ui_template/LIVING_UI.md | 80 - .../living_ui_template/backend/database.py | 75 - .../backend/health_checker.py | 156 -- app/data/living_ui_template/backend/logger.py | 76 - app/data/living_ui_template/backend/main.py | 137 - app/data/living_ui_template/backend/models.py | 142 - .../backend/requirements.txt | 7 - app/data/living_ui_template/backend/routes.py | 418 --- .../backend/services/integration_client.py | 126 - .../living_ui_template/backend/test_runner.py | 1135 -------- .../backend/tests/conftest.py | 69 - .../backend/tests/test_example.py | 117 - .../living_ui_template/config/manifest.json | 50 - app/data/living_ui_template/frontend/App.tsx | 45 - .../frontend/AppController.ts | 195 -- .../frontend/components/MainView.tsx | 80 - .../frontend/components/ui/index.tsx | 1244 --------- app/data/living_ui_template/frontend/main.tsx | 19 - .../frontend/services/ApiService.ts | 230 -- .../frontend/services/ConsoleCapture.ts | 235 -- .../frontend/services/StatePersistence.ts | 175 -- .../frontend/services/UICapture.ts | 326 --- .../frontend/services/index.ts | 84 - .../frontend/styles/global.css | 236 -- app/data/living_ui_template/frontend/types.ts | 20 - app/data/living_ui_template/index.html | 135 - app/data/living_ui_template/package.json | 26 - app/data/living_ui_template/requirements.txt | 9 - app/data/living_ui_template/tsconfig.json | 21 - .../living_ui_template/tsconfig.node.json | 10 - app/data/living_ui_template/vite.config.ts | 25 - app/living_ui/broadcast.py | 4 +- app/living_ui/manager.py | 1939 ++------------ app/living_ui/v2_runner.py | 231 ++ app/living_ui/walk_verify.py | 155 ++ app/subagent/definitions/__init__.py | 1 + app/subagent/definitions/walk_verify.py | 138 + app/subagent/registry.py | 12 + app/subagent/runner.py | 60 +- app/ui_layer/adapters/browser_adapter.py | 215 +- .../ui/CreateLivingUIModal.module.css | 8 +- .../src/components/ui/CreateLivingUIModal.tsx | 142 +- .../src/pages/LivingUI/CreationProgress.tsx | 8 +- .../pages/LivingUI/CreationQuestionForm.tsx | 36 +- .../src/pages/LivingUI/LivingUIPage.tsx | 18 +- .../src/store/slices/livingUiSlice.ts | 24 +- .../browser/frontend/src/types/index.ts | 5 + living-ui-v2/.gitignore | 18 + living-ui-v2/.prettierrc.json | 6 + living-ui-v2/blueprint/LIVING_UI.md | 31 + living-ui-v2/blueprint/frontend/index.html | 12 + living-ui-v2/blueprint/frontend/package.json | 29 + living-ui-v2/blueprint/frontend/src/app.css | 6 + .../blueprint/frontend/src/app/App.tsx | 154 ++ .../blueprint/frontend/src/config.gen.ts | 2 + .../blueprint/frontend/src/kit/.gitkeep | 1 + living-ui-v2/blueprint/frontend/src/main.tsx | 24 + living-ui-v2/blueprint/frontend/tsconfig.json | 21 + .../blueprint/frontend/vite.config.ts | 17 + living-ui-v2/blueprint/manifest.json | 17 + living-ui-v2/blueprint/operations.json | 26 + .../blueprint/pb/pb_hooks/_system.pb.js | 47 + living-ui-v2/blueprint/pb/pb_hooks/ops.pb.js | 20 + .../pb/pb_migrations/1700000000_init_items.js | 31 + living-ui-v2/docs/agent-guide.md | 115 + living-ui-v2/eslint.config.js | 20 + .../examples/.gitkeep | 0 living-ui-v2/kit/kit.json | 5 + living-ui-v2/kit/package.json | 26 + living-ui-v2/kit/src/components/Button.tsx | 58 + living-ui-v2/kit/src/components/Card.tsx | 33 + living-ui-v2/kit/src/components/Dialog.tsx | 49 + living-ui-v2/kit/src/components/Input.tsx | 34 + living-ui-v2/kit/src/components/LoginGate.tsx | 72 + living-ui-v2/kit/src/components/Table.tsx | 64 + living-ui-v2/kit/src/index.ts | 38 + living-ui-v2/kit/src/lib/cn.ts | 7 + living-ui-v2/kit/src/pb/auth.ts | 53 + living-ui-v2/kit/src/pb/client.ts | 80 + living-ui-v2/kit/src/pb/hooks.ts | 135 + living-ui-v2/kit/src/shell/Shell.tsx | 77 + living-ui-v2/kit/src/shell/console-relay.ts | 82 + living-ui-v2/kit/src/shell/toast.tsx | 80 + living-ui-v2/kit/src/theme/bridge.ts | 80 + living-ui-v2/kit/src/theme/tokens.css | 154 ++ living-ui-v2/kit/tsconfig.json | 9 + living-ui-v2/package-lock.json | 2321 +++++++++++++++++ living-ui-v2/package.json | 28 + living-ui-v2/spec/operations.schema.json | 100 + living-ui-v2/spec/pocketbase.version | 1 + living-ui-v2/tools/package.json | 17 + living-ui-v2/tools/src/cli.ts | 52 + living-ui-v2/tools/src/commands/create.ts | 168 ++ living-ui-v2/tools/src/commands/data.ts | 68 + living-ui-v2/tools/src/commands/dev.ts | 85 + living-ui-v2/tools/src/commands/kit-sync.ts | 31 + living-ui-v2/tools/src/commands/ops.ts | 31 + living-ui-v2/tools/src/commands/pb.ts | 77 + living-ui-v2/tools/src/commands/probe.ts | 99 + living-ui-v2/tools/src/commands/run.ts | 76 + living-ui-v2/tools/src/commands/validate.ts | 254 ++ living-ui-v2/tools/src/commands/verify.ts | 91 + living-ui-v2/tools/src/lib/hashes.ts | 84 + living-ui-v2/tools/src/lib/kit.ts | 22 + living-ui-v2/tools/src/lib/log.ts | 25 + living-ui-v2/tools/src/lib/os-adapter.ts | 83 + living-ui-v2/tools/src/lib/paths.ts | 30 + living-ui-v2/tools/src/lib/project.ts | 76 + living-ui-v2/tools/tsconfig.json | 11 + living-ui-v2/tsconfig.base.json | 16 + skills/living-ui-creator/SKILL.md | 526 +--- skills/living-ui-importer/SKILL.md | 153 -- skills/living-ui-manager/SKILL.md | 261 +- skills/living-ui-modify/SKILL.md | 340 +-- 145 files changed, 7390 insertions(+), 11355 deletions(-) create mode 100644 .github/workflows/living-ui-v2.yml create mode 100644 app/data/action/browser_probe.py delete mode 100644 app/data/living_ui_modules/auth/AuthService.ts delete mode 100644 app/data/living_ui_modules/auth/README.md delete mode 100644 app/data/living_ui_modules/auth/auth_types.ts delete mode 100644 app/data/living_ui_modules/auth/backend/auth_middleware.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_models.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_routes.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_service.py delete mode 100644 app/data/living_ui_modules/auth/backend/tests/test_auth.py delete mode 100644 app/data/living_ui_modules/auth/frontend/AuthLayout.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/AuthProvider.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/InviteModal.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/LoginPage.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/MemberList.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/ProfilePage.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/RegisterPage.tsx delete mode 100644 app/data/living_ui_modules/auth/frontend/UserMenu.tsx delete mode 100644 app/data/living_ui_modules/auth/requirements.txt delete mode 100644 app/data/living_ui_sidecar/proxy.py delete mode 100644 app/data/living_ui_sidecar/requirements.txt delete mode 100644 app/data/living_ui_template/.env.example delete mode 100644 app/data/living_ui_template/LIVING_UI.md delete mode 100644 app/data/living_ui_template/backend/database.py delete mode 100644 app/data/living_ui_template/backend/health_checker.py delete mode 100644 app/data/living_ui_template/backend/logger.py delete mode 100644 app/data/living_ui_template/backend/main.py delete mode 100644 app/data/living_ui_template/backend/models.py delete mode 100644 app/data/living_ui_template/backend/requirements.txt delete mode 100644 app/data/living_ui_template/backend/routes.py delete mode 100644 app/data/living_ui_template/backend/services/integration_client.py delete mode 100644 app/data/living_ui_template/backend/test_runner.py delete mode 100644 app/data/living_ui_template/backend/tests/conftest.py delete mode 100644 app/data/living_ui_template/backend/tests/test_example.py delete mode 100644 app/data/living_ui_template/config/manifest.json delete mode 100644 app/data/living_ui_template/frontend/App.tsx delete mode 100644 app/data/living_ui_template/frontend/AppController.ts delete mode 100644 app/data/living_ui_template/frontend/components/MainView.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/index.tsx delete mode 100644 app/data/living_ui_template/frontend/main.tsx delete mode 100644 app/data/living_ui_template/frontend/services/ApiService.ts delete mode 100644 app/data/living_ui_template/frontend/services/ConsoleCapture.ts delete mode 100644 app/data/living_ui_template/frontend/services/StatePersistence.ts delete mode 100644 app/data/living_ui_template/frontend/services/UICapture.ts delete mode 100644 app/data/living_ui_template/frontend/services/index.ts delete mode 100644 app/data/living_ui_template/frontend/styles/global.css delete mode 100644 app/data/living_ui_template/frontend/types.ts delete mode 100644 app/data/living_ui_template/index.html delete mode 100644 app/data/living_ui_template/package.json delete mode 100644 app/data/living_ui_template/requirements.txt delete mode 100644 app/data/living_ui_template/tsconfig.json delete mode 100644 app/data/living_ui_template/tsconfig.node.json delete mode 100644 app/data/living_ui_template/vite.config.ts create mode 100644 app/living_ui/v2_runner.py create mode 100644 app/living_ui/walk_verify.py create mode 100644 app/subagent/definitions/walk_verify.py create mode 100644 living-ui-v2/.gitignore create mode 100644 living-ui-v2/.prettierrc.json create mode 100644 living-ui-v2/blueprint/LIVING_UI.md create mode 100644 living-ui-v2/blueprint/frontend/index.html create mode 100644 living-ui-v2/blueprint/frontend/package.json create mode 100644 living-ui-v2/blueprint/frontend/src/app.css create mode 100644 living-ui-v2/blueprint/frontend/src/app/App.tsx create mode 100644 living-ui-v2/blueprint/frontend/src/config.gen.ts create mode 100644 living-ui-v2/blueprint/frontend/src/kit/.gitkeep create mode 100644 living-ui-v2/blueprint/frontend/src/main.tsx create mode 100644 living-ui-v2/blueprint/frontend/tsconfig.json create mode 100644 living-ui-v2/blueprint/frontend/vite.config.ts create mode 100644 living-ui-v2/blueprint/manifest.json create mode 100644 living-ui-v2/blueprint/operations.json create mode 100644 living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js create mode 100644 living-ui-v2/blueprint/pb/pb_hooks/ops.pb.js create mode 100644 living-ui-v2/blueprint/pb/pb_migrations/1700000000_init_items.js create mode 100644 living-ui-v2/docs/agent-guide.md create mode 100644 living-ui-v2/eslint.config.js rename app/data/living_ui_template/backend/tests/__init__.py => living-ui-v2/examples/.gitkeep (100%) create mode 100644 living-ui-v2/kit/kit.json create mode 100644 living-ui-v2/kit/package.json create mode 100644 living-ui-v2/kit/src/components/Button.tsx create mode 100644 living-ui-v2/kit/src/components/Card.tsx create mode 100644 living-ui-v2/kit/src/components/Dialog.tsx create mode 100644 living-ui-v2/kit/src/components/Input.tsx create mode 100644 living-ui-v2/kit/src/components/LoginGate.tsx create mode 100644 living-ui-v2/kit/src/components/Table.tsx create mode 100644 living-ui-v2/kit/src/index.ts create mode 100644 living-ui-v2/kit/src/lib/cn.ts create mode 100644 living-ui-v2/kit/src/pb/auth.ts create mode 100644 living-ui-v2/kit/src/pb/client.ts create mode 100644 living-ui-v2/kit/src/pb/hooks.ts create mode 100644 living-ui-v2/kit/src/shell/Shell.tsx create mode 100644 living-ui-v2/kit/src/shell/console-relay.ts create mode 100644 living-ui-v2/kit/src/shell/toast.tsx create mode 100644 living-ui-v2/kit/src/theme/bridge.ts create mode 100644 living-ui-v2/kit/src/theme/tokens.css create mode 100644 living-ui-v2/kit/tsconfig.json create mode 100644 living-ui-v2/package-lock.json create mode 100644 living-ui-v2/package.json create mode 100644 living-ui-v2/spec/operations.schema.json create mode 100644 living-ui-v2/spec/pocketbase.version create mode 100644 living-ui-v2/tools/package.json create mode 100755 living-ui-v2/tools/src/cli.ts create mode 100644 living-ui-v2/tools/src/commands/create.ts create mode 100644 living-ui-v2/tools/src/commands/data.ts create mode 100644 living-ui-v2/tools/src/commands/dev.ts create mode 100644 living-ui-v2/tools/src/commands/kit-sync.ts create mode 100644 living-ui-v2/tools/src/commands/ops.ts create mode 100644 living-ui-v2/tools/src/commands/pb.ts create mode 100644 living-ui-v2/tools/src/commands/probe.ts create mode 100644 living-ui-v2/tools/src/commands/run.ts create mode 100644 living-ui-v2/tools/src/commands/validate.ts create mode 100644 living-ui-v2/tools/src/commands/verify.ts create mode 100644 living-ui-v2/tools/src/lib/hashes.ts create mode 100644 living-ui-v2/tools/src/lib/kit.ts create mode 100644 living-ui-v2/tools/src/lib/log.ts create mode 100644 living-ui-v2/tools/src/lib/os-adapter.ts create mode 100644 living-ui-v2/tools/src/lib/paths.ts create mode 100644 living-ui-v2/tools/src/lib/project.ts create mode 100644 living-ui-v2/tools/tsconfig.json create mode 100644 living-ui-v2/tsconfig.base.json delete mode 100644 skills/living-ui-importer/SKILL.md diff --git a/.github/workflows/living-ui-v2.yml b/.github/workflows/living-ui-v2.yml new file mode 100644 index 00000000..ee2e8e16 --- /dev/null +++ b/.github/workflows/living-ui-v2.yml @@ -0,0 +1,61 @@ +name: living-ui-v2 + +# Self-test for the Living UI TEMPLATE code (kit/blueprint/tools) in this repo. +# Scaffolds a throwaway project and runs the local validation gate on it. +# User-made Living UIs never touch this workflow — they validate locally. + +on: + push: + paths: + - 'living-ui-v2/**' + - '.github/workflows/living-ui-v2.yml' + pull_request: + paths: + - 'living-ui-v2/**' + - '.github/workflows/living-ui-v2.yml' + +defaults: + run: + working-directory: living-ui-v2 + +jobs: + gate: + name: gate (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Cache PocketBase binary + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/craftos-living-ui/pb + ~/.cache/craftos-living-ui/pb + ~\AppData\Local\craftos-living-ui\pb + key: pb-${{ runner.os }}-${{ hashFiles('living-ui-v2/spec/pocketbase.version') }} + + - name: Install workspace + run: npm install + + - name: Typecheck (kit + tools) + run: npm run typecheck + + - name: Lint + run: npx eslint . + + - name: Scaffold demo project + run: node tools/src/cli.ts create "CI Demo" --description "CI validation project" --port 8090 + + - name: Link demo workspace + run: npm install + + - name: Validation gate + run: node tools/src/cli.ts validate examples/ci-demo diff --git a/.gitignore b/.gitignore index 8cb33c08..429e4699 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ docs/LIVING_UI_DEVELOPER_GUIDE.md agent_file_system/ACTIONS.md agent_bundle/ **/.craftbot/ -app/data/.file_index/ \ No newline at end of file +app/data/.file_index/ +.playwright-mcp \ No newline at end of file diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index 2ee7bc42..d8cfdda8 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -147,7 +147,8 @@ includes any write/mutate (write_file, stream_edit, clipboard_write), wait, and add_action_sets / remove_action_sets / use_skill / unload_skill. Never emit two of the same single-instance action: combine multiple messages -into ONE send, and use ONE update_todos with the full list. +into ONE send, and use ONE update_todos with the COMPLETE list — the payload +replaces the whole list, so any todo you omit is deleted. A FINAL send_message (continue_work absent or false) must be the ONLY action in its step — pairing it with working actions is contradictory. diff --git a/agent_core/core/prompts/application.py b/agent_core/core/prompts/application.py index c9dbe930..9c57d4a9 100644 --- a/agent_core/core/prompts/application.py +++ b/agent_core/core/prompts/application.py @@ -5,7 +5,7 @@ Contains prompt templates for Living UI and other application features. """ -LIVING_UI_TASK_INSTRUCTION = """Create a Living UI application. +LIVING_UI_TASK_INSTRUCTION = """Create a Living UI application (V2 — PocketBase + React kit). Project ID: {project_id} Project Name: {project_name} @@ -14,76 +14,57 @@ Theme: {theme} Project Path: {project_path} -Follow the living-ui-creator skill instructions. Here's the workflow: +Follow the living-ui-creator skill. Workflow: 1. Read agent_file_system/GLOBAL_LIVING_UI.md — apply its colors, fonts, and rules -2. Phase 0: Ask the user 2+ batches of questions about data, features, design, and layout -3. Document requirements in LIVING_UI.md -4. Break the app into features, then for each feature: - - Re-read LIVING_UI.md (check what's left) and GLOBAL_LIVING_UI.md (refresh design rules) - - Write backend tests first (backend/tests/) - - Create model + routes to pass tests - - Run pytest to verify - - Create frontend types + components - - Update LIVING_UI.md — mark this feature as done, add models/routes/components you created - Do NOT skip features listed in LIVING_UI.md. A working app with all planned features is the goal. -5. Update LIVING_UI.md with implementation details -6. Call living_ui_notify_ready(project_id="{project_id}") +2. Read {project_path}/LIVING_UI.md (plan/index) and {project_path}/reference/requirements.md if present +3. QnA PHASE — MANDATORY unless the requirements are already detailed and + unambiguous: ask the user 1-2 batches of clarifying questions (data to track, + must-have features, design preferences, single- vs multi-user) using + send_message as your FINAL message (continue_work=false — the reply wakes the session), ALWAYS passing the `options` + param with quick-answer choices when the answer space is enumerable + (they render as tap-to-answer chips on the creation screen). Write the agreed requirements to + {project_path}/reference/requirements.md and mirror the feature checklist + into LIVING_UI.md BEFORE any coding. Writing reference/requirements.md is + MANDATORY — it is the binding spec for verification. +3b. This build IS substantial work — the standard run protocol applies as-is + (scope, plan, execute, verify, deliver). Do not skip it because these + numbered steps exist; they only describe the Living-UI-specific parts. +4. OWNERSHIP RULE (the gate enforces this by hashing): + - You may edit ONLY: frontend/src/app/, pb/pb_migrations/, pb/pb_hooks/ (ops.pb.js + and new *.pb.js files), operations.json (non-system entries), LIVING_UI.md + - NEVER touch: frontend/src/kit/, frontend/src/main.tsx, frontend/src/config.gen.ts, + pb/pb_hooks/_system.pb.js, manifest.json, vite/tsconfig files. + Need a component variant? Wrap the kit component in frontend/src/app/ instead. +5. Build order per feature: + - Schema: add a NEW migration in pb/pb_migrations/ (never edit an applied one); + follow the starter migration's field/rule pattern and the project's authMode + - Custom verbs (beyond CRUD): routerAdd route in pb/pb_hooks/ops.pb.js + a matching + entry in operations.json (the gate fails orphan ops; see items.clear-done example) + - UI: build in frontend/src/app/ from kit parts (import from '../kit/index.ts'); + data via useCollection (realtime — never poll or reload); writes via + getPbClient().call(...) (errors toast automatically) + - Update LIVING_UI.md — mark the feature done, record entities/ops/components +6. Quality bar: empty states with a next action, loading states, confirmation dialog + for destructive actions, toasts on CRUD, responsive layout, kit tokens only + (never hardcoded colors — theming is host-owned) +7. Call living_ui_notify_ready(project_id="{project_id}") — it runs the validation + gate (types, build, migrations-on-fresh-db, ops structure, ownership) and launches. + If it returns errors: read ALL of them, fix ALL of them, call it again. -What a GOOD Living UI looks like: -- Professional web app layout — proper spacing, visual hierarchy, sections, headers -- Uses preset components (Button, Card, Input, Modal, Table from './components/ui') — never raw HTML -- Thoughtful layout: sidebar or top nav, content area with grid/list views, detail panels or modals -- Colors from GLOBAL_LIVING_UI.md applied consistently -- Empty state when no data — the app launches with an empty database, users create their own content -- "Add" actions open forms/modals with proper input fields — never auto-create with placeholder text -- Every item is viewable, editable, and deletable through the UI -- Error handling with toast notifications on API failures -- Responsive design that works on different screen sizes +RUN RULE: this run IS the build — there is no "continue in a later turn". +The ONLY valid ways this run ends: a question to the user (a FINAL +send_message, continue_work=false — the reply wakes the session) or +living_ui_notify_ready returning success. Never end_turn mid-build. -When pytest fails: -- Read ALL errors carefully before fixing — fix ALL issues in one go, not one at a time -- If you see an import error, check ALL files for the same pattern and fix them all -- Maximum 3 pytest attempts per feature. If still failing after 3, review your approach -- Common fix: relative imports (from . import X) → absolute imports (from X import Y) +HONESTY RULE: the app is ready ONLY when living_ui_notify_ready returns +status=success. If you cannot make it pass, tell the user the build FAILED and +exactly what is blocking — NEVER claim the app is ready or usable when the +launch failed. A false "ready" is the worst possible outcome. -External integrations (Gmail, YouTube, Discord, Slack, etc.): -- CraftBot has connected external services — use the integration bridge, NOT custom OAuth -- Import: from services.integration_client import integration -- Call: result = await integration.request("google_workspace", "GET", url) -- NEVER build OAuth flows, ask for API keys, or store credentials -- See the "External Integrations" section in SKILL.md for details and examples +Schema gotcha: relation fields require the TARGET COLLECTION'S ID, not its +name — save the target collection first, then reference +app.findCollectionByNameOrId("").id in the dependent collection. -What to AVOID: -- Flat list of items with no visual structure -- Custom CSS when preset components exist -- Hardcoded test data left in the database -- Buttons that create items without user input -- Everything crammed into one component file -- Relative imports in backend code -- Running uvicorn/npm manually — the launch pipeline handles this -- Editing main.py, main.tsx, manifest.json, or tests/conftest.py — system managed -- Rewriting conftest.py — it has the correct imports and test DB setup already - -Your todo list should follow this EXACT pattern — do NOT add extra sub-steps: -Phase 0: Read global config -Phase 0: Ask user batch 1 (data/features) -Phase 0: Ask user batch 2 (design/layout) -Phase 0: Document requirements in LIVING_UI.md -Phase 1: Plan features -Feature 1 - [name]: Backend (tests + model + routes + pytest) -Feature 1 - [name]: Frontend (types + components + controller) -Feature 2 - [name]: Backend (tests + model + routes + pytest) -Feature 2 - [name]: Frontend (types + components + controller) -Feature 3 - [name]: Backend (tests + model + routes + pytest) -Feature 3 - [name]: Frontend (types + components + controller) -... repeat for each feature ... -Update LIVING_UI.md with implementation details -Call living_ui_notify_ready - -IMPORTANT about features: -- Each feature is a USER-FACING capability (e.g., "Board Items", "Media Attachments", "Search/Filter") -- "Backend Setup" or "Frontend Setup" are NOT features — they are layers -- Each feature MUST have BOTH backend AND frontend todos — never just one -- Keep exactly 2 todos per feature (backend + frontend) — do NOT split into 10+ sub-steps -- Write ALL tests for a feature at once, not one endpoint at a time""" +Debugging: frontend runtime errors are relayed to {project_path}/logs/frontend_console.log; +the PocketBase server log is {project_path}/logs/pocketbase.log.""" diff --git a/app/agent_base.py b/app/agent_base.py index 2daeeec4..ef7a8c75 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -1738,6 +1738,9 @@ def _build_living_ui_note(living_ui_project_id: str) -> str: try: from app.living_ui import get_living_ui_manager + from app.config import PROJECT_ROOT + + _lui_cli = f"{PROJECT_ROOT}/living-ui-v2/tools/src/cli.ts" mgr = get_living_ui_manager() if mgr: proj = mgr.get_project(living_ui_project_id) @@ -1747,8 +1750,14 @@ def _build_living_ui_note(living_ui_project_id: str) -> str: f"Project path: {proj.path}\n" f"Read {proj.path}/LIVING_UI.md for app context.\n" f"If debugging issues, FIRST read these logs:\n" - f" - {proj.path}/backend/logs/subprocess_output.log (crashes, stack traces)\n" - f" - {proj.path}/backend/logs/frontend_console.log (frontend errors, network failures)" + f" - {proj.path}/logs/pocketbase.log (server, migrations, crashes)\n" + f" - {proj.path}/logs/frontend_console.log (frontend errors, network failures)\n" + f"To OPERATE the app (read/write data, run its verbs), use the lui CLI via run_shell\n" + f"(preferred over living_ui_http). Use these EXACT absolute commands (the shell's\n" + f"cwd is NOT the repo root — relative paths will fail):\n" + f" node {_lui_cli} ops {proj.path}\n" + f" node {_lui_cli} run {proj.path} --param value\n" + f" node {_lui_cli} data {proj.path} list --limit 20" ) except Exception: pass diff --git a/app/data/action/browser_probe.py b/app/data/action/browser_probe.py new file mode 100644 index 00000000..d867ff58 --- /dev/null +++ b/app/data/action/browser_probe.py @@ -0,0 +1,95 @@ +"""Headless-browser probe of a running Living UI (walk-verify's hands).""" + +from agent_core import action + + +@action( + name="browser_probe", + description=( + "Drive a RUNNING Living UI in a headless browser (invisible — no " + "window). Executes a scripted sequence of steps and returns per-step " + "results, page text, screenshot file paths, and console errors. Use " + "this to verify UI flows a user would perform: navigate, click " + "buttons, fill forms, read what rendered." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=False, + input_schema={ + "url": { + "type": "string", + "example": "http://127.0.0.1:3100", + "description": "Base URL of the running app.", + }, + "steps": { + "type": "array", + "example": [ + {"op": "goto", "value": "/"}, + {"op": "click", "selector": "button:has-text('Add')"}, + {"op": "type", "selector": "input", "value": "hello"}, + {"op": "read", "selector": "main"}, + {"op": "screenshot", "value": "after-add"}, + ], + "description": ( + "Ordered steps (max 40). op: goto|click|type|read|wait|screenshot. " + "selector: CSS/Playwright selector. value: path for goto, text " + "for type, ms for wait, filename for screenshot. read with no " + "selector returns the whole page text." + ), + }, + "project_path": { + "type": "string", + "description": "Project dir — screenshots are saved under its logs/verify/.", + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "steps": {"type": "array", "description": "Per-step {op, ok, detail} results."}, + "console_errors": {"type": "array", "description": "Console/page errors seen."}, + }, + test_payload={ + "url": "http://127.0.0.1:3100", + "steps": [{"op": "goto", "value": "/"}], + "simulated_mode": True, + }, +) +async def browser_probe(input_data: dict) -> dict: + import asyncio + import json + from pathlib import Path + + if input_data.get("simulated_mode", False): + return {"status": "success", "steps": [{"op": "goto", "ok": True, "detail": "/"}], "console_errors": []} + + url = (input_data.get("url") or "").strip() + steps = input_data.get("steps") or [] + if not url or not isinstance(steps, list) or not steps: + return {"status": "error", "message": "url and a non-empty steps array are required"} + + from app.config import PROJECT_ROOT + + cli = Path(PROJECT_ROOT) / "living-ui-v2" / "tools" / "src" / "cli.ts" + out_dir = str(Path(input_data.get("project_path") or "/tmp") / "logs" / "verify") + proc = await asyncio.create_subprocess_exec( + "node", str(cli), "probe", "--url", url, "--steps", json.dumps(steps), "--out", out_dir, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=180) + except asyncio.TimeoutError: + proc.kill() + return {"status": "error", "message": "browser probe timed out after 180s"} + + text = out.decode(errors="replace").strip() + try: + payload = json.loads(text.splitlines()[-1]) + except Exception: + return {"status": "error", "message": f"probe output unparseable: {text[-500:]}"} + if "error" in payload: + return {"status": "error", "message": str(payload["error"])} + return { + "status": "success", + "steps": payload.get("steps", []), + "console_errors": payload.get("consoleErrors", []), + } diff --git a/app/data/action/end_turn.py b/app/data/action/end_turn.py index 7ac75df7..aa39e8ee 100644 --- a/app/data/action/end_turn.py +++ b/app/data/action/end_turn.py @@ -32,6 +32,37 @@ def end_turn(input_data: dict) -> dict: simulated_mode = input_data.get("simulated_mode", False) if not simulated_mode: + # STRUCTURAL GUARD: a Living UI build must never be silently + # abandoned mid-creation. Ending the run leaves the session asleep + # forever (nothing re-wakes it), stranding the user on the creation + # screen. Refuse and keep the run alive. + session_id = input_data.get("_session_id") + if session_id: + try: + from app.living_ui import get_living_ui_manager + + manager = get_living_ui_manager() + project = ( + manager.get_project_by_session_id(session_id) if manager else None + ) + if project is not None and project.status == "creating": + return { + "status": "error", + "message": ( + "REFUSED: this Living UI build is not finished — ending " + "the run now would strand it forever (nothing wakes the " + "session again). Valid ways to stop working: (1) keep " + "building the remaining features, (2) ask the user a " + "question via send_message with wait_for_user_reply=true, " + "or (3) finish with living_ui_notify_ready(project_id=" + f"'{project.id}') and report the result. There is no " + "'continue in a later turn' — this run IS the build." + ), + "end_turn": False, + } + except Exception: + pass # never let the guard itself break turn-ending + import app.internal_action_interface as internal_action_interface internal_action_interface.InternalActionInterface.do_end_turn() diff --git a/app/data/action/living_ui_actions.py b/app/data/action/living_ui_actions.py index 4ea8105f..89c13659 100644 --- a/app/data/action/living_ui_actions.py +++ b/app/data/action/living_ui_actions.py @@ -6,14 +6,16 @@ @action( name="living_ui_scaffold", description=( - "Create and register a new Living UI project from the template. " - "Call this FIRST when building a Living UI from a chat request — i.e. " - "when your task instruction does NOT already contain a 'Project ID' and " - "'Project Path' (those come pre-scaffolded from the Create Living UI modal). " - "This copies the project template (backend/, frontend/, config/), allocates " - "ports, and registers the project so it appears in the user's Living UI list. " - "Returns the project_id and an absolute project_path — use project_path as the " - "base for ALL subsequent file operations so files land in the right folders." + "Create and register a new Living UI project from the template, then " + "dispatch the build to the project's dedicated session. Call this when " + "the user asks for a new Living UI in a regular chat — i.e. when your " + "task instruction does NOT already contain a 'Project ID' and 'Project " + "Path' (those come pre-scaffolded from the Create Living UI modal). " + "This copies the project template (backend/, frontend/, config/), " + "allocates ports, registers the project in the user's Living UI list, " + "and queues the build run in the project's own session. After it " + "returns, inform the user the build has started and end your turn — " + "do NOT write project files or call living_ui_notify_ready yourself." ), default=False, mode="CLI", @@ -28,7 +30,11 @@ "description": { "type": "string", "example": "A dashboard that forecasts stock performance.", - "description": "Short description of what the app does.", + "description": ( + "Description of what the app does. Include EVERY requirement " + "the user has given so far — it becomes the build instruction " + "for the project's session." + ), }, "features": { "type": "array", @@ -41,6 +47,15 @@ "example": "system", "description": "UI theme. Defaults to 'system'.", }, + "auth_mode": { + "type": "string", + "enum": ["none", "multi-user"], + "example": "none", + "description": ( + "Auth mode from the requirements: 'none' for a personal local " + "tool (default), 'multi-user' when the app needs accounts." + ), + }, }, output_schema={ "status": { @@ -51,12 +66,12 @@ "project_id": { "type": "string", "example": "abc12345", - "description": "The created project ID. Pass this to living_ui_notify_ready.", + "description": "The created project ID.", }, "project_path": { "type": "string", "example": "/workspace/living_ui/stock_forecaster_abc12345", - "description": "Absolute base path. Use this for ALL file operations.", + "description": "Absolute project path on disk.", }, "frontend_port": {"type": "integer", "description": "Allocated frontend port."}, "backend_port": {"type": "integer", "description": "Allocated backend port."}, @@ -77,9 +92,6 @@ async def living_ui_scaffold(input_data: dict) -> dict: description = input_data.get("description", "").strip() features = input_data.get("features") or [] theme = input_data.get("theme", "system") - # _session_id is injected by the ActionManager; for a Living UI task it equals - # the task id, which the progress/todo broadcast hooks key off of. - session_id = input_data.get("_session_id") simulated_mode = input_data.get("simulated_mode", False) if not name or not description: @@ -96,7 +108,11 @@ async def living_ui_scaffold(input_data: dict) -> dict: } try: - from app.living_ui import get_living_ui_manager, broadcast_living_ui_created + from app.living_ui import ( + get_living_ui_manager, + broadcast_living_ui_created, + broadcast_living_ui_progress, + ) manager = get_living_ui_manager() if not manager: @@ -117,17 +133,43 @@ async def living_ui_scaffold(input_data: dict) -> dict: description=description, features=features, theme=theme, + auth_mode=input_data.get("auth_mode", "none"), ) - # Associate the project with the running task so the agent's todos and - # progress stream to the Living UI view, then mark it as in-progress. - if session_id: - manager.set_project_task(project.id, session_id) - manager.update_project_status(project.id, "creating") - - # Register it in the browser's project list immediately (modal-parity). + # Register it in the browser's project list immediately and show the + # creation screen (modal-parity). await broadcast_living_ui_created(project.to_dict()) + await broadcast_living_ui_progress( + project.id, "initializing", 10, "Project created, starting development..." + ) + # Hand the build off to the project's dedicated session (parity with + # the browser "+" flow): start_development_run ensures the session + # exists, marks the project as creating, and fires a LIVING_UI_DEV + # trigger carrying the full build instruction, so todos/progress/ + # questions stream to the Living UI view. + dev_session_id = await manager.start_development_run(project.id) + if dev_session_id: + return { + "status": "success", + "project_id": project.id, + "project_path": project.path, + "frontend_port": project.port, + "backend_port": project.backend_port, + "message": ( + f"Project '{project.name}' scaffolded at {project.path}. " + f"The build has been dispatched to the project's dedicated " + f"session — do NOT build it in this session, do NOT write " + f"project files, and do NOT call living_ui_notify_ready " + f"here. Tell the user the build has started and that " + f"progress and any setup questions will appear in the " + f"'{project.name}' Living UI tab, then end your turn." + ), + } + + # Fallback — session runtime not bound (e.g. headless/test contexts): + # keep the legacy inline-build contract in the calling session. + manager.update_project_status(project.id, "creating") return { "status": "success", "project_id": project.id, @@ -137,7 +179,7 @@ async def living_ui_scaffold(input_data: dict) -> dict: "message": ( f"Project '{project.name}' scaffolded at {project.path}. " f"Use this absolute path as the base for ALL file operations " - f"(e.g. {project.path}/backend/models.py, {project.path}/frontend/). " + f"(e.g. {project.path}/frontend/src/app/, {project.path}/pb/pb_migrations/). " f"Do NOT write to bare relative paths. When the build is complete, " f'call living_ui_notify_ready(project_id="{project.id}").' ), @@ -215,23 +257,159 @@ async def living_ui_notify_ready(input_data: dict) -> dict: result = await manager.launch_and_verify(project_id) if result["status"] == "success": - # Notify browser that the UI is ready url = result.get("url", "") port = result.get("port", 0) + _proj_ok = manager.get_project(project_id) + if _proj_ok is not None: + _proj_ok._gate_fp = None + _proj_ok._gate_fp_count = 0 + + # HARD GATE: independent walk-verify of the running app. Success + # is reported only on an all-pass verdict set — the building + # agent cannot self-grade or skip this. + from app.living_ui.walk_verify import run_walk_verify + + project = manager.get_project(project_id) + report = None + if project is not None: + try: + from app.living_ui import broadcast_living_ui_progress + + await broadcast_living_ui_progress( + project_id, + "verifying", + 92, + "Walk-verify: independently testing features against " + "the requirements (this takes a minute)…", + ) + except Exception: + pass + try: + import asyncio as _asyncio + + # Belt-and-suspenders ceiling above the runner's own + # 30-min wall cap: even if the verifier wedges, the + # session turn must end. Timeout = tooling failure + # (blocked), never an app defect. + report = await _asyncio.wait_for( + run_walk_verify(project), timeout=2100 + ) + except _asyncio.TimeoutError: + report = {"kind": "blocked", "passed": [], "defects": [], + "raw": "walk_verify exceeded the 35-minute ceiling"} + except Exception as verify_err: + report = {"kind": "blocked", "passed": [], "defects": [], + "raw": f"walk_verify crashed: {verify_err}"} + try: + kind = (report or {}).get("kind") + passed_n = len((report or {}).get("passed") or []) + if kind == "defects": + outcome = ( + f"Walk-verify: {len(report['defects']) or 'some'} " + "feature(s) FAILED — fixing before launch" + ) + elif kind == "pass": + outcome = f"Walk-verify PASSED: {passed_n} feature(s) work" + elif kind == "incomplete": + outcome = ( + f"Walk-verify: {passed_n} passed, coverage incomplete " + "(some features NOT REACHED)" + ) + else: + outcome = "Walk-verify BLOCKED (tooling) — smoke checks only" + await broadcast_living_ui_progress(project_id, "verifying", 96, outcome) + except Exception: + pass + + kind = (report or {}).get("kind") + if kind == "defects": + # Observed misbehavior — the only thing that blocks a launch. + await manager.stop_project(project_id) + defects = report.get("defects") or [] + raw = (report.get("raw") or "")[:2500] + return { + "status": "error", + "message": ( + "Launch blocked by walk-verify: " + f"{len(defects) or 'some'} feature(s) observed NOT working." + ), + "test_errors": defects[:10] or [raw], + "details": ( + "The walk-verify report (a real browser drove the app):\n" + + raw + + "\n\nFix these features, then call living_ui_notify_ready " + "again. Do NOT tell the user the app is ready." + ), + } + + # Notify browser that the UI is ready await broadcast_living_ui_ready(project_id, url, port) + if kind == "pass": + verified = ( + f" ({len(report.get('passed') or [])} feature(s) walk-verified " + "in a real browser)" + ) + elif kind == "incomplete": + verified = ( + f" (walk-verify: {len(report.get('passed') or [])} passed; " + "coverage INCOMPLETE — some features NOT REACHED. Tell the " + "user which features were not walked; do NOT claim they were " + "tested.)" + ) + elif kind == "blocked": + verified = ( + " (WARNING: walk-verify was BLOCKED — tooling/browser issue, " + "not an app defect. Launch passed smoke checks only: " + + str((report or {}).get("raw") or "")[:200] + + ")" + ) + else: + verified = " (walk-verify unavailable — smoke checks only)" return { "status": "success", - "message": f"Living UI {project_id} is now ready at {url}", + "message": f"Living UI {project_id} is now ready at {url}{verified}", } else: # Return errors directly so the agent can fix them errors = result.get("errors", []) errors_str = "\n".join(errors[:10]) + + # CIRCUIT BREAKER: detect fix attempts that change nothing. The + # fingerprint lives on the in-memory project (this module does not + # persist between action calls). + breaker_note = "" + project = manager.get_project(project_id) + if project is not None: + fp = hash((result.get("step"), errors_str)) + same = getattr(project, "_gate_fp", None) == fp + count = (getattr(project, "_gate_fp_count", 0) + 1) if same else 1 + project._gate_fp = fp + project._gate_fp_count = count + if count >= 6: + breaker_note = ( + f"\n\nSTOP: the EXACT same error has now occurred {count} times " + "in a row. The build is stuck — do NOT try again. Report the " + "failure honestly to the user with a final send_message " + "(state what is blocking and what you tried) and end the run." + ) + elif count >= 3: + breaker_note = ( + f"\n\nWARNING: this is the IDENTICAL error {count} times in a " + "row — your edits are NOT changing the outcome. Do not repeat " + "the same fix. Re-read the annotated error above: the caret " + "marks the EXACT offending expression (there may be several " + "similar ones on the line — fix the one under the caret). " + "Verify your edit actually changed that expression before " + "re-running." + ) return { "status": "error", "message": f"Launch failed at step: {result.get('step', 'unknown')}", "test_errors": errors[:10], - "details": f"Fix these errors and call living_ui_notify_ready again:\n{errors_str}", + "details": ( + f"Fix these errors and call living_ui_notify_ready again:\n{errors_str}" + + breaker_note + ), } except Exception as e: return {"status": "error", "message": f"Failed to launch: {str(e)}"} @@ -420,179 +598,16 @@ async def living_ui_report_progress(input_data: dict) -> dict: } -@action( - name="living_ui_import_external", - description=( - "Import an external app as a Living UI project. " - "Use this when the user wants to add an existing app (Go, Node.js, Python, Rust, static site) " - "to their Living UI dashboard. The agent should first analyze the app source code to determine " - "the runtime, build/install command, start command, and health check strategy, then call this action." - ), - action_sets=["living_ui"], - input_schema={ - "name": { - "type": "string", - "description": "Display name for the project.", - "example": "Glance Dashboard", - }, - "description": { - "type": "string", - "description": "Brief app description.", - "example": "Self-hosted dashboard", - }, - "source_path": { - "type": "string", - "description": "Absolute path to the app source code.", - "example": "/path/to/app", - }, - "app_runtime": { - "type": "string", - "description": "Runtime: node, python, go, rust, docker, static, or unknown.", - "example": "go", - }, - "install_command": { - "type": "string", - "description": "Command to install/build the app (empty if none needed).", - "example": "go build -o app .", - }, - "start_command": { - "type": "string", - "description": "Command to start the app. Use {{PORT}} placeholder for port.", - "example": "./app --port {{PORT}}", - }, - "health_strategy": { - "type": "string", - "description": "Health check: http_get, tcp, or process_alive.", - "example": "http_get", - }, - "health_url": { - "type": "string", - "description": "Health check URL (for http_get). Use {{PORT}} placeholder.", - "example": "http://localhost:{{PORT}}/health", - }, - "port_env_var": { - "type": "string", - "description": "Env var name for port injection (e.g., PORT). Empty if app uses command-line flag.", - "example": "PORT", - }, - "project_id": { - "type": "string", - "description": ( - "If the task instruction provided a pre-created project_id " - "(a tab already shown to the user), pass it here so the import " - "populates that tab. Omit otherwise." - ), - "example": "a1b2c3d4", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "project": {"type": "object", "description": "Project info dict."}, - }, -) -async def living_ui_import_external(input_data: dict) -> dict: - """Import an external app as a Living UI project.""" - try: - from app.living_ui import get_living_ui_manager - - manager = get_living_ui_manager() - if not manager: - return {"status": "error", "message": "Living UI manager not available."} - - result = await manager.import_external_app( - name=input_data.get("name", "External App"), - description=input_data.get("description", ""), - source_path=input_data["source_path"], - app_runtime=input_data.get("app_runtime", "unknown"), - install_command=input_data.get("install_command", ""), - start_command=input_data.get("start_command", ""), - health_strategy=input_data.get("health_strategy", "tcp"), - health_url=input_data.get("health_url", ""), - port_env_var=input_data.get("port_env_var", "PORT"), - project_id=input_data.get("project_id") or None, - ) - return result - except Exception as e: - return {"status": "error", "message": f"Import failed: {str(e)}"} - - -@action( - name="living_ui_import_zip", - description=( - "Import a Living UI project from a ZIP file. " - "The ZIP should contain a previously exported Living UI project. " - "A new project ID and ports are allocated automatically. " - "After importing, launch the project with living_ui_notify_ready." - ), - action_sets=["living_ui"], - input_schema={ - "zip_path": { - "type": "string", - "description": "Absolute path to the ZIP file.", - "example": "/path/to/project.zip", - }, - "name": { - "type": "string", - "description": "Display name for the imported project (optional, auto-detected from manifest).", - "example": "My App", - }, - "project_id": { - "type": "string", - "description": ( - "If the task instruction provided a pre-created project_id " - "(a tab already shown to the user), pass it here so the import " - "populates that tab. Omit otherwise." - ), - "example": "a1b2c3d4", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "project_id": {"type": "string", "example": "a1b2c3d4"}, - "message": {"type": "string"}, - }, -) -async def living_ui_import_zip(input_data: dict) -> dict: - """Import a Living UI project from a ZIP file.""" - try: - from app.living_ui import get_living_ui_manager - - manager = get_living_ui_manager() - if not manager: - return {"status": "error", "message": "Living UI manager not available."} - - zip_path = input_data.get("zip_path", "") - name = input_data.get("name", "") - project_id = input_data.get("project_id") or None - - if not zip_path: - return {"status": "error", "message": "zip_path is required."} - - project = await manager.import_project_zip(zip_path, name, project_id) - - # Clean up the ZIP file after successful import - import os - - try: - os.unlink(zip_path) - except Exception: - pass - - return { - "status": "success", - "project_id": project.id, - "message": f"Imported '{project.name}' ({project.id}). Call living_ui_notify_ready to launch it.", - "project": project.to_dict(), - } - except Exception as e: - return {"status": "error", "message": f"ZIP import failed: {str(e)}"} @action( name="living_ui_http", description=( - "Send an HTTP request to a running Living UI project's backend. " - "Use this to read or modify data in your Living UI (e.g., add a card to a kanban, fetch a list). " + "FALLBACK ONLY — prefer the lui CLI via run_shell " + "(node /living-ui-v2/tools/src/cli.ts ops|run|data — ABSOLUTE path; the exact commands are in the [INTERACTING WITH LIVING UI] note) to " + "operate a Living UI. Use this action only when the shell is " + "unavailable. Sends an HTTP request to a running Living UI project's " + "backend to read or modify data (e.g., add a card to a kanban, fetch a list). " "Pass the project_id and the API path (e.g., '/api/boards/2/cards'); the URL is resolved from the " "project's registered backend. This bypasses the loopback SSRF restriction safely because the " "target is a known Living UI process." diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index c79bddc9..d95d0ee2 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -32,6 +32,16 @@ "will keep working after sending it." ), }, + "options": { + "type": "array", + "example": ["Single user", "Multi-user"], + "description": ( + "Optional quick-answer choices (max 8, short strings). When " + "asking the user a question, ALWAYS provide options if the " + "answer space is enumerable — they render as tap-to-answer " + "chips. The user can still type a free-form reply." + ), + }, }, output_schema={ "status": { @@ -74,7 +84,12 @@ async def send_message(input_data: dict) -> dict: try: from app.living_ui import broadcast_living_ui_question - await broadcast_living_ui_question(session_id, message) + options = input_data.get("options") or [] + if not isinstance(options, list): + options = [] + await broadcast_living_ui_question( + session_id, message, [str(o)[:80] for o in options[:8]] + ) except Exception: pass diff --git a/app/data/action/update_todos.py b/app/data/action/update_todos.py index 7d2bfd12..ad1ae5d3 100644 --- a/app/data/action/update_todos.py +++ b/app/data/action/update_todos.py @@ -22,16 +22,21 @@ input_schema={ "todos": { "type": "array", - "description": 'Array of todo objects. Each object MUST have exactly 2 keys: \'content\' (string: the task text) and \'status\' (string: \'pending\'|\'in_progress\'|\'completed\'). Example: [{"content": "Do X", "status": "completed"}, {"content": "Do Y", "status": "in_progress"}]', + "description": 'Array of todo objects — this payload REPLACES the whole list, so ALWAYS send the complete list (every item you want to keep, not just changes). Each object MUST have exactly 2 keys: \'content\' (string: the task text) and \'status\' (string: \'pending\'|\'in_progress\'|\'completed\'). Example: [{"content": "Do X", "status": "completed"}, {"content": "Do Y", "status": "in_progress"}]', "required": True, - } + }, }, output_schema={ "status": { "type": "string", "example": "success", "description": "Indicates if the update was successful", - } + }, + "message": { + "type": "string", + "example": "List now has 7 todos (3 completed, 1 in progress, 3 pending).", + "description": "Summary of the FULL merged list after this update.", + }, }, test_payload={ "todos": [ @@ -62,6 +67,20 @@ def update_todos(input_data: dict) -> dict: todos, session_id=input_data.get("_session_id") ) status = "success" if result.get("status") in ("ok", "success") else "error" - return {"status": status} + # Echo the resulting list state — the payload replaces the whole list, + # so this is the model's (and the activity feed's) immediate feedback + # on what the list actually became after this call. + updated = result.get("todos", []) or [] + counts = {"completed": 0, "in_progress": 0, "pending": 0} + for t in updated: + key = t.get("status", "pending") + counts[key] = counts.get(key, 0) + 1 + return { + "status": status, + "message": ( + f"List now has {len(updated)} todos ({counts['completed']} completed, " + f"{counts['in_progress']} in progress, {counts['pending']} pending)." + ), + } return {"status": "success"} diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index abced1e0..a849129f 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -1404,7 +1404,7 @@ clipboard clipboard_read, clipboard_write comms send_message_with_attachment -living_ui living_ui_http, living_ui_import_external, living_ui_import_zip, +- Importing external apps/ZIPs is temporarily unavailable (V1 import removed; V2 import workflow pending). living_ui_notify_ready, living_ui_report_progress, living_ui_restart per-platform integrations Discord, Slack, Telegram, Notion, LinkedIn, Jira, GitHub, diff --git a/app/data/living_ui_modules/auth/AuthService.ts b/app/data/living_ui_modules/auth/AuthService.ts deleted file mode 100644 index 7d8ca015..00000000 --- a/app/data/living_ui_modules/auth/AuthService.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Auth Service — handles login, registration, token storage, and authenticated requests. - * - * Copy this file into your project's frontend/services/ directory. - * - * Usage: - * import { authService } from './services/AuthService' - * await authService.login('email@example.com', 'password') - * const user = await authService.getMe() - * authService.logout() - */ - -import type { AuthUser, LoginResponse, MembershipInfo, InviteInfo } from '../auth_types' - -const TOKEN_KEY = 'auth_token' - -class AuthService { - private backendUrl: string - - constructor() { - this.backendUrl = (window as any).__CRAFTBOT_BACKEND_URL__ || 'http://localhost:3101' - } - - getToken(): string | null { - return localStorage.getItem(TOKEN_KEY) - } - - private setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token) - } - - private clearToken(): void { - localStorage.removeItem(TOKEN_KEY) - } - - isAuthenticated(): boolean { - return !!this.getToken() - } - - /** - * Make an authenticated fetch request. Automatically adds the Bearer token. - */ - async authFetch(url: string, options: RequestInit = {}): Promise { - const token = this.getToken() - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record || {}), - } - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - return fetch(url, { ...options, headers }) - } - - async register(email: string, username: string, password: string): Promise { - const resp = await fetch(`${this.backendUrl}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, username, password }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Registration failed' })) - throw new Error(err.detail || 'Registration failed') - } - const data: LoginResponse = await resp.json() - this.setToken(data.token) - return data - } - - async login(email: string, password: string): Promise { - const resp = await fetch(`${this.backendUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Login failed' })) - throw new Error(err.detail || 'Invalid email or password') - } - const data: LoginResponse = await resp.json() - this.setToken(data.token) - return data - } - - async getMe(): Promise { - const token = this.getToken() - if (!token) return null - try { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me`) - if (!resp.ok) { - this.clearToken() - return null - } - const data = await resp.json() - return data.user - } catch { - this.clearToken() - return null - } - } - - logout(): void { - this.clearToken() - } - - // ── Profile ────────────────────────────────────────────────── - - async updateProfile(updates: { username?: string; email?: string }): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me`, { - method: 'PUT', - body: JSON.stringify(updates), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Update failed' })) - throw new Error(err.detail || 'Update failed') - } - return (await resp.json()).user - } - - async changePassword(currentPassword: string, newPassword: string): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me/password`, { - method: 'PUT', - body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Password change failed' })) - throw new Error(err.detail || 'Password change failed') - } - } - - // ── Membership ─────────────────────────────────────────────── - - async getMembers(resourceType: string, resourceId: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}`) - if (!resp.ok) return [] - return (await resp.json()).members || [] - } - - async addMember(resourceType: string, resourceId: number, userId: number, role = 'member'): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}`, { - method: 'POST', - body: JSON.stringify({ user_id: userId, role }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to add member' })) - throw new Error(err.detail || 'Failed to add member') - } - return (await resp.json()).membership - } - - async removeMember(resourceType: string, resourceId: number, userId: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}/${userId}`, { - method: 'DELETE', - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to remove member' })) - throw new Error(err.detail || 'Failed to remove member') - } - } - - // ── Invites ────────────────────────────────────────────────── - - async createInvite(resourceType: string, resourceId: number, defaultRole = 'member', maxUses?: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/invites`, { - method: 'POST', - body: JSON.stringify({ resource_type: resourceType, resource_id: resourceId, default_role: defaultRole, max_uses: maxUses }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to create invite' })) - throw new Error(err.detail || 'Failed to create invite') - } - return (await resp.json()).invite - } - - async acceptInvite(code: string): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/invites/${code}/accept`, { - method: 'POST', - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to accept invite' })) - throw new Error(err.detail || 'Failed to accept invite') - } - return (await resp.json()).membership - } -} - -export const authService = new AuthService() diff --git a/app/data/living_ui_modules/auth/README.md b/app/data/living_ui_modules/auth/README.md deleted file mode 100644 index 8a77482b..00000000 --- a/app/data/living_ui_modules/auth/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# Auth Module — Multi-User Support for Living UI - -Self-contained authentication with SQLite + bcrypt + JWT. No external services needed. - -## Features -- User registration and login (email + password) -- First user automatically becomes admin -- JWT token auth (24h expiry, stored in localStorage) -- Role-based access (admin, member) -- Pre-built React components (LoginPage, RegisterPage, UserMenu) - -## Integration Steps - -### Backend - -1. Copy these files into `backend/`: - - `auth_models.py` — User model - - `auth_service.py` — password hashing + JWT - - `auth_middleware.py` — FastAPI dependencies (get_current_user, require_admin) - - `auth_routes.py` — /auth/register, /auth/login, /auth/me, /auth/users - -2. Append to `backend/requirements.txt`: - ``` - bcrypt>=4.0.0 - PyJWT>=2.8.0 - ``` - -3. In `backend/routes.py`, import and include the auth router: - ```python - from auth_routes import router as auth_router - router.include_router(auth_router) - ``` - -4. Import `User` in `models.py` so the table is created: - ```python - from auth_models import User # noqa: F401 - ``` - -5. Add `user_id` to your data models: - ```python - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - ``` - -6. Protect routes with auth dependency: - ```python - from auth_middleware import get_current_user - - @router.get("/my-items") - def get_my_items(user = Depends(get_current_user), db = Depends(get_db)): - return db.query(Item).filter(Item.user_id == user.id).all() - ``` - -### Frontend - -1. Copy `auth_types.ts` into `frontend/` -2. Copy `AuthService.ts` into `frontend/services/` -3. Copy `AuthProvider.tsx`, `LoginPage.tsx`, `RegisterPage.tsx`, `UserMenu.tsx` into `frontend/components/auth/` - -4. Wrap your app in AuthProvider (in App.tsx): - ```tsx - import { AuthProvider, useAuth } from './components/auth/AuthProvider' - import { LoginPage } from './components/auth/LoginPage' - import { RegisterPage } from './components/auth/RegisterPage' - - function App() { - return ( - - - - ) - } - - function AuthGate() { - const { isAuthenticated, loading } = useAuth() - const [page, setPage] = useState<'login' | 'register'>('login') - - if (loading) return
Loading...
- if (!isAuthenticated) { - return page === 'login' - ? setPage('register')} /> - : setPage('login')} /> - } - return - } - ``` - -5. Add UserMenu to your header: - ```tsx - import { UserMenu } from './components/auth/UserMenu' - -
-

My App

- -
- ``` - -6. Use `authService.authFetch()` instead of `fetch()` for authenticated API calls: - ```typescript - import { authService } from './services/AuthService' - const resp = await authService.authFetch(`${BACKEND_URL}/api/my-items`) - ``` - -### Tests - -Copy `tests/test_auth.py` into `backend/tests/`. Run: -``` -cd backend && python -m pytest tests/test_auth.py -v -``` - -## Membership — Connecting Users to Resources - -The auth module includes a generic **Membership** system for linking users to app resources -(projects, boards, teams, etc.) and an **Invite** system for shareable join links. - -### How it works - -When a user creates a resource (e.g., a project), also create a Membership: -```python -from auth_models import Membership - -@router.post("/projects") -def create_project(data: ..., user = Depends(get_current_user), db = Depends(get_db)): - project = Project(name=data.name, created_by=user.id) - db.add(project) - db.flush() # Get project.id - - # Make creator the owner - membership = Membership(user_id=user.id, resource_type="project", - resource_id=project.id, role="owner") - db.add(membership) - db.commit() - return project.to_dict() -``` - -### Filtering by membership - -Only show resources the user is a member of: -```python -@router.get("/projects") -def get_my_projects(user = Depends(get_current_user), db = Depends(get_db)): - project_ids = [m.resource_id for m in db.query(Membership).filter_by( - user_id=user.id, resource_type="project" - ).all()] - return db.query(Project).filter(Project.id.in_(project_ids)).all() -``` - -### Protecting routes by membership - -Use `require_membership` to ensure the user belongs to the resource: -```python -from auth_middleware import require_membership - -@router.get("/projects/{project_id}/tasks") -def get_tasks(project_id: int, - member = Depends(require_membership("project")), - db = Depends(get_db)): - # Only runs if user is a member of this project - return db.query(Task).filter_by(project_id=project_id).all() -``` - -### Invite links - -Users can generate invite codes to share: -``` -POST /api/auth/invites → creates invite code for a resource -POST /api/auth/invites/{code}/accept → joins the resource -``` - -## Frontend Components for Membership - -### MemberList — show who's in a resource - -```tsx -import { MemberList } from './components/auth/MemberList' - -// In your project settings or sidebar: - -``` - -### InviteModal — create & accept invite codes - -```tsx -import { InviteModal } from './components/auth/InviteModal' - - setShowInvite(false)} -/> -``` - -The modal has two sections: -- **Create invite** — generates a code the owner can share -- **Join with code** — paste an invite code to join - -### ProfilePage — edit account & change password - -```tsx -import { ProfilePage } from './components/auth/ProfilePage' - -// As a page or modal content: -{showProfile && setShowProfile(false)} />} -``` - -### UserMenu — already includes link to profile - -The `UserMenu` component shows the user dropdown with sign-out. The agent should add -a "Profile" option that opens `ProfilePage`. - -## API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | /api/auth/register | No | Create account (first user = admin) | -| POST | /api/auth/login | No | Login, returns JWT | -| GET | /api/auth/me | Yes | Get current user | -| PUT | /api/auth/me | Yes | Update profile (username, email) | -| PUT | /api/auth/me/password | Yes | Change password | -| POST | /api/auth/logout | No | Client-side logout | -| GET | /api/auth/users | Admin | List all users | -| GET | /api/auth/members/{type}/{id} | Member | List members of a resource | -| POST | /api/auth/members/{type}/{id} | Owner | Add a member to a resource | -| DELETE | /api/auth/members/{type}/{id}/{uid} | Owner | Remove a member | -| POST | /api/auth/invites | Owner | Create an invite link | -| POST | /api/auth/invites/{code}/accept | Yes | Accept invite and join | diff --git a/app/data/living_ui_modules/auth/auth_types.ts b/app/data/living_ui_modules/auth/auth_types.ts deleted file mode 100644 index 42ad071b..00000000 --- a/app/data/living_ui_modules/auth/auth_types.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Auth TypeScript interfaces. - * - * Copy this file into your project's frontend/ directory. - */ - -export interface AuthUser { - id: number - email: string - username: string - role: 'admin' | 'member' - isActive: boolean - createdAt: string -} - -export interface AuthState { - user: AuthUser | null - token: string | null - isAuthenticated: boolean - loading: boolean -} - -export interface LoginResponse { - user: AuthUser - token: string -} - -export interface MembershipInfo { - id: number - userId: number - resourceType: string - resourceId: number - role: string - joinedAt: string - user: AuthUser | null -} - -export interface InviteInfo { - id: number - code: string - resourceType: string - resourceId: number - defaultRole: string - isActive: boolean - maxUses: number | null - useCount: number - createdAt: string -} diff --git a/app/data/living_ui_modules/auth/backend/auth_middleware.py b/app/data/living_ui_modules/auth/backend/auth_middleware.py deleted file mode 100644 index fbaa7d82..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_middleware.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Auth Middleware — FastAPI dependencies for protecting routes. - -Copy this file into your project's backend/ directory. - -Usage in routes: - from auth_middleware import get_current_user, require_admin - - @router.get("/my-items") - def get_my_items(user: User = Depends(get_current_user), db: Session = Depends(get_db)): - return db.query(Item).filter(Item.user_id == user.id).all() - - @router.get("/admin/users") - def list_users(user: User = Depends(require_admin), db: Session = Depends(get_db)): - return [u.to_dict() for u in db.query(User).all()] -""" - -from fastapi import Depends, Header, HTTPException -from sqlalchemy.orm import Session - -from auth_models import User, Membership -from auth_service import verify_token -from database import get_db - - -def get_current_user( - authorization: str = Header(None), - db: Session = Depends(get_db), -) -> User: - """FastAPI dependency that extracts and validates the Bearer token.""" - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Not authenticated") - - token = authorization.split(" ", 1)[1] - try: - payload = verify_token(token) - except Exception: - raise HTTPException(status_code=401, detail="Invalid or expired token") - - user_id = int(payload.get("sub", 0)) - user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() - if not user: - raise HTTPException(status_code=401, detail="User not found") - - return user - - -def require_admin(user: User = Depends(get_current_user)) -> User: - """FastAPI dependency that requires the current user to be an admin.""" - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin access required") - return user - - -def require_membership(resource_type: str): - """ - Factory that returns a FastAPI dependency requiring membership in a resource. - - The route must have a path parameter matching the resource_id. - - Usage: - @router.get("/projects/{project_id}/tasks") - def get_tasks( - project_id: int, - user: User = Depends(get_current_user), - member: Membership = Depends(require_membership("project")), - db: Session = Depends(get_db), - ): - return db.query(Task).filter_by(project_id=project_id).all() - """ - from fastapi import Request - - def dependency( - request: Request, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), - ) -> Membership: - # Extract resource_id from path params — try common patterns - resource_id = ( - request.path_params.get(f"{resource_type}_id") - or request.path_params.get("resource_id") - or request.path_params.get("id") - ) - if not resource_id: - raise HTTPException( - status_code=400, detail=f"Missing {resource_type}_id in path" - ) - - # Global admins bypass membership check - if user.role == "admin": - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - ) - .first() - ) - if membership: - return membership - # Admin without membership — create a synthetic one for compatibility - return Membership( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - role="admin", - ) - - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - ) - .first() - ) - if not membership: - raise HTTPException( - status_code=403, detail=f"Not a member of this {resource_type}" - ) - return membership - - return dependency diff --git a/app/data/living_ui_modules/auth/backend/auth_models.py b/app/data/living_ui_modules/auth/backend/auth_models.py deleted file mode 100644 index 40a6c897..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_models.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Auth Models — User accounts and resource membership for multi-user Living UI apps. - -Copy this file into your project's backend/ directory. -Import in your models.py: - from auth_models import User, Membership # noqa: F401 -""" - -import secrets -from datetime import datetime -from sqlalchemy import ( - Column, - Integer, - String, - Boolean, - DateTime, - ForeignKey, - UniqueConstraint, -) -from sqlalchemy.orm import relationship -from models import Base - - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True) - email = Column(String(255), unique=True, nullable=False, index=True) - username = Column(String(100), unique=True, nullable=False) - password_hash = Column(String(255), nullable=False) - role = Column(String(50), default="member") # "admin" or "member" - is_active = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.utcnow) - - memberships = relationship( - "Membership", back_populates="user", cascade="all, delete-orphan" - ) - - def to_dict(self): - return { - "id": self.id, - "email": self.email, - "username": self.username, - "role": self.role, - "isActive": self.is_active, - "createdAt": self.created_at.isoformat() if self.created_at else None, - } - - -class Membership(Base): - """ - Generic membership — links a user to any app resource (project, board, team, etc.). - - Usage: - # Add user to a project as editor - m = Membership(user_id=1, resource_type="project", resource_id=5, role="editor") - db.add(m) - - # Get all members of a project - members = db.query(Membership).filter_by(resource_type="project", resource_id=5).all() - - # Get all projects a user belongs to - project_ids = db.query(Membership.resource_id).filter_by( - user_id=1, resource_type="project" - ).all() - - # Check if user is a member - is_member = db.query(Membership).filter_by( - user_id=1, resource_type="project", resource_id=5 - ).first() is not None - """ - - __tablename__ = "memberships" - __table_args__ = ( - UniqueConstraint( - "user_id", "resource_type", "resource_id", name="uq_membership" - ), - ) - - id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) - resource_type = Column( - String(50), nullable=False - ) # "project", "board", "team", etc. - resource_id = Column(Integer, nullable=False, index=True) - role = Column( - String(50), default="member" - ) # "owner", "admin", "editor", "viewer", "member" - invite_code = Column(String(64), nullable=True) # For pending invites - joined_at = Column(DateTime, default=datetime.utcnow) - - user = relationship("User", back_populates="memberships") - - def to_dict(self): - return { - "id": self.id, - "userId": self.user_id, - "resourceType": self.resource_type, - "resourceId": self.resource_id, - "role": self.role, - "joinedAt": self.joined_at.isoformat() if self.joined_at else None, - "user": self.user.to_dict() if self.user else None, - } - - -class Invite(Base): - """ - Invite links — generate a code that anyone can use to join a resource. - - Usage: - # Create invite link for a project - invite = Invite.create(resource_type="project", resource_id=5, created_by=1) - db.add(invite) - # Share the code: invite.code - - # Accept invite - invite = db.query(Invite).filter_by(code="abc123", is_active=True).first() - membership = Membership(user_id=2, resource_type=invite.resource_type, - resource_id=invite.resource_id, role=invite.default_role) - """ - - __tablename__ = "invites" - - id = Column(Integer, primary_key=True) - code = Column(String(64), unique=True, nullable=False, index=True) - resource_type = Column(String(50), nullable=False) - resource_id = Column(Integer, nullable=False) - default_role = Column(String(50), default="member") - created_by = Column(Integer, ForeignKey("users.id"), nullable=False) - is_active = Column(Boolean, default=True) - max_uses = Column(Integer, nullable=True) # None = unlimited - use_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.utcnow) - - @classmethod - def create( - cls, - resource_type: str, - resource_id: int, - created_by: int, - default_role: str = "member", - max_uses: int = None, - ): - return cls( - code=secrets.token_urlsafe(16), - resource_type=resource_type, - resource_id=resource_id, - created_by=created_by, - default_role=default_role, - max_uses=max_uses, - ) - - def to_dict(self): - return { - "id": self.id, - "code": self.code, - "resourceType": self.resource_type, - "resourceId": self.resource_id, - "defaultRole": self.default_role, - "isActive": self.is_active, - "maxUses": self.max_uses, - "useCount": self.use_count, - "createdAt": self.created_at.isoformat() if self.created_at else None, - } diff --git a/app/data/living_ui_modules/auth/backend/auth_routes.py b/app/data/living_ui_modules/auth/backend/auth_routes.py deleted file mode 100644 index ba8e8b81..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_routes.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Auth Routes — registration, login, user management endpoints. - -Copy this file into your project's backend/ directory. -Then import and include the router in routes.py: - - from auth_routes import router as auth_router - # ... at the bottom of routes.py: - router.include_router(auth_router) -""" - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from auth_models import User, Membership, Invite -from auth_middleware import get_current_user, require_admin -from auth_service import hash_password, verify_password, create_token -from database import get_db - -router = APIRouter(prefix="/auth", tags=["auth"]) - - -class RegisterRequest(BaseModel): - email: str - username: str - password: str - - -class LoginRequest(BaseModel): - email: str - password: str - - -@router.post("/register") -def register(data: RegisterRequest, db: Session = Depends(get_db)): - """Register a new user. First user automatically becomes admin.""" - # Check for existing user - if db.query(User).filter(User.email == data.email).first(): - raise HTTPException(status_code=400, detail="Email already registered") - if db.query(User).filter(User.username == data.username).first(): - raise HTTPException(status_code=400, detail="Username already taken") - - # First user is admin - is_first_user = db.query(User).count() == 0 - role = "admin" if is_first_user else "member" - - user = User( - email=data.email, - username=data.username, - password_hash=hash_password(data.password), - role=role, - ) - db.add(user) - db.commit() - db.refresh(user) - - token = create_token(user.id) - return {"user": user.to_dict(), "token": token} - - -@router.post("/login") -def login(data: LoginRequest, db: Session = Depends(get_db)): - """Login with email and password.""" - user = db.query(User).filter(User.email == data.email).first() - if not user or not verify_password(data.password, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid email or password") - if not user.is_active: - raise HTTPException(status_code=403, detail="Account is deactivated") - - token = create_token(user.id) - return {"user": user.to_dict(), "token": token} - - -@router.get("/me") -def get_me(user: User = Depends(get_current_user)): - """Get the current authenticated user.""" - return {"user": user.to_dict()} - - -@router.post("/logout") -def logout(): - """Logout — client should delete the stored token.""" - return {"message": "Logged out"} - - -@router.get("/users") -def list_users( - user: User = Depends(require_admin), - db: Session = Depends(get_db), -): - """List all users (admin only).""" - users = db.query(User).order_by(User.created_at.desc()).all() - return {"users": [u.to_dict() for u in users]} - - -# ============================================================================ -# Profile — update own account -# ============================================================================ - - -class UpdateProfileRequest(BaseModel): - username: str = None - email: str = None - - -@router.put("/me") -def update_profile( - data: UpdateProfileRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update current user's profile.""" - if data.email and data.email != user.email: - if db.query(User).filter(User.email == data.email, User.id != user.id).first(): - raise HTTPException(status_code=400, detail="Email already in use") - user.email = data.email - if data.username and data.username != user.username: - if ( - db.query(User) - .filter(User.username == data.username, User.id != user.id) - .first() - ): - raise HTTPException(status_code=400, detail="Username already taken") - user.username = data.username - db.commit() - db.refresh(user) - return {"user": user.to_dict()} - - -class ChangePasswordRequest(BaseModel): - current_password: str - new_password: str - - -@router.put("/me/password") -def change_password( - data: ChangePasswordRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Change current user's password.""" - if not verify_password(data.current_password, user.password_hash): - raise HTTPException(status_code=400, detail="Current password is incorrect") - if len(data.new_password) < 6: - raise HTTPException( - status_code=400, detail="Password must be at least 6 characters" - ) - user.password_hash = hash_password(data.new_password) - db.commit() - return {"message": "Password updated"} - - -# ============================================================================ -# Membership — link users to resources (projects, boards, teams, etc.) -# ============================================================================ - - -def _check_membership( - db: Session, - user: User, - resource_type: str, - resource_id: int, - required_roles: tuple = None, -) -> None: - """Verify user has access to a resource. Raises 403 if not. - - Args: - required_roles: If set, user must have one of these roles (e.g., ("owner", "admin")). - If None, any membership is sufficient. - """ - if user.role == "admin": - return # Global admins bypass all checks - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if not membership: - raise HTTPException(status_code=403, detail="Not a member of this resource") - if required_roles and membership.role not in required_roles: - raise HTTPException( - status_code=403, detail=f"Requires role: {' or '.join(required_roles)}" - ) - - -@router.get("/members/{resource_type}/{resource_id}") -def get_members( - resource_type: str, - resource_id: int, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get all members of a resource. Caller must be a member.""" - _check_membership(db, user, resource_type, resource_id) - members = ( - db.query(Membership) - .filter_by(resource_type=resource_type, resource_id=resource_id) - .all() - ) - return {"members": [m.to_dict() for m in members]} - - -class AddMemberRequest(BaseModel): - user_id: int - role: str = "member" - - -@router.post("/members/{resource_type}/{resource_id}") -def add_member( - resource_type: str, - resource_id: int, - data: AddMemberRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Add a user to a resource. Caller must be owner/admin of the resource.""" - _check_membership(db, user, resource_type, resource_id, ("owner", "admin")) - - existing = ( - db.query(Membership) - .filter_by( - user_id=data.user_id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if existing: - raise HTTPException(status_code=400, detail="User is already a member") - - membership = Membership( - user_id=data.user_id, - resource_type=resource_type, - resource_id=resource_id, - role=data.role, - ) - db.add(membership) - db.commit() - db.refresh(membership) - return {"membership": membership.to_dict()} - - -@router.delete("/members/{resource_type}/{resource_id}/{user_id}") -def remove_member( - resource_type: str, - resource_id: int, - user_id: int, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Remove a user from a resource. Caller must be owner/admin or removing themselves.""" - if user.id != user_id: - _check_membership(db, user, resource_type, resource_id, ("owner", "admin")) - - membership = ( - db.query(Membership) - .filter_by( - user_id=user_id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if not membership: - raise HTTPException(status_code=404, detail="Membership not found") - - db.delete(membership) - db.commit() - return {"message": "Member removed"} - - -# ============================================================================ -# Invites — shareable links to join a resource -# ============================================================================ - - -class CreateInviteRequest(BaseModel): - resource_type: str - resource_id: int - default_role: str = "member" - max_uses: int = None - - -@router.post("/invites") -def create_invite( - data: CreateInviteRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Create an invite link for a resource. Caller must be owner/admin.""" - _check_membership( - db, user, data.resource_type, data.resource_id, ("owner", "admin") - ) - - invite = Invite.create( - resource_type=data.resource_type, - resource_id=data.resource_id, - created_by=user.id, - default_role=data.default_role, - max_uses=data.max_uses, - ) - db.add(invite) - db.commit() - db.refresh(invite) - return {"invite": invite.to_dict()} - - -@router.post("/invites/{code}/accept") -def accept_invite( - code: str, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Accept an invite and join the resource.""" - invite = db.query(Invite).filter_by(code=code, is_active=True).first() - if not invite: - raise HTTPException(status_code=404, detail="Invite not found or expired") - - if invite.max_uses and invite.use_count >= invite.max_uses: - raise HTTPException(status_code=410, detail="Invite has reached maximum uses") - - # Check if already a member - existing = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=invite.resource_type, - resource_id=invite.resource_id, - ) - .first() - ) - if existing: - return {"membership": existing.to_dict(), "message": "Already a member"} - - membership = Membership( - user_id=user.id, - resource_type=invite.resource_type, - resource_id=invite.resource_id, - role=invite.default_role, - ) - invite.use_count += 1 - db.add(membership) - db.commit() - db.refresh(membership) - return {"membership": membership.to_dict()} diff --git a/app/data/living_ui_modules/auth/backend/auth_service.py b/app/data/living_ui_modules/auth/backend/auth_service.py deleted file mode 100644 index a6639737..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_service.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Auth Service — password hashing and JWT token management. - -Copy this file into your project's backend/ directory. -""" - -import secrets -from datetime import datetime, timedelta -from pathlib import Path - -import bcrypt -import jwt - -# JWT secret stored in a file so it survives restarts but isn't committed -_SECRET_PATH = Path(__file__).parent / ".jwt_secret" -_JWT_ALGORITHM = "HS256" -_TOKEN_EXPIRY_HOURS = 24 - - -def get_or_create_secret() -> str: - """Read JWT secret from file, or generate and save a new one.""" - if _SECRET_PATH.exists(): - return _SECRET_PATH.read_text(encoding="utf-8").strip() - secret = secrets.token_hex(32) - _SECRET_PATH.write_text(secret, encoding="utf-8") - return secret - - -def hash_password(password: str) -> str: - """Hash a password with bcrypt.""" - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - -def verify_password(password: str, password_hash: str) -> bool: - """Verify a password against a bcrypt hash.""" - return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) - - -def create_token(user_id: int, expires_hours: int = _TOKEN_EXPIRY_HOURS) -> str: - """Create a JWT token for a user.""" - secret = get_or_create_secret() - payload = { - "sub": str(user_id), - "exp": datetime.utcnow() + timedelta(hours=expires_hours), - "iat": datetime.utcnow(), - } - return jwt.encode(payload, secret, algorithm=_JWT_ALGORITHM) - - -def verify_token(token: str) -> dict: - """Verify a JWT token. Returns the payload or raises jwt.InvalidTokenError.""" - secret = get_or_create_secret() - return jwt.decode(token, secret, algorithms=[_JWT_ALGORITHM]) diff --git a/app/data/living_ui_modules/auth/backend/tests/test_auth.py b/app/data/living_ui_modules/auth/backend/tests/test_auth.py deleted file mode 100644 index d176aca1..00000000 --- a/app/data/living_ui_modules/auth/backend/tests/test_auth.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Auth Module Tests — validates registration, login, token auth, and admin access. - -Copy this file into your project's backend/tests/ directory. -Run: cd backend && python -m pytest tests/test_auth.py -v -""" - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.pool import StaticPool - -from models import Base -from main import app -from database import get_db - - -# Test database — in-memory SQLite -test_engine = create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, -) -TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) - - -def override_get_db(): - db = TestSession() - try: - yield db - finally: - db.close() - - -@pytest.fixture(autouse=True) -def setup_db(): - """Create fresh tables for each test.""" - # Import auth models so they're registered with Base - import auth_models # noqa: F401 - - Base.metadata.create_all(bind=test_engine) - yield - Base.metadata.drop_all(bind=test_engine) - - -@pytest.fixture -def client(): - app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as c: - yield c - app.dependency_overrides.clear() - - -class TestRegistration: - def test_register_first_user_is_admin(self, client): - resp = client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "secure123", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["user"]["role"] == "admin" - assert "token" in data - - def test_register_second_user_is_member(self, client): - client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "secure123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "user@example.com", - "username": "user1", - "password": "secure123", - }, - ) - assert resp.status_code == 200 - assert resp.json()["user"]["role"] == "member" - - def test_register_duplicate_email(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "user1", - "password": "pass123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "user2", - "password": "pass123", - }, - ) - assert resp.status_code == 400 - assert "already registered" in resp.json()["detail"] - - def test_register_duplicate_username(self, client): - client.post( - "/api/auth/register", - json={ - "email": "a@example.com", - "username": "sameuser", - "password": "pass123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "b@example.com", - "username": "sameuser", - "password": "pass123", - }, - ) - assert resp.status_code == 400 - assert "already taken" in resp.json()["detail"] - - -class TestLogin: - def test_login_success(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "testuser", - "password": "mypassword", - }, - ) - resp = client.post( - "/api/auth/login", - json={ - "email": "test@example.com", - "password": "mypassword", - }, - ) - assert resp.status_code == 200 - assert "token" in resp.json() - - def test_login_wrong_password(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "testuser", - "password": "correct", - }, - ) - resp = client.post( - "/api/auth/login", - json={ - "email": "test@example.com", - "password": "wrong", - }, - ) - assert resp.status_code == 401 - - def test_login_nonexistent_user(self, client): - resp = client.post( - "/api/auth/login", - json={ - "email": "nobody@example.com", - "password": "pass", - }, - ) - assert resp.status_code == 401 - - -class TestAuthenticatedAccess: - def _register_and_get_token(self, client, email="test@example.com"): - resp = client.post( - "/api/auth/register", - json={ - "email": email, - "username": email.split("@")[0], - "password": "pass123", - }, - ) - return resp.json()["token"] - - def test_get_me(self, client): - token = self._register_and_get_token(client) - resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) - assert resp.status_code == 200 - assert resp.json()["user"]["email"] == "test@example.com" - - def test_get_me_no_token(self, client): - resp = client.get("/api/auth/me") - assert resp.status_code == 401 - - def test_get_me_invalid_token(self, client): - resp = client.get("/api/auth/me", headers={"Authorization": "Bearer invalid"}) - assert resp.status_code == 401 - - -class TestAdminAccess: - def test_admin_can_list_users(self, client): - resp = client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "pass123", - }, - ) - token = resp.json()["token"] - resp = client.get( - "/api/auth/users", headers={"Authorization": f"Bearer {token}"} - ) - assert resp.status_code == 200 - assert len(resp.json()["users"]) == 1 - - def test_member_cannot_list_users(self, client): - # First user is admin - client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "pass123", - }, - ) - # Second user is member - resp = client.post( - "/api/auth/register", - json={ - "email": "member@example.com", - "username": "member", - "password": "pass123", - }, - ) - token = resp.json()["token"] - resp = client.get( - "/api/auth/users", headers={"Authorization": f"Bearer {token}"} - ) - assert resp.status_code == 403 diff --git a/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx b/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx deleted file mode 100644 index 9d0414f5..00000000 --- a/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Auth Layout — shared wrapper for login, register, and profile pages. - * Also exports FormField for consistent label + input pairs. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { ReactNode } from 'react' -import { Card, Input, Alert } from '../ui' - -// ── Centered card layout for auth pages ──────────────────────── - -interface AuthLayoutProps { - title: string - children: ReactNode - error?: string - footer?: ReactNode -} - -export function AuthLayout({ title, children, error, footer }: AuthLayoutProps) { - return ( -
- -

- {title} -

- {error && {error}} - {children} - {footer} -
-
- ) -} - -// ── Label + Input pair ───────────────────────────────────────── - -interface FormFieldProps { - label: string - type?: string - value: string - onChange: (value: string) => void - placeholder?: string - required?: boolean - readOnly?: boolean -} - -const labelStyle: React.CSSProperties = { - display: 'block', fontSize: 'var(--text-sm)', - fontWeight: 'var(--font-weight-medium)' as any, - marginBottom: 'var(--space-1)', color: 'var(--text-secondary)', -} - -export function FormField({ label, type = 'text', value, onChange, placeholder, required, readOnly }: FormFieldProps) { - return ( -
- - onChange(e.target.value)} - placeholder={placeholder} - required={required} - readOnly={readOnly} - /> -
- ) -} - -// ── Switch link ("Don't have an account? Sign up") ───────────── - -interface AuthSwitchLinkProps { - text: string - linkText: string - onClick: () => void -} - -export function AuthSwitchLink({ text, linkText, onClick }: AuthSwitchLinkProps) { - return ( -

- {text}{' '} - -

- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx b/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx deleted file mode 100644 index 64a624d1..00000000 --- a/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Auth Provider — React context for authentication state. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage in App.tsx: - * import { AuthProvider, useAuth } from './components/auth/AuthProvider' - * - * function App() { - * return ( - * - * - * - * ) - * } - * - * function AppContent() { - * const { user, isAuthenticated, logout } = useAuth() - * if (!isAuthenticated) return - * return - * } - */ - -import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' -import type { AuthUser, AuthState } from '../../auth_types' -import { authService } from '../../services/AuthService' - -interface AuthContextValue extends AuthState { - login: (email: string, password: string) => Promise - register: (email: string, username: string, password: string) => Promise - logout: () => void -} - -const AuthContext = createContext(null) - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within ') - return ctx -} - -export function AuthProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - user: null, - token: authService.getToken(), - isAuthenticated: false, - loading: true, - }) - - // Validate existing token on mount - useEffect(() => { - const validate = async () => { - const user = await authService.getMe() - setState({ - user, - token: authService.getToken(), - isAuthenticated: !!user, - loading: false, - }) - } - validate() - }, []) - - const login = useCallback(async (email: string, password: string) => { - const { user, token } = await authService.login(email, password) - setState({ user, token, isAuthenticated: true, loading: false }) - }, []) - - const register = useCallback(async (email: string, username: string, password: string) => { - const { user, token } = await authService.register(email, username, password) - setState({ user, token, isAuthenticated: true, loading: false }) - }, []) - - const logout = useCallback(() => { - authService.logout() - setState({ user: null, token: null, isAuthenticated: false, loading: false }) - }, []) - - return ( - - {children} - - ) -} diff --git a/app/data/living_ui_modules/auth/frontend/InviteModal.tsx b/app/data/living_ui_modules/auth/frontend/InviteModal.tsx deleted file mode 100644 index 15d17a01..00000000 --- a/app/data/living_ui_modules/auth/frontend/InviteModal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Invite Modal — create and share invite links for a resource. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { InviteModal } from './components/auth/InviteModal' - * setShowInvite(false)} - * /> - */ - -import { useState } from 'react' -import { Button, Input, Alert, Modal } from '../ui' -import { authService } from '../../services/AuthService' - -interface InviteModalProps { - resourceType: string - resourceId: number - isOpen: boolean - onClose: () => void -} - -export function InviteModal({ resourceType, resourceId, isOpen, onClose }: InviteModalProps) { - const [inviteCode, setInviteCode] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [copied, setCopied] = useState(false) - - // Accept invite state - const [joinCode, setJoinCode] = useState('') - const [joining, setJoining] = useState(false) - const [joinSuccess, setJoinSuccess] = useState(false) - - const handleCreateInvite = async () => { - setLoading(true) - setError('') - try { - const invite = await authService.createInvite(resourceType, resourceId) - setInviteCode(invite.code) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create invite') - } finally { - setLoading(false) - } - } - - const handleCopy = () => { - navigator.clipboard.writeText(inviteCode) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const handleJoin = async () => { - if (!joinCode.trim()) return - setJoining(true) - setError('') - try { - await authService.acceptInvite(joinCode.trim()) - setJoinSuccess(true) - setTimeout(() => { onClose(); setJoinSuccess(false); setJoinCode('') }, 1500) - } catch (err) { - setError(err instanceof Error ? err.message : 'Invalid invite code') - } finally { - setJoining(false) - } - } - - const handleClose = () => { - setInviteCode('') - setError('') - setCopied(false) - setJoinCode('') - setJoinSuccess(false) - onClose() - } - - if (!isOpen) return null - - return ( - -
- {error && {error}} - - {/* Create Invite Section */} -
-

- Create Invite Link -

- {inviteCode ? ( -
- - -
- ) : ( - - )} -

- Share this code with others so they can join. -

-
- - {/* Divider */} -
-
- or -
-
- - {/* Join Section */} -
-

- Join with Code -

- {joinSuccess ? ( - Joined successfully! - ) : ( -
- setJoinCode(e.target.value)} - placeholder="Paste invite code" - style={{ flex: 1 }} - /> - -
- )} -
-
- - ) -} diff --git a/app/data/living_ui_modules/auth/frontend/LoginPage.tsx b/app/data/living_ui_modules/auth/frontend/LoginPage.tsx deleted file mode 100644 index 7eabd526..00000000 --- a/app/data/living_ui_modules/auth/frontend/LoginPage.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Login Page — email + password form using preset UI components. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { useState } from 'react' -import { Button } from '../ui' -import { useAuth } from './AuthProvider' -import { AuthLayout, FormField, AuthSwitchLink } from './AuthLayout' - -interface LoginPageProps { - onSwitchToRegister: () => void -} - -export function LoginPage({ onSwitchToRegister }: LoginPageProps) { - const { login } = useAuth() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) - try { - await login(email, password) - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed') - } finally { - setLoading(false) - } - } - - return ( - } - > -
- - - - -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/MemberList.tsx b/app/data/living_ui_modules/auth/frontend/MemberList.tsx deleted file mode 100644 index 64328ac3..00000000 --- a/app/data/living_ui_modules/auth/frontend/MemberList.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Member List — shows members of a resource with role badges and remove button. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { MemberList } from './components/auth/MemberList' - * - */ - -import { useState, useEffect, useCallback } from 'react' -import { Button, Badge, Alert } from '../ui' -import { useAuth } from './AuthProvider' -import { authService } from '../../services/AuthService' -import type { MembershipInfo } from '../../auth_types' - -interface MemberListProps { - resourceType: string - resourceId: number - currentUserRole?: string // caller's role in this resource (for showing remove buttons) -} - -export function MemberList({ resourceType, resourceId, currentUserRole }: MemberListProps) { - const { user } = useAuth() - const [members, setMembers] = useState([]) - const [error, setError] = useState('') - const [removing, setRemoving] = useState(null) - - const canManage = currentUserRole === 'owner' || currentUserRole === 'admin' || user?.role === 'admin' - - const loadMembers = useCallback(async () => { - try { - const data = await authService.getMembers(resourceType, resourceId) - setMembers(data) - } catch { - setError('Failed to load members') - } - }, [resourceType, resourceId]) - - useEffect(() => { loadMembers() }, [loadMembers]) - - const handleRemove = async (userId: number) => { - setRemoving(userId) - try { - await authService.removeMember(resourceType, resourceId, userId) - setMembers(prev => prev.filter(m => m.userId !== userId)) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to remove member') - } finally { - setRemoving(null) - } - } - - if (error) return {error} - - return ( -
- {members.length === 0 ? ( -

No members yet

- ) : ( - members.map(member => ( -
- {/* Avatar */} -
- {member.user?.username?.charAt(0).toUpperCase() || '?'} -
- - {/* Info */} -
-
- {member.user?.username || `User #${member.userId}`} - {member.userId === user?.id && ( - (you) - )} -
-
- {member.user?.email} -
-
- - {/* Role badge */} - - {member.role} - - - {/* Remove button */} - {canManage && member.role !== 'owner' && member.userId !== user?.id && ( - - )} -
- )) - )} -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx b/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx deleted file mode 100644 index 6d5a6dab..00000000 --- a/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Profile Page — edit username, email, and change password. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { ProfilePage } from './components/auth/ProfilePage' - * {showProfile && setShowProfile(false)} />} - */ - -import { useState } from 'react' -import { Button, Card, Alert } from '../ui' -import { useAuth } from './AuthProvider' -import { FormField } from './AuthLayout' -import { authService } from '../../services/AuthService' - -interface ProfilePageProps { - onClose?: () => void -} - -export function ProfilePage({ onClose }: ProfilePageProps) { - const { user, logout } = useAuth() - - const [username, setUsername] = useState(user?.username || '') - const [email, setEmail] = useState(user?.email || '') - const [profileMsg, setProfileMsg] = useState('') - const [profileErr, setProfileErr] = useState('') - const [profileLoading, setProfileLoading] = useState(false) - - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [passwordMsg, setPasswordMsg] = useState('') - const [passwordErr, setPasswordErr] = useState('') - const [passwordLoading, setPasswordLoading] = useState(false) - - const handleUpdateProfile = async (e: React.FormEvent) => { - e.preventDefault() - setProfileMsg(''); setProfileErr('') - setProfileLoading(true) - try { - await authService.updateProfile({ username, email }) - setProfileMsg('Profile updated') - } catch (err) { - setProfileErr(err instanceof Error ? err.message : 'Update failed') - } finally { - setProfileLoading(false) - } - } - - const handleChangePassword = async (e: React.FormEvent) => { - e.preventDefault() - setPasswordMsg(''); setPasswordErr('') - if (newPassword !== confirmPassword) { setPasswordErr('Passwords do not match'); return } - if (newPassword.length < 6) { setPasswordErr('Password must be at least 6 characters'); return } - setPasswordLoading(true) - try { - await authService.changePassword(currentPassword, newPassword) - setPasswordMsg('Password changed') - setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') - } catch (err) { - setPasswordErr(err instanceof Error ? err.message : 'Password change failed') - } finally { - setPasswordLoading(false) - } - } - - if (!user) return null - - return ( -
- {onClose && ( -
-

Profile

- -
- )} - - -

- Account Info -

- {profileMsg && {profileMsg}} - {profileErr && {profileErr}} -
- - - - -
- - -

- Change Password -

- {passwordMsg && {passwordMsg}} - {passwordErr && {passwordErr}} -
- - - - - -
- - -

- Sign Out -

-

- You will need to sign in again to access your account. -

- -
-
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx b/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx deleted file mode 100644 index e6e35096..00000000 --- a/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Register Page — email, username, password form using preset UI components. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { useState } from 'react' -import { Button } from '../ui' -import { useAuth } from './AuthProvider' -import { AuthLayout, FormField, AuthSwitchLink } from './AuthLayout' - -interface RegisterPageProps { - onSwitchToLogin: () => void -} - -export function RegisterPage({ onSwitchToLogin }: RegisterPageProps) { - const { register } = useAuth() - const [email, setEmail] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - if (password.length < 6) { - setError('Password must be at least 6 characters') - return - } - - setLoading(true) - try { - await register(email, username, password) - } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed') - } finally { - setLoading(false) - } - } - - return ( - } - > -
- - - - - - -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/UserMenu.tsx b/app/data/living_ui_modules/auth/frontend/UserMenu.tsx deleted file mode 100644 index 3726ca84..00000000 --- a/app/data/living_ui_modules/auth/frontend/UserMenu.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * User Menu — dropdown showing current user with logout option. - * - * Copy this file into your project's frontend/components/auth/ directory. - * Place in your app's header/nav bar. - * - * Usage: - * import { UserMenu } from './components/auth/UserMenu' - *
- *

My App

- * - *
- */ - -import { useState, useRef, useEffect } from 'react' -import { useAuth } from './AuthProvider' -import { Badge } from '../ui' - -export function UserMenu() { - const { user, logout } = useAuth() - const [open, setOpen] = useState(false) - const ref = useRef(null) - - // Close on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, []) - - if (!user) return null - - return ( -
- - - {open && ( -
-
-
- {user.username} -
-
- {user.email} -
- - {user.role} - -
- -
- )} -
- ) -} diff --git a/app/data/living_ui_modules/auth/requirements.txt b/app/data/living_ui_modules/auth/requirements.txt deleted file mode 100644 index c9f6a53d..00000000 --- a/app/data/living_ui_modules/auth/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -bcrypt>=4.0.0 -PyJWT>=2.8.0 diff --git a/app/data/living_ui_sidecar/proxy.py b/app/data/living_ui_sidecar/proxy.py deleted file mode 100644 index a3f51128..00000000 --- a/app/data/living_ui_sidecar/proxy.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -Living UI Sidecar Proxy - -A lightweight reverse proxy that sits in front of external apps, -injecting Living UI features (console capture, health checks, logging) -without modifying the original app. - -Usage: - python proxy.py --app-port 3109 --proxy-port 3108 - -Architecture: - Browser → This proxy (port 3108) → External app (port 3109) - ↓ - - Injects console/network capture into HTML responses - - Provides /health, /api/logs endpoints - - Captures frontend logs to logs/frontend_console.log - - Forwards everything else transparently -""" - -import argparse -import logging -import sys -from datetime import datetime -from pathlib import Path -from typing import List, Optional - -import httpx -from fastapi import FastAPI, Request, Response -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from pydantic import BaseModel - -# Setup logging -LOG_DIR = ( - Path(__file__).parent.parent / "logs" - if (Path(__file__).parent.parent / "logs").exists() - else Path("logs") -) -LOG_DIR.mkdir(parents=True, exist_ok=True) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s | %(levelname)-8s | %(message)s", - handlers=[ - logging.FileHandler(LOG_DIR / "sidecar.log", encoding="utf-8"), - logging.StreamHandler(sys.stderr), - ], -) -logger = logging.getLogger("sidecar") - -# Parse args -parser = argparse.ArgumentParser() -parser.add_argument( - "--app-port", type=int, required=True, help="Port of the actual app" -) -parser.add_argument("--proxy-port", type=int, required=True, help="Port for this proxy") -args, _ = parser.parse_known_args() - -APP_URL = f"http://localhost:{args.app_port}" -FRONTEND_LOG_PATH = LOG_DIR / "frontend_console.log" - -# Console capture script to inject into HTML responses -CAPTURE_SCRIPT = """ - -""" - -# FastAPI app -app = FastAPI(title="Living UI Sidecar Proxy") -app.add_middleware( - CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] -) - -http_client = httpx.AsyncClient(base_url=APP_URL, timeout=30, follow_redirects=True) - - -# ── Living UI endpoints (handled by sidecar, not forwarded) ────────── - - -@app.get("/health") -async def health(): - """Health check — verifies both sidecar and app are running.""" - try: - resp = await http_client.get("/", timeout=5) - app_ok = resp.status_code < 500 - except Exception: - app_ok = False - return { - "status": "healthy" if app_ok else "degraded", - "sidecar": "ok", - "app": "ok" if app_ok else "down", - } - - -class LogEntry(BaseModel): - level: str - message: str - timestamp: Optional[str] = None - - -class LogBatch(BaseModel): - entries: List[LogEntry] - - -@app.post("/api/logs") -async def capture_logs(data: LogBatch): - """Receive frontend console logs from the injected capture script.""" - with open(FRONTEND_LOG_PATH, "a", encoding="utf-8") as f: - for entry in data.entries: - ts = entry.timestamp or datetime.utcnow().isoformat() - f.write(f"{ts} | {entry.level.upper():<7} | {entry.message}\n") - return {"status": "ok", "count": len(data.entries)} - - -# ── Reverse proxy (forwards everything else to the app) ────────────── - - -@app.api_route( - "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] -) -async def proxy(request: Request, path: str): - """Forward all requests to the actual app, inject capture script into HTML responses.""" - # Build the proxied URL - url = f"/{path}" - if request.url.query: - url += f"?{request.url.query}" - - # Forward headers (skip host) - headers = dict(request.headers) - headers.pop("host", None) - - try: - body = await request.body() - resp = await http_client.request( - method=request.method, - url=url, - headers=headers, - content=body if body else None, - ) - except httpx.ConnectError: - return JSONResponse({"error": "App not responding"}, status_code=502) - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=502) - - # Check if response is HTML — inject capture script - content_type = resp.headers.get("content-type", "") - response_body = resp.content - - if "text/html" in content_type: - html = response_body.decode("utf-8", errors="replace") - # Inject capture script before or at end - if "" in html.lower(): - idx = html.lower().rfind("") - html = html[:idx] + CAPTURE_SCRIPT + html[idx:] - else: - html += CAPTURE_SCRIPT - response_body = html.encode("utf-8") - - # Build response with original headers - response_headers = dict(resp.headers) - response_headers.pop("content-length", None) # Will be recalculated - response_headers.pop("content-encoding", None) # We may have modified the content - response_headers.pop("transfer-encoding", None) - - return Response( - content=response_body, - status_code=resp.status_code, - headers=response_headers, - ) - - -if __name__ == "__main__": - import uvicorn - - logger.info( - f"Starting sidecar proxy: localhost:{args.proxy_port} → localhost:{args.app_port}" - ) - uvicorn.run(app, host="0.0.0.0", port=args.proxy_port, log_level="warning") diff --git a/app/data/living_ui_sidecar/requirements.txt b/app/data/living_ui_sidecar/requirements.txt deleted file mode 100644 index 609f6748..00000000 --- a/app/data/living_ui_sidecar/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -fastapi>=0.104.0 -uvicorn>=0.24.0 -httpx>=0.24.0 diff --git a/app/data/living_ui_template/.env.example b/app/data/living_ui_template/.env.example deleted file mode 100644 index 3bf1d1ec..00000000 --- a/app/data/living_ui_template/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Living UI Environment Variables - -# CraftBot WebSocket URL for agent communication -VITE_CRAFTBOT_WS_URL=ws://localhost:7926 - -# Backend API URL (if using Python backend) -VITE_API_URL=http://localhost:{{BACKEND_PORT}} - -# Add your API keys and secrets below -# VITE_API_KEY=your_api_key_here diff --git a/app/data/living_ui_template/LIVING_UI.md b/app/data/living_ui_template/LIVING_UI.md deleted file mode 100644 index 3ef7acb5..00000000 --- a/app/data/living_ui_template/LIVING_UI.md +++ /dev/null @@ -1,80 +0,0 @@ -# {{PROJECT_NAME}} - -{{PROJECT_DESCRIPTION}} - -## Overview - - - -## Requirements - - - -### Entities & Data Model - - -### Layout & Design - - -### Features - - -### Assumptions - - -## Data Model - -### Backend Models (backend/models.py) - - - -| Model | Purpose | Key Fields | -|-------|---------|------------| -| Example | Description | field1, field2 | - -## API Endpoints - -### Custom Routes (backend/routes.py) - - - -| Method | Path | Description | -|--------|------|-------------| -| GET | /example | Description | -| POST | /example | Description | - -## Frontend Components - -### Components (frontend/components/) - - - -| Component | Purpose | -|-----------|---------| -| MainView.tsx | Main UI layout | - -## Key Files - -| File | Purpose | -|------|---------| -| backend/models.py | Database models | -| backend/routes.py | API endpoints | -| frontend/types.ts | TypeScript interfaces | -| frontend/AppController.ts | State management | -| frontend/components/MainView.tsx | Main UI | - -## State Flow - -``` -User Action → Frontend Component → AppController → Backend API → SQLite DB - ↓ - Update UI State -``` - -## Testing - - - -1. Create a new item -2. Refresh the page -3. Verify item persists diff --git a/app/data/living_ui_template/backend/database.py b/app/data/living_ui_template/backend/database.py deleted file mode 100644 index 44910980..00000000 --- a/app/data/living_ui_template/backend/database.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Living UI Database Configuration - -SQLite database setup for persistent state storage. -Uses synchronous SQLite with SQLAlchemy for simplicity and reliability. -""" - -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from models import Base -from pathlib import Path -import logging - -logger = logging.getLogger(__name__) - -# Database file stored in the project directory -DATABASE_PATH = Path(__file__).parent / "living_ui.db" -DATABASE_URL = f"sqlite:///{DATABASE_PATH}" - -# Create engine with check_same_thread=False for FastAPI compatibility -engine = create_engine( - DATABASE_URL, - connect_args={"check_same_thread": False}, - echo=False, # Set to True for SQL debugging -) - -# Enable WAL mode for better concurrent read/write performance (multi-user) -from sqlalchemy import event - - -@event.listens_for(engine, "connect") -def _set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.close() - - -# Session factory -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - -async def init_db(): - """Initialize database tables.""" - logger.info(f"[Database] Creating tables at {DATABASE_PATH}") - Base.metadata.create_all(bind=engine) - - # Ensure default app state exists - from models import AppState - - db = SessionLocal() - try: - state = db.query(AppState).first() - if not state: - state = AppState() - db.add(state) - db.commit() - logger.info("[Database] Created default app state") - finally: - db.close() - - -def get_db(): - """ - Dependency to get database session. - - Usage in routes: - @router.get("/items") - def get_items(db: Session = Depends(get_db)): - return db.query(Item).all() - """ - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/app/data/living_ui_template/backend/health_checker.py b/app/data/living_ui_template/backend/health_checker.py deleted file mode 100644 index dbf06e88..00000000 --- a/app/data/living_ui_template/backend/health_checker.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Living UI Backend Health Checker - -Background thread that periodically verifies the backend is healthy. -Checks both the HTTP health endpoint and database connectivity. -Writes status to logs/health_status.json for the manager watchdog to read. -Self-terminates if too many consecutive failures occur. -""" - -import json -import logging -import os -import threading -import urllib.request -from datetime import datetime -from pathlib import Path - -logger = logging.getLogger(__name__) - -LOG_DIR = Path(__file__).parent / "logs" - -_checker_thread: threading.Thread | None = None -_stop_event = threading.Event() - -# Number of consecutive failures before self-terminating -MAX_CONSECUTIVE_FAILURES = 5 -CHECK_INTERVAL_SECONDS = 60 -HEALTH_STATUS_FILE = LOG_DIR / "health_status.json" - - -def _write_status( - health_ok: bool, - db_ok: bool, - consecutive_failures: int, - error: str | None = None, -): - """Write current health status to JSON file for external monitoring.""" - LOG_DIR.mkdir(parents=True, exist_ok=True) - status = { - "last_check": datetime.now().isoformat(), - "health_endpoint": "ok" if health_ok else "fail", - "db_connectivity": "ok" if db_ok else "fail", - "consecutive_failures": consecutive_failures, - "error": error, - } - try: - HEALTH_STATUS_FILE.write_text(json.dumps(status, indent=2), encoding="utf-8") - except Exception as e: - logger.warning(f"[HealthChecker] Failed to write status file: {e}") - - -def _check_health_endpoint(port: int) -> bool: - """Hit the local /health endpoint.""" - try: - url = f"http://localhost:{port}/health" - resp = urllib.request.urlopen(url, timeout=5) - return resp.status == 200 - except Exception: - return False - - -def _check_db() -> bool: - """Verify database connectivity with a simple query.""" - try: - from sqlalchemy import text - from database import engine - - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - return True - except Exception: - return False - - -def _run_checker(port: int): - """Main checker loop running in a background thread.""" - consecutive_failures = 0 - - # Wait a bit before first check to let the server fully start - if _stop_event.wait(timeout=15): - return - - logger.info( - f"[HealthChecker] Started - checking every {CHECK_INTERVAL_SECONDS}s " - f"(max {MAX_CONSECUTIVE_FAILURES} consecutive failures before exit)" - ) - - while not _stop_event.is_set(): - health_ok = _check_health_endpoint(port) - db_ok = _check_db() - - if health_ok and db_ok: - if consecutive_failures > 0: - logger.info( - f"[HealthChecker] Recovered after {consecutive_failures} failure(s)" - ) - consecutive_failures = 0 - _write_status(health_ok, db_ok, consecutive_failures) - else: - consecutive_failures += 1 - error_parts = [] - if not health_ok: - error_parts.append("health endpoint not responding") - if not db_ok: - error_parts.append("database connectivity failed") - error_msg = "; ".join(error_parts) - - logger.warning( - f"[HealthChecker] Check failed ({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {error_msg}" - ) - _write_status(health_ok, db_ok, consecutive_failures, error=error_msg) - - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: - logger.critical( - f"[HealthChecker] {MAX_CONSECUTIVE_FAILURES} consecutive failures - " - f"self-terminating. Last error: {error_msg}" - ) - _write_status( - health_ok, - db_ok, - consecutive_failures, - error=f"SELF-TERMINATED: {error_msg}", - ) - # Hard exit so the manager watchdog detects the crash and can restart - os._exit(1) - - _stop_event.wait(timeout=CHECK_INTERVAL_SECONDS) - - -def start_health_checker(port: int): - """Start the background health checker thread.""" - global _checker_thread - - if _checker_thread is not None and _checker_thread.is_alive(): - logger.warning("[HealthChecker] Already running") - return - - _stop_event.clear() - _checker_thread = threading.Thread( - target=_run_checker, args=(port,), daemon=True, name="health-checker" - ) - _checker_thread.start() - logger.info(f"[HealthChecker] Starting for port {port}") - - -def stop_health_checker(): - """Stop the background health checker thread.""" - global _checker_thread - - if _checker_thread is None: - return - - _stop_event.set() - _checker_thread.join(timeout=5) - _checker_thread = None - logger.info("[HealthChecker] Stopped") diff --git a/app/data/living_ui_template/backend/logger.py b/app/data/living_ui_template/backend/logger.py deleted file mode 100644 index cd6608c2..00000000 --- a/app/data/living_ui_template/backend/logger.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Living UI Backend Logger - -Persistent file-based logging for Living UI backend. -Logs are written to the project's logs/ directory with automatic rotation. -Each session (server start) creates a new log file, old logs are retained. -""" - -import logging -import os -import sys -from datetime import datetime -from pathlib import Path - -# Log directory lives inside the project's backend folder -LOG_DIR = Path(__file__).parent / "logs" -LOG_DIR.mkdir(parents=True, exist_ok=True) - - -def setup_logging() -> logging.Logger: - """ - Configure persistent file-based logging for the backend. - - Creates a timestamped log file per session so each server run - is independently traceable. Also logs to stderr for subprocess capture. - - Returns: - The root logger, configured with file + stream handlers. - """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - log_file = LOG_DIR / f"backend_{timestamp}.log" - - formatter = logging.Formatter( - "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - # File handler - captures everything (DEBUG+) - file_handler = logging.FileHandler(log_file, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(formatter) - - # Stream handler - INFO+ to stderr (captured by manager subprocess pipes) - stream_handler = logging.StreamHandler(sys.stderr) - stream_handler.setLevel(logging.INFO) - stream_handler.setFormatter(formatter) - - # Configure root logger - root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) - root_logger.addHandler(file_handler) - root_logger.addHandler(stream_handler) - - # Also capture uvicorn logs into the same file - for uvi_logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): - uvi_logger = logging.getLogger(uvi_logger_name) - uvi_logger.handlers = [] # Remove default handlers - uvi_logger.addHandler(file_handler) - uvi_logger.addHandler(stream_handler) - uvi_logger.propagate = False - - root_logger.info(f"[Logger] Session log started: {log_file}") - root_logger.info(f"[Logger] Python {sys.version}") - root_logger.info(f"[Logger] CWD: {os.getcwd()}") - - return root_logger - - -def cleanup_old_logs(keep: int = 20): - """Remove old log files, keeping the most recent `keep` files.""" - log_files = sorted(LOG_DIR.glob("backend_*.log"), reverse=True) - for old_log in log_files[keep:]: - try: - old_log.unlink() - except Exception: - pass diff --git a/app/data/living_ui_template/backend/main.py b/app/data/living_ui_template/backend/main.py deleted file mode 100644 index 8f93b11e..00000000 --- a/app/data/living_ui_template/backend/main.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Living UI Python Backend - -FastAPI backend for Living UI projects. -Provides REST API for state management and data persistence. - -To run manually: - uvicorn main:app --port {{BACKEND_PORT}} --reload -""" - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from routes import router -from database import init_db -from logger import setup_logging, cleanup_old_logs -from pathlib import Path -import logging - -# Initialize persistent file-based logging before anything else -setup_logging() -cleanup_old_logs(keep=20) -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Initialize database on startup.""" - logger.info("[Backend] Initializing database...") - await init_db() - logger.info("[Backend] Database initialized") - yield - logger.info("[Backend] Shutting down...") - - -app = FastAPI( - title="{{PROJECT_NAME}} API", - description="Backend API for {{PROJECT_NAME}} Living UI", - version="1.0.0", - lifespan=lifespan, -) - -# CORS configuration for frontend -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routes -app.include_router(router, prefix="/api") - -# Auto-include additional routers from routes/ directory (if any) -import importlib -import pkgutil - -_routes_dir = Path(__file__).parent / "routes" -if _routes_dir.exists() and (_routes_dir / "__init__.py").exists(): - for _imp, _mod, _pkg in pkgutil.iter_modules([str(_routes_dir)]): - _m = importlib.import_module(f"routes.{_mod}") - if hasattr(_m, "router"): - app.include_router(_m.router, prefix="/api") - - -@app.get("/health") -async def health_check(): - """Health check endpoint for process management.""" - return {"status": "healthy", "project": "{{PROJECT_ID}}"} - - -# ============================================================================ -# Frontend Console Log Capture (registered on app directly, not on router, -# so it survives agent rewrites of routes.py) -# ============================================================================ -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime - -_FRONTEND_LOG_PATH = Path(__file__).parent / "logs" / "frontend_console.log" - - -class _FrontendLogEntry(BaseModel): - level: str - message: str - timestamp: Optional[str] = None - - -class _FrontendLogBatch(BaseModel): - entries: List[_FrontendLogEntry] - - -@app.post("/api/logs") -async def capture_frontend_logs(data: _FrontendLogBatch): - """Capture frontend console logs for agent debugging.""" - _FRONTEND_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(_FRONTEND_LOG_PATH, "a", encoding="utf-8") as f: - for entry in data.entries: - ts = entry.timestamp or datetime.utcnow().isoformat() - f.write(f"{ts} | {entry.level.upper():<5} | {entry.message}\n") - return {"status": "ok", "count": len(data.entries)} - - -# ============================================================================ -# Serve frontend static files (built by Vite) — enables single-port access -# for LAN/tunnel sharing. Must be registered LAST (catch-all). -# ============================================================================ -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - -_DIST_DIR = Path(__file__).parent.parent / "dist" -_DIST_ASSETS = _DIST_DIR / "assets" -if _DIST_DIR.exists() and _DIST_ASSETS.exists(): - _CONFIG_DIR = Path(__file__).parent.parent / "config" - - @app.get("/config/manifest.json") - async def serve_manifest(): - manifest = _CONFIG_DIR / "manifest.json" - if manifest.exists(): - return FileResponse(manifest) - return {"error": "manifest not found"} - - app.mount("/assets", StaticFiles(directory=str(_DIST_ASSETS)), name="assets") - - @app.get("/{path:path}") - async def spa_fallback(path: str): - file_path = _DIST_DIR / path - if file_path.is_file(): - return FileResponse(file_path) - return FileResponse(_DIST_DIR / "index.html") - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port={{BACKEND_PORT}}) diff --git a/app/data/living_ui_template/backend/models.py b/app/data/living_ui_template/backend/models.py deleted file mode 100644 index dbf4143a..00000000 --- a/app/data/living_ui_template/backend/models.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Living UI Data Models - -SQLAlchemy models for data persistence. -Includes a flexible AppState model for storing arbitrary JSON state, -plus example Item model for reference. -""" - -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON -from sqlalchemy.ext.declarative import declarative_base -from datetime import datetime -from typing import Dict, Any - -Base = declarative_base() - - -class AppState(Base): - """ - Flexible application state storage. - - Stores the entire app state as JSON, allowing any structure. - This is the primary model used by the default state management. - - The agent should extend this with custom models for complex data needs. - """ - - __tablename__ = "app_state" - - id = Column(Integer, primary_key=True, default=1) - data = Column(JSON, default=dict) # Stores arbitrary state as JSON - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for API response.""" - return { - "id": self.id, - "data": self.data or {}, - "createdAt": self.created_at.isoformat() if self.created_at else None, - "updatedAt": self.updated_at.isoformat() if self.updated_at else None, - } - - def update_data(self, updates: Dict[str, Any]) -> None: - """Merge updates into existing data.""" - current = self.data or {} - current.update(updates) - self.data = current - self.updated_at = datetime.utcnow() - - -# ============================================================================ -# Example models for reference - Agent should customize these -# ============================================================================ - - -class UISnapshot(Base): - """ - UI state snapshot for agent observation. - - Frontend periodically posts UI state here. - Agent can GET this to observe the UI without WebSocket. - """ - - __tablename__ = "ui_snapshot" - - id = Column(Integer, primary_key=True, default=1) - html_structure = Column(Text, nullable=True) # Simplified DOM structure - visible_text = Column(JSON, default=list) # Array of visible text content - input_values = Column(JSON, default=dict) # Form field values - component_state = Column(JSON, default=dict) # Registered component states - current_view = Column(String(255), nullable=True) # Current route/view - viewport = Column(JSON, default=dict) # Window dimensions, scroll position - timestamp = Column(DateTime, default=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "htmlStructure": self.html_structure, - "visibleText": self.visible_text or [], - "inputValues": self.input_values or {}, - "componentState": self.component_state or {}, - "currentView": self.current_view, - "viewport": self.viewport or {}, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } - - -class UIScreenshot(Base): - """ - UI screenshot for agent visual observation. - - Frontend captures and posts screenshot here. - Agent can GET this to see the UI visually. - """ - - __tablename__ = "ui_screenshot" - - id = Column(Integer, primary_key=True, default=1) - image_data = Column(Text, nullable=True) # Base64 encoded PNG - width = Column(Integer, nullable=True) - height = Column(Integer, nullable=True) - timestamp = Column(DateTime, default=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "imageData": self.image_data, - "width": self.width, - "height": self.height, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } - - -class Item(Base): - """ - Example model for list-based data (todos, notes, etc.) - - Customize or replace this model based on your Living UI needs. - """ - - __tablename__ = "items" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - completed = Column(Boolean, default=False) - order = Column(Integer, default=0) - extra_data = Column( - JSON, default=dict - ) # Flexible extra data (avoid 'metadata' - reserved in SQLAlchemy) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "title": self.title, - "description": self.description, - "completed": self.completed, - "order": self.order, - "extraData": self.extra_data or {}, - "createdAt": self.created_at.isoformat() if self.created_at else None, - "updatedAt": self.updated_at.isoformat() if self.updated_at else None, - } diff --git a/app/data/living_ui_template/backend/requirements.txt b/app/data/living_ui_template/backend/requirements.txt deleted file mode 100644 index a850540e..00000000 --- a/app/data/living_ui_template/backend/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Living UI Backend Dependencies -fastapi>=0.104.0 -uvicorn>=0.24.0 -sqlalchemy>=2.0.0 -pydantic>=2.0.0 -pytest>=7.0.0 -httpx>=0.24.0 diff --git a/app/data/living_ui_template/backend/routes.py b/app/data/living_ui_template/backend/routes.py deleted file mode 100644 index 85dff98e..00000000 --- a/app/data/living_ui_template/backend/routes.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -Living UI API Routes - -REST API endpoints for state management and data operations. -Provides both generic state storage and example CRUD operations. -""" - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from pydantic import BaseModel -from typing import Dict, Any, List, Optional -from database import get_db -from models import AppState, Item, UISnapshot, UIScreenshot -from datetime import datetime -import logging - -logger = logging.getLogger(__name__) -router = APIRouter() - - -# ============================================================================ -# Pydantic Schemas -# ============================================================================ - - -class StateUpdate(BaseModel): - """Schema for updating app state.""" - - data: Dict[str, Any] - - -class ActionRequest(BaseModel): - """Schema for executing an action.""" - - action: str - payload: Optional[Dict[str, Any]] = None - - -class ItemCreate(BaseModel): - """Schema for creating an item.""" - - title: str - description: Optional[str] = None - extra_data: Optional[Dict[str, Any]] = None - - -class ItemUpdate(BaseModel): - """Schema for updating an item.""" - - title: Optional[str] = None - description: Optional[str] = None - completed: Optional[bool] = None - order: Optional[int] = None - extra_data: Optional[Dict[str, Any]] = None - - -class UISnapshotUpdate(BaseModel): - """Schema for updating UI snapshot.""" - - htmlStructure: Optional[str] = None - visibleText: Optional[List[str]] = None - inputValues: Optional[Dict[str, Any]] = None - componentState: Optional[Dict[str, Any]] = None - currentView: Optional[str] = None - viewport: Optional[Dict[str, Any]] = None - - -class UIScreenshotUpdate(BaseModel): - """Schema for updating UI screenshot.""" - - imageData: str # Base64 encoded PNG - width: Optional[int] = None - height: Optional[int] = None - - -# ============================================================================ -# State Management Routes (Primary API) -# ============================================================================ - - -@router.get("/state") -def get_state(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current application state. - - Returns the stored state data, or empty dict if no state exists. - Frontend calls this on mount to restore state. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data={}) - db.add(state) - db.commit() - db.refresh(state) - return state.data or {} - - -@router.put("/state") -def update_state(update: StateUpdate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Update the application state. - - Merges the provided data with existing state. - Returns the complete updated state. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data=update.data) - db.add(state) - else: - state.update_data(update.data) - db.commit() - db.refresh(state) - logger.info(f"[Routes] State updated: {list(update.data.keys())}") - return state.data or {} - - -@router.post("/state/replace") -def replace_state(update: StateUpdate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Replace the entire application state. - - Unlike PUT /state which merges, this completely replaces the state. - Use with caution. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data=update.data) - db.add(state) - else: - state.data = update.data - db.commit() - db.refresh(state) - logger.info("[Routes] State replaced") - return state.data or {} - - -@router.delete("/state") -def clear_state(db: Session = Depends(get_db)) -> Dict[str, str]: - """ - Clear all application state. - - Resets state to empty dict. - """ - state = db.query(AppState).first() - if state: - state.data = {} - db.commit() - logger.info("[Routes] State cleared") - return {"status": "cleared"} - - -@router.post("/action") -def execute_action( - request: ActionRequest, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Execute a named action. - - This is a generic endpoint for custom actions. - The agent should customize this based on the Living UI's needs. - - Example actions: - - {"action": "reset"} - Reset to initial state - - {"action": "increment", "payload": {"key": "counter"}} - """ - action = request.action - payload = request.payload or {} - - logger.info(f"[Routes] Executing action: {action}") - - # Get current state - state = db.query(AppState).first() - if not state: - state = AppState(data={}) - db.add(state) - - current_data = state.data or {} - - # Handle built-in actions - if action == "reset": - state.data = {} - db.commit() - return {"status": "reset", "data": {}} - - elif action == "increment": - key = payload.get("key", "counter") - current_data[key] = current_data.get(key, 0) + 1 - state.data = current_data - db.commit() - return {"status": "incremented", "data": current_data} - - elif action == "decrement": - key = payload.get("key", "counter") - current_data[key] = current_data.get(key, 0) - 1 - state.data = current_data - db.commit() - return {"status": "decremented", "data": current_data} - - # Custom actions should be added here by the agent - # Example: - # elif action == "feed_pet": - # current_data["pet"]["hunger"] = min(100, current_data.get("pet", {}).get("hunger", 50) + 25) - # state.data = current_data - # db.commit() - # return {"status": "fed", "data": current_data} - - else: - # Unknown action - return current state without changes - logger.warning(f"[Routes] Unknown action: {action}") - return {"status": "unknown_action", "action": action, "data": current_data} - - -# ============================================================================ -# Item CRUD Routes (Example for list-based data) -# ============================================================================ - - -@router.get("/items") -def list_items(db: Session = Depends(get_db)) -> List[Dict[str, Any]]: - """Get all items, ordered by their order field.""" - items = db.query(Item).order_by(Item.order, Item.id).all() - return [item.to_dict() for item in items] - - -@router.post("/items") -def create_item(data: ItemCreate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """Create a new item.""" - # Get max order to put new item at end - max_order = db.query(Item).count() - item = Item( - title=data.title, - description=data.description, - extra_data=data.extra_data or {}, - order=max_order, - ) - db.add(item) - db.commit() - db.refresh(item) - logger.info(f"[Routes] Created item: {item.id}") - return item.to_dict() - - -@router.get("/items/{item_id}") -def get_item(item_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]: - """Get a specific item by ID.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - return item.to_dict() - - -@router.put("/items/{item_id}") -def update_item( - item_id: int, data: ItemUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """Update an existing item.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - - if data.title is not None: - item.title = data.title - if data.description is not None: - item.description = data.description - if data.completed is not None: - item.completed = data.completed - if data.order is not None: - item.order = data.order - if data.extra_data is not None: - item.extra_data = data.extra_data - - db.commit() - db.refresh(item) - logger.info(f"[Routes] Updated item: {item_id}") - return item.to_dict() - - -@router.delete("/items/{item_id}") -def delete_item(item_id: int, db: Session = Depends(get_db)) -> Dict[str, str]: - """Delete an item.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - - db.delete(item) - db.commit() - logger.info(f"[Routes] Deleted item: {item_id}") - return {"status": "deleted", "id": str(item_id)} - - -# ============================================================================ -# UI Observation Routes (Agent API) -# ============================================================================ - - -@router.get("/ui-snapshot") -def get_ui_snapshot(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current UI snapshot. - - Returns the latest UI state captured by the frontend. - Agent uses this to observe the UI without WebSocket. - - Response includes: - - htmlStructure: Simplified DOM structure - - visibleText: Array of visible text on screen - - inputValues: Current form field values - - componentState: State of registered components - - currentView: Current route/view - - viewport: Window dimensions and scroll position - - timestamp: When the snapshot was captured - """ - snapshot = db.query(UISnapshot).first() - if not snapshot: - return { - "htmlStructure": None, - "visibleText": [], - "inputValues": {}, - "componentState": {}, - "currentView": None, - "viewport": {}, - "timestamp": None, - "status": "no_snapshot", - } - return snapshot.to_dict() - - -@router.post("/ui-snapshot") -def update_ui_snapshot( - data: UISnapshotUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Update the UI snapshot. - - Frontend calls this periodically to report UI state. - This replaces WebSocket-based state reporting. - """ - snapshot = db.query(UISnapshot).first() - if not snapshot: - snapshot = UISnapshot() - db.add(snapshot) - - if data.htmlStructure is not None: - snapshot.html_structure = data.htmlStructure - if data.visibleText is not None: - snapshot.visible_text = data.visibleText - if data.inputValues is not None: - snapshot.input_values = data.inputValues - if data.componentState is not None: - snapshot.component_state = data.componentState - if data.currentView is not None: - snapshot.current_view = data.currentView - if data.viewport is not None: - snapshot.viewport = data.viewport - - snapshot.timestamp = datetime.utcnow() - - db.commit() - db.refresh(snapshot) - logger.info("[Routes] UI snapshot updated") - return snapshot.to_dict() - - -@router.get("/ui-screenshot") -def get_ui_screenshot(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current UI screenshot. - - Returns the latest screenshot captured by the frontend as base64 PNG. - Agent uses this for visual observation of the UI. - - Response includes: - - imageData: Base64 encoded PNG image - - width: Image width in pixels - - height: Image height in pixels - - timestamp: When the screenshot was captured - - To use the image: - - Decode base64: base64.b64decode(imageData) - - Or display in HTML: - """ - screenshot = db.query(UIScreenshot).first() - if not screenshot or not screenshot.image_data: - return { - "imageData": None, - "width": None, - "height": None, - "timestamp": None, - "status": "no_screenshot", - } - return screenshot.to_dict() - - -@router.post("/ui-screenshot") -def update_ui_screenshot( - data: UIScreenshotUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Update the UI screenshot. - - Frontend calls this to post a screenshot of the current UI. - Screenshot should be a base64 encoded PNG. - """ - screenshot = db.query(UIScreenshot).first() - if not screenshot: - screenshot = UIScreenshot() - db.add(screenshot) - - screenshot.image_data = data.imageData - screenshot.width = data.width - screenshot.height = data.height - screenshot.timestamp = datetime.utcnow() - - db.commit() - db.refresh(screenshot) - logger.info(f"[Routes] UI screenshot updated ({data.width}x{data.height})") - return {"status": "updated", "timestamp": screenshot.timestamp.isoformat()} diff --git a/app/data/living_ui_template/backend/services/integration_client.py b/app/data/living_ui_template/backend/services/integration_client.py deleted file mode 100644 index dee26124..00000000 --- a/app/data/living_ui_template/backend/services/integration_client.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -CraftBot Integration Client — call external APIs through CraftBot. - -Living UIs are shareable, so they never store credentials. Instead, -requests go through CraftBot which injects auth headers server-side. - -Usage: - from services.integration_client import integration - - # Check what's available - integrations = await integration.get_integrations() - - # Make an authenticated API call - result = await integration.request( - integration="google_workspace", - method="GET", - url="https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", - ) - if result["status"] == 200: - channels = result["data"] -""" - -import os -import httpx -from typing import Any, Dict, List, Optional - -BRIDGE_URL = os.environ.get("CRAFTBOT_BRIDGE_URL", "") -BRIDGE_TOKEN = os.environ.get("CRAFTBOT_BRIDGE_TOKEN", "") - - -class IntegrationClient: - """Proxy client for calling external APIs through CraftBot.""" - - def __init__(self): - self._client: Optional[httpx.AsyncClient] = None - - def _ensure_client(self) -> httpx.AsyncClient: - if self._client is None: - self._client = httpx.AsyncClient(timeout=30) - return self._client - - @property - def available(self) -> bool: - """Whether the CraftBot integration bridge is available.""" - return bool(BRIDGE_URL and BRIDGE_TOKEN) - - def _auth_headers(self) -> Dict[str, str]: - return {"Authorization": f"Bearer {BRIDGE_TOKEN}"} - - async def get_integrations(self) -> List[Dict[str, Any]]: - """ - List available integrations and their connection status. - - Returns a list like: - [ - {"id": "google_workspace", "connected": true, "granted": true}, - {"id": "slack", "connected": true, "granted": false}, - {"id": "discord", "connected": false, "granted": false}, - ] - """ - if not self.available: - return [] - try: - client = self._ensure_client() - r = await client.get( - f"{BRIDGE_URL}/api/integrations/available", - headers=self._auth_headers(), - ) - if r.status_code == 200: - return r.json().get("integrations", []) - return [] - except Exception: - return [] - - async def request( - self, - integration: str, - method: str, - url: str, - headers: Optional[Dict[str, str]] = None, - body: Any = None, - ) -> Dict[str, Any]: - """ - Make an authenticated request to an external API via CraftBot proxy. - - Args: - integration: Platform ID (e.g., "google_workspace", "slack", "discord") - method: HTTP method (GET, POST, PUT, DELETE) - url: Full URL to the external API endpoint - headers: Optional extra headers (e.g., custom Accept header) - body: Optional request body (dict for JSON) - - Returns: - {"status": 200, "data": {...}} on success - {"status": 4xx/5xx, "data": "error message"} on failure - {"error": "..."} if bridge itself fails - """ - if not self.available: - return {"error": "Integration bridge not available"} - - try: - client = self._ensure_client() - r = await client.post( - f"{BRIDGE_URL}/api/integrations/proxy", - headers=self._auth_headers(), - json={ - "integration": integration, - "method": method, - "url": url, - "headers": headers or {}, - "body": body, - }, - ) - return r.json() - except Exception as e: - return {"error": str(e)} - - async def close(self): - """Close the HTTP client.""" - if self._client: - await self._client.aclose() - self._client = None - - -# Singleton — import and use directly -integration = IntegrationClient() diff --git a/app/data/living_ui_template/backend/test_runner.py b/app/data/living_ui_template/backend/test_runner.py deleted file mode 100644 index c0eee614..00000000 --- a/app/data/living_ui_template/backend/test_runner.py +++ /dev/null @@ -1,1135 +0,0 @@ -""" -Living UI Backend Test Runner - -Auto-discovers and tests backend routes without agent involvement. -Four modes: - --internal : Pre-server validation (imports, models, route registration) - --unit : Auto-generated CRUD unit tests against temp DB - --compatibility : Frontend-backend route compatibility check - --external : Post-server HTTP smoke tests (requires running server) - -Usage: - python test_runner.py --internal - python test_runner.py --unit - python test_runner.py --compatibility - python test_runner.py --external --port 3101 -""" - -import argparse -import json -import logging -import re -import sys -import traceback -import urllib.request -import urllib.error -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Set, Tuple - -LOG_DIR = Path(__file__).parent / "logs" -LOG_DIR.mkdir(parents=True, exist_ok=True) - -logger = logging.getLogger("test_runner") - -# Routes to skip during smoke tests (framework/template-provided, not agent code) -SKIP_PATHS = {"/health", "/docs", "/redoc", "/openapi.json"} -# Template-provided UI observation routes — complex payloads (base64 images, DOM), skip in smoke tests -SKIP_API_PREFIXES = ( - "/api/ui-snapshot", - "/api/ui-screenshot", -) - - -# ============================================================================ -# Auto-payload generation from OpenAPI schemas -# ============================================================================ - - -def generate_payload_from_schema( - schema: Dict[str, Any], definitions: Dict[str, Any] -) -> Dict[str, Any]: - """ - Generate a minimal valid payload from an OpenAPI/JSON Schema definition. - - Handles $ref resolution and generates test values for common types. - Only includes required fields. - """ - if "$ref" in schema: - ref_name = schema["$ref"].split("/")[-1] - schema = definitions.get(ref_name, {}) - - if schema.get("type") != "object": - return {} - - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - - # If no required fields specified, include all properties - if not required: - required = set(properties.keys()) - - payload = {} - for field_name, field_schema in properties.items(): - if field_name not in required: - continue - if field_name.startswith("_"): - continue - payload[field_name] = _generate_value(field_schema, definitions) - - return payload - - -def _generate_value(schema: Dict[str, Any], definitions: Dict[str, Any]) -> Any: - """Generate a test value for a single field based on its schema.""" - if "$ref" in schema: - ref_name = schema["$ref"].split("/")[-1] - ref_schema = definitions.get(ref_name, {}) - return generate_payload_from_schema(ref_schema, definitions) - - field_type = schema.get("type", "string") - - if field_type == "string": - if "enum" in schema: - return schema["enum"][0] - # Use format hints for better test values - fmt = schema.get("format", "") - if fmt == "date-time": - return "2026-01-01T00:00:00" - elif fmt == "date": - return "2026-01-01" - elif fmt == "email": - return "test@test.com" - elif fmt == "uri" or fmt == "url": - return "http://test.com" - return "test" - elif field_type == "integer": - return schema.get("minimum", 1) - elif field_type == "number": - return schema.get("minimum", 1.0) - elif field_type == "boolean": - return True - elif field_type == "array": - # Generate an array with one item of the correct type - items_schema = schema.get("items", {}) - if items_schema: - return [_generate_value(items_schema, definitions)] - return [] - elif field_type == "object": - # Check if it has properties (structured) or is a free-form dict - if schema.get("properties"): - return generate_payload_from_schema(schema, definitions) - # Free-form object (e.g., Dict[str, Any]) - return {} - elif field_type == "null": - return None - - # anyOf / oneOf — pick the first non-null type - for key in ("anyOf", "oneOf"): - if key in schema: - for variant in schema[key]: - if variant.get("type") != "null": - return _generate_value(variant, definitions) - - return "test" - - -# ============================================================================ -# Internal Tests (pre-server) -# ============================================================================ - - -def run_internal_tests() -> Dict[str, Any]: - """ - Run pre-server validation tests. - - - Import validation for main, routes, models, database - - Route discovery from FastAPI app - - Model verification (SQLAlchemy tables) - - Returns dict with status, errors, and discovered routes. - """ - result = { - "status": "pass", - "errors": [], - "routes": [], - "timestamp": datetime.now().isoformat(), - "mode": "internal", - } - - # Test 1: Import validation - modules_to_test = ["database", "models", "routes", "main"] - for module_name in modules_to_test: - try: - __import__(module_name) - logger.info(f"[IMPORT] {module_name} — OK") - except Exception as e: - error_msg = f"Failed to import {module_name}: {e}" - logger.error(f"[IMPORT] {error_msg}") - result["errors"].append( - { - "test": "import", - "module": module_name, - "error": str(e), - "traceback": traceback.format_exc(), - } - ) - result["status"] = "fail" - - if result["status"] == "fail": - # No point continuing if imports fail - _write_result(result, "test_discovery.json") - return result - - # Test 2: Route discovery - try: - from main import app - - openapi_schema = app.openapi() - definitions = openapi_schema.get("components", {}).get("schemas", {}) - paths = openapi_schema.get("paths", {}) - - for path, methods in paths.items(): - for method, details in methods.items(): - if method.upper() in ("GET", "POST", "PUT", "DELETE", "PATCH"): - # Check for request body schema - body_schema = None - has_request_body = False - request_body = details.get("requestBody", {}) - if request_body: - has_request_body = True - content = request_body.get("content", {}) - json_content = content.get("application/json", {}) - body_schema = json_content.get("schema") - - # Check for path parameters - path_params = [] - for param in details.get("parameters", []): - if param.get("in") == "path": - path_params.append(param["name"]) - - route_info = { - "method": method.upper(), - "path": path, - "has_request_body": has_request_body, - "body_schema": body_schema, - "path_params": path_params, - "level": "light", - } - result["routes"].append(route_info) - logger.info(f"[ROUTE] {method.upper()} {path}") - - if not any(r["path"].startswith("/api") for r in result["routes"]): - result["errors"].append( - { - "test": "route_discovery", - "error": "No /api/* routes found — backend has no application routes registered", - } - ) - result["status"] = "fail" - else: - api_count = sum(1 for r in result["routes"] if r["path"].startswith("/api")) - logger.info(f"[ROUTES] Discovered {api_count} API route(s)") - - except Exception as e: - result["errors"].append( - { - "test": "route_discovery", - "error": str(e), - "traceback": traceback.format_exc(), - } - ) - result["status"] = "fail" - - # Test 3: Model/table verification - try: - from models import Base - - # Verify tables can be created (uses in-memory check, doesn't modify real DB) - table_names = list(Base.metadata.tables.keys()) - logger.info(f"[MODELS] Found {len(table_names)} table(s): {table_names}") - - if not table_names: - result["errors"].append( - {"test": "models", "error": "No SQLAlchemy models/tables defined"} - ) - result["status"] = "fail" - - except Exception as e: - result["errors"].append( - {"test": "models", "error": str(e), "traceback": traceback.format_exc()} - ) - result["status"] = "fail" - - # Test 4: System file integrity — verify critical system features weren't removed - system_checks = _check_system_files() - for check in system_checks: - if check["status"] == "fail": - result["errors"].append( - {"test": "system_integrity", "error": check["error"]} - ) - result["status"] = "fail" - logger.error(f"[SYSTEM] {check['error']}") - else: - logger.info(f"[SYSTEM] {check['name']} — OK") - - _write_result(result, "test_discovery.json") - return result - - -def _check_system_files() -> List[Dict[str, Any]]: - """Check that critical system features haven't been removed from template files.""" - checks = [] - backend_dir = ( - Path(__file__).parent.parent / "backend" - if (Path(__file__).parent.parent / "backend").exists() - else Path(__file__).parent - ) - project_root = Path(__file__).parent.parent - - # Check main.py has /health endpoint - main_py = backend_dir / "main.py" - if main_py.exists(): - content = main_py.read_text(encoding="utf-8") - if "/health" not in content: - checks.append( - { - "name": "health_endpoint", - "status": "fail", - "error": "main.py is missing /health endpoint. Add: @app.get('/health') async def health_check(): return {'status': 'healthy'}", - } - ) - else: - checks.append({"name": "health_endpoint", "status": "pass"}) - - if "/api/logs" not in content: - checks.append( - { - "name": "logs_endpoint", - "status": "fail", - "error": "main.py is missing POST /api/logs endpoint for frontend console capture. Restore it from the template or add: @app.post('/api/logs') that accepts {entries: [{level, message, timestamp}]} and writes to logs/frontend_console.log", - } - ) - else: - checks.append({"name": "logs_endpoint", "status": "pass"}) - - if "setup_logging" not in content: - checks.append( - { - "name": "logging_setup", - "status": "fail", - "error": "main.py is missing setup_logging() call. Add: from logger import setup_logging, cleanup_old_logs; setup_logging(); cleanup_old_logs(keep=20)", - } - ) - else: - checks.append({"name": "logging_setup", "status": "pass"}) - - # Health checker is handled by the manager watchdog — no longer required in main.py - checks.append({"name": "health_checker", "status": "pass"}) - else: - checks.append( - {"name": "main_py", "status": "fail", "error": "main.py not found"} - ) - - # Check index.html has console capture script - index_html = project_root / "index.html" - if index_html.exists(): - content = index_html.read_text(encoding="utf-8") - if "ConsoleCapture" not in content and "/api/logs" not in content: - checks.append( - { - "name": "console_capture", - "status": "fail", - "error": "index.html is missing the ConsoleCapture script. Restore it from the template — it should be an inline - - - - - - - - - - diff --git a/app/data/living_ui_template/package.json b/app/data/living_ui_template/package.json deleted file mode 100644 index 903a9ae1..00000000 --- a/app/data/living_ui_template/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "{{PROJECT_NAME}}", - "version": "1.0.0", - "description": "{{PROJECT_DESCRIPTION}}", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" - }, - "dependencies": { - "html2canvas": "^1.4.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "lucide-react": "^0.460.0", - "react-toastify": "^10.0.0" - }, - "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.0.0", - "typescript": "^5.0.0", - "vite": "^5.0.0" - } -} diff --git a/app/data/living_ui_template/requirements.txt b/app/data/living_ui_template/requirements.txt deleted file mode 100644 index fbbd4fe5..00000000 --- a/app/data/living_ui_template/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Python backend dependencies for Living UI -# Uncomment if backend functionality is needed - -# fastapi>=0.100.0 -# uvicorn>=0.23.0 -# sqlalchemy>=2.0.0 -# aiosqlite>=0.19.0 -# pydantic>=2.0.0 -# httpx>=0.24.0 diff --git a/app/data/living_ui_template/tsconfig.json b/app/data/living_ui_template/tsconfig.json deleted file mode 100644 index cda9bcf8..00000000 --- a/app/data/living_ui_template/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["frontend"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/app/data/living_ui_template/tsconfig.node.json b/app/data/living_ui_template/tsconfig.node.json deleted file mode 100644 index 42872c59..00000000 --- a/app/data/living_ui_template/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/app/data/living_ui_template/vite.config.ts b/app/data/living_ui_template/vite.config.ts deleted file mode 100644 index a30ac34c..00000000 --- a/app/data/living_ui_template/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - port: {{PORT}}, - host: true, - proxy: { - '/api': 'http://localhost:{{BACKEND_PORT}}', - }, - }, - preview: { - port: {{PORT}}, - host: true, - proxy: { - '/api': 'http://localhost:{{BACKEND_PORT}}', - }, - }, - build: { - outDir: 'dist', - sourcemap: true, - }, -}) diff --git a/app/living_ui/broadcast.py b/app/living_ui/broadcast.py index c32ea67f..3a2d5739 100644 --- a/app/living_ui/broadcast.py +++ b/app/living_ui/broadcast.py @@ -104,7 +104,7 @@ async def broadcast_living_ui_created(project: Dict[str, Any]) -> bool: return False -async def broadcast_living_ui_question(session_id: str, message: str) -> bool: +async def broadcast_living_ui_question(session_id: str, message: str, options=None) -> bool: """Mirror an agent's final question onto the Living UI creation screen, so the user can answer even with the chat closed. @@ -124,7 +124,7 @@ async def broadcast_living_ui_question(session_id: str, message: str) -> bool: project = None if not project or getattr(project, "status", None) != "creating": return False - await _broadcast_question_callback(project.id, session_id, message) + await _broadcast_question_callback(project.id, session_id, message, options) return True diff --git a/app/living_ui/manager.py b/app/living_ui/manager.py index 16f7e71d..a2e0ec9b 100644 --- a/app/living_ui/manager.py +++ b/app/living_ui/manager.py @@ -61,6 +61,7 @@ class LivingUIProject: session_id: Optional[str] = None auto_launch: bool = False # Auto-launch on CraftBot startup log_cleanup: bool = True # Clean logs on restart + style_pack: str = "" # wizard-chosen default style pack (host may override) project_type: str = "native" # 'native' or 'external' app_runtime: Optional[str] = ( None # 'go', 'node', 'python', 'rust', 'docker', 'static' @@ -69,8 +70,6 @@ class LivingUIProject: tunnel_url: Optional[str] = None # Public tunnel URL (NOT serialized) tunnel_process: Optional[subprocess.Popen] = None # Tunnel process (NOT serialized) process: Optional[subprocess.Popen] = None # Frontend process - backend_process: Optional[subprocess.Popen] = None # Backend process - app_process: Optional[subprocess.Popen] = None # Single process for external apps def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" @@ -91,8 +90,10 @@ def to_dict(self) -> Dict[str, Any]: "sessionId": self.session_id, "autoLaunch": self.auto_launch, "logCleanup": self.log_cleanup, + "stylePack": self.style_pack, "projectType": self.project_type, "appRuntime": self.app_runtime, + "livingUIVersion": 2, "tunnelUrl": self.tunnel_url, } @@ -100,16 +101,14 @@ def to_dict(self) -> Dict[str, Any]: class LivingUIManager: """Manages Living UI project lifecycle.""" - def __init__(self, workspace_root: Path, template_path: Path): + def __init__(self, workspace_root: Path): """ Initialize the Living UI Manager. Args: workspace_root: Root directory for Living UI projects - template_path: Path to the Living UI template """ self.workspace_root = Path(workspace_root) - self.template_path = Path(template_path) self.projects: Dict[str, LivingUIProject] = {} self._next_port = 3100 self._port_range = (3100, 3199) @@ -128,6 +127,13 @@ def __init__(self, workspace_root: Path, template_path: Path): self.living_ui_dir = self.workspace_root / "living_ui" self.living_ui_dir.mkdir(parents=True, exist_ok=True) + # V2 runner (PocketBase single-process projects). New projects are V2; + # V1 projects keep launching through the legacy pipeline. + from app.config import PROJECT_ROOT + from app.living_ui.v2_runner import V2Runner + + self.v2_runner = V2Runner(Path(PROJECT_ROOT) / "living-ui-v2") + # Load existing projects self._load_projects() @@ -246,29 +252,17 @@ async def _watchdog_loop(self) -> None: retry_counts.pop(project_id, None) continue - backend_dead = ( - project.backend_process is not None - and project.backend_process.poll() is not None - ) frontend_dead = ( project.process is not None and project.process.poll() is not None ) - - # Also check via port if process handles are None - # (can happen if manager was reloaded but processes survived) - if not backend_dead and project.backend_port: - if project.backend_process is None and not self._is_port_in_use( - project.backend_port - ): - backend_dead = True if not frontend_dead and project.port: if project.process is None and not self._is_port_in_use( project.port ): frontend_dead = True - if not backend_dead and not frontend_dead: + if not frontend_dead: # Everything healthy, reset retry counter if project_id in retry_counts: logger.info( @@ -279,12 +273,8 @@ async def _watchdog_loop(self) -> None: # Something is dead retries = retry_counts.get(project_id, 0) - crash_target = [] - if backend_dead: - crash_target.append("backend") - if frontend_dead: - crash_target.append("frontend") - crash_str = " + ".join(crash_target) + crash_target = ["app"] + crash_str = "app" if retries >= len(self.WATCHDOG_RETRY_DELAYS): # Exhausted retries — escalate to agent @@ -306,25 +296,19 @@ async def _watchdog_loop(self) -> None: await asyncio.sleep(delay) - # Attempt restart + # Attempt restart (single PocketBase process) restart_ok = True - if backend_dead: - project.backend_process = None - success = await self.launch_backend(project_id) - if not success: - logger.error( - f"[LIVING_UI:WATCHDOG] Backend restart failed for {project_id}" - ) - restart_ok = False - - if frontend_dead: - project.process = None - success = await self._relaunch_frontend(project_id) - if not success: - logger.error( - f"[LIVING_UI:WATCHDOG] Frontend restart failed for {project_id}" - ) - restart_ok = False + project.process = None + try: + project.process = await self.v2_runner.start( + Path(project.path), project.port + ) + restart_ok = await self.v2_runner.wait_healthy(project.port) + except Exception as e: + logger.error( + f"[LIVING_UI:WATCHDOG] restart failed for {project_id}: {e}" + ) + restart_ok = False if restart_ok: logger.info( @@ -339,73 +323,6 @@ async def _watchdog_loop(self) -> None: logger.error(f"[LIVING_UI:WATCHDOG] Unexpected error: {e}") await asyncio.sleep(self.WATCHDOG_INTERVAL) - async def _relaunch_frontend(self, project_id: str) -> bool: - """ - Relaunch just the frontend process for a project. - - Lightweight alternative to launch_project — reuses existing port, - skips npm install, doesn't touch backend. - """ - project = self.projects.get(project_id) - if not project: - return False - - project_path = Path(project.path) - port = project.port - if not port: - return False - - # Kill anything on the port first - if self._is_port_in_use(port): - self._kill_process_on_port(port) - await asyncio.sleep(1) - - try: - # Open timestamped log file for subprocess output - frontend_log = self._create_frontend_log(project_path) - frontend_log_handle = open(frontend_log, "a", encoding="utf-8") - frontend_log_handle.write( - f"\n{'=' * 60}\n[{datetime.now().isoformat()}] " - f"Relaunching frontend on port {port}\n{'=' * 60}\n" - ) - frontend_log_handle.flush() - - process = subprocess.Popen( - ["npm", "run", "preview", "--", "--port", str(port)], - cwd=str(project_path), - stdout=frontend_log_handle, - stderr=frontend_log_handle, - shell=True if os.name == "nt" else False, - ) - - project.process = process - - server_ready = await self._wait_for_server(port, timeout=15) - if not server_ready: - frontend_log_handle.flush() - try: - recent = frontend_log.read_text(encoding="utf-8")[-500:] - except Exception: - recent = "" - logger.error( - f"[LIVING_UI] Frontend relaunch failed for {project_id}. Log tail:\n{recent}" - ) - if process.poll() is None: - process.terminate() - project.process = None - frontend_log_handle.close() - return False - - project.url = f"http://localhost:{port}" - logger.info( - f"[LIVING_UI] Frontend relaunched for {project_id} on port {port}" - ) - return True - - except Exception as e: - logger.error(f"[LIVING_UI] Frontend relaunch error for {project_id}: {e}") - return False - async def _escalate_crash(self, project_id: str, crash_targets: List[str]) -> None: """ Escalate a crash to the agent by creating a fix task. @@ -479,7 +396,6 @@ async def _escalate_crash(self, project_id: str, crash_targets: List[str]) -> No project.status = "error" project.error = f"{crash_str} crashed after {len(self.WATCHDOG_RETRY_DELAYS)} restart attempts" project.process = None - project.backend_process = None self._save_projects() # Wake the project's session to investigate and fix @@ -509,8 +425,10 @@ async def _escalate_crash(self, project_id: str, crash_targets: List[str]) -> No 4. Verify the project is running by checking that the restart succeeded Follow the living-ui-creator skill instructions for the project structure. -The backend is a FastAPI app at {project.path}/backend/main.py -The frontend is a Vite+React app at {project.path}/frontend/""" +The app is a single PocketBase process; its log is {project.path}/logs/pocketbase.log +and frontend console errors are in {project.path}/logs/frontend_console.log. +Schema is in {project.path}/pb/pb_migrations/, hooks in {project.path}/pb/pb_hooks/, +UI in {project.path}/frontend/src/app/.""" try: session = self.ensure_project_session(project) @@ -561,6 +479,7 @@ def _load_projects(self) -> None: session_id=project_data.get("sessionId"), auto_launch=project_data.get("autoLaunch", False), log_cleanup=project_data.get("logCleanup", True), + style_pack=project_data.get("stylePack", ""), project_type=project_data.get("projectType", "native"), app_runtime=project_data.get("appRuntime"), ) @@ -746,146 +665,87 @@ def _kill_process_by_pid(self, pid: str) -> bool: logger.warning(f"[LIVING_UI] Failed to kill process {pid}: {e}") return False - async def _wait_for_server(self, port: int, timeout: int = 10) -> bool: - """ - Wait for a server to start listening on a port. - - Args: - port: The port to check - timeout: Maximum seconds to wait - - Returns: - True if server is responding, False if timeout - """ - for _ in range(timeout * 2): - if self._is_port_in_use(port): - return True - await asyncio.sleep(0.5) - return False - - async def _wait_for_health_check(self, url: str, timeout: int = 15) -> bool: - """ - Wait for a server's health endpoint to respond. - - Args: - url: The health check URL (e.g., http://localhost:3101/health) - timeout: Maximum seconds to wait - - Returns: - True if health check passes, False if timeout - """ - import urllib.request - import urllib.error + # ======================================================================== + # Manifest-driven launch pipeline + # ======================================================================== - for _ in range(timeout * 2): - try: - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=2) as response: - if response.status == 200: - return True - except ( - urllib.error.URLError, - urllib.error.HTTPError, - TimeoutError, - OSError, - ): - pass - await asyncio.sleep(0.5) - return False + async def _launch_v2(self, project: LivingUIProject) -> dict: + """V2 launch pipeline: install → validation gate → serve → health. - async def _run_backend_tests( - self, project_id: str, mode: str, port: int = 0 - ) -> bool: + One PocketBase process serves both the API and the built frontend + (living-ui-v2 spec D5); errors come back machine-readable so the + building agent can fix and retry. """ - Run backend tests using test_runner.py. - - Args: - project_id: Project ID to test - mode: "internal" (pre-server) or "external" (post-server HTTP tests) - port: Backend port (required for external mode) + from app.living_ui.v2_runner import V2RunnerUnavailable - Returns: - True if all tests pass, False otherwise - """ - project = self.projects.get(project_id) - if not project: - return False + project_path = Path(project.path) - backend_path = Path(project.path) / "backend" - test_runner = backend_path / "test_runner.py" - if not test_runner.exists(): - logger.warning( - f"[LIVING_UI] No test_runner.py for {project_id}, skipping {mode} tests" - ) - return True # No tests = pass (backwards compat with older projects) + def _fail(step: str, errors: list) -> dict: + project.status = "error" + project.error = "; ".join(str(e)[:500] for e in errors) + self._save_projects() + return {"status": "error", "step": step, "errors": errors} - logger.info( - f"[LIVING_UI] Running {mode} tests for {project.name} ({project_id})..." - ) + try: + self.v2_runner.ensure_available() + except V2RunnerUnavailable as e: + return _fail("setup", [str(e)]) - cmd = [sys.executable, str(test_runner), f"--{mode}"] - if mode == "external" and port: - cmd.extend(["--port", str(port)]) + # Clear any stale process/port before relaunching. + if project.process and project.process.poll() is None: + self._terminate_process(project.process) + project.process = None + if not project.port: + project.port = self._allocate_port() + else: + self._kill_process_on_port(project.port) try: - proc = await asyncio.create_subprocess_exec( - *cmd, - cwd=str(backend_path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - _, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) - - stderr_str = stderr.decode("utf-8", errors="replace").strip() + await self.v2_runner.install(project_path) + except Exception as e: + return _fail("install", [str(e)]) - if stderr_str: - # stderr contains the test runner's logging output - for line in stderr_str.split("\n")[-20:]: # Last 20 lines - logger.debug(f"[LIVING_UI:TEST] {line}") + gate = await self.v2_runner.gate(project_path) + if not gate.passed: + return _fail("validation", [gate.output]) - if proc.returncode == 0: - logger.info( - f"[LIVING_UI] {mode.capitalize()} tests passed for {project_id}" - ) - return True - else: - # Read the detailed results file - if mode == "internal": - results_file = backend_path / "logs" / "test_discovery.json" - else: - results_file = backend_path / "logs" / "test_results.json" - - error_details = "" - if results_file.exists(): - try: - results = json.loads(results_file.read_text(encoding="utf-8")) - errors = results.get("errors", []) - error_details = "; ".join( - f"[{e.get('test', '?')}] {e.get('error', '?')}" - for e in errors[:5] - ) - except Exception: - pass + try: + project.process = await self.v2_runner.start(project_path, project.port) + except Exception as e: + return _fail("start", [str(e)]) - logger.error( - f"[LIVING_UI] {mode.capitalize()} tests failed for {project_id}: {error_details or stderr_str[-500:]}" - ) - return False + if not await self.v2_runner.wait_healthy(project.port): + self._terminate_process(project.process) + project.process = None + return _fail("health", [f"/api/health not responding on :{project.port}"]) - except asyncio.TimeoutError: - logger.error( - f"[LIVING_UI] {mode.capitalize()} tests timed out for {project_id}" - ) - return False - except Exception as e: - logger.error( - f"[LIVING_UI] Failed to run {mode} tests for {project_id}: {e}" + # Walk-verify smoke pass (headless, invisible): app must mount with + # zero console errors. 'skipped' (no browser) never blocks a launch. + url = f"http://127.0.0.1:{project.port}" + verify_status, verify_detail = await self.v2_runner.verify( + Path(project.path), url + ) + if verify_status == "fail": + self._terminate_process(project.process) + project.process = None + return _fail("verify", [verify_detail]) + if verify_status == "skipped": + logger.warning( + f"[LIVING_UI:V2] verify skipped for {project.id}: {verify_detail}" ) - return False - # ======================================================================== - # Manifest-driven launch pipeline - # ======================================================================== + project.status = "running" + project.url = f"http://127.0.0.1:{project.port}" + project.backend_url = project.url + project.error = None + self._save_projects() + logger.info(f"[LIVING_UI:V2] {project.name} running at {project.url}") + return { + "status": "success", + "url": project.url, + "backend_url": project.url, + "port": project.port, + } async def launch_and_verify(self, project_id: str) -> dict: """ @@ -920,598 +780,7 @@ async def launch_and_verify(self, project_id: str) -> dict: "errors": [f"Project path not found: {project.path}"], } - # Load manifest - manifest_path = project_path / "config" / "manifest.json" - if not manifest_path.exists(): - return { - "status": "error", - "step": "setup", - "errors": ["config/manifest.json not found"], - } - - try: - # Ensure ports are allocated and available - if not project.port: - project.port = self._allocate_port() - if not project.backend_port: - project.backend_port = self._allocate_port() - - # Read manifest and resolve ports — always use project's current ports - # regardless of what's hardcoded in the manifest file - manifest_raw = manifest_path.read_text(encoding="utf-8") - - # Extract old ports from manifest to do replacement - manifest_tmp = json.loads(manifest_raw) - old_ports = manifest_tmp.get("ports", {}) - old_frontend = str(old_ports.get("frontend", old_ports.get("app", ""))) - old_backend = str(old_ports.get("backend", "")) - - # Replace old ports with current allocated ports in manifest and source files - if old_frontend and old_frontend != str(project.port): - manifest_raw = manifest_raw.replace(old_frontend, str(project.port)) - if old_backend and old_backend != str(project.backend_port): - manifest_raw = manifest_raw.replace( - old_backend, str(project.backend_port) - ) - - manifest = json.loads(manifest_raw) - - # Write updated manifest back to disk so frontend can read correct ports - if old_frontend != str(project.port) or old_backend != str( - project.backend_port - ): - manifest_path.write_text( - json.dumps(manifest, indent=2), encoding="utf-8" - ) - logger.info( - f"[LIVING_UI:PIPELINE] Updated manifest ports: frontend={project.port}, backend={project.backend_port}" - ) - except Exception as e: - return { - "status": "error", - "step": "setup", - "errors": [f"Failed to parse manifest: {e}"], - } - - pipeline = manifest.get("pipeline", {}) - if not pipeline: - return { - "status": "error", - "step": "setup", - "errors": ["No pipeline defined in manifest"], - } - - logger.info( - f"[LIVING_UI:PIPELINE] Starting launch pipeline for {project.name} ({project_id})" - ) - - # Ensure index.html has the CraftBot theme sync listener (self-healing for older installs) - self._patch_theme_listener(project_path) - - # Check for single-process mode (external apps) - app_cfg = pipeline.get("app") - if app_cfg: - return await self._launch_single_process( - project_id, project, project_path, app_cfg - ) - - # Stop any existing processes from previous launch attempts - # This prevents orphan uvicorn/vite processes accumulating on repeated calls - if project.backend_process and project.backend_process.poll() is None: - logger.info( - "[LIVING_UI:PIPELINE] Killing existing backend process before relaunch" - ) - project.backend_process.terminate() - project.backend_process = None - if project.process and project.process.poll() is None: - logger.info( - "[LIVING_UI:PIPELINE] Killing existing frontend process before relaunch" - ) - project.process.terminate() - project.process = None - - # Check if source files changed since last successful launch - files_changed = self._has_files_changed(project_path) - - if not files_changed: - logger.info( - "[LIVING_UI:PIPELINE] No source changes detected — skipping tests/build, starting servers directly" - ) - # Fast path — just start servers - return await self._launch_servers_only( - project_id, project, project_path, pipeline - ) - - # Clean up old log files so each launch starts fresh (if enabled) - if project.log_cleanup: - self._cleanup_project_logs(project_path) - - # ================================================================ - # PHASE 1: Parallel validation (collect ALL errors before starting) - # ================================================================ - - backend_cfg = pipeline.get("backend") - frontend_cfg = pipeline.get("frontend") - - # Run backend and frontend validation tracks in parallel - backend_task = None - frontend_task = None - - if backend_cfg: - backend_cwd = project_path / backend_cfg.get("cwd", "backend") - backend_task = asyncio.create_task( - self._validate_backend_track( - project_id, project_path, backend_cfg, backend_cwd - ) - ) - - if frontend_cfg: - frontend_cwd = project_path / frontend_cfg.get("cwd", ".") - if str(frontend_cwd) == ".": - frontend_cwd = project_path - frontend_task = asyncio.create_task( - self._validate_frontend_track(project_id, frontend_cfg, frontend_cwd) - ) - - # Wait for both tracks to complete - all_errors: List[str] = [] - - if backend_task: - backend_errors = await backend_task - all_errors.extend(backend_errors) - - if frontend_task: - frontend_errors = await frontend_task - all_errors.extend(frontend_errors) - - # If ANY errors from either track, return them all at once - if all_errors: - logger.error( - f"[LIVING_UI:PIPELINE] Validation failed with {len(all_errors)} error(s)" - ) - for err in all_errors[:10]: - logger.error(f"[LIVING_UI:PIPELINE] {err}") - project.status = "error" - project.error = f"{len(all_errors)} validation error(s)" - self._save_projects() - return {"status": "error", "step": "validation", "errors": all_errors} - - logger.info("[LIVING_UI:PIPELINE] All validation passed, starting servers...") - - # ================================================================ - # PHASE 2: Start servers (sequential — needs running processes) - # ================================================================ - - # --- Start backend --- - if backend_cfg: - backend_cwd = project_path / backend_cfg.get("cwd", "backend") - backend_port = project.backend_port - if not backend_port: - backend_port = self._allocate_port() - project.backend_port = backend_port - - if not await self._ensure_port_available(backend_port): - return { - "status": "error", - "step": "backend.port", - "errors": [ - f"Port {backend_port} is occupied and could not be freed" - ], - } - - start_cmd = backend_cfg.get("start", "") - if not start_cmd: - return { - "status": "error", - "step": "backend.start", - "errors": ["No start command in manifest"], - } - - logs_dir = backend_cwd / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - log_file = logs_dir / "subprocess_output.log" - - # Generate bridge token for integration proxy - from uuid import uuid4 - - project.bridge_token = str(uuid4()) - - backend_process = self._start_process( - backend_cwd, start_cmd, log_file, port=backend_port, project=project - ) - project.backend_process = backend_process - logger.info(f"[LIVING_UI:PIPELINE] Backend starting on port {backend_port}") - - # Health check - health_url = backend_cfg.get("health") - if health_url: - healthy = await self._wait_for_health_check(health_url, timeout=20) - if not healthy: - log_tail = self._read_log_tail(log_file, 1000) - if backend_process.poll() is not None: - err = f"Backend process exited with code {backend_process.returncode}" - else: - err = f"Backend not responding at {health_url}" - backend_process.terminate() - project.backend_process = None - return { - "status": "error", - "step": "backend.health", - "errors": [err, log_tail], - } - - project.backend_url = f"http://localhost:{backend_port}" - logger.info(f"[LIVING_UI:PIPELINE] Backend healthy on port {backend_port}") - - # Post-start tests (external smoke tests) - for test in backend_cfg.get("post_start_tests", []): - result = await self._run_pipeline_command( - backend_cwd, - test["command"], - step_name=f"backend.post_start.{test['name']}", - ) - if result["status"] == "error" and test.get("required", True): - errors = ( - self._collect_test_errors(project_path, test["name"]) - or result["errors"] - ) - await self.stop_backend(project_id) - return { - "status": "error", - "step": f"backend.post_start.{test['name']}", - "errors": errors, - } - - # --- Start frontend --- - if frontend_cfg: - frontend_cwd = project_path / frontend_cfg.get("cwd", ".") - if str(frontend_cwd) == ".": - frontend_cwd = project_path - - frontend_port = project.port - if not frontend_port: - frontend_port = self._allocate_port() - project.port = frontend_port - - if not await self._ensure_port_available(frontend_port): - await self.stop_backend(project_id) - return { - "status": "error", - "step": "frontend.port", - "errors": [ - f"Port {frontend_port} is occupied and could not be freed" - ], - } - - start_cmd = frontend_cfg.get("start", "") - if not start_cmd: - await self.stop_backend(project_id) - return { - "status": "error", - "step": "frontend.start", - "errors": ["No start command in manifest"], - } - - frontend_log = self._create_frontend_log(project_path) - - frontend_process = self._start_process( - frontend_cwd, start_cmd, frontend_log, port=frontend_port - ) - project.process = frontend_process - project.port = frontend_port - logger.info( - f"[LIVING_UI:PIPELINE] Frontend starting on port {frontend_port}" - ) - - server_ready = await self._wait_for_server(frontend_port, timeout=15) - if not server_ready: - log_tail = self._read_log_tail(frontend_log, 1000) - if frontend_process.poll() is not None: - err = f"Frontend process exited with code {frontend_process.returncode}" - else: - err = f"Frontend not responding on port {frontend_port}" - frontend_process.terminate() - project.process = None - await self.stop_backend(project_id) - return { - "status": "error", - "step": "frontend.health", - "errors": [err, log_tail], - } - - project.url = f"http://localhost:{frontend_port}" - logger.info(f"[LIVING_UI:PIPELINE] Frontend ready on port {frontend_port}") - - # === SUCCESS === - project.status = "running" - project.error = None - self._save_projects() - self._save_launch_timestamp(project_path) - - logger.info( - f"[LIVING_UI:PIPELINE] Launch complete for {project.name} ({project_id})" - ) - if project.url: - logger.info(f"[LIVING_UI:PIPELINE] Frontend: {project.url}") - if project.backend_url: - logger.info(f"[LIVING_UI:PIPELINE] Backend: {project.backend_url}") - - return { - "status": "success", - "url": project.url, - "backend_url": project.backend_url, - "port": project.port, - } - - async def _launch_servers_only( - self, - project_id: str, - project: "LivingUIProject", - project_path: Path, - pipeline: dict, - ) -> dict: - """Fast path: start servers without running tests/build (no source changes detected).""" - backend_cfg = pipeline.get("backend") - frontend_cfg = pipeline.get("frontend") - - # Start backend - if backend_cfg: - backend_cwd = project_path / backend_cfg.get("cwd", "backend") - backend_port = project.backend_port - if not backend_port: - backend_port = self._allocate_port() - project.backend_port = backend_port - - if not await self._ensure_port_available(backend_port): - return { - "status": "error", - "step": "backend.port", - "errors": [f"Port {backend_port} occupied"], - } - - start_cmd = backend_cfg.get("start", "") - if start_cmd: - logs_dir = backend_cwd / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - log_file = logs_dir / "subprocess_output.log" - - # Generate bridge token for integration proxy - from uuid import uuid4 - - project.bridge_token = str(uuid4()) - - backend_process = self._start_process( - backend_cwd, start_cmd, log_file, port=backend_port, project=project - ) - project.backend_process = backend_process - logger.info( - f"[LIVING_UI:PIPELINE] Backend starting on port {backend_port} (fast)" - ) - - health_url = backend_cfg.get("health") - if health_url: - healthy = await self._wait_for_health_check(health_url, timeout=20) - if not healthy: - log_tail = self._read_log_tail(log_file, 1000) - if backend_process.poll() is not None: - err = f"Backend process exited with code {backend_process.returncode}" - else: - err = f"Backend not responding at {health_url}" - backend_process.terminate() - project.backend_process = None - return { - "status": "error", - "step": "backend.health", - "errors": [err, log_tail], - } - - project.backend_url = f"http://localhost:{backend_port}" - logger.info( - f"[LIVING_UI:PIPELINE] Backend healthy on port {backend_port}" - ) - - # Start frontend - if frontend_cfg: - frontend_cwd = project_path / frontend_cfg.get("cwd", ".") - if str(frontend_cwd) == ".": - frontend_cwd = project_path - - frontend_port = project.port - if not frontend_port: - frontend_port = self._allocate_port() - project.port = frontend_port - - if not await self._ensure_port_available(frontend_port): - await self.stop_backend(project_id) - return { - "status": "error", - "step": "frontend.port", - "errors": [f"Port {frontend_port} occupied"], - } - - start_cmd = frontend_cfg.get("start", "") - if start_cmd: - frontend_log = self._create_frontend_log(project_path) - frontend_process = self._start_process( - frontend_cwd, start_cmd, frontend_log, port=frontend_port - ) - project.process = frontend_process - project.port = frontend_port - logger.info( - f"[LIVING_UI:PIPELINE] Frontend starting on port {frontend_port} (fast)" - ) - - server_ready = await self._wait_for_server(frontend_port, timeout=15) - if not server_ready: - log_tail = self._read_log_tail(frontend_log, 1000) - if frontend_process.poll() is not None: - err = f"Frontend process exited with code {frontend_process.returncode}" - else: - err = f"Frontend not responding on port {frontend_port}" - frontend_process.terminate() - project.process = None - await self.stop_backend(project_id) - return { - "status": "error", - "step": "frontend.health", - "errors": [err, log_tail], - } - - project.url = f"http://localhost:{frontend_port}" - logger.info( - f"[LIVING_UI:PIPELINE] Frontend ready on port {frontend_port}" - ) - - project.status = "running" - project.error = None - self._save_projects() - self._save_launch_timestamp(project_path) - - logger.info( - f"[LIVING_UI:PIPELINE] Fast launch complete for {project.name} ({project_id})" - ) - return { - "status": "success", - "url": project.url, - "backend_url": project.backend_url, - "port": project.port, - } - - async def _validate_backend_track( - self, project_id: str, project_path: Path, backend_cfg: dict, backend_cwd: Path - ) -> List[str]: - """ - Run backend validation: install → internal tests → unit + compatibility tests (parallel). - Returns list of error strings (empty = all passed). - """ - errors: List[str] = [] - - # 1. Install - install_cmd = backend_cfg.get("install") - if install_cmd and backend_cwd.exists(): - result = await self._run_pipeline_command( - backend_cwd, install_cmd, step_name="backend.install" - ) - if result["status"] == "error": - errors.append( - f"[backend.install] {result['errors'][0] if result.get('errors') else 'install failed'}" - ) - return errors # Can't test without dependencies - - # 2. Internal tests (must run first — generates test_discovery.json) - tests = backend_cfg.get("tests", []) - internal_tests = [t for t in tests if t["name"] == "internal"] - other_tests = [t for t in tests if t["name"] != "internal"] - - for test in internal_tests: - result = await self._run_pipeline_command( - backend_cwd, test["command"], step_name=f"backend.tests.{test['name']}" - ) - if result["status"] == "error" and test.get("required", True): - detailed = self._collect_test_errors(project_path, test["name"]) - errors.extend(detailed or result.get("errors", [])) - - # 3. Remaining tests in parallel (unit + compatibility) - if other_tests: - parallel_tasks = [] - for test in other_tests: - parallel_tasks.append( - self._run_pipeline_command( - backend_cwd, - test["command"], - step_name=f"backend.tests.{test['name']}", - ) - ) - results = await asyncio.gather(*parallel_tasks) - - for test, result in zip(other_tests, results): - if result["status"] == "error" and test.get("required", True): - detailed = self._collect_test_errors(project_path, test["name"]) - errors.extend(detailed or result.get("errors", [])) - - return errors - - async def _validate_frontend_track( - self, project_id: str, frontend_cfg: dict, frontend_cwd: Path - ) -> List[str]: - """ - Run frontend validation: install → build. - Returns list of error strings (empty = all passed). - """ - errors: List[str] = [] - - # 1. Install - install_cmd = frontend_cfg.get("install") - if install_cmd: - needs_install = not (frontend_cwd / "node_modules").exists() - if needs_install: - result = await self._run_pipeline_command( - frontend_cwd, install_cmd, step_name="frontend.install" - ) - if result["status"] == "error": - errors.append( - f"[frontend.install] {result['errors'][0] if result.get('errors') else 'install failed'}" - ) - return errors # Can't build without dependencies - - # 2. Build - build_cmd = frontend_cfg.get("build") - if build_cmd: - result = await self._run_pipeline_command( - frontend_cwd, build_cmd, step_name="frontend.build", timeout=240 - ) - if result["status"] == "error": - build_errors = result.get("errors", ["build failed"]) - for err in build_errors: - errors.append(f"[frontend.build] {err}") - - return errors - - async def _run_pipeline_command( - self, cwd: Path, command: str, step_name: str, timeout: int = 1200 - ) -> dict: - """Run a single pipeline command. Returns {"status": "success"} or {"status": "error", ...}.""" - command = self._resolve_python_in_command(command) - - logger.info(f"[LIVING_UI:PIPELINE] [{step_name}] Running: {command}") - - try: - proc = await asyncio.create_subprocess_shell( - command, - cwd=str(cwd), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - stdout_str = stdout.decode("utf-8", errors="replace").strip() - stderr_str = stderr.decode("utf-8", errors="replace").strip() - - if proc.returncode == 0: - logger.info(f"[LIVING_UI:PIPELINE] [{step_name}] OK") - return {"status": "success"} - else: - # Combine stdout and stderr for error context - output = (stderr_str or stdout_str)[-1000:] - logger.error( - f"[LIVING_UI:PIPELINE] [{step_name}] FAILED (exit code {proc.returncode})" - ) - return { - "status": "error", - "step": step_name, - "errors": [output] - if output - else [f"Command failed with exit code {proc.returncode}"], - } - except asyncio.TimeoutError: - logger.error(f"[LIVING_UI:PIPELINE] [{step_name}] TIMEOUT ({timeout}s)") - return { - "status": "error", - "step": step_name, - "errors": [f"Command timed out after {timeout}s"], - } - except Exception as e: - logger.error(f"[LIVING_UI:PIPELINE] [{step_name}] ERROR: {e}") - return {"status": "error", "step": step_name, "errors": [str(e)]} + return await self._launch_v2(project) async def _ensure_port_available(self, port: int) -> bool: """Ensure a port is available, killing orphan processes if needed.""" @@ -1530,131 +799,6 @@ async def _ensure_port_available(self, port: int) -> bool: _python_path_cache: Optional[str] = None @classmethod - def _find_real_python(cls) -> str: - """Find a usable system Python interpreter, skipping the Microsoft - Store stub alias. - - On Windows, `%LocalAppData%\\Microsoft\\WindowsApps\\python.exe` is - an "App Execution Alias" stub that prints "Python was not found; - run without arguments to install from the Microsoft Store..." and - exits non-zero — even when the user HAS python.org's Python - installed elsewhere. The stub is high on PATH so a naive - `shutil.which("python")` returns it, leading to silent failures. - - Strategy: - 1. Walk every entry returned by `shutil.which`-style PATH lookup - (using PATHEXT-aware multi-candidate search) for both - `python3` and `python`. - 2. Skip anything in WindowsApps (the Store-stub directory). - 3. Validate each remaining candidate by running it with - `--version` and checking it actually printed "Python". - 4. Fall back to the well-known python.org install locations. - Cached after first hit because shelling out to test takes a few ms. - """ - if cls._python_path_cache: - return cls._python_path_cache - - seen = set() - - def _candidates_via_path(): - # shutil.which returns ONLY the first match. We want to walk - # every PATH entry so a Store stub doesn't shadow a real Python. - path_dirs = os.environ.get("PATH", "").split(os.pathsep) - exts = [""] + os.environ.get("PATHEXT", ".EXE;.BAT;.CMD").split(os.pathsep) - for d in path_dirs: - if not d: - continue - for name in ("python3", "python"): - for ext in exts: - full = os.path.join(d, name + ext) - if os.path.isfile(full): - yield full - - def _candidates_well_known(): - user = os.path.expanduser("~") - for ver in ("313", "312", "311", "310"): - yield rf"C:\Python{ver}\python.exe" - yield os.path.join( - user, - "AppData", - "Local", - "Programs", - "Python", - f"Python{ver}", - "python.exe", - ) - - for path in list(_candidates_via_path()) + list(_candidates_well_known()): - key = path.lower() - if key in seen: - continue - seen.add(key) - # Microsoft Store App Execution Alias stub — never works. - if "\\windowsapps\\" in key.replace("/", "\\"): - continue - if not os.path.isfile(path): - continue - try: - result = subprocess.run( - [path, "--version"], - capture_output=True, - text=True, - timeout=5, - ) - except Exception: - continue - output = (result.stdout or "") + (result.stderr or "") - if result.returncode == 0 and "Python" in output: - cls._python_path_cache = path - logger.info( - f"[LIVING_UI] Resolved system Python: {path} ({output.strip()})" - ) - return path - return "" - - @classmethod - def _resolve_python_in_command(cls, command: str) -> str: - """Replace a leading `pip`/`python`/`python3` token with a real - interpreter path. - - In source mode `sys.executable` is the running Python — correct. - - In a PyInstaller-frozen agent (`sys.frozen == True`), - `sys.executable` is the agent EXE itself, not a Python interpreter. - Substituting it would spawn the entire agent again with junk args, - which used to crash (run.py treats `-m pip install ...` as agent - CLI flags and falls into print_step → OSError 22). Find a real - system Python via `_find_real_python` (which skips the Microsoft - Store stub alias). Log loudly if absent so the failure mode is - "command not found" rather than "agent recursion crash". - """ - if not ( - command.startswith("pip ") - or command.startswith("python3 ") - or command.startswith("python ") - ): - return command - - py = sys.executable - if getattr(sys, "frozen", False): - py = cls._find_real_python() - if not py: - logger.error( - "[LIVING_UI] Project needs python/pip but no real system " - "Python was found. The Microsoft Store stub at " - "%LocalAppData%\\Microsoft\\WindowsApps doesn't count — " - "install Python 3.10+ from python.org. Command was: %s", - command, - ) - py = "python" # will raise FileNotFoundError at spawn time - if command.startswith("pip "): - return f'"{py}" -m pip {command[4:]}' - if command.startswith("python3 "): - return f'"{py}" {command[8:]}' - if command.startswith("python "): - return f'"{py}" {command[7:]}' - return command - def _start_process( self, cwd: Path, @@ -1708,79 +852,11 @@ def _start_process( cwd=str(cwd), env=env, stdout=log_handle, - stderr=log_handle, - shell=True, - ) - return process - - def _collect_test_errors(self, project_path: Path, test_name: str) -> List[str]: - """Read test result JSON files and extract error messages.""" - errors = [] - # Map test names to result files - file_map = { - "internal": "test_discovery.json", - "unit": "test_unit.json", - "compatibility": "test_compatibility.json", - "external": "test_results.json", - } - result_file = ( - project_path - / "backend" - / "logs" - / file_map.get(test_name, f"test_{test_name}.json") - ) - if result_file.exists(): - try: - data = json.loads(result_file.read_text(encoding="utf-8")) - for err in data.get("errors", []): - errors.append(f"[{err.get('test', '?')}] {err.get('error', '?')}") - except Exception: - pass - return errors - - @staticmethod - def _cleanup_project_logs(project_path: Path) -> None: - """Clean up old log files so each launch/restart starts fresh.""" - log_files_to_clean = [ - project_path / "backend" / "logs" / "subprocess_output.log", - project_path / "backend" / "logs" / "frontend_console.log", - project_path / "backend" / "logs" / "test_discovery.json", - project_path / "backend" / "logs" / "test_unit.json", - project_path / "backend" / "logs" / "test_compatibility.json", - project_path / "backend" / "logs" / "test_results.json", - project_path / "backend" / "logs" / "health_status.json", - project_path / "logs" / "frontend_output.log", # Legacy non-timestamped - project_path / "backend" / "logs" / "latest.log", # Legacy pointer file - ] - for log_file in log_files_to_clean: - try: - if log_file.exists(): - log_file.unlink() - except Exception: - pass - # Clean up old session logs — keep only the 5 most recent of each type - backend_logs_dir = project_path / "backend" / "logs" - if backend_logs_dir.exists(): - session_logs = sorted(backend_logs_dir.glob("backend_*.log"), reverse=True) - for old_log in session_logs[5:]: - try: - old_log.unlink() - except Exception: - pass - frontend_logs_dir = project_path / "logs" - if frontend_logs_dir.exists(): - session_logs = sorted( - frontend_logs_dir.glob("frontend_*.log"), reverse=True + stderr=log_handle, + shell=True, ) - for old_log in session_logs[5:]: - try: - old_log.unlink() - except Exception: - pass - - logger.debug("[LIVING_UI:PIPELINE] Cleaned up old log files") + return process - @staticmethod def _create_frontend_log(project_path: Path) -> Path: """Create a timestamped frontend log file path.""" logs_dir = project_path / "logs" @@ -1788,88 +864,6 @@ def _create_frontend_log(project_path: Path) -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return logs_dir / f"frontend_{timestamp}.log" - @staticmethod - def _has_files_changed(project_path: Path) -> bool: - """Check if any source files changed since last successful launch.""" - last_launch_file = project_path / ".last_launch" - if not last_launch_file.exists(): - return True # No record = assume changed - - try: - last_launch_time = last_launch_file.stat().st_mtime - except Exception: - return True - - source_extensions = { - ".py", - ".ts", - ".tsx", - ".js", - ".jsx", - ".json", - ".html", - ".css", - ".md", - } - skip_dirs = {"node_modules", "__pycache__", "dist", "logs", ".git"} - - for filepath in project_path.rglob("*"): - if filepath.is_file() and filepath.suffix in source_extensions: - if any(skip in filepath.parts for skip in skip_dirs): - continue - if filepath.stat().st_mtime > last_launch_time: - return True - return False - - @staticmethod - def _patch_theme_listener(project_path: Path) -> None: - """Inject CraftBot theme-sync listener into index.html if not already present.""" - index_html = project_path / "index.html" - if not index_html.exists(): - return - try: - content = index_html.read_text(encoding="utf-8") - if "craftbot-theme-request" in content: - return # Already patched - snippet = ( - "\n \n" - " \n" - ) - patched = content.replace("", snippet + "", 1) - index_html.write_text(patched, encoding="utf-8") - logger.info(f"[LIVING_UI] Patched theme listener into {index_html}") - except Exception as e: - logger.warning(f"[LIVING_UI] Could not patch index.html: {e}") - - @staticmethod - def _save_launch_timestamp(project_path: Path) -> None: - """Save current time as last successful launch timestamp.""" - last_launch_file = project_path / ".last_launch" - try: - last_launch_file.write_text(datetime.now().isoformat(), encoding="utf-8") - except Exception: - pass - @staticmethod def _read_log_tail(log_file: Path, chars: int = 1000) -> str: """Read the last N characters of a log file.""" @@ -1879,184 +873,6 @@ def _read_log_tail(log_file: Path, chars: int = 1000) -> str: except Exception: return "(could not read log)" - async def launch_backend(self, project_id: str) -> bool: - """ - Launch the backend (FastAPI) server for a Living UI project. - - The backend holds all state and persists to SQLite. - It should be launched before the frontend. - - Args: - project_id: Project ID to launch backend for - - Returns: - True if backend launch was successful - """ - project = self.projects.get(project_id) - if not project: - logger.error(f"[LIVING_UI] Project not found: {project_id}") - return False - - project_path = Path(project.path) - backend_path = project_path / "backend" - - if not backend_path.exists(): - logger.warning(f"[LIVING_UI] No backend directory for {project_id}") - return True # Not an error, just no backend - - # If backend port is occupied, allocate a new one instead of killing - backend_port = project.backend_port - if backend_port and self._is_port_in_use(backend_port): - logger.info( - f"[LIVING_UI] Port {backend_port} occupied, allocating a new port..." - ) - self._release_port(backend_port) - backend_port = self._allocate_port() - project.backend_port = backend_port - logger.info(f"[LIVING_UI] Allocated new backend port: {backend_port}") - - # Allocate port if needed - if not backend_port: - backend_port = self._allocate_port() - project.backend_port = backend_port - - try: - # Start the FastAPI backend using uvicorn - logger.info( - f"[LIVING_UI] Starting backend for {project_id} on port {backend_port}" - ) - - # Backend has its own file-based logger (logger.py in template), - # but also capture subprocess stdout/stderr to a fallback log file - # so we can diagnose startup crashes before the app logger initializes - logs_dir = backend_path / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - subprocess_log = logs_dir / "subprocess_output.log" - subprocess_log_handle = open(subprocess_log, "a", encoding="utf-8") - subprocess_log_handle.write( - f"\n{'=' * 60}\n[{datetime.now().isoformat()}] Starting uvicorn on port {backend_port}\n{'=' * 60}\n" - ) - subprocess_log_handle.flush() - - # Generate bridge token for integration proxy - from uuid import uuid4 - - bridge_token = str(uuid4()) - project.bridge_token = bridge_token - - # Build env with integration bridge vars - bridge_port = int(os.environ.get("BROWSER_PORT", "7926")) - backend_env = os.environ.copy() - backend_env["CRAFTBOT_BRIDGE_URL"] = f"http://localhost:{bridge_port}" - backend_env["CRAFTBOT_BRIDGE_TOKEN"] = bridge_token - - # Use python -m uvicorn to run the backend - if os.name == "nt": - # Windows - backend_process = subprocess.Popen( - [ - sys.executable, - "-m", - "uvicorn", - "main:app", - "--host", - "0.0.0.0", - "--port", - str(backend_port), - ], - cwd=str(backend_path), - env=backend_env, - stdout=subprocess_log_handle, - stderr=subprocess_log_handle, - shell=True, - creationflags=subprocess.CREATE_NO_WINDOW - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, - ) - else: - # Linux/Mac - backend_process = subprocess.Popen( - [ - sys.executable, - "-m", - "uvicorn", - "main:app", - "--host", - "0.0.0.0", - "--port", - str(backend_port), - ], - cwd=str(backend_path), - env=backend_env, - stdout=subprocess_log_handle, - stderr=subprocess_log_handle, - ) - - project.backend_process = backend_process - - # Wait for health check to pass - health_url = f"http://localhost:{backend_port}/health" - logger.info( - f"[LIVING_UI] Waiting for backend health check at {health_url}..." - ) - backend_ready = await self._wait_for_health_check(health_url, timeout=20) - - if not backend_ready: - # Backend didn't start - read the subprocess log for diagnostics - subprocess_log_handle.flush() - try: - recent_output = subprocess_log.read_text(encoding="utf-8")[-1000:] - except Exception: - recent_output = "(could not read subprocess log)" - if backend_process.poll() is not None: - logger.error( - f"[LIVING_UI] Backend process exited with code {backend_process.returncode}. Log tail:\n{recent_output}" - ) - else: - logger.error( - f"[LIVING_UI] Backend not responding on port {backend_port}. Log tail:\n{recent_output}" - ) - backend_process.terminate() - project.backend_process = None - subprocess_log_handle.close() - return False - - project.backend_url = f"http://localhost:{backend_port}" - logger.info( - f"[LIVING_UI] Backend started successfully on port {backend_port}" - ) - return True - - except Exception as e: - logger.error(f"[LIVING_UI] Failed to launch backend: {e}") - return False - - async def stop_backend(self, project_id: str) -> bool: - """ - Stop the backend server for a Living UI project. - - Args: - project_id: Project ID to stop backend for - - Returns: - True if stop was successful - """ - project = self.projects.get(project_id) - if not project: - return False - - if project.backend_process: - self._terminate_process(project.backend_process) - project.backend_process = None - - # Also try to kill by port in case process reference is stale - if project.backend_port and self._is_port_in_use(project.backend_port): - self._kill_process_on_port(project.backend_port) - - project.backend_url = None - logger.info(f"[LIVING_UI] Stopped backend for {project_id}") - return True - def _terminate_process(self, process: subprocess.Popen) -> None: """Terminate a subprocess, killing the entire process tree on Windows.""" try: @@ -2181,7 +997,6 @@ def cleanup_on_startup(self) -> None: if project.status == "running": project.status = "stopped" project.process = None - project.backend_process = None project.url = None project.backend_url = None self._save_projects() @@ -2231,6 +1046,8 @@ async def create_project( features: List[str] = None, data_source: Optional[str] = None, theme: str = "system", + auth_mode: str = "none", + style_pack: str = "", ) -> LivingUIProject: """ Create a new Living UI project from template. @@ -2247,45 +1064,39 @@ async def create_project( """ project_id = self._generate_id() sanitized_name = self._sanitize_name(name) - project_path = self.living_ui_dir / f"{sanitized_name}_{project_id}" + folder = f"{sanitized_name}_{project_id}" - # Allocate ports - frontend_port = self._allocate_port() - backend_port = self._allocate_port() + # New projects are V2 (PocketBase single-process). The tools CLI does + # the real scaffolding: blueprint copy, kit vendoring, placeholder + # substitution, superuser bootstrap, system-file hash canon. + port = self._allocate_port() + if auth_mode not in ("none", "multi-user"): + auth_mode = "none" - # Copy template try: - shutil.copytree(self.template_path, project_path) - logger.info(f"[LIVING_UI] Copied template to {project_path}") + result = await self.v2_runner.scaffold( + name=name, + description=description, + parent_dir=self.living_ui_dir, + port=port, + project_id=project_id, + auth_mode=auth_mode, + folder=folder, + style=style_pack or None, + ) except Exception as e: - self._release_port(frontend_port) - self._release_port(backend_port) - raise RuntimeError(f"Failed to copy template: {e}") - - # Replace template placeholders (including ports for source code) - self._replace_placeholders( - project_path, - { - "{{PROJECT_ID}}": project_id, - "{{PROJECT_NAME}}": name, - "{{PROJECT_DESCRIPTION}}": description, - "{{PORT}}": str(frontend_port), - "{{BACKEND_PORT}}": str(backend_port), - "{{THEME}}": theme, - "{{CREATED_AT}}": datetime.now().isoformat(), - "{{FEATURES}}": ", ".join(features or []), - }, - ) + self._release_port(port) + raise RuntimeError(f"Failed to scaffold V2 project: {e}") - # Create project instance project = LivingUIProject( id=project_id, name=name, description=description, - path=str(project_path), + path=str(result.path), status="created", - port=frontend_port, - backend_port=backend_port, + style_pack=style_pack or "", + port=port, + backend_port=None, features=features or [], theme=theme, ) @@ -2293,7 +1104,64 @@ async def create_project( self.projects[project_id] = project self._save_projects() - logger.info(f"[LIVING_UI] Created project: {name} ({project_id})") + logger.info(f"[LIVING_UI] Created V2 project: {name} ({project_id})") + return project + + async def import_project_zip( + self, zip_path: str, name: Optional[str] = None + ) -> LivingUIProject: + """Import a V2 Living UI project from an exported ZIP. + + Round-trip with export: new identity + port, shipped credentials + stripped, kit re-vendored and hashes re-canonized via kit-sync. + """ + import tempfile + import zipfile + + project_id = self._generate_id() + with tempfile.TemporaryDirectory() as tmp: + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + root = Path(tmp) + candidates = [root] + [d for d in root.iterdir() if d.is_dir()] + src = next((c for c in candidates if (c / "manifest.json").exists()), None) + if src is None: + raise ValueError("ZIP does not contain a Living UI project (no manifest.json)") + manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8")) + if manifest.get("livingUIVersion") != 2: + raise ValueError("Only Living UI V2 projects can be imported") + + display = name or manifest.get("name") or "Imported App" + port = self._allocate_port() + dest = self.living_ui_dir / f"{self._sanitize_name(display)}_{project_id}" + shutil.copytree(src, dest) + + # Never trust shipped credentials or runtime state. + (dest / ".superuser").unlink(missing_ok=True) + + # Rewrite identity + port (pipeline start command embeds the port). + old_port = manifest.get("port") + manifest["id"], manifest["name"], manifest["port"] = project_id, display, port + if isinstance(manifest.get("pipeline"), dict) and old_port: + manifest["pipeline"] = json.loads( + json.dumps(manifest["pipeline"]).replace(str(old_port), str(port)) + ) + (dest / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + + # Kit re-vendor + hash re-canon (identity rewrite invalidated the canon). + await self.v2_runner.kit_sync(dest) + + project = LivingUIProject( + id=project_id, + name=display, + description=manifest.get("description", ""), + path=str(dest), + status="stopped", + port=port, + ) + self.projects[project_id] = project + self._save_projects() + logger.info(f"[LIVING_UI] Imported V2 project: {display} ({project_id})") return project def create_placeholder_project( @@ -2302,10 +1170,8 @@ def create_placeholder_project( """Register a lightweight "creating" project so a tab/progress screen appears immediately, before the real import/install populates it. - Used by the import (ZIP/GitHub) and marketplace flows so they behave - like the form-create flow (which registers its project synchronously). - The actual importer — import_project_zip / import_external_app / - install_from_marketplace — must adopt this id (pass project_id=...) so + Used by async install flows (future V2 import/marketplace) so they + behave like the form-create flow; the installer must adopt this id so it overwrites this entry instead of creating a second tab. Intentionally NOT persisted to disk: a placeholder that never gets @@ -2327,38 +1193,6 @@ def create_placeholder_project( ) return project - def _replace_placeholders( - self, directory: Path, replacements: Dict[str, str] - ) -> None: - """Replace placeholders in all text files in directory.""" - text_extensions = { - ".ts", - ".tsx", - ".js", - ".jsx", - ".json", - ".html", - ".css", - ".md", - ".py", - ".txt", - ".env", - } - - for filepath in directory.rglob("*"): - if filepath.is_file() and filepath.suffix in text_extensions: - try: - content = filepath.read_text(encoding="utf-8") - modified = False - for placeholder, value in replacements.items(): - if placeholder in content: - content = content.replace(placeholder, value) - modified = True - if modified: - filepath.write_text(content, encoding="utf-8") - except Exception as e: - logger.warning(f"[LIVING_UI] Failed to process {filepath}: {e}") - async def install_from_marketplace( self, app_id: str, @@ -2637,14 +1471,6 @@ async def launch_project(self, project_id: str) -> bool: project.process = None actually_alive = False - if ( - project.backend_process is not None - and project.backend_process.poll() is not None - ): - logger.warning( - f"[LIVING_UI] Backend process dead for {project_id} (stale status)" - ) - project.backend_process = None actually_alive = False if ( @@ -2680,168 +1506,6 @@ async def launch_project(self, project_id: str) -> bool: # External app support # ------------------------------------------------------------------ - async def _launch_single_process( - self, - project_id: str, - project: "LivingUIProject", - project_path: Path, - app_cfg: dict, - ) -> dict: - """Launch a single-process app with sidecar proxy for logging/health.""" - # Allocate two ports: proxy (user-facing) and app (internal) - proxy_port = project.port - if not proxy_port: - proxy_port = self._allocate_port() - project.port = proxy_port - - app_port = project.backend_port - if not app_port: - app_port = self._allocate_port() - project.backend_port = app_port - - if not await self._ensure_port_available(proxy_port): - return { - "status": "error", - "step": "app.port", - "errors": [f"Port {proxy_port} occupied"], - } - if not await self._ensure_port_available(app_port): - return { - "status": "error", - "step": "app.port", - "errors": [f"Port {app_port} occupied"], - } - - cwd = project_path / app_cfg.get("cwd", ".") - - # Install step (optional) - install_cmd = app_cfg.get("install", "") - if install_cmd: - logger.info(f"[LIVING_UI:PIPELINE] [app.install] Running: {install_cmd}") - result = await self._run_pipeline_command(cwd, install_cmd, "app.install") - if result["status"] == "error": - return result - - # Start the app on the internal port - start_cmd = app_cfg.get("start", "") - if not start_cmd: - return { - "status": "error", - "step": "app.start", - "errors": ["No start command in manifest"], - } - - logs_dir = project_path / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - log_file = logs_dir / "app_output.log" - - # Build extra env vars — use app_port for the app itself - extra_env = {} - for k, v in app_cfg.get("env", {}).items(): - extra_env[k] = ( - str(v) - .replace("{{PORT}}", str(app_port)) - .replace("{{BACKEND_PORT}}", str(app_port)) - ) - # Always override PORT with the internal app port — manifest may have a stale hardcoded value - extra_env["PORT"] = str(app_port) - - # Replace port placeholders in start command with internal app port - start_cmd = start_cmd.replace("{{PORT}}", str(app_port)).replace( - "{{BACKEND_PORT}}", str(app_port) - ) - - # Generate bridge token - from uuid import uuid4 - - project.bridge_token = str(uuid4()) - - app_process = self._start_process( - cwd, - start_cmd, - log_file, - port=app_port, - project=project, - extra_env=extra_env, - ) - project.app_process = app_process - logger.info(f"[LIVING_UI:PIPELINE] App starting on internal port {app_port}") - - # Health check on the app's internal port - health_cfg = app_cfg.get("health", {}) - # Replace port placeholders in health URL with app_port - if isinstance(health_cfg, dict) and "url" in health_cfg: - health_cfg = dict(health_cfg) - health_cfg["url"] = ( - health_cfg["url"] - .replace("{{PORT}}", str(app_port)) - .replace("{{BACKEND_PORT}}", str(app_port)) - ) - elif isinstance(health_cfg, str): - health_cfg = health_cfg.replace("{{PORT}}", str(app_port)).replace( - "{{BACKEND_PORT}}", str(app_port) - ) - - healthy = await self._check_health_with_strategy( - health_cfg, app_port, app_process - ) - if not healthy: - log_tail = self._read_log_tail(log_file, 1000) - if app_process.poll() is not None: - err = f"App process exited with code {app_process.returncode}" - else: - err = f"App not responding on port {app_port}" - app_process.terminate() - project.app_process = None - return {"status": "error", "step": "app.health", "errors": [err, log_tail]} - - logger.info(f"[LIVING_UI:PIPELINE] App healthy on internal port {app_port}") - - # Start the sidecar proxy on the user-facing port - sidecar_path = ( - Path(__file__).parent.parent / "data" / "living_ui_sidecar" / "proxy.py" - ) - if sidecar_path.exists(): - sidecar_cmd = f'python "{sidecar_path}" --app-port {app_port} --proxy-port {proxy_port}' - sidecar_log = logs_dir / "sidecar_output.log" - sidecar_process = self._start_process( - project_path, sidecar_cmd, sidecar_log, port=proxy_port, project=project - ) - project.process = sidecar_process # Store sidecar as frontend process (gets stopped with stop_project) - logger.info( - f"[LIVING_UI:PIPELINE] Sidecar proxy starting: port {proxy_port} → app port {app_port}" - ) - - # Wait for sidecar to be ready - sidecar_healthy = await self._wait_for_health_check( - f"http://localhost:{proxy_port}/health", timeout=15 - ) - if not sidecar_healthy: - logger.warning( - f"[LIVING_UI:PIPELINE] Sidecar not responding, app still accessible directly on port {app_port}" - ) - project.url = f"http://localhost:{app_port}" - else: - project.url = f"http://localhost:{proxy_port}" - logger.info(f"[LIVING_UI:PIPELINE] Sidecar ready on port {proxy_port}") - else: - logger.warning( - "[LIVING_UI:PIPELINE] Sidecar proxy not found, running app without proxy" - ) - project.url = f"http://localhost:{app_port}" - - project.backend_url = f"http://localhost:{app_port}" - project.status = "running" - self._save_projects() - - logger.info(f"[LIVING_UI:PIPELINE] App ready: {project.url}") - return { - "status": "success", - "url": project.url, - "port": proxy_port, - } - - @staticmethod def _append_node_args(command: str, extra_args: str) -> str: """Append CLI args to an npm/pnpm/yarn run command using `--`, or to a direct binary call.""" if re.match(r"^\s*(?:npm|pnpm|yarn)\s+run\s+\S+", command): @@ -2929,109 +1593,6 @@ def uses(name: str) -> bool: return new_start, new_env - async def import_external_app( - self, - name: str, - description: str, - source_path: str, - app_runtime: str = "unknown", - install_command: str = "", - start_command: str = "", - health_strategy: str = "tcp", - health_url: str = "", - port_env_var: str = "PORT", - project_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Import an external app as a Living UI project.""" - # Adopt the placeholder id when provided so the tab spawned at request - # time becomes this project instead of a second tab appearing. - project_id = project_id or self._generate_id() - sanitized_name = self._sanitize_name(name) - project_path = self.living_ui_dir / f"{sanitized_name}_{project_id}" - - try: - # Copy source to workspace - shutil.copytree(source_path, project_path) - logger.info(f"[LIVING_UI] Copied external app to {project_path}") - except Exception as e: - return {"status": "error", "error": f"Failed to copy app: {e}"} - - # Allocate two ports: proxy (user-facing) and app (internal) - proxy_port = self._allocate_port() - app_port = self._allocate_port() - - # Create config directory and manifest - config_dir = project_path / "config" - config_dir.mkdir(exist_ok=True) - logs_dir = project_path / "logs" - logs_dir.mkdir(exist_ok=True) - - # Build health config — uses app_port (internal) - health_cfg: Any = {"strategy": health_strategy} - if health_strategy == "http_get": - health_cfg["url"] = health_url or "http://localhost:{{PORT}}" - health_cfg["timeout"] = 30 - - env_dict: Dict[str, str] = {port_env_var: "{{PORT}}"} if port_env_var else {} - - # Auto-normalize Node.js dev-server start commands so the app binds to - # CraftBot's allocated port and doesn't pop a system browser tab. - if app_runtime == "node": - start_command, env_dict = self._normalize_node_start_command( - project_path, start_command, env_dict - ) - - # Generate manifest - manifest = { - "id": project_id, - "name": name, - "version": "1.0.0", - "description": description, - "projectType": "external", - "appRuntime": app_runtime, - "livingUIVersion": "1.0", - "ports": {"frontend": proxy_port, "backend": app_port}, - "pipeline": { - "app": { - "cwd": ".", - "install": install_command, - "start": start_command, - "env": env_dict, - "health": health_cfg, - } - }, - "agentAwareness": {"enabled": False, "observationMode": "external"}, - } - - manifest_path = config_dir / "manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2)) - - project = LivingUIProject( - id=project_id, - name=name, - description=description, - path=str(project_path), - status="created", - port=proxy_port, - backend_port=app_port, - project_type="external", - app_runtime=app_runtime, - ) - - # Preserve the session link from an adopted placeholder so todo/question - # broadcasts (keyed by session id) keep targeting this tab. - existing = self.projects.get(project_id) - if existing and existing.session_id: - project.session_id = existing.session_id - self.projects[project_id] = project - self._save_projects() - - logger.info(f"[LIVING_UI] Imported external app: {name} ({project_id})") - return { - "status": "success", - "project": project.to_dict(), - } - async def _check_health_with_strategy( self, health_cfg, port: int, process, timeout: int = 30 ) -> bool: @@ -3086,13 +1647,12 @@ async def stop_all_projects(self) -> None: ) logger.info("[LIVING_UI] All projects stopped") - async def stop_project(self, project_id: str, stop_backend: bool = True) -> bool: + async def stop_project(self, project_id: str) -> bool: """ - Stop a running Living UI project (frontend and optionally backend). + Stop a running Living UI project (its single PocketBase process). Args: project_id: Project ID to stop - stop_backend: Whether to also stop the backend (default: True) Returns: True if stop was successful @@ -3102,12 +1662,7 @@ async def stop_project(self, project_id: str, stop_backend: bool = True) -> bool logger.error(f"[LIVING_UI] Project not found: {project_id}") return False - # Stop app process (external/single-process apps) - if project.app_process: - self._terminate_process(project.app_process) - project.app_process = None - - # Stop frontend process + # Stop the app process if project.process: self._terminate_process(project.process) project.process = None @@ -3118,10 +1673,6 @@ async def stop_project(self, project_id: str, stop_backend: bool = True) -> bool project.url = None - # Stop backend if requested - if stop_backend: - await self.stop_backend(project_id) - project.status = "stopped" self._save_projects() @@ -3251,118 +1802,6 @@ def export_project_zip(self, project_id: str) -> Path: logger.info(f"[LIVING_UI] Exported project '{project.name}' to {zip_path}") return zip_path - async def import_project_zip( - self, zip_path: str, name: str = "", project_id: Optional[str] = None - ) -> "LivingUIProject": - """Import a Living UI project from a ZIP file. - - The ZIP should contain a project directory structure with at least - a config/manifest.json. Ports are allocated automatically. When - project_id is provided, the import adopts that id (overwriting the - placeholder tab spawned at request time) instead of generating a new - one — preventing a duplicate tab. - """ - zip_file = Path(zip_path) - if not zip_file.exists(): - raise FileNotFoundError(f"ZIP file not found: {zip_path}") - - # Extract to a temp directory first to inspect contents - with tempfile.TemporaryDirectory() as tmp_dir: - with zipfile.ZipFile(zip_file, "r") as zf: - zf.extractall(tmp_dir) - - tmp_path = Path(tmp_dir) - - # Check if files are nested inside a single directory - entries = list(tmp_path.iterdir()) - if len(entries) == 1 and entries[0].is_dir(): - extracted_root = entries[0] - else: - extracted_root = tmp_path - - # Read manifest if it exists - manifest_path = extracted_root / "config" / "manifest.json" - manifest = {} - if manifest_path.exists(): - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - pass - - # Determine project name - if not name: - name = manifest.get( - "name", zip_file.stem.replace("livingui_", "").rsplit("_", 1)[0] - ) - if not name: - name = "imported_project" - - # Adopt the placeholder id when provided, else generate a new one - project_id = project_id or self._generate_id() - sanitized_name = self._sanitize_name(name) - project_path = self.living_ui_dir / f"{sanitized_name}_{project_id}" - - # Copy to Living UI workspace - shutil.copytree(extracted_root, project_path) - - # Allocate new ports - frontend_port = self._allocate_port() - backend_port = self._allocate_port() - - # Update manifest with new ID and ports - manifest_path = project_path / "config" / "manifest.json" - if manifest_path.exists(): - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - old_id = manifest.get("id", "") - old_port = str( - manifest.get("ports", {}).get( - "frontend", manifest.get("ports", {}).get("app", "") - ) - ) - old_backend = str(manifest.get("ports", {}).get("backend", "")) - - manifest_raw = manifest_path.read_text(encoding="utf-8") - if old_id: - manifest_raw = manifest_raw.replace(old_id, project_id) - if old_port and old_port != str(frontend_port): - manifest_raw = manifest_raw.replace(old_port, str(frontend_port)) - if old_backend and old_backend != str(backend_port): - manifest_raw = manifest_raw.replace(old_backend, str(backend_port)) - - manifest_path.write_text(manifest_raw, encoding="utf-8") - manifest = json.loads(manifest_raw) - except Exception as e: - logger.warning(f"[LIVING_UI] Could not update imported manifest: {e}") - - # Determine project type from manifest - project_type = manifest.get("projectType", "native") - app_runtime = manifest.get("appRuntime") - description = manifest.get("description", "") - - project = LivingUIProject( - id=project_id, - name=name, - description=description, - path=str(project_path), - status="ready", - port=frontend_port, - backend_port=backend_port, - project_type=project_type, - app_runtime=app_runtime, - ) - - # Preserve the session link from an adopted placeholder so todo/question - # broadcasts (keyed by session id) keep targeting this tab. - existing = self.projects.get(project_id) - if existing and existing.session_id: - project.session_id = existing.session_id - self.projects[project_id] = project - self._save_projects() - - logger.info(f"[LIVING_UI] Imported project '{name}' ({project_id}) from ZIP") - return project - def get_project_url(self, project_id: str) -> Optional[str]: """Get the URL for a running project.""" project = self.projects.get(project_id) diff --git a/app/living_ui/v2_runner.py b/app/living_ui/v2_runner.py new file mode 100644 index 00000000..4bd4bba7 --- /dev/null +++ b/app/living_ui/v2_runner.py @@ -0,0 +1,231 @@ +"""V2 Living UI runner — thin adapter between CraftBot and the living-ui-v2 +workspace (spec REQUIREMENTS §14/I1). + +Consumes only the two public contracts: + * the tools CLI (`create`, `validate`, `pb path`) for scaffold/gate/binary, + * the manifest pipeline semantics (single PocketBase process, /api/health). + +Knows nothing about the manager's registry, sessions, or broadcasting — +the manager composes this class; it never reaches back. +""" + +import asyncio +import json +import logging +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +GATE_TIMEOUT_S = 600 +INSTALL_TIMEOUT_S = 600 +HEALTH_TIMEOUT_S = 30 + + +@dataclass +class V2ScaffoldResult: + path: Path + id: str + slug: str + port: int + + +@dataclass +class V2GateResult: + passed: bool + output: str + + +class V2RunnerUnavailable(RuntimeError): + """Node or the living-ui-v2 workspace is missing.""" + + +class V2Runner: + """Drives V2 projects through scaffold → install → gate → serve.""" + + def __init__(self, workspace_dir: Path): + self.workspace_dir = Path(workspace_dir) + self._node = shutil.which("node") + + # ------------------------------------------------------------------ setup + + @property + def cli_path(self) -> Path: + return self.workspace_dir / "tools" / "src" / "cli.ts" + + def ensure_available(self) -> None: + if self._node is None: + raise V2RunnerUnavailable( + "Node.js >= 24 is required to build Living UIs (not found on PATH)." + ) + if not self.cli_path.exists(): + raise V2RunnerUnavailable( + f"living-ui-v2 workspace not found at {self.workspace_dir}" + ) + + def _cli(self, *args: str) -> list: + return [self._node, str(self.cli_path), *args] + + async def _run( + self, cmd: list, timeout: int, cwd: Optional[Path] = None + ) -> "tuple[int, str]": + """Run a command, return (exit_code, combined_output).""" + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(cwd) if cwd else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return 124, f"timed out after {timeout}s: {' '.join(map(str, cmd))}" + return proc.returncode or 0, out.decode(errors="replace") + + # ------------------------------------------------------------- lifecycle + + async def scaffold( + self, + name: str, + description: str, + parent_dir: Path, + port: int, + project_id: str, + auth_mode: str = "none", + folder: Optional[str] = None, + style: Optional[str] = None, + ) -> V2ScaffoldResult: + """Scaffold via `lui create --json` (copies blueprint, vendors kit, + substitutes placeholders, bootstraps superuser, canonizes hashes).""" + self.ensure_available() + args = [ + "create", + name, + "--description", + description, + "--dir", + str(parent_dir), + "--port", + str(port), + "--auth", + auth_mode, + "--id", + project_id, + "--json", + ] + if folder is not None: + args += ["--folder", folder] + if style: + args += ["--style", style] + code, out = await self._run(self._cli(*args), timeout=GATE_TIMEOUT_S) + if code != 0: + raise RuntimeError(f"scaffold failed:\n{out}") + # --json prints exactly one JSON line (steps log lines precede it). + payload = json.loads(out.strip().splitlines()[-1]) + return V2ScaffoldResult( + path=Path(payload["path"]), + id=payload["id"], + slug=payload["slug"], + port=int(payload["port"]), + ) + + async def install(self, project_dir: Path) -> None: + """Install frontend deps (skipped when node_modules already exists).""" + frontend = project_dir / "frontend" + if (frontend / "node_modules").exists(): + return + code, out = await self._run( + ["npm", "install", "--no-audit", "--no-fund"], + timeout=INSTALL_TIMEOUT_S, + cwd=frontend, + ) + if code != 0: + raise RuntimeError(f"npm install failed:\n{out[-4000:]}") + + async def gate(self, project_dir: Path) -> V2GateResult: + """Run the validation gate; output is the machine-readable error list.""" + self.ensure_available() + code, out = await self._run( + self._cli("validate", str(project_dir)), timeout=GATE_TIMEOUT_S + ) + return V2GateResult(passed=code == 0, output=out) + + async def kit_sync(self, project_dir: Path) -> None: + """Re-vendor the kit and re-canonize system-file hashes (used after + import, where identity rewrites invalidate the shipped hash canon).""" + code, out = await self._run( + self._cli("kit-sync", str(project_dir)), timeout=GATE_TIMEOUT_S + ) + if code != 0: + raise RuntimeError(f"kit-sync failed:\n{out[-2000:]}") + + async def pb_binary(self) -> Path: + code, out = await self._run(self._cli("pb", "path"), timeout=300) + if code != 0: + raise RuntimeError(f"could not resolve PocketBase binary:\n{out}") + return Path(out.strip().splitlines()[-1]) + + async def start(self, project_dir: Path, port: int) -> subprocess.Popen: + """Start the single production process: PocketBase serving app + API.""" + pb_bin = await self.pb_binary() + pb_dir = project_dir / "pb" + logs_dir = project_dir / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_file = open(logs_dir / "pocketbase.log", "a") + process = subprocess.Popen( + [ + str(pb_bin), + "serve", + f"--http=127.0.0.1:{port}", + "--dir", + str(pb_dir / "pb_data"), + "--hooksDir", + str(pb_dir / "pb_hooks"), + "--migrationsDir", + str(pb_dir / "pb_migrations"), + "--publicDir", + str(pb_dir / "pb_public"), + ], + stdout=log_file, + stderr=subprocess.STDOUT, + ) + logger.info(f"[LIVING_UI:V2] started PocketBase pid={process.pid} port={port}") + return process + + async def verify(self, project_dir: Path, url: str) -> "tuple[str, str]": + """Headless smoke verification of the running app (walk-verify core). + + Returns (status, detail) where status is 'pass' | 'fail' | 'skipped'. + Skipped (no browser installed) must not block a launch. + """ + code, out = await self._run( + self._cli("verify", str(project_dir), "--url", url), timeout=120 + ) + detail = out.strip().splitlines()[-1] if out.strip() else "{}" + if code == 0: + return "pass", detail + if code == 2: + return "skipped", detail + return "fail", detail + + async def wait_healthy(self, port: int, timeout: int = HEALTH_TIMEOUT_S) -> bool: + """Poll /api/health until 200 or timeout.""" + import urllib.request + + deadline = asyncio.get_event_loop().time() + timeout + url = f"http://127.0.0.1:{port}/api/health" + while asyncio.get_event_loop().time() < deadline: + try: + status = await asyncio.to_thread( + lambda: urllib.request.urlopen(url, timeout=2).status + ) + if status == 200: + return True + except Exception: + pass + await asyncio.sleep(0.5) + return False diff --git a/app/living_ui/walk_verify.py b/app/living_ui/walk_verify.py new file mode 100644 index 00000000..7a5b4d76 --- /dev/null +++ b/app/living_ui/walk_verify.py @@ -0,0 +1,155 @@ +"""Walk-verify hard gate (Living UI). + +Runs the ``walk_verify`` sub-agent against a RUNNING project and parses its +verdicts. Called by ``living_ui_notify_ready`` AFTER a successful launch — +success is only reported to the building agent when every feature verdict +is pass/unverified. Structural by design: the building agent cannot skip it +or grade itself. +""" + +import logging +import re +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _runtime(): + from app.internal_action_interface import InternalActionInterface as I + + parts = ( + I.subagent_manager, + I.action_manager, + I.action_library, + I.llm_interface, + I.event_stream_manager, + ) + return None if any(p is None for p in parts) else parts + + +async def run_walk_verify(project: Any) -> Optional[Dict[str, Any]]: + """Run the walk_verify sub-agent for a running project. + + Returns the parsed verdict dict, or None when the sub-agent runtime is + unavailable (headless/test contexts) — callers treat None as 'skipped', + never as 'pass'. + """ + runtime = _runtime() + if runtime is None: + return None + mgr, action_manager, action_library, llm, event_stream_manager = runtime + + from app.subagent.runner import SubAgentRunner + + query = ( + f"Verify the Living UI project '{project.name}'.\n" + f"project_id: {project.id}\n" + f"project_path: {project.path}\n" + f"base_url: http://127.0.0.1:{project.port}\n" + f"Requirements: read {project.path}/reference/requirements.md " + f"(fallback: the feature checklist in {project.path}/LIVING_UI.md)." + ) + + sub = mgr.spawn( + agent_type="walk_verify", + query=query, + parent_task_id=project.session_id, + parent_temp_dir=None, + ) + runner = SubAgentRunner( + subagent_manager=mgr, + action_manager=action_manager, + action_library=action_library, + event_stream_manager=event_stream_manager, + llm_interface=llm, + ) + + # Same dedicated log file as agent-spawned sub-agents: + # //sub_walk_verify_.log + from app.logger import ( + add_subagent_log_sink, + logger as app_logger, + remove_subagent_log_sink, + ) + + short_id = sub.id[4:] if sub.id.startswith("sub_") else sub.id + agent_tag = f"sub:{sub.agent_type}:{short_id}" + log_session = project.session_id or "main" + sink_id = add_subagent_log_sink(agent_tag, log_session) + try: + with app_logger.contextualize(agent=agent_tag, session=log_session): + sub = await runner.run_to_completion(sub) + finally: + remove_subagent_log_sink(sink_id) + + raw = (getattr(sub, "result", None) or "").strip() + return parse_check_report(raw) + + +# --------------------------------------------------------------------------- +# Check-report parsing (ported from PR #388 — pure, testable, no I/O). +# --------------------------------------------------------------------------- + +_BLOCKED_MARKERS = ( + "mcp server connection lost", + "browser mcp", + "browser is unavailable", + "browser tool", + "no features could be tested", + "could not launch a browser", +) + + +def _reads_as_blocked(result_text: str) -> bool: + body = (result_text or "").lower() + return any(marker in body for marker in _BLOCKED_MARKERS) + + +def parse_check_report(text: str) -> Dict[str, Any]: + """Classify a walk_verify result. kinds: + pass | defects | incomplete (NOT REACHED, defect-free) | blocked.""" + text = text or "" + m = re.search(r"VERDICT:\s*(PASS|FAIL|BLOCKED)", text, re.IGNORECASE) + verdict = m.group(1).upper() if m else None + + # A FAIL whose body describes a blockage is a blockage wearing a FAIL + # costume — never dispatch fixes for defects nobody observed. + if verdict == "FAIL" and _reads_as_blocked(text): + verdict = "BLOCKED" + + if verdict == "PASS": + return {"kind": "pass", "passed": _passed(text), "defects": [], "raw": text} + + if verdict == "FAIL": + # Feature lines come from the FEATURES section ONLY — prose in + # FAILURES/BLOCKED BY must never become a work order. + feature_section = re.split( + r"^\s*(?:FAILURES|BLOCKED BY)\b", + text, + maxsplit=1, + flags=re.MULTILINE | re.IGNORECASE, + )[0] + passed = _passed(feature_section) + defects = [ + d.strip() + for d in re.findall( + r"^-\s+(?!.*\bNOT REACHED\b).*(?:—|–|:|-)\s*FAIL\b.*$", + feature_section, + re.MULTILINE, + ) + ] + if not defects and re.search(r"NOT REACHED", feature_section, re.IGNORECASE): + return {"kind": "incomplete", "passed": passed, "defects": [], "raw": text} + return {"kind": "defects", "passed": passed, "defects": defects, "raw": text} + + return {"kind": "blocked", "passed": [], "defects": [], "raw": text} + + +def _passed(section: str) -> list: + return [ + f.strip() + for f in re.findall( + r"^-\s+(.{1,120}?)\s*(?:—|–|:|-)\s*PASS\b", section, re.MULTILINE + ) + if f.strip() + ] diff --git a/app/subagent/definitions/__init__.py b/app/subagent/definitions/__init__.py index 94a6876a..f722ee37 100644 --- a/app/subagent/definitions/__init__.py +++ b/app/subagent/definitions/__init__.py @@ -22,4 +22,5 @@ """ from app.subagent.definitions import research_agent # noqa: F401 +from app.subagent.definitions import walk_verify # noqa: F401 # from app.subagent.definitions import validation_agent # noqa: F401 diff --git a/app/subagent/definitions/walk_verify.py b/app/subagent/definitions/walk_verify.py new file mode 100644 index 00000000..82e2591e --- /dev/null +++ b/app/subagent/definitions/walk_verify.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""walk_verify — the independent "does it actually work?" gate. + +A coding agent that both writes AND signs off can convince itself a compiling +shell is "done". This agent is the independent CI: it drives the RUNNING app +in a real browser against the requirements and returns a per-feature verdict. +It is READ-ONLY — it never edits code; failures go back to the build session +to fix. Spawned by ``living_ui_notify_ready`` after launch; the launch is not +"ready" until this passes. (Contract ported from PR #388.) +""" + +from app.subagent.registry import register_subagent + +SYSTEM_PROMPT = """\ +You verify that a built app actually WORKS. It is RUNNING in a browser; your +job is to use it the way its user will and decide, per feature, whether it +genuinely works — not whether it compiles. You never edit code. + +ALLOWED ACTIONS (you cannot use anything else): +{action_list} + +Every action call must be: +{{"action_name": "", "parameters": {{...all required fields...}}}} +(the key is "parameters"). A tool error like "X is required" means YOUR call +was malformed — retry the same action with corrected parameters; it is never +evidence about the app. + +THE QUERY gives you the app URL, the project path, and the requirements path. +If any is missing, sub_task_end status="failed" naming what was missing. + +BROWSER RULES (violating these blinds you): +- Call mcp_playwright-mcp_browser_snapshot / browser_take_screenshot with NO + filename — bare, the snapshot/console/network arrive INLINE in the result. +- The snapshot is an accessibility tree with element refs — use those refs for + browser_click / browser_type targets. +- If the mcp_playwright browser tools are unavailable in this environment + (the runner tells you an action is "not installed"), fall back to + browser_probe (scripted steps: goto/click/type/read/screenshot with CSS + selectors) plus living_ui_http for API checks. + +YOUR WALK: +1. read_file the requirements → a numbered list of the FEATURES a user should + be able to do (one per capability). +2. Open the app: browser_navigate to the app URL, then browser_snapshot. If + the page is blank, an error boundary, or only skeletons, that is a FAIL for + everything — the app doesn't run. +3. For EACH feature, actually DO it with realistic data (browser_click / + browser_type / browser_fill_form), in the order a first user would (onboard + first, then the flows that need that state). After each step, snapshot and + confirm the app RESPONDED: data appeared, navigation happened, the value + updated, it persisted. "The control exists" is NOT working — it must DO the + thing. +4. After each flow, check mcp_playwright-mcp_browser_console_messages — a + runtime error during normal use = FAIL for that feature. ONLY errors that + appeared DURING YOUR OWN flows count: the browser is shared, so never + request the full history (all=true), and never judge from errors you did + not see happen after your own first navigate. +5. PERSISTENCE — do this once, for a feature that saves data: after creating a + record, browser_navigate to the app URL again (a full reload) and snapshot. + If the data is gone, that feature is a FAIL ("saves" that vanish on reload + are the most common way an app looks finished and isn't). +6. Decide each feature and end. + +VERDICTS (mechanical, not stylistic): +V1. PASS a feature ONLY with concrete evidence from an action YOU ran: a + snapshot showing the result, a value you read back. "The code looks right" + is not evidence. +V2. A feature you could not exercise (control missing/unreachable, flow blocked, + placeholder / "coming soon" / dead button) = FAIL, with what you observed. +V3. No minor category: one console error during normal use = FAIL; a feature + that "mostly" works = FAIL. +V4. FAIL means YOU SAW THE APP MISBEHAVE. If you could not exercise the app at + all — the browser tools error out, the MCP connection is lost, the URL is + unreachable — that is NOT the app's fault and NOT a FAIL: end with + VERDICT: BLOCKED and say what stopped you. Reporting "all features FAIL — + could not connect" sends engineers to fix features that may be fine. +V5. BUDGET YOUR TURNS. Your verdict must be DELIVERED before the iteration + cap — a walk that dies at the cap reports nothing. If you cannot cover + everything, report what you verified and mark the rest '— NOT REACHED' + (never FAIL): honest partial coverage beats fabricated completeness. + +OUTPUT — end with ONE sub_task_end call, status="completed", and this in +`result` (plain text, NOT JSON): +``` +VERDICT: PASS | FAIL | BLOCKED +FEATURES: +- — PASS — +- — FAIL — +- — NOT REACHED +FAILURES (only if any FAIL): +- : +BLOCKED BY (only if BLOCKED): +- +``` +VERDICT is PASS only if EVERY feature in your scope passed (NOT REACHED +entries mean the walk is incomplete). Use FAIL only for behaviour you +observed; use BLOCKED when you never got to observe any. +""" + + +register_subagent( + name="walk_verify", + description=( + "Independently drives a RUNNING Living UI in a real browser against " + "its requirements; returns per-feature PASS/FAIL verdicts with evidence" + ), + system_prompt=SYSTEM_PROMPT, + actions=[ + # Real browser (playwright MCP), read-only. + "mcp_playwright-mcp_browser_navigate", + "mcp_playwright-mcp_browser_snapshot", + "mcp_playwright-mcp_browser_click", + "mcp_playwright-mcp_browser_type", + "mcp_playwright-mcp_browser_fill_form", + "mcp_playwright-mcp_browser_press_key", + "mcp_playwright-mcp_browser_select_option", + "mcp_playwright-mcp_browser_wait_for", + "mcp_playwright-mcp_browser_console_messages", + "mcp_playwright-mcp_browser_network_requests", + "mcp_playwright-mcp_browser_take_screenshot", + # Fallback browser + API when MCP is unavailable. + "browser_probe", + "living_ui_http", + # Read the requirements + inspect (never edit). + "read_file", + "grep_files", + "list_folder", + ], + max_iterations=50, + max_wall_seconds=1800, + # The MCP browser is SHARED and long-lived: its console history contains + # other agents' visits to OLD builds. all=False scopes every console read + # to recent entries so a walk can't condemn a fresh build with a dead + # build's crashes. + param_overrides=( + ("mcp_playwright-mcp_browser_console_messages", (("all", False),)), + ), +) diff --git a/app/subagent/registry.py b/app/subagent/registry.py index 878d05dc..7fd086d6 100644 --- a/app/subagent/registry.py +++ b/app/subagent/registry.py @@ -54,6 +54,16 @@ class SubAgentDefinition: actions: Tuple[str, ...] max_iterations: int max_wall_seconds: int + # Forced parameters per action: ((action_name, ((param, value), ...)), ...) + # e.g. scope shared-browser console reads to recent entries only. + param_overrides: Tuple[Tuple[str, Tuple[Tuple[str, object], ...]], ...] = () + + def overrides_for(self, action_name: str) -> Dict[str, object]: + """The forced parameters for one action ({} when none).""" + for name, pairs in self.param_overrides: + if name == action_name: + return dict(pairs) + return {} @property def compiled_actions(self) -> List[str]: @@ -74,6 +84,7 @@ def register_subagent( actions: Iterable[str], max_iterations: int, max_wall_seconds: int, + param_overrides: Tuple[Tuple[str, Tuple[Tuple[str, object], ...]], ...] = (), ) -> None: """Register a sub-agent type. @@ -135,6 +146,7 @@ def register_subagent( actions=tuple(cleaned), max_iterations=max_iterations, max_wall_seconds=max_wall_seconds, + param_overrides=param_overrides, ) logger.debug( f"[SubAgentRegistry] Registered {name!r} " diff --git a/app/subagent/runner.py b/app/subagent/runner.py index 7df2b1da..957ff7c3 100644 --- a/app/subagent/runner.py +++ b/app/subagent/runner.py @@ -63,6 +63,10 @@ # Max LLM format-error retries per turn before the runner aborts the sub-agent. _MAX_PARSE_RETRIES = 3 +# Hard ceiling on ONE LLM round-trip. Generous (large prompts + slow +# providers) but finite — the wall-clock cap depends on calls returning. +_LLM_CALL_TIMEOUT_S = 300 + # Sub-agents only ever do action selection — never GUI or reasoning calls — # so a single call type covers their entire lifetime. _SUBAGENT_CALL_TYPE = LLMCallType.ACTION_SELECTION @@ -263,7 +267,9 @@ def _fail_unparseable(self, sub: SubAgent, parse_error: Optional[str]) -> None: async def _dispatch_action(self, sub: SubAgent, decision: Dict[str, Any]) -> None: action_name = decision.get("action_name") or "" - parameters = decision.get("parameters") or {} + # Models frequently emit "params" instead of "parameters" — accept both + # (dropping the payload silently starved every tool call of its input). + parameters = decision.get("parameters") or decision.get("params") or {} if not isinstance(parameters, dict): parameters = {} @@ -282,6 +288,16 @@ async def _dispatch_action(self, sub: SubAgent, decision: Dict[str, Any]) -> Non ) return + # Apply registry-forced parameters (e.g. shared-browser hygiene). + from app.subagent.registry import get_subagent_definition + + try: + forced = get_subagent_definition(sub.agent_type).overrides_for(action_name) + except Exception: + forced = {} + if forced: + parameters = {**parameters, **forced} + action = self.action_library.retrieve_action(action_name) if action is None: msg = ( @@ -415,14 +431,34 @@ async def _invoke_llm( ``system_prompt_for_new_session`` is passed every turn so the LLM interface can recreate the session if a context-overflow reset happened underneath us. + + Hard per-call timeout: a connection that dies mid-request (e.g. a + laptop sleep/wake severing the socket) otherwise blocks this await + forever — and the runner's wall-clock cap is only checked BETWEEN + turns, so one dead socket wedged the whole session + (observed: 20260724181301, 19-minute hang). """ - return await self.llm_interface.generate_response_with_session_async( - task_id=sub.id, - call_type=_SUBAGENT_CALL_TYPE, - user_prompt=user_prompt, - system_prompt_for_new_session=system_prompt, - prompt_name=f"SUBAGENT_{sub.agent_type.upper()}", - ) + import asyncio + + try: + return await asyncio.wait_for( + self.llm_interface.generate_response_with_session_async( + task_id=sub.id, + call_type=_SUBAGENT_CALL_TYPE, + user_prompt=user_prompt, + system_prompt_for_new_session=system_prompt, + prompt_name=f"SUBAGENT_{sub.agent_type.upper()}", + ), + timeout=_LLM_CALL_TIMEOUT_S, + ) + except asyncio.TimeoutError as timeout_err: + raise LLMConsecutiveFailureError( + 1, + last_error=TimeoutError( + f"sub-agent LLM call exceeded {_LLM_CALL_TIMEOUT_S}s " + "(connection presumed dead)" + ), + ) from timeout_err @staticmethod def _augment_with_retry_hint(base: str, attempt: int, error: str) -> str: @@ -501,6 +537,14 @@ def _parse_decision( if not isinstance(parsed, dict): return None, "parsed value is not a dict" if "action_name" not in parsed: + # Salvage: models under repeated correction sometimes emit just the + # bare final-result object. Wrap it as an explicit terminator so a + # usable result is never thrown away over formatting. + if any(k in parsed for k in ("result", "verdicts", "summary")): + return { + "action_name": "sub_task_end", + "parameters": {"status": "completed", "result": json.dumps(parsed)}, + }, None return None, "missing 'action_name' field" return parsed, None diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 09318a85..9d78acc2 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -702,12 +702,7 @@ def __init__( self._staged_bundles: Dict[str, bytes] = {} # Living UI manager - template_path = ( - Path(__file__).parent.parent.parent / "data" / "living_ui_template" - ) - self._living_ui_manager = LivingUIManager( - workspace_root=AGENT_WORKSPACE_ROOT, template_path=template_path - ) + self._living_ui_manager = LivingUIManager(workspace_root=AGENT_WORKSPACE_ROOT) # Bind session manager and trigger service for project sessions agent = self._controller.agent self._living_ui_manager.bind_session_manager( @@ -870,6 +865,9 @@ async def _on_start(self) -> None: self._app.router.add_post( "/api/living-ui/import", self._living_ui_import_handler ) + self._app.router.add_post( + "/api/living-ui/stage", self._living_ui_stage_handler + ) # Workspace and chat HTTP upload routes self._app.router.add_post( @@ -2461,14 +2459,47 @@ async def _handle_living_ui_create(self, data: Dict[str, Any]) -> None: return # Create the project (directory/template) + auth_mode = data.get("authMode", "none") + layout = data.get("layout", "") + style_pack = data.get("stylePack", "") + ref_files = data.get("referenceFiles") or [] + + # Fold wizard choices into the build description so they land in + # the task instruction and reference/requirements.md. + extras = [] + if layout and layout != "free": + extras.append(f"Layout preference: {layout}") + if style_pack: + extras.append(f"Style pack (visual theme): {style_pack}") + if ref_files: + names = ", ".join(Path(f).name for f in ref_files[:10]) + extras.append( + f"Reference files (design sketches/docs) in reference/: {names} — " + "study them before designing the UI." + ) + if extras: + description = description + "\n\n" + "\n".join(extras) + project = await self._living_ui_manager.create_project( name=name, description=description, features=features, data_source=data_source, theme=theme, + auth_mode=auth_mode, + style_pack=style_pack, ) + # Move staged reference files into the project. + if ref_files: + ref_dir = Path(project.path) / "reference" + ref_dir.mkdir(parents=True, exist_ok=True) + for f in ref_files[:10]: + src = Path(f) + staging_root = Path(self._living_ui_manager.living_ui_dir) / "_staging" + if src.exists() and staging_root in src.parents: + shutil.move(str(src), str(ref_dir / src.name)) + # Broadcast project created await self._broadcast( { @@ -2477,6 +2508,7 @@ async def _handle_living_ui_create(self, data: Dict[str, Any]) -> None: "success": True, "projectId": project.id, "project": project.to_dict(), + "stylePack": style_pack, }, } ) @@ -2704,6 +2736,40 @@ async def _living_ui_export_handler(self, request: "web.Request") -> "web.Respon logger.error(f"[LIVING_UI] Export error: {e}") return web.json_response({"error": str(e)}, status=500) + async def _living_ui_stage_handler(self, request: "web.Request") -> "web.Response": + """Stage a reference file (sketch/screenshot/doc) for a NEW Living UI. + + Saves under living_ui/_staging/refs/ and returns {"path": ...}. The + create flow moves staged files into the project's reference/ dir. + """ + from aiohttp import web + + try: + reader = await request.multipart() + saved = None + async for part in reader: + if part.name == "file": + filename = Path(part.filename or "reference.bin").name + staging = Path(self._living_ui_manager.living_ui_dir) / "_staging" / "refs" + staging.mkdir(parents=True, exist_ok=True) + target = staging / filename + i = 1 + while target.exists(): + target = staging / f"{target.stem.split('__')[0]}__{i}{target.suffix}" + i += 1 + with open(target, "wb") as f: + while True: + chunk = await part.read_chunk() + if not chunk: + break + f.write(chunk) + saved = str(target) + if saved is None: + return web.json_response({"error": "no file"}, status=400) + return web.json_response({"path": saved}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + async def _living_ui_import_handler(self, request: "web.Request") -> "web.Response": """HTTP handler: stage a ZIP file upload and return the temp path. @@ -3195,11 +3261,12 @@ async def broadcast_living_ui_created(self, project: Dict[str, Any]) -> None: ) async def broadcast_living_ui_question( - self, project_id: str, session_id: str, message: str + self, project_id: str, session_id: str, message: str, options=None ) -> None: """Mirror an agent question onto the creation screen so the user can answer from the Living UI page even when the chat panel is closed. The - on-screen answer is sent back as a reply targeting `session_id`.""" + on-screen answer is sent back as a reply targeting `session_id`. + `options` (list of strings) renders as tap-to-answer chips.""" await self._broadcast( { "type": "living_ui_question", @@ -3207,6 +3274,7 @@ async def broadcast_living_ui_question( "projectId": project_id, "sessionId": session_id, "message": message, + "options": options or [], }, } ) @@ -6380,128 +6448,39 @@ async def _handle_marketplace_install( ) async def _handle_living_ui_import(self, source: str, name: str) -> None: - """Handle import of an external app or ZIP — queues an import run - (with the importer skill) in the placeholder project's session.""" + """Import a Living UI. V2 supports exported ZIPs (round-trip with + export); GitHub/path/foreign-app adoption returns with the V2 import + workflow (WORKFLOWS §7).""" if not source: return - - is_zip = source.lower().endswith(".zip") - - # Spawn a placeholder tab immediately so the user sees the import is - # underway (mirrors the form-create flow). The importer skill adopts - # this project_id so the same tab transitions to the running app. - placeholder = self._living_ui_manager.create_placeholder_project(name) - project_id = placeholder.id - await self.broadcast_living_ui_created(placeholder.to_dict()) - await self._broadcast( - { - "type": "living_ui_status", - "data": { - "projectId": project_id, - "phase": "initializing", - "progress": 10, - "message": "Importing project...", - }, - } - ) - - adopt_note = ( - f"A tab has already been created for this import with " - f'project_id="{project_id}". You MUST pass project_id="{project_id}" ' - f"to the import action so it populates that existing tab instead of " - f"creating a duplicate.\n\n" - ) - - if is_zip: - import_instruction = ( - f"Import this Living UI project from a ZIP file:\n" - f"ZIP path: {source}\n" - f"Name: {name}\n\n" - f"{adopt_note}" - f"Steps:\n" - f'1. Call living_ui_import_zip (project_id="{project_id}") to extract and register the project\n' - f"2. Review the project structure and manifest\n" - f"3. Install dependencies if needed\n" - f"4. Launch the app and verify it works\n" - f"5. Clean up the ZIP file after successful import" - ) - else: - import_instruction = ( - f"Import this external app as a Living UI:\n" - f"Source: {source}\n" - f"Name: {name}\n\n" - f"{adopt_note}" - f"Follow the living-ui-importer skill instructions:\n" - f"1. Clone/copy the source code\n" - f"2. Detect the app type (Go, Node, Python, etc.) — NEVER use Docker if native build is possible\n" - f"3. Determine build/install command, start command, port config, and health check\n" - f'4. Call living_ui_import_external with the detected configuration and project_id="{project_id}"\n' - f"5. Launch the app and verify it works\n" - f"6. Create LIVING_UI.md documenting the app" - ) - - # The project's dedicated session hosts the import run, so - # question-mirroring and todo broadcasts (keyed by session id) - # target this tab. - import_session = self._living_ui_manager.ensure_project_session(placeholder) - - if import_session: - from app.triggers import TriggerSource, TriggerSpec - - await self._controller.agent.trigger_service.emit( - TriggerSpec( - source=TriggerSource.LIVING_UI_IMPORT, - description=import_instruction, - priority=50, - session_id=import_session.id, - payload={ - "type": "living_ui_import", - "source": source, - "workflow_skills": ["living-ui-importer"], - "workflow_action_sets": [ - "file_operations", - "code_execution", - "living_ui", - "core", - ], - }, - ) - ) - else: - # Couldn't create the session — don't leave a stuck "creating" tab. + if not source.lower().endswith(".zip"): await self._broadcast( { "type": "living_ui_error", "data": { - "projectId": project_id, - "error": "Failed to start import run", + "projectId": "", + "error": ( + "Only exported Living UI ZIPs can be imported right " + "now — GitHub/path import returns with the V2 " + "import workflow." + ), }, } ) - - # Mirror the import into chat as a system message so the request is - # visible in the conversation (not just the new tab). - origin = "uploaded ZIP file" if is_zip else source + return try: - await self._display_chat_message( - "System", - f"**Living UI: {name}**\n\nImporting from {origin}.\n\n" - "Setting up your app now — track progress in the new tab.", - "system", + project = await self._living_ui_manager.import_project_zip( + source, name or None ) + await self.broadcast_living_ui_created(project.to_dict()) except Exception as e: - logger.debug(f"[LIVING_UI] import chat message failed: {e}") - - await self._broadcast( - { - "type": "living_ui_import", - "data": {"status": "started", "name": name, "source": source}, - } - ) - - # ===================== - # WhatsApp QR Code Flow - # ===================== + await self._broadcast( + { + "type": "living_ui_error", + "data": {"projectId": "", "error": f"Import failed: {e}"}, + } + ) + return async def _handle_whatsapp_start_qr(self) -> None: """Start WhatsApp Web session and return QR code.""" diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.module.css b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.module.css index a63e31b5..2b4857a9 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.module.css @@ -338,9 +338,9 @@ remaining vertical space inside .centeredForm. Targeted via :has() so only the description block stretches — the project-name field stays its natural height. */ +/* The wizard form scrolls; fields keep natural height. */ .centeredForm .formGroup:has(.textareaLarge) { - flex: 1; - min-height: 0; + flex: none; } /* Scrollable body for form-based tabs */ @@ -399,7 +399,9 @@ .textareaLarge { width: 100%; - flex: 1; + flex: none; + min-height: 120px; + resize: vertical; padding: var(--space-2) var(--space-3); font-size: var(--text-sm); font-family: inherit; diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx index 24ad3bb7..89815432 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx @@ -34,6 +34,31 @@ interface MarketplaceApp { const MAX_WORDS = 5000 +const LAYOUTS: { id: string; label: string }[] = [ + { id: 'free', label: 'Free — agent decides' }, + { id: 'sidebar-body', label: 'Sidebar + body' }, + { id: 'topnav-body', label: 'Top nav + body' }, + { id: 'hero-cards', label: 'Hero + cards' }, + { id: 'split-view', label: 'Split view' }, + { id: 'dashboard-grid', label: 'Dashboard grid' }, + { id: 'columns-board', label: 'Columns board' }, + { id: 'single-page', label: 'Single page' }, +] + +const STYLE_PACKS: { id: string; label: string; bg: string; accent: string }[] = [ + { id: 'craftbot', label: 'CraftBot', bg: '#1d1d22', accent: '#ff4f18' }, + { id: 'normal', label: 'Modern', bg: '#16181f', accent: '#3b82f6' }, + { id: 'glass', label: 'Glass', bg: '#1e2436', accent: '#818cf8' }, + { id: 'classic', label: 'Classic', bg: '#26231b', accent: '#d4a017' }, + { id: 'velvet', label: 'Velvet', bg: '#281826', accent: '#ec4899' }, + { id: 'ink', label: 'Ink', bg: '#f5f5f5', accent: '#111111' }, + { id: 'acid', label: 'Acid', bg: '#1b2513', accent: '#a3e635' }, + { id: 'blueprint', label: 'Blueprint', bg: '#102039', accent: '#60a5fa' }, + { id: 'ocean', label: 'Ocean', bg: '#102635', accent: '#38bdf8' }, + { id: 'forest', label: 'Forest', bg: '#14261a', accent: '#4ade80' }, + { id: 'pastel', label: 'Pastel', bg: '#251c2e', accent: '#c084fc' }, +] + function countWords(text: string): number { const trimmed = text.trim() if (!trimmed) return 0 @@ -61,6 +86,29 @@ export function CreateLivingUIModal({ isOpen, onClose, onSubmit, onInstalled }: const [configuringApp, setConfiguringApp] = useState(null) const installTimeoutsRef = useRef>>(new Map()) const [customValues, setCustomValues] = useState>({}) + const [theme, setTheme] = useState<'system' | 'light' | 'dark'>('system') + const [authMode, setAuthMode] = useState<'none' | 'multi-user'>('none') + const [layout, setLayout] = useState('free') + const [stylePack, setStylePack] = useState('craftbot') + const [refFiles, setRefFiles] = useState<{ name: string; path: string }[]>([]) + const [uploadingRef, setUploadingRef] = useState(false) + + const uploadRefFiles = async (files: FileList | File[]) => { + setUploadingRef(true) + try { + for (const file of Array.from(files).slice(0, 10 - refFiles.length)) { + const fd = new FormData() + fd.append('file', file) + const res = await fetch('/api/living-ui/stage', { method: 'POST', body: fd }) + if (res.ok) { + const data = await res.json() + setRefFiles(prev => [...prev, { name: file.name, path: data.path }]) + } + } + } finally { + setUploadingRef(false) + } + } // Marketplace filter state const [searchQuery, setSearchQuery] = useState('') @@ -288,7 +336,15 @@ export function CreateLivingUIModal({ isOpen, onClose, onSubmit, onInstalled }: const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!validate()) return - onSubmit({ name: name.trim(), description: description.trim() }) + onSubmit({ + name: name.trim(), + description: description.trim(), + theme, + authMode, + layout, + stylePack, + referenceFiles: refFiles.map(f => f.path), + }) } // Fully unmount when closed and no installs pending; stay mounted (invisible) while installs run @@ -514,7 +570,7 @@ export function CreateLivingUIModal({ isOpen, onClose, onSubmit, onInstalled }: placeholder="Describe what you want the Living UI to display and do. Be specific about the data, layout, interactions, styling preferences, and any external APIs or data sources to use..." value={description} onChange={e => setDescription(e.target.value)} - rows={12} + rows={5} />
@@ -526,6 +582,88 @@ export function CreateLivingUIModal({ isOpen, onClose, onSubmit, onInstalled }:
{errors.description && {errors.description}}
+ +
+ +
e.preventDefault()} + onDrop={e => { e.preventDefault(); void uploadRefFiles(e.dataTransfer.files) }} + onClick={() => document.getElementById('lui-ref-input')?.click()} + style={{ border: '1px dashed var(--color-border, #444)', borderRadius: 8, padding: '14px', textAlign: 'center', cursor: 'pointer', fontSize: 13, opacity: 0.85 }} + > + {uploadingRef ? 'Uploading…' : 'Drop design sketches, screenshots, or documents — or click to browse'} + { if (e.target.files) void uploadRefFiles(e.target.files) }} /> +
+ {refFiles.length > 0 && ( +
+ {refFiles.map(f => ( + + {f.name} + + + ))} +
+ )} +
+ +
+ +
+ {LAYOUTS.map(l => ( + + ))} +
+
+ +
+ +
+ {STYLE_PACKS.map(t => ( + + ))} +
+
+ +
+
+ + +
+
+ + +
+
diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx index a758b72c..19b61c9e 100644 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx +++ b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx @@ -8,6 +8,7 @@ import styles from './LivingUIPage.module.css' interface Props { projectName: string todos: LivingUITodo[] | undefined + statusMessage?: string } const HINTS = [ @@ -70,7 +71,7 @@ function deriveProgressView(todos: LivingUITodo[] | undefined): ProgressView { } } -export function CreationProgress({ projectName, todos }: Props) { +export function CreationProgress({ projectName, todos, statusMessage }: Props) { const hint = useRotatingHint(HINTS) const view = useMemo(() => deriveProgressView(todos), [todos]) @@ -85,6 +86,11 @@ export function CreationProgress({ projectName, todos }: Props) {
{view.stepLabel} + {statusMessage && ( + + {statusMessage} + + )}
diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationQuestionForm.tsx b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationQuestionForm.tsx index 3c754bef..81713f59 100644 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationQuestionForm.tsx +++ b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationQuestionForm.tsx @@ -3,6 +3,7 @@ import { ArrowRight, MessagesSquare } from 'lucide-react' import styles from './CreationQuestionForm.module.css' interface Props { + options?: string[] projectName: string message: string onAnswer?: (text: string) => void @@ -51,7 +52,7 @@ function parseQuestions(message: string): ParsedQuestions { * the normal reply path, resuming the task — so answering here or in chat are * equivalent (whichever lands first wins). */ -export function CreationQuestionForm({ projectName, message, onAnswer }: Props) { +export function CreationQuestionForm({ projectName, message, options, onAnswer }: Props) { const parsed = useMemo(() => parseQuestions(message), [message]) const multi = parsed.items.length > 0 const [single, setSingle] = useState('') @@ -131,6 +132,39 @@ export function CreationQuestionForm({ projectName, message, onAnswer }: Props) )} + {options && options.length > 0 && ( +
+ {options.map(opt => ( + + ))} +
+ )} +
Answer here or in chat · ⌘/Ctrl + Enter + + + {loading ? ( +

Loading…

+ ) : error !== null ? ( +

{error}

+ ) : ( + + rows={records} + rowKey={(r) => r.id} + emptyMessage="No items yet — add your first one above." + columns={[ + { + key: 'done', + header: '', + className: 'w-10', + render: (r) => ( + void toggleDone(r)} + aria-label={`Mark "${r.title}" ${r.done ? 'not done' : 'done'}`} + /> + ), + }, + { + key: 'title', + header: 'Title', + render: (r) => ( + {r.title} + ), + }, + { + key: 'actions', + header: '', + className: 'w-20 text-right', + render: (r) => ( + + ), + }, + ]} + /> + )} +
+ + + { + if (!open) setPendingDelete(null); + }} + title="Delete item?" + description={pendingDelete !== null ? `"${pendingDelete.title}" will be permanently removed.` : undefined} + footer={ + <> + + + + } + > + + + + ); +} diff --git a/living-ui-v2/blueprint/frontend/src/config.gen.ts b/living-ui-v2/blueprint/frontend/src/config.gen.ts new file mode 100644 index 00000000..95843429 --- /dev/null +++ b/living-ui-v2/blueprint/frontend/src/config.gen.ts @@ -0,0 +1,2 @@ +// SYSTEM FILE — generated at scaffold time (spec P1/B4). Do not edit. +export const AUTH_MODE = '{{AUTH_MODE}}' as 'none' | 'multi-user'; diff --git a/living-ui-v2/blueprint/frontend/src/kit/.gitkeep b/living-ui-v2/blueprint/frontend/src/kit/.gitkeep new file mode 100644 index 00000000..9a566c26 --- /dev/null +++ b/living-ui-v2/blueprint/frontend/src/kit/.gitkeep @@ -0,0 +1 @@ +# The kit is vendored here by `lui create` / `lui kit-sync` (spec D6). diff --git a/living-ui-v2/blueprint/frontend/src/main.tsx b/living-ui-v2/blueprint/frontend/src/main.tsx new file mode 100644 index 00000000..6aad2890 --- /dev/null +++ b/living-ui-v2/blueprint/frontend/src/main.tsx @@ -0,0 +1,24 @@ +// SYSTEM FILE — managed by tooling, never edited by agents (spec P1/K4). +import { createRoot } from 'react-dom/client'; +import { StrictMode } from 'react'; +import { LoginGate, Shell } from './kit/index.ts'; +import { AUTH_MODE } from './config.gen.ts'; +import { App } from './app/App.tsx'; +import './app.css'; + +const rootEl = document.getElementById('root'); +if (rootEl === null) throw new Error('missing #root element'); + +createRoot(rootEl).render( + + + {AUTH_MODE === 'multi-user' ? ( + + + + ) : ( + + )} + + , +); diff --git a/living-ui-v2/blueprint/frontend/tsconfig.json b/living-ui-v2/blueprint/frontend/tsconfig.json new file mode 100644 index 00000000..3b910a93 --- /dev/null +++ b/living-ui-v2/blueprint/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/living-ui-v2/blueprint/frontend/vite.config.ts b/living-ui-v2/blueprint/frontend/vite.config.ts new file mode 100644 index 00000000..2ae203c2 --- /dev/null +++ b/living-ui-v2/blueprint/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// SYSTEM FILE — managed by tooling (spec P1). +// Build output goes to ../pb/pb_public: PocketBase serves the app (spec D5). +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + outDir: '../pb/pb_public', + emptyOutDir: true, + }, + server: { + port: Number(process.env['LUI_DEV_PORT'] ?? 5173), + strictPort: false, + }, +}); diff --git a/living-ui-v2/blueprint/manifest.json b/living-ui-v2/blueprint/manifest.json new file mode 100644 index 00000000..d35290b7 --- /dev/null +++ b/living-ui-v2/blueprint/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "{{PROJECT_ID}}", + "name": "{{PROJECT_NAME}}", + "description": "{{PROJECT_DESCRIPTION}}", + "livingUIVersion": 2, + "createdAt": "{{CREATED_AT}}", + "authMode": "{{AUTH_MODE}}", + "port": "{{PORT}}", + "pbVersion": "{{PB_VERSION}}", + "kitVersion": "{{KIT_VERSION}}", + "pipeline": { + "install": "npm install --prefix frontend", + "build": "npm run build --prefix frontend", + "start": "pocketbase serve --http=127.0.0.1:{{PORT}} --dir pb/pb_data --hooksDir pb/pb_hooks --migrationsDir pb/pb_migrations --publicDir pb/pb_public", + "health": "/api/health" + } +} diff --git a/living-ui-v2/blueprint/operations.json b/living-ui-v2/blueprint/operations.json new file mode 100644 index 00000000..015fc784 --- /dev/null +++ b/living-ui-v2/blueprint/operations.json @@ -0,0 +1,26 @@ +{ + "opsVersion": 1, + "operations": [ + { + "name": "health", + "description": "Liveness check (PocketBase built-in).", + "system": true, + "params": {}, + "executor": { "type": "http", "method": "GET", "path": "/api/health" } + }, + { + "name": "ops.list", + "description": "Return this operations manifest.", + "system": true, + "params": {}, + "executor": { "type": "http", "method": "GET", "path": "/api/_ops" } + }, + { + "name": "items.clear-done", + "description": "Delete all completed items and return how many were removed.", + "destructive": true, + "params": {}, + "executor": { "type": "http", "method": "POST", "path": "/api/ops/items/clear-done" } + } + ] +} diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js new file mode 100644 index 00000000..9b24019b --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js @@ -0,0 +1,47 @@ +/// +/** + * SYSTEM HOOKS — managed by tooling, never edited by agents (spec P1). + * - GET /api/_ops operations manifest discovery (spec O4) + * - POST /api/_console frontend console relay sink (spec K8/D12) + * (Health is PocketBase's built-in /api/health.) + */ + +// Allow embedding in the CraftBot shell: PocketBase sets +// X-Frame-Options: sameorigin by default, which blocks the host iframe. +// Loopback-bound local apps are safe to frame. +routerUse((e) => { + // Must run BEFORE e.next(): headers are flushed with the first body byte, + // so post-next deletion is a no-op. PB's own SAMEORIGIN setter runs before + // user middleware, so a pre-next delete removes it for good. + e.response.header().del('X-Frame-Options'); + return e.next(); +}); + +routerAdd('GET', '/api/_ops', (e) => { + const path = $filepath.join(__hooks, '..', '..', 'operations.json'); + const raw = toString($os.readFile(path)); + return e.json(200, JSON.parse(raw)); +}); + +routerAdd('POST', '/api/_console', (e) => { + const body = e.requestInfo().body; + const entries = Array.isArray(body?.entries) ? body.entries : []; + if (entries.length === 0) return e.json(200, { ok: true }); + + const logsDir = $filepath.join(__hooks, '..', '..', 'logs'); + $os.mkdirAll(logsDir, 0o755); + const logFile = $filepath.join(logsDir, 'frontend_console.log'); + + let existing = ''; + try { + existing = toString($os.readFile(logFile)); + } catch { + // first write + } + const lines = entries + .slice(0, 50) + .map((x) => JSON.stringify({ ts: x.ts, level: x.level, message: String(x.message).slice(0, 4000) })) + .join('\n'); + $os.writeFile(logFile, existing + lines + '\n', 0o644); + return e.json(200, { ok: true }); +}); diff --git a/living-ui-v2/blueprint/pb/pb_hooks/ops.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/ops.pb.js new file mode 100644 index 00000000..bc7f65bc --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_hooks/ops.pb.js @@ -0,0 +1,20 @@ +/// +/** + * AGENT HOOKS — custom verbs beyond CRUD live here (spec B3/D4). + * Every route here must have a matching entry in operations.json (the gate + * enforces it) so any agent can discover it via GET /api/_ops. + */ + +// READING A REQUEST BODY — the ONLY correct way in PB hooks: +// const data = e.requestInfo().body; // pre-parsed object +// NEVER use e.request.body / toString(e.request.body): that is a Go stream +// and reads as EMPTY, so your param checks will 400 on every request. + +// items.clear-done — working example op: bulk-delete completed items. +routerAdd('POST', '/api/ops/items/clear-done', (e) => { + const records = e.app.findRecordsByFilter('items', 'done = true', '', 0, 0); + for (const record of records) { + e.app.delete(record); + } + return e.json(200, { cleared: records.length }); +}); diff --git a/living-ui-v2/blueprint/pb/pb_migrations/1700000000_init_items.js b/living-ui-v2/blueprint/pb/pb_migrations/1700000000_init_items.js new file mode 100644 index 00000000..88f84dfd --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_migrations/1700000000_init_items.js @@ -0,0 +1,31 @@ +/// +/** + * Starter collection. Agent-owned: extend or replace via new migrations. + * Rules are set at scaffold time from the auth mode (spec B4/B6): + * none → '' (open; acceptable only because the app binds loopback) + * multi-user → '@request.auth.id != ""' (authenticated users only) + */ +migrate( + (app) => { + const collection = new Collection({ + type: 'base', + name: 'items', + listRule: '{{AUTH_RULE}}', + viewRule: '{{AUTH_RULE}}', + createRule: '{{AUTH_RULE}}', + updateRule: '{{AUTH_RULE}}', + deleteRule: '{{AUTH_RULE}}', + fields: [ + { name: 'title', type: 'text', required: true, max: 200 }, + { name: 'done', type: 'bool' }, + { name: 'created', type: 'autodate', onCreate: true }, + { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ], + }); + app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId('items'); + app.delete(collection); + }, +); diff --git a/living-ui-v2/docs/agent-guide.md b/living-ui-v2/docs/agent-guide.md new file mode 100644 index 00000000..3903d3bc --- /dev/null +++ b/living-ui-v2/docs/agent-guide.md @@ -0,0 +1,115 @@ +# Agent Guide — Building and Operating a Living UI (V2) + +Audience: the agent building or operating a Living UI project. This is the +source document CraftBot's `living-ui-*` skills compile from (spec A5). + +--- + +## 1. The one rule that matters: ownership + +Every file in a project has exactly one owner. You edit **only** these paths: + +| Path | What goes there | +|------|-----------------| +| `frontend/src/app/` | All UI code: pages, components, features | +| `pb/pb_migrations/` | Schema: one migration per change, never edit an applied one | +| `pb/pb_hooks/ops.pb.js` (+ new `*.pb.js`) | Custom verbs beyond CRUD | +| `operations.json` | Declarations for every custom verb (non-`system` entries) | +| `LIVING_UI.md` | Your plan/context/index — keep it current | +| `reference/` | Requirements and materials handed to you | + +Everything else — `frontend/src/kit/`, `main.tsx`, `config.gen.ts`, configs, +`_system.pb.js`, `manifest.json` — is **system-managed**. The validation gate +hashes those files and **fails the build if you touched them** (ownership step). +Need different behavior from a kit component? Wrap it in `app/`: + +```tsx +// app/components/DueBadge.tsx — compose, never edit kit files +import { cn } from '../../kit/index.ts'; +export function DueBadge({ overdue }: { overdue: boolean }) { /* … */ } +``` + +## 2. The build loop + +1. Read `reference/requirements.md` and `LIVING_UI.md`. +2. Schema first: add a migration in `pb/pb_migrations/` (see §3). +3. Custom verbs (if any): hook route + `operations.json` entry (see §4). +4. UI: build in `frontend/src/app/` from kit parts (see §5). +5. Run the gate: `lui validate ` — fix, repeat. The gate is: + types → build → migrations-on-fresh-db → ops structure/routing → ownership. +6. Frontend runtime errors land in `logs/frontend_console.log` (console.error/ + warn + uncaught errors are relayed automatically). Read it when the UI + "looks fine but doesn't work". + +## 3. Schema (PocketBase migrations) + +- One JS migration per change; never modify an already-applied file. +- Wire format: PB gives every record `id`, `created`, `updated` (autodate + fields declared in the starter migration — follow that pattern). +- **Rules are the security boundary** (spec B6). The scaffold set them from the + auth mode: open (`''`) for `none`, `@request.auth.id != ""` for `multi-user`. + New collections MUST follow the project's mode — check `manifest.json` + `authMode`. In multi-user apps, owner-scoped data uses a `relation` field to + `users` and rules like `owner = @request.auth.id`. + +## 4. Operations (your public verb surface) + +Anything an outside agent should be able to *do* to this app must be declared +in `operations.json` (schema: `spec/operations.schema.json`; discovery: +`GET /api/_ops`). The gate enforces: every non-system `http`/`job` op must +match a `routerAdd` route in `pb_hooks`, and it *warns* about routes you +forgot to declare. + +- `http` — normal case: a hook route (see `ops.pb.js` for a working example, + `items.clear-done`). +- `crud` — parameterized collection access, no hook needed. +- `job` — POST route returning `{jobId}`, status at `GET /api/_jobs/{jobId}`. +- Mark data-deleting ops `"destructive": true` — hosts confirm before running. + +## 5. Frontend + +- Import everything from `../kit/index.ts` (the public API). Internals move + without notice. +- Data: `useCollection('items', { sort: '-created' })` — realtime by default; + never poll, never reload. Writes: `getPbClient().call((pb) => …)` — errors + toast automatically; add `{ silent: true }` only when you handle them. +- Auth: in `multi-user` projects the shell already wraps your app in + `LoginGate`; use `useAuth()` for the current user and logout. +- Styling: Tailwind utilities + kit tokens (`var(--lui-*)`). Never hardcode + colors — theming is host-owned and must keep working when the host switches + style packs or dark mode. +- Required UX (from GLOBAL rules): empty states with a next action, loading + states, confirmation dialogs for destructive actions, toasts on CRUD, + responsive layout. + +## 6. Commands you'll use + +``` +lui validate # the gate — run after every meaningful change +lui dev # PocketBase + Vite HMR (development) +lui kit-sync # re-vendor the kit (only when instructed) +lui pb path # the pinned PocketBase binary +``` + +You never start production servers yourself — hosts use `manifest.json`'s +pipeline (`install` / `build` / `start` / `health`). + +## 7. Operating an existing app (no code edits!) + +Use the CLI — it resolves the port, authenticates, and validates params: + +``` +lui ops # what can this app do? +lui run --param value # execute a declared op +lui data list --filter '...' --limit 20 +lui data create --json '{...}' +``` + +1. `lui ops` (or `GET /api/_ops`) → discover the verb surface. +2. Declared op exists → `lui run` it (DESTRUCTIVE ops: confirm first). +3. No op → `lui data` for plain CRUD; read freely, write only what the app's + own UI offers. +4. Would require new code → that's a *modification*, not an operation. Say so. + +The `.superuser` file (0600) holds the machine superuser for administrative +API access. Never print, copy, or ship it. diff --git a/living-ui-v2/eslint.config.js b/living-ui-v2/eslint.config.js new file mode 100644 index 00000000..b41343bb --- /dev/null +++ b/living-ui-v2/eslint.config.js @@ -0,0 +1,20 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['**/node_modules/**', '**/dist/**', '**/pb_public/**', '**/pb_data/**'] }, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/consistent-type-imports': 'error', + }, + }, + { + // PocketBase JSVM files: plain JS run by goja — triple-slash typing is the + // official PB convention there (no module system available). + files: ['**/pb_hooks/**/*.js', '**/pb_migrations/**/*.js'], + rules: { + '@typescript-eslint/triple-slash-reference': 'off', + }, + }, +); diff --git a/app/data/living_ui_template/backend/tests/__init__.py b/living-ui-v2/examples/.gitkeep similarity index 100% rename from app/data/living_ui_template/backend/tests/__init__.py rename to living-ui-v2/examples/.gitkeep diff --git a/living-ui-v2/kit/kit.json b/living-ui-v2/kit/kit.json new file mode 100644 index 00000000..a17be30f --- /dev/null +++ b/living-ui-v2/kit/kit.json @@ -0,0 +1,5 @@ +{ + "version": "0.3.0", + "description": "Living UI kit \u2014 system-managed. Vendored into projects; never edited by agents.", + "publicApi": "src/index.ts" +} diff --git a/living-ui-v2/kit/package.json b/living-ui-v2/kit/package.json new file mode 100644 index 00000000..f3b68388 --- /dev/null +++ b/living-ui-v2/kit/package.json @@ -0,0 +1,26 @@ +{ + "name": "@livingui/kit", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Living UI kit source of truth — vendored into projects at scaffold time", + "scripts": { + "typecheck": "tsc -p ." + }, + "peerDependencies": { + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + } +} diff --git a/living-ui-v2/kit/src/components/Button.tsx b/living-ui-v2/kit/src/components/Button.tsx new file mode 100644 index 00000000..712a4ad1 --- /dev/null +++ b/living-ui-v2/kit/src/components/Button.tsx @@ -0,0 +1,58 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ButtonHTMLAttributes } from 'react'; +import { cn } from '../lib/cn.ts'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-[var(--lui-radius)] text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--lui-accent)]', + { + variants: { + variant: { + primary: 'bg-[var(--lui-accent)] text-[var(--lui-accent-contrast)] hover:opacity-90', + secondary: + 'border border-[var(--lui-border)] bg-[var(--lui-surface)] hover:bg-[var(--lui-border)]/40', + danger: 'bg-red-600 text-white hover:bg-red-700', + ghost: 'hover:bg-[var(--lui-border)]/40', + }, + size: { + sm: 'h-8 px-3', + md: 'h-9 px-4', + lg: 'h-10 px-6', + }, + }, + defaultVariants: { variant: 'primary', size: 'md' }, + }, +); + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + loading?: boolean | undefined; +} + +export function Button({ + className, + variant, + size, + loading = false, + disabled, + children, + type, + ...props +}: ButtonProps): React.JSX.Element { + return ( + + ); +} diff --git a/living-ui-v2/kit/src/components/Card.tsx b/living-ui-v2/kit/src/components/Card.tsx new file mode 100644 index 00000000..a1a85d71 --- /dev/null +++ b/living-ui-v2/kit/src/components/Card.tsx @@ -0,0 +1,33 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../lib/cn.ts'; + +export function Card({ className, ...props }: HTMLAttributes): React.JSX.Element { + return ( +
+ ); +} + +export function CardHeader({ + title, + actions, +}: { + title: ReactNode; + actions?: ReactNode; +}): React.JSX.Element { + return ( +
+

{title}

+ {actions !== undefined &&
{actions}
} +
+ ); +} + +export function CardBody({ className, ...props }: HTMLAttributes): React.JSX.Element { + return
; +} diff --git a/living-ui-v2/kit/src/components/Dialog.tsx b/living-ui-v2/kit/src/components/Dialog.tsx new file mode 100644 index 00000000..c0bc84d0 --- /dev/null +++ b/living-ui-v2/kit/src/components/Dialog.tsx @@ -0,0 +1,49 @@ +import * as RadixDialog from '@radix-ui/react-dialog'; +import type { ReactNode } from 'react'; +import { cn } from '../lib/cn.ts'; + +export interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string | undefined; + children: ReactNode; + footer?: ReactNode | undefined; + className?: string | undefined; +} + +/** Modal dialog (Radix-based): escape/overlay close, focus trap, portal. */ +export function Dialog({ + open, + onOpenChange, + title, + description, + children, + footer, + className, +}: DialogProps): React.JSX.Element { + return ( + + + + + {title} + {description !== undefined ? ( + + {description} + + ) : ( + {title} + )} +
{children}
+ {footer !== undefined &&
{footer}
} +
+
+
+ ); +} diff --git a/living-ui-v2/kit/src/components/Input.tsx b/living-ui-v2/kit/src/components/Input.tsx new file mode 100644 index 00000000..4d4ea937 --- /dev/null +++ b/living-ui-v2/kit/src/components/Input.tsx @@ -0,0 +1,34 @@ +import type { InputHTMLAttributes } from 'react'; +import { useId } from 'react'; +import { cn } from '../lib/cn.ts'; + +export interface InputProps extends InputHTMLAttributes { + label?: string | undefined; + error?: string | undefined; +} + +export function Input({ className, label, error, id, ...props }: InputProps): React.JSX.Element { + const autoId = useId(); + const inputId = id ?? autoId; + + return ( +
+ {label !== undefined && ( + + )} + + {error !== undefined &&

{error}

} +
+ ); +} diff --git a/living-ui-v2/kit/src/components/LoginGate.tsx b/living-ui-v2/kit/src/components/LoginGate.tsx new file mode 100644 index 00000000..f7f1bbba --- /dev/null +++ b/living-ui-v2/kit/src/components/LoginGate.tsx @@ -0,0 +1,72 @@ +/** + * LoginGate (spec B4 multi-user): renders children only when authenticated; + * otherwise a minimal email/password login/register form. The shell's toast + * handler surfaces auth errors — no custom error plumbing here. + */ +import { useState, type FormEvent, type ReactNode } from 'react'; +import { useAuth } from '../pb/auth.ts'; +import { Button } from './Button.tsx'; +import { Card, CardBody, CardHeader } from './Card.tsx'; +import { Input } from './Input.tsx'; + +export function LoginGate({ children }: { children: ReactNode }): React.JSX.Element { + const { userId, login, register } = useAuth(); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + + if (userId !== null) return <>{children}; + + const submit = async (event: FormEvent): Promise => { + event.preventDefault(); + setBusy(true); + try { + if (mode === 'login') await login(email, password); + else await register(email, password); + } catch { + /* surfaced by shell toast */ + } finally { + setBusy(false); + } + }; + + return ( +
+ + + +
void submit(e)}> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + +
+
+
+
+ ); +} diff --git a/living-ui-v2/kit/src/components/Table.tsx b/living-ui-v2/kit/src/components/Table.tsx new file mode 100644 index 00000000..16509645 --- /dev/null +++ b/living-ui-v2/kit/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { cn } from '../lib/cn.ts'; + +export interface Column { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + className?: string | undefined; +} + +export interface TableProps { + columns: Array>; + rows: T[]; + rowKey: (row: T) => string; + emptyMessage?: string | undefined; + className?: string | undefined; +} + +/** Typed data table with a built-in empty state (spec: empty states required). */ +export function Table({ + columns, + rows, + rowKey, + emptyMessage = 'Nothing here yet.', + className, +}: TableProps): React.JSX.Element { + if (rows.length === 0) { + return ( +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
+ {col.render(row)} +
+
+ ); +} diff --git a/living-ui-v2/kit/src/index.ts b/living-ui-v2/kit/src/index.ts new file mode 100644 index 00000000..d2fe9501 --- /dev/null +++ b/living-ui-v2/kit/src/index.ts @@ -0,0 +1,38 @@ +/** + * Living UI kit — PUBLIC API (spec K6). + * Anything exported here is the contract (append-only within a major version). + * Anything not exported is internal and may change without notice. + */ + +// Shell & feedback +export { Shell } from './shell/Shell.tsx'; +export { toast, Toaster } from './shell/toast.tsx'; + +// Data layer +export { getPbClient, setPbClient, PbClient } from './pb/client.ts'; +export type { NormalizedPbError } from './pb/client.ts'; +export { useCollection, useRecord } from './pb/hooks.ts'; +export type { CollectionQuery, CollectionState, RecordState } from './pb/hooks.ts'; + +// Auth (multi-user mode) +export { useAuth } from './pb/auth.ts'; +export type { AuthState } from './pb/auth.ts'; +export { LoginGate } from './components/LoginGate.tsx'; + +// Theme +export { ThemeBridge } from './theme/bridge.ts'; +export type { ThemeMode } from './theme/bridge.ts'; + +// Components +export { Button } from './components/Button.tsx'; +export type { ButtonProps } from './components/Button.tsx'; +export { Input } from './components/Input.tsx'; +export type { InputProps } from './components/Input.tsx'; +export { Card, CardHeader, CardBody } from './components/Card.tsx'; +export { Dialog } from './components/Dialog.tsx'; +export type { DialogProps } from './components/Dialog.tsx'; +export { Table } from './components/Table.tsx'; +export type { Column, TableProps } from './components/Table.tsx'; + +// Utilities +export { cn } from './lib/cn.ts'; diff --git a/living-ui-v2/kit/src/lib/cn.ts b/living-ui-v2/kit/src/lib/cn.ts new file mode 100644 index 00000000..a69ed829 --- /dev/null +++ b/living-ui-v2/kit/src/lib/cn.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** Merge Tailwind class lists (shadcn convention). */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/living-ui-v2/kit/src/pb/auth.ts b/living-ui-v2/kit/src/pb/auth.ts new file mode 100644 index 00000000..9c9d75cf --- /dev/null +++ b/living-ui-v2/kit/src/pb/auth.ts @@ -0,0 +1,53 @@ +/** + * Auth hook (spec B4, multi-user mode) — thin wrapper over PB's users auth. + * State derives from pb.authStore; login/register/logout go through the + * client seam so errors surface consistently. + */ +import { useCallback, useEffect, useState } from 'react'; +import { getPbClient } from './client.ts'; + +export interface AuthState { + userId: string | null; + email: string | null; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; +} + +export function useAuth(): AuthState { + const client = getPbClient(); + const [userId, setUserId] = useState(client.pb.authStore.record?.id ?? null); + const [email, setEmail] = useState( + (client.pb.authStore.record?.['email'] as string | undefined) ?? null, + ); + + useEffect(() => { + return client.pb.authStore.onChange(() => { + setUserId(client.pb.authStore.record?.id ?? null); + setEmail((client.pb.authStore.record?.['email'] as string | undefined) ?? null); + }); + }, [client]); + + const login = useCallback( + async (loginEmail: string, password: string): Promise => { + await client.call((pb) => pb.collection('users').authWithPassword(loginEmail, password)); + }, + [client], + ); + + const register = useCallback( + async (registerEmail: string, password: string): Promise => { + await client.call((pb) => + pb.collection('users').create({ email: registerEmail, password, passwordConfirm: password }), + ); + await login(registerEmail, password); + }, + [client, login], + ); + + const logout = useCallback((): void => { + client.pb.authStore.clear(); + }, [client]); + + return { userId, email, login, register, logout }; +} diff --git a/living-ui-v2/kit/src/pb/client.ts b/living-ui-v2/kit/src/pb/client.ts new file mode 100644 index 00000000..821d96e3 --- /dev/null +++ b/living-ui-v2/kit/src/pb/client.ts @@ -0,0 +1,80 @@ +/** + * PB client wrapper — the single seam for all network access (spec K2/K7). + * + * Resolution order for the backend URL: + * 1. VITE_PB_URL (dev mode: Vite dev server proxies nothing; PB runs separately) + * 2. same-origin (production: PocketBase serves the built app from pb_public) + */ +import PocketBase, { ClientResponseError } from 'pocketbase'; + +export type PbErrorHandler = (error: NormalizedPbError) => void; + +export interface NormalizedPbError { + status: number; + message: string; + isAbort: boolean; + raw: unknown; +} + +function normalize(err: unknown): NormalizedPbError { + if (err instanceof ClientResponseError) { + return { + status: err.status, + message: err.response?.['message'] ?? err.message, + isAbort: err.isAbort, + raw: err, + }; + } + return { + status: 0, + message: err instanceof Error ? err.message : String(err), + isAbort: false, + raw: err, + }; +} + +/** Owns the PocketBase instance and error fan-out. One per app. */ +export class PbClient { + readonly pb: PocketBase; + private handlers: PbErrorHandler[] = []; + + constructor(baseUrl?: string) { + const envUrl = + typeof import.meta !== 'undefined' + ? (import.meta as unknown as { env?: Record }).env?.['VITE_PB_URL'] + : undefined; + this.pb = new PocketBase(baseUrl ?? envUrl ?? window.location.origin); + } + + onError(handler: PbErrorHandler): () => void { + this.handlers.push(handler); + return () => { + this.handlers = this.handlers.filter((h) => h !== handler); + }; + } + + /** Run a PB call; normalized errors reach every onError subscriber unless silenced. */ + async call(fn: (pb: PocketBase) => Promise, opts?: { silent?: boolean }): Promise { + try { + return await fn(this.pb); + } catch (err) { + const normalized = normalize(err); + if (!normalized.isAbort && opts?.silent !== true) { + for (const handler of this.handlers) handler(normalized); + } + throw normalized; + } + } +} + +let singleton: PbClient | null = null; + +/** App-wide client accessor. Shell creates it; everything else consumes it. */ +export function getPbClient(): PbClient { + if (singleton === null) singleton = new PbClient(); + return singleton; +} + +export function setPbClient(client: PbClient): void { + singleton = client; +} diff --git a/living-ui-v2/kit/src/pb/hooks.ts b/living-ui-v2/kit/src/pb/hooks.ts new file mode 100644 index 00000000..6fc1386c --- /dev/null +++ b/living-ui-v2/kit/src/pb/hooks.ts @@ -0,0 +1,135 @@ +/** + * Realtime data hooks — Living UIs are living by default (spec K2). + * Strategy: full fetch + realtime subscription; events trigger a debounced refetch + * (simple, always-consistent; optimize per-event later if ever needed). + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RecordModel, UnsubscribeFunc } from 'pocketbase'; +import { getPbClient } from './client.ts'; + +export interface CollectionQuery { + filter?: string; + sort?: string; + expand?: string; +} + +export interface CollectionState { + records: T[]; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useCollection( + collection: string, + query: CollectionQuery = {}, +): CollectionState { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const timer = useRef | null>(null); + const { filter, sort, expand } = query; + + const fetchAll = useCallback(async () => { + const client = getPbClient(); + try { + const options: Record = {}; + if (filter !== undefined) options['filter'] = filter; + if (sort !== undefined) options['sort'] = sort; + if (expand !== undefined) options['expand'] = expand; + const list = await client.call((pb) => pb.collection(collection).getFullList(options)); + setRecords(list); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + }, [collection, filter, sort, expand]); + + const scheduleRefetch = useCallback(() => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = setTimeout(() => void fetchAll(), 120); + }, [fetchAll]); + + useEffect(() => { + let unsubscribe: UnsubscribeFunc | null = null; + let cancelled = false; + + void fetchAll(); + void getPbClient() + .call((pb) => pb.collection(collection).subscribe('*', scheduleRefetch), { silent: true }) + .then((fn) => { + if (cancelled) void fn(); + else unsubscribe = fn; + }) + .catch(() => { + /* realtime unavailable — data still loads, just not live */ + }); + + return () => { + cancelled = true; + if (timer.current !== null) clearTimeout(timer.current); + if (unsubscribe !== null) void unsubscribe(); + }; + }, [collection, fetchAll, scheduleRefetch]); + + return { records, loading, error, refresh: () => void fetchAll() }; +} + +export interface RecordState { + record: T | null; + loading: boolean; + error: string | null; +} + +export function useRecord(collection: string, id: string | null): RecordState { + const [record, setRecord] = useState(null); + const [loading, setLoading] = useState(id !== null); + const [error, setError] = useState(null); + + useEffect(() => { + if (id === null) { + setRecord(null); + setLoading(false); + return; + } + let unsubscribe: UnsubscribeFunc | null = null; + let cancelled = false; + setLoading(true); + + const client = getPbClient(); + void client + .call((pb) => pb.collection(collection).getOne(id)) + .then((r) => { + if (!cancelled) setRecord(r); + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load record'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + void client + .call( + (pb) => + pb.collection(collection).subscribe(id, (e) => { + setRecord(e.action === 'delete' ? null : e.record); + }), + { silent: true }, + ) + .then((fn) => { + if (cancelled) void fn(); + else unsubscribe = fn; + }) + .catch(() => {}); + + return () => { + cancelled = true; + if (unsubscribe !== null) void unsubscribe(); + }; + }, [collection, id]); + + return { record, loading, error }; +} diff --git a/living-ui-v2/kit/src/shell/Shell.tsx b/living-ui-v2/kit/src/shell/Shell.tsx new file mode 100644 index 00000000..61d03481 --- /dev/null +++ b/living-ui-v2/kit/src/shell/Shell.tsx @@ -0,0 +1,77 @@ +/** + * App shell (spec K4): error boundary + toast portal + theme bridge + console + * relay + PB error surfacing. `main.tsx` mounts ; the + * agent writes App downward and never touches this file. + */ +import { Component, useEffect, type ReactNode } from 'react'; +import { getPbClient } from '../pb/client.ts'; +import { ThemeBridge } from '../theme/bridge.ts'; +import { ConsoleRelay } from './console-relay.ts'; +import { Toaster, toast } from './toast.tsx'; + +interface BoundaryProps { + children: ReactNode; +} + +interface BoundaryState { + error: Error | null; +} + +class ErrorBoundary extends Component { + override state: BoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): BoundaryState { + return { error }; + } + + override componentDidCatch(error: Error): void { + console.error(`ErrorBoundary caught: ${error.message}`); + } + + override render(): ReactNode { + if (this.state.error !== null) { + return ( +
+
+

Something went wrong

+

{this.state.error.message}

+ +
+
+ ); + } + return this.props.children; + } +} + +export function Shell({ children }: { children: ReactNode }): React.JSX.Element { + useEffect(() => { + const bridge = new ThemeBridge(); + const relay = new ConsoleRelay(); + bridge.start(); + relay.start(); + const offError = getPbClient().onError((err) => { + toast.error(err.status === 0 ? 'Network error — is the backend running?' : err.message); + }); + return () => { + offError(); + relay.stop(); + bridge.stop(); + }; + }, []); + + return ( + +
+ {children} +
+ +
+ ); +} diff --git a/living-ui-v2/kit/src/shell/console-relay.ts b/living-ui-v2/kit/src/shell/console-relay.ts new file mode 100644 index 00000000..0f114416 --- /dev/null +++ b/living-ui-v2/kit/src/shell/console-relay.ts @@ -0,0 +1,82 @@ +/** + * Console relay (spec K8/D12) — the entire agent-observation layer. + * Captures console.error/warn + uncaught errors/rejections and ships them, + * batched, to the app's own backend (`POST /api/_console`, a blueprint hook), + * where they land in a log the build loop reads. Nothing else is captured. + */ + +interface RelayEntry { + level: 'error' | 'warn'; + message: string; + ts: number; +} + +const FLUSH_INTERVAL_MS = 2000; +const MAX_BATCH = 50; + +export class ConsoleRelay { + private queue: RelayEntry[] = []; + private timer: ReturnType | null = null; + private restore: Array<() => void> = []; + + start(): void { + if (this.timer !== null) return; + + for (const level of ['error', 'warn'] as const) { + const original = console[level].bind(console); + console[level] = (...args: unknown[]): void => { + original(...args); + this.push(level, args.map(stringify).join(' ')); + }; + this.restore.push(() => { + console[level] = original; + }); + } + + const onError = (event: ErrorEvent): void => this.push('error', `uncaught: ${event.message}`); + const onRejection = (event: PromiseRejectionEvent): void => + this.push('error', `unhandledrejection: ${stringify(event.reason)}`); + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + this.restore.push(() => window.removeEventListener('error', onError)); + this.restore.push(() => window.removeEventListener('unhandledrejection', onRejection)); + + this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS); + } + + stop(): void { + if (this.timer !== null) clearInterval(this.timer); + this.timer = null; + for (const fn of this.restore) fn(); + this.restore = []; + } + + private push(level: RelayEntry['level'], message: string): void { + this.queue.push({ level, message: message.slice(0, 4000), ts: Date.now() }); + if (this.queue.length > MAX_BATCH) this.queue = this.queue.slice(-MAX_BATCH); + } + + private async flush(): Promise { + if (this.queue.length === 0) return; + const batch = this.queue.splice(0, MAX_BATCH); + try { + await fetch('/api/_console', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries: batch }), + }); + } catch { + // Relay must never create its own error loop; drop on failure. + } + } +} + +function stringify(value: unknown): string { + if (typeof value === 'string') return value; + if (value instanceof Error) return `${value.name}: ${value.message}`; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/living-ui-v2/kit/src/shell/toast.tsx b/living-ui-v2/kit/src/shell/toast.tsx new file mode 100644 index 00000000..1654a535 --- /dev/null +++ b/living-ui-v2/kit/src/shell/toast.tsx @@ -0,0 +1,80 @@ +/** Minimal toast system — no dependency, composable, token-styled. */ +import { useEffect, useState } from 'react'; +import { cn } from '../lib/cn.ts'; + +export type ToastKind = 'success' | 'error' | 'info'; + +export interface Toast { + id: number; + kind: ToastKind; + message: string; +} + +type Listener = (toasts: Toast[]) => void; + +class ToastStore { + private toasts: Toast[] = []; + private listeners: Listener[] = []; + private nextId = 1; + + subscribe(listener: Listener): () => void { + this.listeners.push(listener); + listener(this.toasts); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + show(kind: ToastKind, message: string, ttlMs = 4000): void { + const toast: Toast = { id: this.nextId++, kind, message }; + this.toasts = [...this.toasts, toast]; + this.emit(); + setTimeout(() => this.dismiss(toast.id), ttlMs); + } + + dismiss(id: number): void { + this.toasts = this.toasts.filter((t) => t.id !== id); + this.emit(); + } + + private emit(): void { + for (const l of this.listeners) l(this.toasts); + } +} + +const store = new ToastStore(); + +export const toast = { + success: (msg: string): void => store.show('success', msg), + error: (msg: string): void => store.show('error', msg), + info: (msg: string): void => store.show('info', msg), +}; + +const KIND_CLASSES: Record = { + success: 'border-emerald-500/40 text-emerald-700 dark:text-emerald-300', + error: 'border-red-500/40 text-red-700 dark:text-red-300', + info: 'border-sky-500/40 text-sky-700 dark:text-sky-300', +}; + +export function Toaster(): React.JSX.Element { + const [toasts, setToasts] = useState([]); + useEffect(() => store.subscribe(setToasts), []); + + return ( +
+ {toasts.map((t) => ( + + ))} +
+ ); +} diff --git a/living-ui-v2/kit/src/theme/bridge.ts b/living-ui-v2/kit/src/theme/bridge.ts new file mode 100644 index 00000000..f4a4bf7a --- /dev/null +++ b/living-ui-v2/kit/src/theme/bridge.ts @@ -0,0 +1,80 @@ +/** + * Theme bridge (spec K3) — host-owned theming with a standalone fallback. + * + * Inside CraftBot: listens for `livingui-theme` postMessage + * { type: 'livingui-theme', themeId, mode: 'light'|'dark', customColors? } + * and announces readiness with `craftbot-theme-request` so the host replays. + * + * Standalone (no embedding host): follows the system color scheme. + * + * All theming lands as attributes/custom properties on ; components only + * ever read design tokens (no hardcoded colors anywhere in the kit). + */ + +export type ThemeMode = 'light' | 'dark'; + +interface HostThemeMessage { + type: 'livingui-theme'; + themeId?: string; + mode?: ThemeMode; + customColors?: Partial>; +} + +const CUSTOM_PROP_MAP: Record = { + bg: '--lui-bg', + surface: '--lui-surface', + text: '--lui-text', + accent: '--lui-accent', +}; + +export class ThemeBridge { + private detach: Array<() => void> = []; + private hostControlled = false; + + start(): void { + const onMessage = (event: MessageEvent): void => { + const data = event.data as HostThemeMessage | null; + if (data === null || typeof data !== 'object' || data.type !== 'livingui-theme') return; + this.hostControlled = true; + this.apply(data); + }; + window.addEventListener('message', onMessage); + this.detach.push(() => window.removeEventListener('message', onMessage)); + + // Standalone fallback: system preference, live-updating — until a host speaks. + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const onSystem = (): void => { + if (!this.hostControlled) this.setMode(media.matches ? 'dark' : 'light'); + }; + onSystem(); + media.addEventListener('change', onSystem); + this.detach.push(() => media.removeEventListener('change', onSystem)); + + // Ask the host (if any) to replay its theme. + if (window.parent !== window) { + window.parent.postMessage({ type: 'craftbot-theme-request' }, '*'); + } + } + + stop(): void { + for (const fn of this.detach) fn(); + this.detach = []; + } + + private apply(msg: HostThemeMessage): void { + if (msg.mode !== undefined) this.setMode(msg.mode); + if (msg.themeId !== undefined) { + document.documentElement.setAttribute('data-style', msg.themeId); + } + const root = document.documentElement; + for (const [key, prop] of Object.entries(CUSTOM_PROP_MAP)) { + const value = msg.customColors?.[key as keyof NonNullable]; + if (typeof value === 'string') root.style.setProperty(prop, value); + else root.style.removeProperty(prop); + } + } + + private setMode(mode: ThemeMode): void { + document.documentElement.setAttribute('data-theme', mode); + } +} diff --git a/living-ui-v2/kit/src/theme/tokens.css b/living-ui-v2/kit/src/theme/tokens.css new file mode 100644 index 00000000..35459056 --- /dev/null +++ b/living-ui-v2/kit/src/theme/tokens.css @@ -0,0 +1,154 @@ +/* + * Living UI design tokens (spec K3). Components read ONLY these custom + * properties — never hardcoded colors. The host (or standalone bridge) sets + * data-theme; hosts may override any token via custom colors. + */ +:root, +:root[data-theme='light'] { + --lui-bg: #f7f7f8; + --lui-surface: #ffffff; + --lui-text: #1a1a1e; + --lui-muted: #6b7280; + --lui-border: #e5e7eb; + --lui-accent: #ff4f18; + --lui-accent-contrast: #ffffff; + --lui-radius: 0.5rem; +} + +:root[data-theme='dark'] { + --lui-bg: #131316; + --lui-surface: #1d1d22; + --lui-text: #f2f2f5; + --lui-muted: #9ca3af; + --lui-border: #2e2e35; + --lui-accent: #ff4f18; + --lui-accent-contrast: #ffffff; +} + +/* ---- Host style packs (LivingUIThemeModal presets; bridge sets data-style). + 'craftbot' is the brand default (same as base). 'custom' uses the custom + color properties the bridge injects — no block needed here. */ + +:root[data-style='normal'] { --lui-accent: #2563eb; } +:root[data-style='normal'][data-theme='dark'] { --lui-accent: #3b82f6; } + +:root[data-style='ocean'] { + --lui-accent: #0284c7; + --lui-bg: #f0f7fb; + --lui-border: #d3e5ef; +} +:root[data-style='ocean'][data-theme='dark'] { + --lui-accent: #38bdf8; + --lui-bg: #0b1b26; + --lui-surface: #102635; + --lui-border: #1e3a4d; +} + +:root[data-style='forest'] { + --lui-accent: #16a34a; + --lui-bg: #f2f8f2; + --lui-border: #d6e7d6; +} +:root[data-style='forest'][data-theme='dark'] { + --lui-accent: #4ade80; + --lui-bg: #0e1a12; + --lui-surface: #14261a; + --lui-border: #22402c; +} + +:root[data-style='pastel'] { + --lui-accent: #a855f7; + --lui-bg: #faf7fd; + --lui-surface: #fffdfa; + --lui-border: #eadff5; +} +:root[data-style='pastel'][data-theme='dark'] { + --lui-accent: #c084fc; + --lui-bg: #1a1420; + --lui-surface: #251c2e; + --lui-border: #3a2d47; +} + +:root[data-style='glass'] { + --lui-bg: #eef1f8; + --lui-surface: rgba(255, 255, 255, 0.72); + --lui-border: rgba(120, 130, 160, 0.25); + --lui-accent: #6366f1; +} +:root[data-style='glass'][data-theme='dark'] { + --lui-bg: #10131c; + --lui-surface: rgba(30, 36, 54, 0.72); + --lui-border: rgba(140, 150, 190, 0.22); + --lui-accent: #818cf8; +} + +:root[data-style='classic'] { + --lui-bg: #f5f2ea; + --lui-surface: #fffdf7; + --lui-border: #ddd6c5; + --lui-accent: #b8860b; + --lui-radius: 0.25rem; +} +:root[data-style='classic'][data-theme='dark'] { + --lui-bg: #1c1a14; + --lui-surface: #26231b; + --lui-border: #3d3828; + --lui-accent: #d4a017; +} + +:root[data-style='velvet'] { + --lui-bg: #f8f2f6; + --lui-surface: #fffbfe; + --lui-border: #e8d8e4; + --lui-accent: #9d174d; +} +:root[data-style='velvet'][data-theme='dark'] { + --lui-bg: #1c1018; + --lui-surface: #281826; + --lui-border: #43263c; + --lui-accent: #ec4899; +} + +:root[data-style='ink'] { + --lui-bg: #ffffff; + --lui-surface: #ffffff; + --lui-border: #111111; + --lui-accent: #111111; + --lui-accent-contrast: #ffffff; + --lui-radius: 0; +} +:root[data-style='ink'][data-theme='dark'] { + --lui-bg: #0a0a0a; + --lui-surface: #0a0a0a; + --lui-border: #f5f5f5; + --lui-accent: #f5f5f5; + --lui-accent-contrast: #0a0a0a; +} + +:root[data-style='acid'] { + --lui-bg: #fafff2; + --lui-surface: #ffffff; + --lui-border: #d9f99d; + --lui-accent: #65a30d; +} +:root[data-style='acid'][data-theme='dark'] { + --lui-bg: #131a0c; + --lui-surface: #1b2513; + --lui-border: #365314; + --lui-accent: #a3e635; + --lui-accent-contrast: #1a2e05; +} + +:root[data-style='blueprint'] { + --lui-bg: #eef4fb; + --lui-surface: #ffffff; + --lui-border: #93c5fd; + --lui-accent: #1d4ed8; + --lui-radius: 0.125rem; +} +:root[data-style='blueprint'][data-theme='dark'] { + --lui-bg: #0b1526; + --lui-surface: #102039; + --lui-border: #1e40af; + --lui-accent: #60a5fa; +} diff --git a/living-ui-v2/kit/tsconfig.json b/living-ui-v2/kit/tsconfig.json new file mode 100644 index 00000000..853c74ec --- /dev/null +++ b/living-ui-v2/kit/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": true + }, + "include": ["src"] +} diff --git a/living-ui-v2/package-lock.json b/living-ui-v2/package-lock.json new file mode 100644 index 00000000..86826f0f --- /dev/null +++ b/living-ui-v2/package-lock.json @@ -0,0 +1,2321 @@ +{ + "name": "living-ui-v2", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "living-ui-v2", + "version": "0.1.0", + "workspaces": [ + "kit", + "tools", + "examples/*/frontend" + ], + "devDependencies": { + "@types/node": "^24.0.0", + "eslint": "^9.0.0", + "prettier": "^3.3.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.8.0" + }, + "engines": { + "node": ">=24" + } + }, + "blueprint/frontend": { + "name": "living-ui-app", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, + "examples/ann-probe/frontend": { + "name": "lui-app-ann-probe", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, + "examples/ci-demo/frontend": { + "name": "lui-app-ci-demo", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, + "examples/demo-tasks/frontend": { + "name": "lui-app-demo-tasks", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, + "examples/team-tasks/frontend": { + "name": "lui-app-team-tasks", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, + "kit": { + "name": "@livingui/kit", + "version": "0.1.0", + "devDependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "peerDependencies": { + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "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": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "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.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "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.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@livingui/kit": { + "resolved": "kit", + "link": true + }, + "node_modules/@livingui/tools": { + "resolved": "tools", + "link": true + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", + "integrity": "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.1.tgz", + "integrity": "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.17.tgz", + "integrity": "sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-effect-event": "0.0.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.5.tgz", + "integrity": "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.14.tgz", + "integrity": "sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.3.tgz", + "integrity": "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.15.tgz", + "integrity": "sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.9.tgz", + "integrity": "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.8.tgz", + "integrity": "sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.1.tgz", + "integrity": "sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.3.tgz", + "integrity": "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.5.tgz", + "integrity": "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.4", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.4.tgz", + "integrity": "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.3.tgz", + "integrity": "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.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": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "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/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@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.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "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": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "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": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pocketbase": { + "version": "0.26.9", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.9.tgz", + "integrity": "sha512-Tiv1/hNuUzRdvT0d8hF03dfzuefQ1WdSRp1A1q3wzFC/WYhQcbU/Qlaubl/3ZDo6xvFXBS8JAgBS/L+ms7nkVQ==", + "dev": true, + "license": "MIT" + }, + "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", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "tools": { + "name": "@livingui/tools", + "version": "0.1.0", + "bin": { + "lui": "src/cli.ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "playwright": "^1.61.1" + } + } + } +} diff --git a/living-ui-v2/package.json b/living-ui-v2/package.json new file mode 100644 index 00000000..b1514266 --- /dev/null +++ b/living-ui-v2/package.json @@ -0,0 +1,28 @@ +{ + "name": "living-ui-v2", + "private": true, + "version": "0.1.0", + "description": "Living UI V2 — kit, blueprint, and tools for agent-built web apps", + "type": "module", + "workspaces": [ + "kit", + "tools", + "examples/*/frontend" + ], + "scripts": { + "lui": "node tools/src/cli.ts", + "typecheck": "npm run typecheck --workspaces --if-present", + "lint": "eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@types/node": "^24.0.0", + "eslint": "^9.0.0", + "prettier": "^3.3.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.8.0" + }, + "engines": { + "node": ">=24" + } +} diff --git a/living-ui-v2/spec/operations.schema.json b/living-ui-v2/spec/operations.schema.json new file mode 100644 index 00000000..c60e6af9 --- /dev/null +++ b/living-ui-v2/spec/operations.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://craftos.dev/schemas/living-ui/operations-v1.json", + "title": "Living UI operations manifest (opsVersion 1)", + "description": "The Agent interface of a Living UI app (spec REQUIREMENTS §8). Discoverable at GET /api/_ops.", + "type": "object", + "required": ["opsVersion", "operations"], + "additionalProperties": false, + "properties": { + "opsVersion": { "const": 1 }, + "operations": { + "type": "array", + "items": { "$ref": "#/$defs/operation" } + } + }, + "$defs": { + "operation": { + "type": "object", + "required": ["name", "description", "params", "executor"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "description": "Unique verb name, e.g. \"items.clear-done\"." + }, + "description": { "type": "string", "minLength": 1, "maxLength": 500 }, + "system": { + "type": "boolean", + "description": "True for tooling-maintained built-ins (health, ops.list, …). Agents never edit system entries." + }, + "destructive": { + "type": "boolean", + "description": "Marks ops that delete/overwrite data; hosts show confirmation and AGENT-ACCESS may require per-call consent (AC-E3)." + }, + "schedule": { + "type": "string", + "pattern": "^(every [0-9]+[smh]|daily [0-2][0-9]:[0-5][0-9]|hourly)$", + "description": "Optional recurrence, e.g. \"every 15m\", \"daily 09:00\", \"hourly\"." + }, + "params": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/param" }, + "description": "Typed parameters keyed by name." + }, + "executor": { "$ref": "#/$defs/executor" } + } + }, + "param": { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": { "enum": ["string", "number", "boolean"] }, + "description": { "type": "string" }, + "required": { "type": "boolean", "default": false }, + "default": {}, + "enum": { "type": "array", "items": { "type": ["string", "number"] } } + } + }, + "executor": { + "oneOf": [ + { + "type": "object", + "required": ["type", "method", "path"], + "additionalProperties": false, + "properties": { + "type": { "const": "http" }, + "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }, + "path": { "type": "string", "pattern": "^/api/" } + }, + "description": "Calls a pb_hooks route. Params become the JSON body (POST/PUT/PATCH) or query string (GET/DELETE)." + }, + { + "type": "object", + "required": ["type", "collection", "action"], + "additionalProperties": false, + "properties": { + "type": { "const": "crud" }, + "collection": { "type": "string" }, + "action": { "enum": ["list", "create", "update", "delete"] }, + "filter": { "type": "string", "description": "Fixed PB filter applied to list/update/delete." } + }, + "description": "Declarative pointer to parameterized collection CRUD — no hook needed." + }, + { + "type": "object", + "required": ["type", "method", "path"], + "additionalProperties": false, + "properties": { + "type": { "const": "job" }, + "method": { "const": "POST" }, + "path": { "type": "string", "pattern": "^/api/" } + }, + "description": "Starts long-running work; the route MUST return {\"jobId\": …} and status MUST be pollable at GET /api/_jobs/{jobId}." + } + ] + } + } +} diff --git a/living-ui-v2/spec/pocketbase.version b/living-ui-v2/spec/pocketbase.version new file mode 100644 index 00000000..0c1df6ae --- /dev/null +++ b/living-ui-v2/spec/pocketbase.version @@ -0,0 +1 @@ +0.39.7 diff --git a/living-ui-v2/tools/package.json b/living-ui-v2/tools/package.json new file mode 100644 index 00000000..fe84ad75 --- /dev/null +++ b/living-ui-v2/tools/package.json @@ -0,0 +1,17 @@ +{ + "name": "@livingui/tools", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Living UI workspace CLI — create, dev, validate, pb, kit-sync, pack", + "bin": { + "lui": "./src/cli.ts" + }, + "scripts": { + "typecheck": "tsc -p ." + }, + "devDependencies": { + "@types/node": "^24.0.0", + "playwright": "^1.61.1" + } +} diff --git a/living-ui-v2/tools/src/cli.ts b/living-ui-v2/tools/src/cli.ts new file mode 100755 index 00000000..1d5e9900 --- /dev/null +++ b/living-ui-v2/tools/src/cli.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * lui — Living UI workspace CLI. + * + * Thin dispatcher: each command is a module in ./commands exporting + * { summary, run }. Composition over a framework (spec W4). + */ +import { log } from './lib/log.ts'; + +const COMMANDS: Record = { + pb: { summary: 'Fetch/inspect the pinned PocketBase binary (cached per-OS)' }, + create: { summary: 'Scaffold a new Living UI project from the blueprint' }, + dev: { summary: 'Run a project in development mode (Vite + PocketBase)' }, + validate: { summary: 'Run the validation gate on a project' }, + verify: { summary: 'Headless smoke verification of a RUNNING project (mount, console, screenshot)' }, + ops: { summary: "List a project's declared operations (its agent-facing verbs)" }, + run: { summary: 'Execute a declared operation against the RUNNING app' }, + data: { summary: 'Read/write collection records of the RUNNING app (list/get/create/update/delete)' }, + probe: { summary: 'Scripted headless-browser walk of the RUNNING app (goto/click/type/read/screenshot)' }, + 'kit-sync': { summary: 'Re-vendor the kit into a project (wholesale replace)' }, +}; + +async function main(): Promise { + const [, , name, ...args] = process.argv; + + if (!name || name === 'help' || name === '--help') { + log.raw('lui — Living UI workspace CLI\n'); + for (const [cmd, meta] of Object.entries(COMMANDS)) { + log.raw(` lui ${cmd.padEnd(10)} ${meta.summary}`); + } + return 0; + } + + if (!(name in COMMANDS)) { + log.error(`Unknown command: ${name}`); + log.raw(`Try: lui help`); + return 1; + } + + const mod = (await import(`./commands/${name}.ts`)) as { + run: (args: string[]) => Promise; + }; + return mod.run(args); +} + +main().then( + (code) => process.exit(code), + (err: unknown) => { + log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); diff --git a/living-ui-v2/tools/src/commands/create.ts b/living-ui-v2/tools/src/commands/create.ts new file mode 100644 index 00000000..91ba67ad --- /dev/null +++ b/living-ui-v2/tools/src/commands/create.ts @@ -0,0 +1,168 @@ +/** + * lui create [--description "…"] [--dir ] [--port ] + * Scaffold a project from the blueprint: copy, vendor kit, substitute + * placeholders (spec P4/D6). + */ +import { execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeSystemHashes } from '../lib/hashes.ts'; +import { vendorKitInto } from '../lib/kit.ts'; +import { log } from '../lib/log.ts'; +import { blueprintDir, examplesDir, pinnedPbVersion } from '../lib/paths.ts'; +import { ensurePbBinary } from './pb.ts'; + +/** + * Machine superuser (spec B5): lets tooling/agents administer the project via + * PB APIs. Credentials live only in the project-local, 0600, gitignored + * `.superuser` file — never in code. + */ +async function bootstrapSuperuser(projectDir: string): Promise { + const pbBin = await ensurePbBinary(); + const pbDir = join(projectDir, 'pb'); + const email = 'agent@lui.local'; + const password = randomBytes(18).toString('base64url'); + + execFileSync( + pbBin, + [ + 'superuser', + 'upsert', + email, + password, + '--dir', + join(pbDir, 'pb_data'), + '--migrationsDir', + join(pbDir, 'pb_migrations'), + '--hooksDir', + join(pbDir, 'pb_hooks'), + ], + { stdio: 'pipe' }, + ); + + writeFileSync(join(projectDir, '.superuser'), JSON.stringify({ email, password }) + '\n', { + mode: 0o600, + }); +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +function argValue(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +export async function run(args: string[]): Promise { + const name = args.find((a) => !a.startsWith('--')); + if (name === undefined) { + log.error( + 'Usage: lui create [--description "…"] [--dir ] [--port ] [--auth none|multi-user]', + ); + return 1; + } + const description = argValue(args, '--description') ?? `${name} — a Living UI app`; + const parent = argValue(args, '--dir') ?? examplesDir(); + const port = Number(argValue(args, '--port') ?? 8090); + const authMode = argValue(args, '--auth') ?? 'none'; + if (authMode !== 'none' && authMode !== 'multi-user') { + log.error(`--auth must be "none" or "multi-user" (got "${authMode}")`); + return 1; + } + const jsonOutput = args.includes('--json'); + const style = argValue(args, '--style'); + + const slug = slugify(name); + const id = argValue(args, '--id') ?? randomBytes(4).toString('hex'); + const folder = argValue(args, '--folder') ?? slug; + const projectDir = join(parent, folder); + + if (existsSync(projectDir)) { + log.error(`Already exists: ${projectDir}`); + return 1; + } + + log.step(`Scaffolding "${name}" → ${projectDir}`); + cpSync(blueprintDir(), projectDir, { recursive: true }); + + log.step('Vendoring kit…'); + const kitV = vendorKitInto(projectDir); + + const substitutions: Record = { + '{{PROJECT_ID}}': id, + '{{PROJECT_NAME}}': name, + '{{PROJECT_DESCRIPTION}}': description, + '{{PORT}}': String(port), + '{{CREATED_AT}}': new Date().toISOString(), + '{{PB_VERSION}}': pinnedPbVersion(), + '{{KIT_VERSION}}': kitV, + '{{AUTH_MODE}}': authMode, + '{{AUTH_RULE}}': authMode === 'multi-user' ? '@request.auth.id != ""' : '', + }; + + for (const rel of [ + 'manifest.json', + 'LIVING_UI.md', + join('frontend', 'index.html'), + join('frontend', 'src', 'config.gen.ts'), + join('pb', 'pb_migrations', '1700000000_init_items.js'), + ]) { + const file = join(projectDir, rel); + const isJson = rel.endsWith('.json'); + let text = readFileSync(file, 'utf8'); + for (const [token, value] of Object.entries(substitutions)) { + // JSON files need string-escaped values (descriptions can be multiline). + text = text.replaceAll(token, isJson ? JSON.stringify(value).slice(1, -1) : value); + } + writeFileSync(file, text); + } + + // Stamp the default style pack (host theme still overrides at runtime). + if (style !== undefined && style !== 'craftbot') { + const idx = join(projectDir, 'frontend', 'index.html'); + writeFileSync( + idx, + readFileSync(idx, 'utf8').replace('data-theme="light"', `data-theme="light" data-style="${style}"`), + ); + } + + // Port must be a number in the manifest; the pipeline string keeps it inline. + const manifestPath = join(projectDir, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { port: unknown }; + manifest.port = port; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + + // Unique package name so npm workspaces don't collide. + const pkgPath = join(projectDir, 'frontend', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string }; + pkg.name = `lui-app-${slug}`; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + + // Pre-create the log files agents read while debugging — an empty file + // reads cleanly; a missing one derails the diagnosis. + mkdirSync(join(projectDir, 'logs'), { recursive: true }); + for (const logName of ['frontend_console.log', 'pocketbase.log']) { + const logFile = join(projectDir, 'logs', logName); + if (!existsSync(logFile)) writeFileSync(logFile, ''); + } + + log.step('Bootstrapping superuser (initializes pb_data + applies migrations)…'); + await bootstrapSuperuser(projectDir); + + // Canonize system files LAST — after every tooling edit (spec §11 step 5). + writeSystemHashes(projectDir); + + if (jsonOutput) { + log.raw(JSON.stringify({ path: projectDir, id, slug, port, authMode, kitVersion: kitV })); + } else { + log.ok(`Created ${projectDir} (id ${id}, port ${port}, auth ${authMode}, kit ${kitV})`); + log.raw(`Next: npm install && node tools/src/cli.ts dev ${projectDir}`); + } + return 0; +} diff --git a/living-ui-v2/tools/src/commands/data.ts b/living-ui-v2/tools/src/commands/data.ts new file mode 100644 index 00000000..22b7b4f2 --- /dev/null +++ b/living-ui-v2/tools/src/commands/data.ts @@ -0,0 +1,68 @@ +/** + * lui data [list|get |create|update |delete ] + * [--json '{...}'] [--filter '...'] [--sort '...'] [--limit N] + * Generic collection access against the RUNNING app (superuser-authed when + * the project has a .superuser file). + */ +import { log } from '../lib/log.ts'; +import { loadProject, request } from '../lib/project.ts'; + +function flag(args: string[], name: string): string | undefined { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : undefined; +} + +export async function run(args: string[]): Promise { + const positional = args.filter((a, i) => !a.startsWith('--') && !(args[i - 1] ?? '').startsWith('--')); + const [dirArg, collection, verb = 'list', id] = positional; + if (dirArg === undefined || collection === undefined) { + log.error( + "Usage: lui data [list|get |create|update |delete ] [--json '{...}'] [--filter '...'] [--sort '...'] [--limit N]", + ); + return 1; + } + const project = loadProject(dirArg); + const base = `/api/collections/${collection}/records`; + const jsonBody = flag(args, 'json'); + const body = jsonBody === undefined ? undefined : JSON.parse(jsonBody); + + let res; + switch (verb) { + case 'list': { + const qs = new URLSearchParams(); + const filter = flag(args, 'filter'); + const sort = flag(args, 'sort'); + const limit = flag(args, 'limit'); + if (filter !== undefined) qs.set('filter', filter); + if (sort !== undefined) qs.set('sort', sort); + if (limit !== undefined) qs.set('perPage', limit); + res = await request(project, 'GET', qs.size ? `${base}?${qs}` : base); + break; + } + case 'get': + if (id === undefined) return usageError('get needs an id'); + res = await request(project, 'GET', `${base}/${id}`); + break; + case 'create': + if (body === undefined) return usageError("create needs --json '{...}'"); + res = await request(project, 'POST', base, body); + break; + case 'update': + if (id === undefined || body === undefined) return usageError("update needs and --json '{...}'"); + res = await request(project, 'PATCH', `${base}/${id}`, body); + break; + case 'delete': + if (id === undefined) return usageError('delete needs an id'); + res = await request(project, 'DELETE', `${base}/${id}`); + break; + default: + return usageError(`unknown verb "${verb}"`); + } + log.raw(res.body || `(HTTP ${res.status}${res.status < 300 ? ', ok' : ''})`); + return res.status < 300 ? 0 : 1; + + function usageError(msg: string): number { + log.error(msg); + return 1; + } +} diff --git a/living-ui-v2/tools/src/commands/dev.ts b/living-ui-v2/tools/src/commands/dev.ts new file mode 100644 index 00000000..cc41a67f --- /dev/null +++ b/living-ui-v2/tools/src/commands/dev.ts @@ -0,0 +1,85 @@ +/** + * lui dev — development mode: PocketBase (backend, hooks, data) + + * Vite dev server (HMR frontend) pointing at it. Ctrl+C stops both. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { log } from '../lib/log.ts'; +import { osAdapter } from '../lib/os-adapter.ts'; +import { ensurePbBinary } from './pb.ts'; + +export async function run(args: string[]): Promise { + const projectDir = args[0]; + if (projectDir === undefined || !existsSync(join(projectDir, 'manifest.json'))) { + log.error('Usage: lui dev (must contain manifest.json)'); + return 1; + } + + const manifest = JSON.parse(readFileSync(join(projectDir, 'manifest.json'), 'utf8')) as { + port: number; + name: string; + }; + const pbPort = manifest.port; + const vitePort = pbPort + 1; + const pbBin = await ensurePbBinary(); + const pbDir = join(projectDir, 'pb'); + mkdirSync(join(pbDir, 'pb_data'), { recursive: true }); + + log.info(`${manifest.name}: PocketBase on :${pbPort}, Vite on :${vitePort}`); + + const children: ChildProcess[] = []; + const stop = (): void => { + for (const child of children) { + if (child.pid !== undefined) osAdapter.terminate(child.pid); + } + }; + process.on('SIGINT', () => { + stop(); + process.exit(0); + }); + process.on('SIGTERM', () => { + stop(); + process.exit(0); + }); + + const pb = spawn( + pbBin, + [ + 'serve', + `--http=127.0.0.1:${pbPort}`, + '--dir', + join(pbDir, 'pb_data'), + '--hooksDir', + join(pbDir, 'pb_hooks'), + '--migrationsDir', + join(pbDir, 'pb_migrations'), + '--publicDir', + join(pbDir, 'pb_public'), + ], + { stdio: 'inherit' }, + ); + children.push(pb); + + const vite = spawn('npm', ['run', 'dev'], { + cwd: join(projectDir, 'frontend'), + stdio: 'inherit', + env: { + ...process.env, + VITE_PB_URL: `http://127.0.0.1:${pbPort}`, + LUI_DEV_PORT: String(vitePort), + }, + }); + children.push(vite); + + return new Promise((resolve) => { + let exited = 0; + for (const child of children) { + child.on('exit', () => { + exited += 1; + if (exited === 1) stop(); + if (exited === children.length) resolve(0); + }); + } + }); +} diff --git a/living-ui-v2/tools/src/commands/kit-sync.ts b/living-ui-v2/tools/src/commands/kit-sync.ts new file mode 100644 index 00000000..74ceac6d --- /dev/null +++ b/living-ui-v2/tools/src/commands/kit-sync.ts @@ -0,0 +1,31 @@ +/** + * lui kit-sync — re-vendor the kit (wholesale replace, spec V2). + * Used by hosts on launch (auto for patch/minor) and opt-in externally (D8). + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeSystemHashes } from '../lib/hashes.ts'; +import { vendorKitInto } from '../lib/kit.ts'; +import { log } from '../lib/log.ts'; + +export async function run(args: string[]): Promise { + const projectDir = args[0]; + if (projectDir === undefined || !existsSync(join(projectDir, 'manifest.json'))) { + log.error('Usage: lui kit-sync (must contain manifest.json)'); + return 1; + } + + const version = vendorKitInto(projectDir); + + const manifestPath = join(projectDir, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { kitVersion?: string }; + const previous = manifest.kitVersion ?? 'unknown'; + manifest.kitVersion = version; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + + // Re-canonize: the sync itself is the new legitimate system-file state. + writeSystemHashes(projectDir); + + log.ok(`Kit ${previous} → ${version} in ${projectDir} (rebuild required)`); + return 0; +} diff --git a/living-ui-v2/tools/src/commands/ops.ts b/living-ui-v2/tools/src/commands/ops.ts new file mode 100644 index 00000000..93b3175c --- /dev/null +++ b/living-ui-v2/tools/src/commands/ops.ts @@ -0,0 +1,31 @@ +/** + * lui ops — the app's declared verb surface (spec O1/O4). + * The agent-facing capability card: what this app can DO. + */ +import { log } from '../lib/log.ts'; +import { loadOps, loadProject } from '../lib/project.ts'; + +export async function run(args: string[]): Promise { + const dirArg = args.find((a) => !a.startsWith('--')); + if (dirArg === undefined) { + log.error('Usage: lui ops '); + return 1; + } + const project = loadProject(dirArg); + const ops = loadOps(project); + + log.raw(`${project.name} (${project.id}) — ${project.baseUrl}\n`); + for (const op of ops) { + const params = Object.entries(op.params ?? {}) + .map(([k, v]) => `--${k} <${v.type}>${v.required ? '' : '?'}`) + .join(' '); + const flags = [op.system ? 'system' : '', op.destructive ? 'DESTRUCTIVE' : ''] + .filter(Boolean) + .join(', '); + log.raw(` ${op.name}${params ? ' ' + params : ''}${flags ? ` [${flags}]` : ''}`); + log.raw(` ${op.description}`); + } + log.raw(`\nRun one: lui run ${dirArg} [--param value ...]`); + log.raw(`Data: lui data ${dirArg} [list|get |create|update |delete ] [--json '{...}'] [--filter '...']`); + return 0; +} diff --git a/living-ui-v2/tools/src/commands/pb.ts b/living-ui-v2/tools/src/commands/pb.ts new file mode 100644 index 00000000..65da2462 --- /dev/null +++ b/living-ui-v2/tools/src/commands/pb.ts @@ -0,0 +1,77 @@ +/** + * lui pb — manage the pinned PocketBase binary. + * + * lui pb fetch download + cache the pinned version for this OS/arch + * lui pb path print the cached binary path (fetches if missing) + * lui pb version print the pinned version + */ +import { execFileSync } from 'node:child_process'; +import { chmodSync, createWriteStream, existsSync } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import type { ReadableStream as WebReadableStream } from 'node:stream/web'; +import { pipeline } from 'node:stream/promises'; +import { log } from '../lib/log.ts'; +import { osAdapter } from '../lib/os-adapter.ts'; +import { pinnedPbVersion } from '../lib/paths.ts'; + +const RELEASE_BASE = 'https://github.com/pocketbase/pocketbase/releases/download'; + +export async function ensurePbBinary(): Promise { + const version = pinnedPbVersion(); + const cacheDir = osAdapter.pbCacheDir(version); + const binPath = join(cacheDir, osAdapter.pbBinaryName()); + + if (existsSync(binPath)) return binPath; + + const asset = osAdapter.pbAssetName(version); + const url = `${RELEASE_BASE}/v${version}/${asset}`; + const zipPath = join(cacheDir, asset); + + log.step(`Downloading PocketBase v${version} (${asset})…`); + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok || res.body === null) { + throw new Error(`Download failed (${res.status}) — ${url}`); + } + await pipeline( + Readable.fromWeb(res.body as unknown as WebReadableStream), + createWriteStream(zipPath), + ); + + log.step('Extracting…'); + osAdapter.extractZip(zipPath, cacheDir); + await unlink(zipPath); + + if (!existsSync(binPath)) { + throw new Error(`Extraction did not produce ${binPath}`); + } + if (osAdapter.platform !== 'win32') chmodSync(binPath, 0o755); + + const reported = execFileSync(binPath, ['--version'], { encoding: 'utf8' }).trim(); + log.ok(`Cached ${binPath} (${reported})`); + return binPath; +} + +export async function run(args: string[]): Promise { + const sub = args[0] ?? 'fetch'; + + switch (sub) { + case 'fetch': { + await ensurePbBinary(); + return 0; + } + case 'path': { + log.raw(await ensurePbBinary()); + return 0; + } + case 'version': { + log.raw(pinnedPbVersion()); + return 0; + } + default: + log.error(`Unknown subcommand: pb ${sub}`); + log.raw('Try: lui pb fetch | path | version'); + return 1; + } +} diff --git a/living-ui-v2/tools/src/commands/probe.ts b/living-ui-v2/tools/src/commands/probe.ts new file mode 100644 index 00000000..dbadec22 --- /dev/null +++ b/living-ui-v2/tools/src/commands/probe.ts @@ -0,0 +1,99 @@ +/** + * lui probe --url --steps '' [--out ] + * Scripted headless-browser walk (walk-verify's hands). Steps: + * {"op":"goto","value":"/"} | {"op":"click","selector":"..."} | + * {"op":"type","selector":"...","value":"..."} | {"op":"read","selector":"..."} | + * {"op":"wait","value":"800"} | {"op":"screenshot","value":"name"} + * Output: one JSON line {steps:[{op,ok,detail}], consoleErrors:[...]}. + */ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { log } from '../lib/log.ts'; + +interface Step { + op: 'goto' | 'click' | 'type' | 'read' | 'wait' | 'screenshot'; + selector?: string; + value?: string; +} + +function argValue(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +export async function run(args: string[]): Promise { + const url = argValue(args, '--url'); + const stepsRaw = argValue(args, '--steps'); + const outDir = argValue(args, '--out') ?? '/tmp/lui-probe'; + if (url === undefined || stepsRaw === undefined) { + log.error("Usage: lui probe --url http://127.0.0.1: --steps '[{\"op\":\"goto\",\"value\":\"/\"}]' [--out dir]"); + return 1; + } + let chromium; + try { + ({ chromium } = await import('playwright')); + } catch { + log.raw(JSON.stringify({ error: 'playwright not installed' })); + return 2; + } + const steps = JSON.parse(stepsRaw) as Step[]; + mkdirSync(outDir, { recursive: true }); + + const consoleErrors: string[] = []; + const results: Array<{ op: string; ok: boolean; detail: string }> = []; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.on('console', (m) => { + if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); + }); + page.on('response', (res) => { + if (res.status() >= 400) consoleErrors.push(`HTTP ${res.status()}: ${res.request().method()} ${res.url().slice(0, 200)}`); + }); + page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message.slice(0, 300)}`)); + + for (const step of steps.slice(0, 40)) { + try { + switch (step.op) { + case 'goto': + await page.goto(url + (step.value ?? '/'), { waitUntil: 'load', timeout: 15000 }); + await page.waitForTimeout(800); + results.push({ op: 'goto', ok: true, detail: page.url() }); + break; + case 'click': + await page.click(step.selector ?? '', { timeout: 5000 }); + await page.waitForTimeout(500); + results.push({ op: 'click', ok: true, detail: step.selector ?? '' }); + break; + case 'type': + await page.fill(step.selector ?? '', step.value ?? '', { timeout: 5000 }); + results.push({ op: 'type', ok: true, detail: step.selector ?? '' }); + break; + case 'read': { + const text = step.selector + ? await page.innerText(step.selector, { timeout: 5000 }) + : await page.innerText('body'); + results.push({ op: 'read', ok: true, detail: text.slice(0, 1500) }); + break; + } + case 'wait': + await page.waitForTimeout(Math.min(Number(step.value ?? 500), 5000)); + results.push({ op: 'wait', ok: true, detail: step.value ?? '500' }); + break; + case 'screenshot': { + const file = join(outDir, `${step.value ?? 'shot'}-${Date.now()}.png`); + await page.screenshot({ path: file }); + results.push({ op: 'screenshot', ok: true, detail: file }); + break; + } + } + } catch (e) { + results.push({ op: step.op, ok: false, detail: e instanceof Error ? e.message.slice(0, 300) : String(e) }); + } + } + } finally { + await browser.close(); + } + log.raw(JSON.stringify({ steps: results, consoleErrors: consoleErrors.slice(0, 10) })); + return 0; +} diff --git a/living-ui-v2/tools/src/commands/run.ts b/living-ui-v2/tools/src/commands/run.ts new file mode 100644 index 00000000..3de7ef24 --- /dev/null +++ b/living-ui-v2/tools/src/commands/run.ts @@ -0,0 +1,76 @@ +/** + * lui run [--param value ...] + * Execute a declared operation against the RUNNING app (spec O2 executors). + */ +import { log } from '../lib/log.ts'; +import { loadOps, loadProject, request, type Operation } from '../lib/project.ts'; + +function collectParams(args: string[]): Record { + const params: Record = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a !== undefined && a.startsWith('--')) { + const value = args[i + 1]; + if (value !== undefined && !value.startsWith('--')) { + params[a.slice(2)] = value; + i++; + } else { + params[a.slice(2)] = 'true'; + } + } + } + return params; +} + +export async function run(args: string[]): Promise { + const positional = args.filter((a, i) => !a.startsWith('--') && (i === 0 || !(args[i - 1] ?? '').startsWith('--'))); + const [dirArg, opName] = positional; + if (dirArg === undefined || opName === undefined) { + log.error('Usage: lui run [--param value ...]'); + return 1; + } + const project = loadProject(dirArg); + const op: Operation | undefined = loadOps(project).find((o) => o.name === opName); + if (op === undefined) { + log.error(`Unknown op "${opName}". Try: lui ops ${dirArg}`); + return 1; + } + + const params = collectParams(args.slice(2)); + const missing = Object.entries(op.params ?? {}) + .filter(([k, v]) => v.required === true && !(k in params)) + .map(([k]) => k); + if (missing.length > 0) { + log.error(`Missing required param(s): ${missing.join(', ')}`); + return 1; + } + + const exec = op.executor; + if (exec.type === 'http' || exec.type === 'job') { + const method = exec.method ?? 'POST'; + let path = exec.path ?? ''; + let body: unknown; + if (method === 'GET' || method === 'DELETE') { + const qs = new URLSearchParams(params).toString(); + if (qs) path += (path.includes('?') ? '&' : '?') + qs; + } else { + body = params; + } + const res = await request(project, method, path, body); + log.raw(res.body || `(HTTP ${res.status}, empty body)`); + return res.status >= 200 && res.status < 300 ? 0 : 1; + } + if (exec.type === 'crud') { + const collection = exec.collection ?? ''; + const action = exec.action ?? 'list'; + if (action === 'list') { + const res = await request(project, 'GET', `/api/collections/${collection}/records`); + log.raw(res.body); + return res.status < 300 ? 0 : 1; + } + log.error(`crud action "${action}" not supported via run — use: lui data ${dirArg} ${collection} ...`); + return 1; + } + log.error(`Unsupported executor type: ${exec.type}`); + return 1; +} diff --git a/living-ui-v2/tools/src/commands/validate.ts b/living-ui-v2/tools/src/commands/validate.ts new file mode 100644 index 00000000..a7ab51ad --- /dev/null +++ b/living-ui-v2/tools/src/commands/validate.ts @@ -0,0 +1,254 @@ +/** + * lui validate — the validation gate (spec §11, D7 scope for M1): + * 1. tsc --noEmit (types) + * 2. vite build (build; lands in pb/pb_public) + * 3. migrations apply (against a FRESH temp pb_data) + * 4. operations.json (structural validation) + * Machine-readable failures: one line per error, `step: message`. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { verifySystemHashes } from '../lib/hashes.ts'; +import { log } from '../lib/log.ts'; +import { ensurePbBinary } from './pb.ts'; + +interface GateError { + step: string; + message: string; +} + +interface Operation { + name?: unknown; + description?: unknown; + system?: unknown; + params?: unknown; + executor?: { + type?: unknown; + method?: unknown; + path?: unknown; + collection?: unknown; + action?: unknown; + }; +} + +/** Extract routerAdd(method, path) declarations from every pb_hooks file. */ +function collectHookRoutes(projectDir: string): Set { + const hooksDir = join(projectDir, 'pb', 'pb_hooks'); + const routes = new Set(); + if (!existsSync(hooksDir)) return routes; + for (const name of readdirSync(hooksDir)) { + if (!name.endsWith('.pb.js')) continue; + const source = readFileSync(join(hooksDir, name), 'utf8'); + const re = /routerAdd\(\s*['"](GET|POST|PUT|PATCH|DELETE)['"]\s*,\s*['"]([^'"]+)['"]/g; + for (const match of source.matchAll(re)) { + routes.add(`${match[1]} ${match[2]}`); + } + } + return routes; +} + +/** + * Append the offending SOURCE to every error that names a location, in any + * gate step — agents fix the wrong thing when they only see line numbers. + * Handles `path(line,col)` (tsc), `path:line:col` (esbuild/vite), and + * `failed to apply migration ` (PocketBase, no line info → whole file). + */ +function annotateErrors(output: string, searchDirs: string[]): string { + const readSource = (rel: string): string | null => { + for (const dir of searchDirs) { + const abs = join(dir, rel); + if (existsSync(abs)) return readFileSync(abs, 'utf8'); + } + return null; + }; + + const annotateAt = (line: string, rel: string, lineNo: number, col: number): string => { + const content = readSource(rel); + if (content === null) return line; + const source = content.split('\n')[lineNo - 1] ?? ''; + const start = Math.max(0, col - 60); + const snippet = source.slice(start, start + 140); + const caret = ' '.repeat(Math.min(Math.max(col - 1 - start, 0), 139)) + '^ THE OFFENDING EXPRESSION IS HERE (col ' + col + ')'; + return `${line}\n | ${snippet}\n | ${caret}`; + }; + + return output + .split('\n') + .map((line) => { + const paren = line.match(/^(.+?)\((\d+),(\d+)\): /); + if (paren !== null) { + return annotateAt(line, paren[1] ?? '', Number(paren[2]), Number(paren[3])); + } + const colon = line.match(/([\w./-]+\.(?:tsx?|css|js))[(:](\d+)[,:](\d+)/); + if (colon !== null) { + return annotateAt(line, colon[1] ?? '', Number(colon[2]), Number(colon[3])); + } + const migration = line.match(/failed to apply migration ([\w.-]+\.js)/); + if (migration !== null) { + const content = readSource(join('pb', 'pb_migrations', migration[1] ?? '')); + if (content !== null) { + const head = content.split('\n').slice(0, 80).join('\n'); + return `${line}\n --- ${migration[1]} (the failing migration) ---\n${head}`; + } + } + return line; + }) + .join('\n'); +} + +function runStep(errors: GateError[], step: string, fn: () => void): void { + try { + fn(); + log.ok(step); + } catch (err) { + const message = + err instanceof Error && 'stdout' in err + ? String((err as Error & { stdout?: unknown }).stdout ?? err.message) + : err instanceof Error + ? err.message + : String(err); + errors.push({ step, message: message.trim().slice(0, 4000) }); + log.error(`${step} failed`); + } +} + +function validateOps(projectDir: string): void { + const raw = readFileSync(join(projectDir, 'operations.json'), 'utf8'); + const parsed = JSON.parse(raw) as { opsVersion?: unknown; operations?: unknown }; + if (parsed.opsVersion !== 1) throw new Error('opsVersion must be 1'); + if (!Array.isArray(parsed.operations)) throw new Error('operations must be an array'); + + const routes = collectHookRoutes(projectDir); + const coveredRoutes = new Set(); + const seen = new Set(); + + for (const op of parsed.operations as Operation[]) { + if (typeof op.name !== 'string' || !/^[a-z][a-z0-9._-]{0,63}$/.test(op.name)) { + throw new Error(`invalid op name: ${JSON.stringify(op.name)} (see spec/operations.schema.json)`); + } + if (seen.has(op.name)) throw new Error(`duplicate op name: ${op.name}`); + seen.add(op.name); + if (typeof op.description !== 'string' || op.description === '') { + throw new Error(`${op.name}: description required`); + } + + const type = op.executor?.type; + if (type === 'crud') { + if (typeof op.executor?.collection !== 'string' || typeof op.executor?.action !== 'string') { + throw new Error(`${op.name}: crud executor needs collection + action`); + } + continue; + } + if (type !== 'http' && type !== 'job') { + throw new Error(`${op.name}: executor.type must be http|crud|job`); + } + const method = op.executor?.method; + const path = op.executor?.path; + if (typeof method !== 'string' || typeof path !== 'string' || !path.startsWith('/api/')) { + throw new Error(`${op.name}: http/job executor needs method + /api/... path`); + } + + // O3 structural check: non-system ops must resolve to a declared hook route. + // System entries may point at PB built-ins (e.g. /api/health). + const key = `${method} ${path}`; + coveredRoutes.add(key); + if (op.system !== true && !routes.has(key)) { + throw new Error( + `${op.name}: no pb_hooks route matches "${key}" — declare the route with routerAdd or fix the op`, + ); + } + } + + // O3 coverage: hook routes not covered by any op are warnings, not errors. + for (const route of routes) { + const path = route.split(' ')[1] ?? ''; + if (path.startsWith('/api/_')) continue; // system plumbing (_ops, _console, _jobs) + if (!coveredRoutes.has(route)) { + log.warn(`route not declared as an operation: ${route} (agents can't discover it)`); + } + } +} + +export async function run(args: string[]): Promise { + const projectDir = args[0]; + if (projectDir === undefined || !existsSync(join(projectDir, 'manifest.json'))) { + log.error('Usage: lui validate (must contain manifest.json)'); + return 1; + } + + const frontendDir = join(projectDir, 'frontend'); + const errors: GateError[] = []; + + runStep(errors, 'types (tsc --noEmit)', () => { + execFileSync('npm', ['run', 'typecheck'], { cwd: frontendDir, stdio: 'pipe', encoding: 'utf8' }); + }); + + runStep(errors, 'build (vite)', () => { + execFileSync('npm', ['run', 'build'], { cwd: frontendDir, stdio: 'pipe', encoding: 'utf8' }); + }); + + const pbBin = await ensurePbBinary(); + runStep(errors, 'migrations (fresh pb_data)', () => { + const tempData = mkdtempSync(join(tmpdir(), 'lui-migrate-')); + try { + // NOTE: `pocketbase migrate up` exits 0 even when a migration fails — + // it only PRINTS the error. Scan output; never trust the exit code. + const out = execFileSync( + pbBin, + [ + 'migrate', + 'up', + '--dir', + tempData, + '--migrationsDir', + join(projectDir, 'pb', 'pb_migrations'), + '--hooksDir', + join(projectDir, 'pb', 'pb_hooks'), + ], + { stdio: 'pipe', encoding: 'utf8' }, + ); + const failure = out.split('\n').find((l) => /^\s*Error[:\s]/.test(l)); + if (failure !== undefined) { + throw new Error( + `${failure.trim()}\nHint: relation fields need the target collection's ID — ` + + `save the target first, then use app.findCollectionByNameOrId('').id`, + ); + } + } finally { + rmSync(tempData, { recursive: true, force: true }); + } + }); + + runStep(errors, 'operations.json (structure)', () => validateOps(projectDir)); + + runStep(errors, 'ownership (system files unmodified)', () => { + const drift = verifySystemHashes(projectDir); + const problems: string[] = [ + ...drift.modified.map((p) => `modified: ${p}`), + ...drift.missing.map((p) => `deleted: ${p}`), + ...drift.added.map((p) => `added: ${p}`), + ]; + if (problems.length > 0) { + throw new Error( + `system-managed files changed outside tooling (spec P1):\n${problems.join('\n')}\n` + + `If a kit upgrade is intended, run kit-sync; agent edits belong in app-owned paths.`, + ); + } + }); + + // Enrich every located error with its source before reporting. + for (const e of errors) { + e.message = annotateErrors(e.message, [frontendDir, projectDir]); + } + + if (errors.length > 0) { + log.raw(''); + for (const e of errors) log.raw(`${e.step}: ${e.message.split('\n').slice(0, 15).join('\n')}`); + log.error(`Gate: ${errors.length} step(s) failed`); + return 1; + } + log.ok('Gate: all steps passed'); + return 0; +} diff --git a/living-ui-v2/tools/src/commands/verify.ts b/living-ui-v2/tools/src/commands/verify.ts new file mode 100644 index 00000000..c81ded33 --- /dev/null +++ b/living-ui-v2/tools/src/commands/verify.ts @@ -0,0 +1,91 @@ +/** + * lui verify --url — headless smoke verification (spec WD11, + * the deterministic core of walk-verify): + * 1. app mounts (#root renders real content) + * 2. zero console errors / page crashes while loading + settling + * 3. screenshot evidence saved to logs/verify/home.png (WD7) + * Always invisible: headless chromium, no window, no focus stealing. + * Output: one JSON verdict line. Exit 0 pass, 1 fail, 2 skipped (no browser). + */ +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { log } from '../lib/log.ts'; + +interface Verdict { + status: 'pass' | 'fail' | 'skipped'; + checks: Record; + consoleErrors: string[]; + screenshot: string | null; +} + +function argValue(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +export async function run(args: string[]): Promise { + const projectDir = args.find((a) => !a.startsWith('--')); + const url = argValue(args, '--url'); + if (projectDir === undefined || url === undefined || !existsSync(join(projectDir, 'manifest.json'))) { + log.error('Usage: lui verify --url http://127.0.0.1:'); + return 1; + } + + let chromium; + try { + ({ chromium } = await import('playwright')); + } catch { + log.raw(JSON.stringify({ status: 'skipped', reason: 'playwright not installed' })); + return 2; + } + + const verifyDir = join(projectDir, 'logs', 'verify'); + mkdirSync(verifyDir, { recursive: true }); + const screenshotPath = join(verifyDir, 'home.png'); + + const consoleErrors: string[] = []; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 500)); + }); + page.on('response', (res) => { + if (res.status() >= 400) consoleErrors.push(`HTTP ${res.status()}: ${res.request().method()} ${res.url().slice(0, 200)}`); + }); + page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message.slice(0, 500)}`)); + + // NOTE: never wait for 'networkidle' — Living UIs hold a permanent SSE + // connection (realtime subscriptions), so the network is never idle. + let loaded = true; + try { + await page.goto(url, { waitUntil: 'load', timeout: 20000 }); + await page.waitForTimeout(1500); // let React mount + realtime settle + } catch { + loaded = false; + } + + const mounted = loaded + ? await page + .evaluate(() => { + const root = document.getElementById('root'); + return root !== null && root.childElementCount > 0 && root.innerText.trim().length > 0; + }) + .catch(() => false) + : false; + + await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {}); + + const checks = { loaded, mounted, noConsoleErrors: consoleErrors.length === 0 }; + const verdict: Verdict = { + status: Object.values(checks).every(Boolean) ? 'pass' : 'fail', + checks, + consoleErrors: consoleErrors.slice(0, 10), + screenshot: existsSync(screenshotPath) ? screenshotPath : null, + }; + log.raw(JSON.stringify(verdict)); + return verdict.status === 'pass' ? 0 : 1; + } finally { + await browser.close(); + } +} diff --git a/living-ui-v2/tools/src/lib/hashes.ts b/living-ui-v2/tools/src/lib/hashes.ts new file mode 100644 index 00000000..788c0e73 --- /dev/null +++ b/living-ui-v2/tools/src/lib/hashes.ts @@ -0,0 +1,84 @@ +/** + * Ownership hashes (spec §11 step 5 / P1): system-managed files are recorded + * at scaffold/kit-sync time; the gate detects agent edits by re-hashing. + * Tooling is the only writer of both the files and the hash manifest. + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const HASH_FILE = join('.lui', 'system-hashes.json'); + +/** System-managed paths, relative to the project root (files or directories). */ +const SYSTEM_PATHS = [ + 'frontend/src/kit', + 'frontend/src/main.tsx', + 'frontend/src/config.gen.ts', + 'frontend/src/app.css', + 'frontend/index.html', + 'frontend/vite.config.ts', + 'frontend/tsconfig.json', + 'pb/pb_hooks/_system.pb.js', + 'manifest.json', +]; + +function sha256(file: string): string { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function toPosix(p: string): string { + return p.split(sep).join('/'); +} + +/** Hash every system file; directories are walked, missing entries skipped. */ +export function computeSystemHashes(projectDir: string): Record { + const hashes: Record = {}; + const addFile = (abs: string): void => { + hashes[toPosix(relative(projectDir, abs))] = sha256(abs); + }; + + const stack = SYSTEM_PATHS.map((p) => join(projectDir, p)).filter((p) => existsSync(p)); + while (stack.length > 0) { + const current = stack.pop() as string; + if (statSync(current).isDirectory()) { + for (const name of readdirSync(current)) stack.push(join(current, name)); + } else { + addFile(current); + } + } + return hashes; +} + +/** Record the current state as canonical (called by create and kit-sync). */ +export function writeSystemHashes(projectDir: string): void { + mkdirSync(join(projectDir, '.lui'), { recursive: true }); + const hashes = computeSystemHashes(projectDir); + writeFileSync(join(projectDir, HASH_FILE), JSON.stringify(hashes, null, 2) + '\n'); +} + +export interface OwnershipDrift { + modified: string[]; + missing: string[]; + added: string[]; +} + +/** Compare current state to the recorded canon. */ +export function verifySystemHashes(projectDir: string): OwnershipDrift { + const file = join(projectDir, HASH_FILE); + if (!existsSync(file)) { + throw new Error(`missing ${HASH_FILE} — run kit-sync to (re)establish system-file canon`); + } + const recorded = JSON.parse(readFileSync(file, 'utf8')) as Record; + const current = computeSystemHashes(projectDir); + + const drift: OwnershipDrift = { modified: [], missing: [], added: [] }; + for (const [path, hash] of Object.entries(recorded)) { + const now = current[path]; + if (now === undefined) drift.missing.push(path); + else if (now !== hash) drift.modified.push(path); + } + for (const path of Object.keys(current)) { + if (!(path in recorded)) drift.added.push(path); + } + return drift; +} diff --git a/living-ui-v2/tools/src/lib/kit.ts b/living-ui-v2/tools/src/lib/kit.ts new file mode 100644 index 00000000..73e8e2fd --- /dev/null +++ b/living-ui-v2/tools/src/lib/kit.ts @@ -0,0 +1,22 @@ +/** Kit vendoring — wholesale copy, never merge (spec V2/D6). */ +import { cpSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { kitDir } from './paths.ts'; + +export function kitVersion(): string { + const meta = JSON.parse(readFileSync(join(kitDir(), 'kit.json'), 'utf8')) as { version: string }; + return meta.version; +} + +/** + * Vendor the kit into a project: replace frontend/src/kit entirely with the + * workspace kit source + its version marker. Safe because the folder is + * system-managed — no agent edits can live there (spec P1). + */ +export function vendorKitInto(projectDir: string): string { + const dest = join(projectDir, 'frontend', 'src', 'kit'); + rmSync(dest, { recursive: true, force: true }); + cpSync(join(kitDir(), 'src'), dest, { recursive: true }); + cpSync(join(kitDir(), 'kit.json'), join(dest, 'kit.json')); + return kitVersion(); +} diff --git a/living-ui-v2/tools/src/lib/log.ts b/living-ui-v2/tools/src/lib/log.ts new file mode 100644 index 00000000..8790aee4 --- /dev/null +++ b/living-ui-v2/tools/src/lib/log.ts @@ -0,0 +1,25 @@ +/** Minimal logger — no dependencies, ANSI only when stdout is a TTY. */ + +const tty = process.stdout.isTTY === true; +const paint = (code: string, s: string): string => (tty ? `\x1b[${code}m${s}\x1b[0m` : s); + +export const log = { + raw(msg: string): void { + console.log(msg); + }, + info(msg: string): void { + console.log(`${paint('36', 'ℹ')} ${msg}`); + }, + step(msg: string): void { + console.log(`${paint('35', '▸')} ${msg}`); + }, + ok(msg: string): void { + console.log(`${paint('32', '✓')} ${msg}`); + }, + warn(msg: string): void { + console.warn(`${paint('33', '⚠')} ${msg}`); + }, + error(msg: string): void { + console.error(`${paint('31', '✗')} ${msg}`); + }, +}; diff --git a/living-ui-v2/tools/src/lib/os-adapter.ts b/living-ui-v2/tools/src/lib/os-adapter.ts new file mode 100644 index 00000000..682e630a --- /dev/null +++ b/living-ui-v2/tools/src/lib/os-adapter.ts @@ -0,0 +1,83 @@ +/** + * OSAdapter — the single home for platform-specific behavior (spec W5/D14). + * Commands never branch on process.platform; they ask the adapter. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type Platform = 'darwin' | 'linux' | 'win32'; + +export class OSAdapter { + readonly platform: Platform; + readonly arch: 'amd64' | 'arm64'; + + constructor(platform: NodeJS.Platform = process.platform, arch: string = process.arch) { + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { + throw new Error(`Unsupported platform: ${platform}`); + } + this.platform = platform; + this.arch = arch === 'arm64' ? 'arm64' : 'amd64'; + } + + /** Central per-host PocketBase binary cache, versioned (spec B1). */ + pbCacheDir(version: string): string { + const override = process.env['LIVING_UI_PB_CACHE']; + const base = + override ?? + { + darwin: join(homedir(), 'Library', 'Caches', 'craftos-living-ui', 'pb'), + linux: join(homedir(), '.cache', 'craftos-living-ui', 'pb'), + win32: join(process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local'), 'craftos-living-ui', 'pb'), + }[this.platform]; + const dir = join(base, version); + mkdirSync(dir, { recursive: true }); + return dir; + } + + /** GitHub release asset name for the pinned version. */ + pbAssetName(version: string): string { + const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[this.platform]; + return `pocketbase_${version}_${os}_${this.arch}.zip`; + } + + pbBinaryName(): string { + return this.platform === 'win32' ? 'pocketbase.exe' : 'pocketbase'; + } + + /** + * Extract a zip without npm deps: bsdtar handles zip on macOS and Windows 10+; + * on Linux prefer unzip, fall back to bsdtar. + */ + extractZip(zipPath: string, destDir: string): void { + if (this.platform === 'linux') { + try { + execFileSync('unzip', ['-o', zipPath, '-d', destDir], { stdio: 'pipe' }); + return; + } catch { + // fall through to tar + } + } + execFileSync('tar', ['-xf', zipPath, '-C', destDir], { stdio: 'pipe' }); + } + + /** Terminate a process tree. */ + terminate(pid: number): void { + if (this.platform === 'win32') { + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'pipe' }); + return; + } + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already gone + } + } + + binaryExists(dir: string): boolean { + return existsSync(join(dir, this.pbBinaryName())); + } +} + +export const osAdapter = new OSAdapter(); diff --git a/living-ui-v2/tools/src/lib/paths.ts b/living-ui-v2/tools/src/lib/paths.ts new file mode 100644 index 00000000..75803900 --- /dev/null +++ b/living-ui-v2/tools/src/lib/paths.ts @@ -0,0 +1,30 @@ +/** Workspace path resolution — single source of truth for where things live. */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The living-ui-v2 workspace root (this file is tools/src/lib/paths.ts). */ +export function workspaceRoot(): string { + return dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); +} + +export function kitDir(): string { + return join(workspaceRoot(), 'kit'); +} + +export function blueprintDir(): string { + return join(workspaceRoot(), 'blueprint'); +} + +export function examplesDir(): string { + return join(workspaceRoot(), 'examples'); +} + +/** Exact PocketBase version pinned by the spec (D9). */ +export function pinnedPbVersion(): string { + const file = join(workspaceRoot(), 'spec', 'pocketbase.version'); + if (!existsSync(file)) { + throw new Error(`Missing ${file} — the PB pin is required (spec B1).`); + } + return readFileSync(file, 'utf8').trim(); +} diff --git a/living-ui-v2/tools/src/lib/project.ts b/living-ui-v2/tools/src/lib/project.ts new file mode 100644 index 00000000..64ce3229 --- /dev/null +++ b/living-ui-v2/tools/src/lib/project.ts @@ -0,0 +1,76 @@ +/** Operate-command helpers: resolve a project, its port, ops, and auth. */ +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export interface ProjectRef { + dir: string; + name: string; + id: string; + port: number; + baseUrl: string; +} + +export function loadProject(projectDir: string): ProjectRef { + const dir = resolve(projectDir); + const manifestPath = join(dir, 'manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error(`Not a Living UI project (no manifest.json): ${dir}`); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + name: string; + id: string; + port: number; + }; + return { + dir, + name: manifest.name, + id: manifest.id, + port: manifest.port, + baseUrl: `http://127.0.0.1:${manifest.port}`, + }; +} + +export interface Operation { + name: string; + description: string; + system?: boolean; + destructive?: boolean; + params?: Record; + executor: { type: string; method?: string; path?: string; collection?: string; action?: string }; +} + +export function loadOps(project: ProjectRef): Operation[] { + const raw = JSON.parse(readFileSync(join(project.dir, 'operations.json'), 'utf8')) as { + operations: Operation[]; + }; + return raw.operations ?? []; +} + +/** Superuser token via the project-local .superuser file (absent on imports). */ +export async function authToken(project: ProjectRef): Promise { + const credFile = join(project.dir, '.superuser'); + if (!existsSync(credFile)) return null; + const { email, password } = JSON.parse(readFileSync(credFile, 'utf8')); + const res = await fetch(`${project.baseUrl}/api/collections/_superusers/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: email, password }), + }); + if (!res.ok) return null; + return ((await res.json()) as { token: string }).token; +} + +export async function request( + project: ProjectRef, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: string }> { + const headers: Record = { 'Content-Type': 'application/json' }; + const token = await authToken(project); + if (token !== null) headers['Authorization'] = token; + const init: RequestInit = { method, headers }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await fetch(`${project.baseUrl}${path}`, init); + return { status: res.status, body: await res.text() }; +} diff --git a/living-ui-v2/tools/tsconfig.json b/living-ui-v2/tools/tsconfig.json new file mode 100644 index 00000000..bed6a934 --- /dev/null +++ b/living-ui-v2/tools/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true + }, + "include": ["src"] +} diff --git a/living-ui-v2/tsconfig.base.json b/living-ui-v2/tsconfig.base.json new file mode 100644 index 00000000..d823eb74 --- /dev/null +++ b/living-ui-v2/tsconfig.base.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + } +} diff --git a/skills/living-ui-creator/SKILL.md b/skills/living-ui-creator/SKILL.md index e8dc307e..44fefc37 100644 --- a/skills/living-ui-creator/SKILL.md +++ b/skills/living-ui-creator/SKILL.md @@ -1,439 +1,159 @@ --- name: living-ui-creator -description: Create custom Living UI applications with backend-first architecture. Scaffolds, develops, tests, and launches dynamic web apps with persistent state. +description: Create Living UI applications (V2 — PocketBase backend, React kit frontend). Scaffolds, develops, validates, and launches local web apps with persistent state and realtime UI. action-sets: - file_operations - code_execution - living_ui --- -# Living UI Creator +# Living UI Creator (V2) -Create interactive web applications that persist state and survive page reloads. +A Living UI is a self-contained local web app: **one PocketBase process** (data, +auth, realtime, custom verbs) serving a **React frontend built from a preset +kit**. You declare schema, compose UI, wire verbs — the platform owns the rest. -## Architecture Overview +## Step 0: Have a registered project (MANDATORY FIRST) -Living UI uses a **backend-first, stateless frontend** pattern: +1. **Task instruction contains `Project ID` + `Project Path`** → the project is + already scaffolded. Use those values. **Skip scaffolding.** +2. **No Project ID in your instruction** (user asked in a regular chat) → call + `living_ui_scaffold(name, description, auth_mode)` — it scaffolds AND + dispatches the build to the project's dedicated session. Tell the user the + build started, then end your turn. Do NOT build in the chat session. -``` -┌─────────────────────────────────────────────────────────────────┐ -│ BACKEND (FastAPI + SQLite) │ -│ Location: backend/ │ -│ - THE source of truth for ALL application state │ -│ - Persists data to SQLite database │ -│ - Exposes REST API at http://localhost: │ -│ - State survives page reloads and tab switches │ -├─────────────────────────────────────────────────────────────────┤ -│ FRONTEND (React + TypeScript) │ -│ Location: frontend/ │ -│ - Stateless view layer - fetches state FROM backend │ -│ - Sends user actions TO backend │ -│ - Uses localStorage as cache only (fallback) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Key Principle**: Frontend is a dumb view. Backend owns all state. - -## Architecture Decision - -Before coding, determine what your app needs: +Pick `auth_mode` from requirements: `none` (personal local tool — default) or +`multi-user` (accounts; the kit's LoginGate wraps the app automatically). -| Need | Solution | -|------|----------| -| Persist user data | Database models (SQLite) | -| Fetch external data | Backend proxy endpoint | -| Agent provides data | `PUT /api/state` to push data | -| Agent reads app data | `GET /api/state` endpoint | -| Agent observes UI | `GET /api/ui-snapshot` (auto-captured) | -| Agent sees visually | `GET /api/ui-screenshot` | -| Agent triggers actions | `POST /api/action` | -| Complex UI state | Multiple frontend components | -| Multiple users with own data | Add auth module from `app/data/living_ui_modules/auth/` | -| User roles (admin/member) | Auth module + role checks in routes | +## The ownership rule (the gate enforces this) -**Default:** Most apps need all layers (DB + Backend + Frontend). -**Agent APIs are built-in** - no extra work needed. +Edit ONLY: -See [MVC-A.md](references/MVC-A.md) for detailed architecture guidance. +| Path | Purpose | +|------|---------| +| `frontend/src/app/` | all UI code | +| `pb/pb_migrations/` | schema — one NEW migration per change | +| `pb/pb_hooks/ops.pb.js` + new `*.pb.js` | custom verbs | +| `operations.json` | declarations for those verbs (non-`system` entries) | +| `LIVING_UI.md` | your plan/context/index — keep current | -## Multi-User / Auth Support - -If the app needs multiple users, login, teams, or shared data: -1. Read `app/data/living_ui_modules/auth/README.md` for the full integration guide -2. Copy the module files into your project and wire them up as documented - -**When to add auth:** user mentioned "multiple users", "team", "sharing", "login", or the app manages per-user data (task tracker, CRM, project manager). If unsure, ask during Phase 0. - -## Directory Structure +NEVER edit `frontend/src/kit/`, `frontend/src/main.tsx`, `frontend/src/config.gen.ts`, +`pb/pb_hooks/_system.pb.js`, `manifest.json`, or build configs — the validation +gate hashes them and **fails the build** if they changed. Need a variant of a +kit component? Wrap it in `app/`: +```tsx +// frontend/src/app/components/DueBadge.tsx +import { cn } from '../../kit/index.ts'; +export function DueBadge({ overdue }: { overdue: boolean }) { /* compose */ } ``` -project_root/ -├── backend/ # Python FastAPI backend -│ ├── main.py # FastAPI app entry point (rarely edit) -│ ├── models.py # SQLAlchemy models - EDIT THIS for data -│ ├── routes.py # API endpoints - EDIT THIS for actions -│ ├── database.py # DB connection (rarely edit) -│ └── living_ui.db # SQLite database (auto-created) -│ -├── frontend/ # React TypeScript frontend -│ ├── main.tsx # Entry point (rarely edit) -│ ├── App.tsx # Main app component -│ ├── AppController.ts # State management & backend communication -│ ├── types.ts # TypeScript interfaces - EDIT THIS -│ ├── components/ # React components - EDIT/ADD HERE -│ │ ├── ui/ # Pre-built UI components (USE THESE) -│ │ │ └── index.tsx # Button, Card, Input, Modal, etc. -│ │ └── MainView.tsx # Main UI component -│ ├── services/ # API & UI capture (rarely edit) -│ │ ├── ApiService.ts # Backend API client -│ │ └── UICapture.ts # UI snapshot/screenshot for agent -│ └── styles/global.css # CraftBot design tokens -│ -├── config/manifest.json # Project metadata (port info here) -├── index.html -├── package.json -├── vite.config.ts -└── LIVING_UI.md # Project documentation - UPDATE THIS -``` - -## UI Components (MANDATORY) -Use preset components for ALL standard UI elements — `Button`, `Card`, `Input`, `Modal`, `Alert`, `Table`, etc. -Do NOT create custom buttons, inputs, cards, or write custom CSS for standard elements. - -```typescript -import { Button, Card, Input, Alert, Table, Modal } from './components/ui' +## Before coding + +1. Read `agent_file_system/GLOBAL_LIVING_UI.md` — colors, fonts, enforced rules. +2. Read `{project_path}/LIVING_UI.md` and `reference/requirements.md` (if present). +3. **QnA phase (mandatory unless requirements are already detailed and + unambiguous):** ask the user 1–2 batches of clarifying questions — data to + track, must-have features, design preferences, single- vs multi-user — via + a FINAL `send_message` (`continue_work=false` — the reply wakes the session) (questions appear on the + creation screen). Write the agreed requirements to + `reference/requirements.md` before any coding. +4. A Living UI build is substantial work — the standard run protocol applies + as-is (scope, plan, execute, verify, deliver); this skill adds nothing to + it. `reference/requirements.md` is the binding spec verification checks + against; mirror the feature checklist in `LIVING_UI.md`. + +## Per feature: schema → verbs → UI + +**Schema** — add a new file in `pb/pb_migrations/` (never edit an applied one). +Follow the starter migration's pattern exactly: field types, `autodate` +created/updated, and rules matching the project's `authMode` (`manifest.json`): +`''` open rules for `none`; `@request.auth.id != ""` (or owner-scoped +`owner = @request.auth.id` with a `relation` to `users`) for `multi-user`. + +**Relation fields — the #1 migration mistake:** `collectionId` must be the +target collection's **ID, never its name**. Save the target collection first, +then reference it: + +```js +const words = new Collection({ name: 'words', /* … */ }); +app.save(words); +const reviews = new Collection({ + name: 'reviews', + fields: [ + { name: 'word', type: 'relation', required: true, + collectionId: app.findCollectionByNameOrId('words').id, cascadeDelete: true }, + /* … */ + ], +}); +app.save(reviews); ``` -See [COMPONENTS.md](references/COMPONENTS.md) for full reference, icons (lucide-react), and toasts (react-toastify). - -## Agent API (Built-in) - -Living UI provides standard HTTP endpoints for agent observation: - -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `/api/ui-snapshot` | GET | UI state (DOM, text, form values) | -| `/api/ui-screenshot` | GET | Visual screenshot (PNG base64) | -| `/api/state` | GET/PUT | Application data | -| `/api/action` | POST | Trigger actions | - -Frontend auto-captures UI state on meaningful events (page load, state changes, user interactions). See [MVC-A.md](references/MVC-A.md) for details. - -## Development Workflow - -Follow these phases in order. Use TodoWrite to track progress. - -### Step 0: Create the Project Scaffold (MANDATORY FIRST STEP) - -Before writing any code, you MUST have a registered project with a real `project_id` -and an absolute `project_path`. There are two cases: - -1. **Your task instruction already contains a `Project ID` and `Project Path`** — - the project was scaffolded for you (Create Living UI modal flow). **Skip scaffolding.** - Use that `project_id` and `project_path` directly. - -2. **No Project ID / Project Path in your task instruction** (you're building from a - chat request) — call `living_ui_scaffold` FIRST to create and register the project: - - ``` - living_ui_scaffold(name="", description="") - ``` - - It copies the template (`backend/`, `frontend/`, `config/`), allocates ports, and - registers the project so it appears in the user's Living UI list. It returns - `project_id` and an absolute `project_path`. - -**CRITICAL — file path rule (applies to ALL phases):** -- Treat `project_path` as the base for **every** file operation. The relative paths in - this skill (`backend/models.py`, `frontend/components/`, `LIVING_UI.md`, etc.) are - relative to `project_path`. -- When calling `write_file`, `read_file`, or running tests, use the **absolute path**: - `{project_path}/backend/models.py`, `{project_path}/frontend/components/MainView.tsx`, - `cd {project_path}/backend && python -m pytest tests/`. -- **NEVER write to bare relative paths** like `backend/models.py` — they land in the - CraftBot process directory, scattering files at the wrong root and breaking launch. - -### Before You Start: Read and Apply Global Config - -Read `agent_file_system/GLOBAL_LIVING_UI.md` for global design preferences and rules. - -**You MUST apply these settings in your code:** - -- **Primary/Secondary/Accent Colors**: Use these hex values in your CSS and component styles. Set them as CSS custom properties in `frontend/styles/global.css` or use them directly in components. Example: if Primary Color is `#6366f1`, use it for primary buttons, active states, links, and accent elements. -- **Font Family**: Apply as the `font-family` in `global.css` body styles. -- **Enabled rules `[x]`**: Treat as hard requirements — your code must implement them. -- **Disabled rules `[ ]`**: Skip these features. -- **Always Enforced rules**: These are non-negotiable — always follow them. -- Per-project requirements from Phase 0 Q&A override global settings when they conflict. - -### Phase 0: Requirement Gathering (MANDATORY — minimum 2 batches) - -Before coding, gather requirements from the user through a conversational interview. -Use `send_message` with `wait_for_user_reply=True` to ask questions and wait for answers. - -**Reference:** Read [QUESTIONNAIRE.md](references/QUESTIONNAIRE.md) for question categories and examples. - -**CRITICAL RULES:** -- You MUST ask at least 2 batches of questions. Never skip to coding after just 1 batch. -- Batch 1 MUST cover data/features. Batch 2 MUST cover design/visual preferences. -- If the user gives short or vague answers, DO NOT skip Batch 2. Instead, offer specific choices (e.g., "Would you prefer a card grid or a kanban column layout?"). -- If the user explicitly says "just build it" or "skip the questions" — then and ONLY then can you stop early. A short answer to one question is NOT "skip." -- **EXPAND VAGUE ANSWERS**: When a user gives a brief or vague reply (e.g., "basic user stuff", "normal layout", "simple dashboard"), you MUST expand it into specific features, then confirm with the user before proceeding. See "Expanding Vague Answers" in [QUESTIONNAIRE.md](references/QUESTIONNAIRE.md) for common mappings. - -**Process:** - -1. **Analyze the project description** — identify what's clear and what's ambiguous -2. **Batch 1: Data & Features (REQUIRED)** — ask 2-4 questions: - - Open with a warm acknowledgment of the project idea - - Focus on: what entities/items exist, how they relate, what operations are needed - - Use `send_message` with `wait_for_user_reply=True` -3. **Batch 2: Design & Layout (REQUIRED)** — always ask this, even if Batch 1 answers were short: - - Acknowledge Batch 1 answers briefly - - Focus on: layout style (grid/kanban/list/freeform), visual style, color preferences, detail views vs modals - - Offer concrete choices rather than open-ended questions (e.g., "Card grid like Pinterest, or columns like Trello?") - - Use `send_message` with `wait_for_user_reply=True` -4. **Batch 3 (optional)** — only if significant gaps remain after Batch 2 -5. **Expand vague answers** — after each batch, review the user's responses: - - If any answer is vague ("basic", "normal", "simple", "standard", "the usual"), expand it into concrete features using the mappings in QUESTIONNAIRE.md - - Confirm your expansion: "By 'basic user stuff' I'll include: login/signup, user profiles, member list, and role-based access (admin/member). Does that sound right?" - - Wait for user to confirm or correct before proceeding - - Document the **expanded** version in LIVING_UI.md, not the vague original -6. **Fill gaps with assumptions** — after gathering answers: - - State your assumptions explicitly to the user - - See "Safe Assumptions" in QUESTIONNAIRE.md for defaults -6. **Document in LIVING_UI.md (MANDATORY)** — you MUST fill in the Requirements section NOW, before moving to Phase 1: - - Fill in ALL subsections: Entities & Data Model, Layout & Design, Features, Assumptions - - Replace ALL HTML comments (``) with actual content - - Replace ALL example/placeholder data with real data - - This becomes the source of truth for all subsequent phases - - **DO NOT proceed to Phase 1 until LIVING_UI.md has real content** - -**When to stop asking:** -- After Batch 2, unless there are major gaps (then do Batch 3) -- If user explicitly says "just build it" or "skip" — stop and assume the rest -- Never ask more than 3 batches total - -**Tone:** Warm and conversational. Offer concrete choices, not just open-ended questions. Acknowledge answers before asking more. - -**Example Batch 1 (Data & Features):** -> "Love the idea! Before I start building, a few quick questions about what goes on the board: -> 1. What kinds of items will you add? (notes, images, videos, links, docs — all of these?) -> 2. What info should each item have? (just the content, or also title, description, tags, status?) -> 3. Do you need to organize items into categories or groups?" - -**Example Batch 2 (Design & Layout):** -> "Thanks! Now a couple questions about how it should look: -> 1. Layout preference — card grid (like Pinterest), columns (like Trello), or a list view? -> 2. When you click an item, should it open in a detail panel on the side, a full modal, or expand in place? -> 3. Any color/visual preference? (dark theme, light, colorful, minimal — or I'll use a clean modern default)" - -### Phase 1: Plan Features +**Custom verbs** — anything beyond CRUD is a `routerAdd` route in +`pb/pb_hooks/ops.pb.js` PLUS a matching entry in `operations.json` (see the +working `items.clear-done` example). The gate fails ops without routes and +warns about routes without ops. Mark data-deleting ops `"destructive": true`. +Plain CRUD needs no verb — the PB API and the kit hooks already cover it. -Read the requirements from LIVING_UI.md (Phase 0) and break the app into **features**. -A feature is a complete user-facing capability (e.g., "Board Items", "Media Attachments", "Search/Filter"). +**Request bodies in hooks: `e.requestInfo().body` ONLY** (a pre-parsed +object). `toString(e.request.body)` reads a Go stream as EMPTY — your handler +will 400 on every request and the error will falsely blame the client. -Create a feature list in your todo list. Order by dependency (core data first, then enhancements). +**Naming: kebab-case everywhere, all three places must agree** — the op `name` +in operations.json, the `routerAdd` path in pb_hooks, and every frontend call: +`"plan.generate"` ↔ `/api/ops/plan-generate` ↔ `fetch('/api/ops/plan-generate')`. +Pick the names once, before writing any of the three. -Example feature breakdown for a research board: -1. Board Items (create, view, edit, delete items with title/description) -2. Categories/Sections (organize items into groups) -3. Media Attachments (images, videos, links on items) -4. Search & Filter (find items by text, category, tags) -5. Drag & Drop (reorder items) +**Load-time calls must survive an EMPTY database.** A fresh app has no records: +never call ops or filtered queries at page load that 400 without data — gate +them behind existence checks (e.g. only call plan ops after a profile exists). +The launch verifier fails the app on any first-paint console error. -If Phase 0 was skipped (requirements are very detailed in the description), -document them in LIVING_UI.md now before proceeding. +**UI** — build in `frontend/src/app/`, importing ONLY from `../kit/index.ts`: -### Phase 2-7: Build Features (repeat for each feature) +- Read data with `useCollection('name', { sort: '-created' })` — it is + **realtime**; never poll, never reload. +- Write with `await getPbClient().call((pb) => pb.collection('name').create(...))` + — failures toast automatically. +- Components: `Button, Input, Card/CardHeader/CardBody, Dialog, Table, LoginGate`, + plus `toast` for feedback and `useAuth()` in multi-user apps. +- Style with Tailwind utilities + kit tokens (`var(--lui-*)`). Never hardcode + colors — theming is host-owned (style packs + dark mode must keep working). +- Required UX: empty states with an action, loading states, confirmation + dialogs for destructive actions, toasts on CRUD, responsive layout. -Build one feature at a time, fully completing each before moving to the next. -For each feature, follow this cycle: +Update `LIVING_UI.md` after each feature (entities table, ops list, checklist). -#### Step A: Write Tests First +## Finish: validate + launch -**Edit: `backend/tests/test_{feature}.py`** - -Write tests that describe the expected API behavior BEFORE writing routes. -The template provides `conftest.py` with a test client and temporary in-memory database. -These tests will FAIL initially — that's expected. - -```python -# Example: tests/test_items.py -def test_create_item(client): - """Should create a new item.""" - response = client.post("/api/items", json={ - "title": "Test Item", - "description": "A test item", - }) - assert response.status_code == 200 - data = response.json() - assert data["title"] == "Test Item" - assert "id" in data - -def test_get_items(client): - """Should return all items.""" - client.post("/api/items", json={"title": "Item 1"}) - client.post("/api/items", json={"title": "Item 2"}) - response = client.get("/api/items") - assert response.status_code == 200 - assert len(response.json()) == 2 - -def test_delete_item(client): - """Should delete an item and return 404 on re-fetch.""" - item = client.post("/api/items", json={"title": "To Delete"}).json() - response = client.delete(f"/api/items/{item['id']}") - assert response.status_code == 200 - assert client.get(f"/api/items/{item['id']}").status_code == 404 ``` - -**What to test:** -- CRUD operations (create, read, update, delete) -- Business logic (e.g., deleting a section deletes its cards) -- Edge cases (e.g., non-existent item returns 404) -- Relationships (e.g., item belongs to section) - -**The `client` and `db` fixtures** are provided by `conftest.py`. -**Delete `tests/test_example.py`** after creating your first test file. - -#### Step B: Create Backend (model + routes) - -**Edit: `backend/models.py`** — add the model for this feature: -- NEVER use `metadata` as column name (reserved by SQLAlchemy) -- Always include `to_dict()` method for JSON serialization -- If model name conflicts with Python built-ins, use alias: `from models import List as ListModel` - -**Edit: `backend/routes.py`** — add routes to make your tests pass: -- Write routes that satisfy each test assertion -- Use absolute imports only - -#### Step C: Verify Backend - -Run tests to verify your backend works: -```bash -cd backend && python -m pytest tests/ -v --tb=short +living_ui_notify_ready(project_id="") ``` -**Fix any failures before proceeding.** Do NOT move to frontend until all tests pass. - -#### Step D: Create Frontend for This Feature - -**Edit: `frontend/types.ts`** — add TypeScript interfaces for this feature's models -**Edit: `frontend/AppController.ts`** — add methods to call this feature's API endpoints - - For the backend URL, use: `const BACKEND_URL = (window as any).__CRAFTBOT_BACKEND_URL__ || 'http://localhost:3101'` - - NEVER hardcode a specific port — the port may change between launches -**Edit: `frontend/components/`** — create React components for this feature -**Edit: `frontend/components/MainView.tsx`** — wire the new components into the main view - -Use preset UI components (Button, Card, Input, Modal, etc.) — see the UI Component Presets section. -Apply colors from GLOBAL_LIVING_UI.md. - -#### Step E: Move to Next Feature - -Update your todo list — mark this feature complete, start the next one. -Repeat Steps A-D for each feature. - -### Phase 8: Final Review - -After all features are built, review your code: -- Backend routes use **absolute imports** (`from models import ...` NOT `from . import ...`) -- Backend `routes.py` does NOT add `/api` prefix to route paths -- All `to_dict()` methods return all fields -- TypeScript types match backend model output -- Components import correctly from relative paths -- All tests pass: `cd backend && python -m pytest tests/ -v` - -**DO NOT run:** `npm run dev`, `npm run build`, `npm run preview`, or `uvicorn` manually. -The launch pipeline handles all building, testing, and serving automatically. - -### Phase 9: Update Documentation (MANDATORY) +It runs the gate — **types → build → migrations-on-fresh-db → ops → ownership** +— then starts the app and health-checks it. On errors: read ALL of them, fix +ALL of them, call it again. Never start servers manually. -**Edit: `LIVING_UI.md`** — you MUST update ALL sections with real implementation details: - -- **Overview**: What the app does, who it's for -- **Data Model table**: List every SQLAlchemy model with purpose and key fields (replace example rows) -- **API Endpoints table**: List every custom route with method, path, description (replace example rows) -- **Frontend Components table**: List every component with purpose -- **Key Files table**: Update if you added new files -- Remove ALL HTML comments (``) and placeholder/example data -- **DO NOT proceed to Phase 10 if LIVING_UI.md still has placeholder content** - -### Phase 10: Launch (MANDATORY) - -**YOU MUST call `living_ui_notify_ready` to complete the task.** - -This action runs the full launch pipeline automatically: -- Installs backend dependencies (`pip install -r requirements.txt`) -- Runs import validation, unit tests, and frontend-backend compatibility checks -- Starts the backend server and verifies health -- Runs external smoke tests against the running backend -- Installs frontend dependencies and builds (`npm install && npm run build`) -- Starts the frontend server - -If any step fails, the action returns the specific errors. Fix them and call again. - -**CRITICAL - project_id Parameter:** -- The `project_id` is in your **task instruction** (e.g., "Project ID: abc12345"), or - it was returned by `living_ui_scaffold` in Step 0 if you scaffolded from chat -- **DO NOT use task session ID** - that's different -- The project_id is a short hex string like `c8cda731` - -``` -living_ui_notify_ready(project_id="") -``` +**HONESTY RULE:** the app is ready ONLY when `living_ui_notify_ready` returns +`status: success`. If you cannot make it pass, tell the user the build +**failed** and exactly what's blocking. Never claim a broken app is ready. ## Debugging -When something goes wrong, read the log files and check [TROUBLESHOOTING.md](references/TROUBLESHOOTING.md). - -## Files Summary - -| File | Purpose | When to Edit | -|------|---------|--------------| -| `backend/models.py` | Database models | Define data entities | -| `backend/routes.py` | API endpoints | Add CRUD operations | -| `frontend/types.ts` | TypeScript types | Match backend models | -| `frontend/components/` | UI components | Build the interface | -| `frontend/AppController.ts` | State management | Connect UI to backend | -| `LIVING_UI.md` | Documentation | Document your app | - -## Quality & Completion - -See [STANDARDS.md](references/STANDARDS.md) for quality requirements and [VERIFY.md](references/VERIFY.md) for the pre-launch checklist. - -## External Integrations - -CraftBot has connected services (Google, Discord, Slack, etc.). Living UIs access them via a built-in bridge — never build OAuth or store credentials yourself. See [INTEGRATIONS.md](references/INTEGRATIONS.md). - -## FORBIDDEN Actions - -- NEVER write to bare relative paths (`backend/models.py`) — always use the absolute `{project_path}/...` so files land in the project, not the CraftBot root -- NEVER skip Step 0 — you must have a registered `project_id`/`project_path` (from the task instruction or `living_ui_scaffold`) before writing any code -- NEVER use `metadata` as a column name in SQLAlchemy -- NEVER use relative imports in backend code (`from . import` or `from .models import`) -- NEVER add `/api` prefix to route paths in `routes.py` (the router prefix handles this) -- NEVER run `npm run dev`, `npm run build`, `npm run preview`, or `uvicorn` manually -- NEVER store important state only in React (use backend) -- NEVER use raw HTML elements (` + +
+ + ) : ( + <> + +

Writing the requirements & starting the build…

+

+ Your answers are being turned into a complete specification. You'll be + taken to the live build view in a moment. +

+ + )} +
+ ) + } + + if (step === 'interview') { + return ( +
+ {interviewLoading ? ( +
+ +

Preparing your interview…

+

+ The agent is reading your configuration and deciding what it still + needs to know. +

+
+ ) : interviewError ? ( +
+

{interviewError}

+
+ + + +
+
+ ) : question ? ( +
+
+ + + Question {qIndex + 1} of {questions.length} + + +
+

{question.question}

+ {question.why &&

{question.why}

} +
+ {question.options.map(opt => { + const selected = (answers[question.id] || []).includes(opt) + return ( + + ) + })} +
+
+ setFreeText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && canContinue) handleContinue() }} + /> + +
+
+ ) : null} +
+ ) + } + + // step === 'configure' + return ( +
+
+
+
+ +
+
+ + {iconOpen && ( +
+ +
+ {Object.entries(LIVING_UI_ICONS).map(([iconName, Cmp]) => { + const value = `lucide:${iconName}` + return ( + + ) + })} +
+
+ )} +
+ setName(e.target.value)} + maxLength={50} + /> +
+ {errors.name && {errors.name}} +
+ +
+ +