diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..bf9c2c4e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +node_modules/ +.env +.env.local +*.log +dist/ +.DS_Store +Thumbs.db + +# Windows reserved device names +nul +NUL + +# Rust build artifacts +target/ + +# Tauri build output +coeadapt-launcher/src-tauri/target/ +coeadapt-launcher/src-tauri/binaries/* +!coeadapt-launcher/src-tauri/binaries/.gitkeep + +# Lock files (platform-specific) +*.lock +!bun.lock + +# Secrets and license keys +*.key +!*.pub + +# Development artifacts +Brand/ +/*.png +.claude/ +.playwright-mcp/ +console-errors.log + +# AI prompt files, build prompts, and internal docs (never commit to public repo) +*-Prompt.md +*-prompt.md +CLAUDE.md +docs/COEADAPT_API.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b997751f8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing to Career-Box + +Thanks for your interest in contributing. Career-Box is an open-source project and we welcome contributions of all kinds — bug fixes, new workspace images, launcher improvements, documentation, and more. + +## Getting started + +### Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [Podman Desktop](https://podman-desktop.io/) +- [Bun](https://bun.sh/) (for the launcher frontend and MCP server) +- [Rust](https://rustup.rs/) (for the Tauri backend) +- [Git](https://git-scm.com/) + +### Setup + +```bash +# Clone the repo +git clone https://github.com/coeadapt/Career-Box.git +cd Career-Box + +# Install launcher dependencies +cd coeadapt-launcher +bun install +cd mcp-server && bun install && cd .. + +# Run the launcher in dev mode +bun run tauri dev +``` + +### Standalone mode (no CoeAdapt account needed) + +The default `.env` ships with a placeholder Clerk key, which automatically activates **standalone mode**. You can develop and test all workspace, container, and AI features without a CoeAdapt account. + +To develop CoeAdapt-specific features (Navi chat, career tracking, account management), you'll need a valid Clerk key. Contact the maintainers or sign up at [coeadapt.com](https://coeadapt.com). See the [Environment configuration](README.md#environment-configuration) section in the README for details. + +## What you can work on + +### Workspace images + +The `dockerfile-kasm-*` files and `src/*/install/` scripts define the containerized applications. To add or modify an image: + +1. Create or edit a `dockerfile-kasm-` in the repo root +2. Add install scripts in `src/ubuntu/install//` +3. Add documentation in `docs//README.md` +4. Test the build: `docker build -t kasmweb/:dev -f dockerfile-kasm- .` + +Follow the patterns in existing images. See Kasm's [image building guide](https://kasmweb.com/docs/latest/how_to/building_images.html) for details on how Kasm images work. + +### Coeadapt Launcher + +The launcher lives in `coeadapt-launcher/` and is built with Tauri v2 + React + TypeScript. See [coeadapt-launcher/README.md](coeadapt-launcher/README.md) for the full architecture and project structure. + +- **Frontend** (`src/`): React components, pages, hooks, and utilities +- **Backend** (`src-tauri/`): Rust commands for Docker management, disk monitoring, health checks +- **MCP Server** (`mcp-server/`): Node.js server exposing workspace tools via the Model Context Protocol + +### Documentation + +Improvements to READMEs, guides, and inline comments are always welcome. + +## Submitting changes + +1. Fork the repository +2. Create a feature branch from `develop`: `git checkout -b feature/my-change` +3. Make your changes +4. Test locally (build the launcher, build any modified Docker images) +5. Commit with a clear message describing what and why +6. Open a pull request against `develop` + +### Commit messages + +Use clear, descriptive commit messages. Prefix with the area of change: + +- `feat:` — new features +- `fix:` — bug fixes +- `docs:` — documentation changes +- `security:` — security patches +- `refactor:` — code restructuring without behavior change + +### Pull request guidelines + +- Keep PRs focused — one logical change per PR +- Include a description of what changed and why +- If adding a new workspace image, include a screenshot or description of what it provides +- Reference any related issues + +## Security + +If you discover a security vulnerability, please **do not** open a public issue. See [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. + +## License + +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE.md). diff --git a/Dockerfile.fastfix b/Dockerfile.fastfix new file mode 100644 index 000000000..93c600cb1 --- /dev/null +++ b/Dockerfile.fastfix @@ -0,0 +1,5 @@ +FROM career-box-60fps +USER root +COPY ./src/ubuntu/install/careerclaw/fix_startup.sh /tmp/fix_startup.sh +RUN sed -i 's/\r$//' /tmp/fix_startup.sh && bash /tmp/fix_startup.sh && rm /tmp/fix_startup.sh +USER 1000 diff --git a/LAUNCH_PLAN.md b/LAUNCH_PLAN.md new file mode 100644 index 000000000..39b5fe46c --- /dev/null +++ b/LAUNCH_PLAN.md @@ -0,0 +1,555 @@ +# Career-Box Launch & CoeAdapt Integration — Master Plan + +**Status:** Draft +**Last updated:** 2026-02-25 +**Prepared for:** alexander-acker / CoeAdapt team + +--- + +## Executive Summary + +Career-Box is a containerized career workspace that pairs a Kasm-based Linux desktop with an AI agent gateway (CareerClaw). The Coeadapt Launcher is a Tauri v2 desktop app that makes the entire system accessible to non-technical users. This plan covers two interleaved tracks: + +1. **Launch readiness** — everything needed to ship Career-Box v1.0 as a standalone, downloadable product. +2. **CoeAdapt platform integration** — connecting the launcher and workspace to the CoeAdapt web application for Navi, career tracking, and cloud sync. + +The plan is organized into six phases, each with concrete deliverables, owners, and acceptance criteria. Phases 1–3 are sequential prerequisites. Phases 4–6 can run in parallel once Phase 3 is complete. + +--- + +## Table of Contents + +1. [Current State Assessment](#phase-0-current-state-assessment) +2. [Phase 1: Foundation & Infrastructure](#phase-1-foundation--infrastructure) +3. [Phase 2: CoeAdapt API Integration](#phase-2-coeadapt-api-integration) +4. [Phase 3: Workspace Image & CareerClaw](#phase-3-workspace-image--careerclaw) +5. [Phase 4: Distribution & Auto-Update](#phase-4-distribution--auto-update) +6. [Phase 5: Quality & Security](#phase-5-quality--security) +7. [Phase 6: Launch Operations](#phase-6-launch-operations) +8. [Risk Register](#risk-register) +9. [Success Metrics](#success-metrics) +10. [Open Questions](#open-questions) + +--- + +## Phase 0: Current State Assessment + +### What's built and working + +| Component | Status | Notes | +|-----------|--------|-------| +| Tauri v2 launcher shell | Done | React 19, Tailwind v4, Rust backend | +| Docker/Podman detection | Done | Auto-detects runtime and daemon state | +| Container lifecycle | Done | Pull (with streaming progress), create, start, stop, reset | +| Multi-step setup wizard | Done | Auto-advancing, non-technical language | +| Dashboard | Done | Workspace status, controls, disk usage | +| System tray | Done | Start/stop/open/show/quit, AI status polling | +| Hide-to-tray on close | Done | Prevents accidental exit | +| Disk monitoring | Done | 15 GB minimum, 5 GB low-space banner | +| Claude Desktop integration | Done | Auto-detect, config injection, backup | +| MCP server (sidecar) | Done | 8 tools: shell, filesystem, screenshot, apps, progress | +| SSL certificate management | Done | Trust/untrust workspace CA from launcher | +| Settings page | Done | AI Connection + Workspace + General tabs | +| Clerk auth scaffolding | Done | ClerkProvider, mode detection, auth guard | +| CoeAdapt API client | Done | Typed client with JWT + device token auth | +| Navi chat page | Done | Basic send/receive UI with streaming support | +| Account settings tab | Done | Profile display, device token, sign out | +| Standalone mode detection | Done | Auto-detects from VITE_CLERK_PUBLISHABLE_KEY | +| CareerClaw install script | Done | Dependencies, build, CLI, gateway, systemd | +| Security hardening | Done | 10+ patches documented in SECURITY.md | +| 80+ workspace Dockerfiles | Done | Inherited from Kasm upstream | + +### What's missing or incomplete + +| Gap | Priority | Blocking? | +|-----|----------|-----------| +| No `.env` / `.env.example` committed | High | Yes — new devs can't configure CoeAdapt mode | +| Updater pubkey is placeholder | Critical | Yes — auto-update will fail in production | +| No CI/CD for launcher builds | High | Yes — no reproducible release pipeline | +| No E2E or integration tests | High | No — but risky to ship without | +| CoeAdapt API endpoints are stubbed in client but untested | High | Yes — integration will break silently | +| Navi chat uses non-streaming `sendMessage` despite streaming UI | Medium | No — but UX is degraded | +| No error boundaries in React | Medium | No — but crashes blank the app | +| No telemetry or crash reporting | Medium | No — but debugging production issues will be blind | +| No license compliance check on 80+ upstream images | Medium | Yes for distribution | +| Docker image `coeadapt/workspace:latest` not built or published | Critical | Yes — the app downloads an image that doesn't exist yet | +| No GitHub Actions workflow | High | Yes — no CI for PRs or releases | +| MCP sidecar binary not cross-compiled | High | Yes — only works on build platform | + +--- + +## Phase 1: Foundation & Infrastructure + +**Goal:** Establish the build, test, and release infrastructure needed for everything else. + +**Duration:** 1–2 weeks + +### 1.1 Environment configuration + +- [ ] Create `coeadapt-launcher/.env.example` with documented variables: + ```env + VITE_CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME + VITE_COEADAPT_API_URL=http://localhost:5000 + ``` +- [ ] Add `.env` to `.gitignore` (already present — verify) +- [ ] Document env setup in `coeadapt-launcher/README.md` dev section + +### 1.2 GitHub Actions CI + +- [ ] Create `.github/workflows/ci.yml`: + - Lint (TypeScript + Rust) + - Type-check (`tsc --noEmit`) + - Build frontend (`bun run build`) + - Build MCP sidecar (`cd mcp-server && bun run build`) + - `cargo check` and `cargo clippy` for Rust backend + - Run on push to `develop` and `main`, and on all PRs +- [ ] Create `.github/workflows/release.yml`: + - Triggered by git tag `v*` + - Matrix build: Windows (x64), macOS (x64 + arm64), Linux (x64) + - Cross-compile MCP sidecar for each target + - Build Tauri app, upload artifacts to GitHub Releases + - Generate update manifest for Tauri updater +- [ ] Migrate from GitLab CI (existing `.gitlab-ci.yml`) or keep both if Kasm upstream images still build on GitLab + +### 1.3 Tauri updater configuration + +- [ ] Generate real signing keypair for Tauri updater +- [ ] Replace `REPLACE_WITH_REAL_PUBKEY_BEFORE_RELEASE` in `tauri.conf.json` +- [ ] Set up update manifest hosting (GitHub Releases or `releases.coeadapt.com`) +- [ ] Store private key in CI secrets, never in repo + +### 1.4 MCP sidecar cross-compilation + +- [ ] Update `mcp-server/build.ts` to produce platform-specific binaries: + - `coeadapt-mcp-x86_64-pc-windows-msvc.exe` + - `coeadapt-mcp-x86_64-apple-darwin` + - `coeadapt-mcp-aarch64-apple-darwin` + - `coeadapt-mcp-x86_64-unknown-linux-gnu` +- [ ] Update `tauri.conf.json` `externalBin` to use platform-specific paths +- [ ] Verify sidecar launches correctly on all three platforms + +### 1.5 Error boundaries and resilience + +- [ ] Add React error boundary wrapping `` with a user-friendly fallback +- [ ] Add `window.onerror` / `window.onunhandledrejection` logging +- [ ] Add graceful degradation when MCP sidecar fails to start + +--- + +## Phase 2: CoeAdapt API Integration + +**Goal:** Wire the launcher to the live CoeAdapt web application so that authenticated users get Navi, career tracking, and cloud sync. + +**Duration:** 2–3 weeks + +### 2.1 Authentication flow + +- [ ] Obtain production Clerk publishable key from CoeAdapt dashboard +- [ ] Test Clerk sign-in/sign-up flow end-to-end in the Tauri webview +- [ ] Verify CSP in `tauri.conf.json` allows all required Clerk domains +- [ ] Handle Clerk token expiry and refresh gracefully +- [ ] Test device token generation (`/api/career-box/generate-token`) +- [ ] Verify device token is persisted via `tauri-plugin-store` and survives app restart +- [ ] Implement token refresh logic (detect expiry, auto-regenerate) + +### 2.2 CoeAdapt API contract validation + +Validate each API endpoint in `lib/api.ts` against the live CoeAdapt backend: + +| Endpoint | Method | Verified? | +|----------|--------|-----------| +| `/api/career-box/health` | GET | [ ] | +| `/api/career-box/verify-token` | POST | [ ] | +| `/api/career-box/generate-token` | POST | [ ] | +| `/api/auth/user` | GET | [ ] | +| `/api/plans/me` | GET | [ ] | +| `/api/plans/:id` | GET | [ ] | +| `/api/plans/:planId/tasks` | GET | [ ] | +| `/api/tasks/me` | GET | [ ] | +| `/api/tasks/:id` | GET | [ ] | +| `/api/tasks/:id` | PUT | [ ] | +| `/api/tasks/:taskId/evidence` | POST | [ ] | +| `/api/goals/me` | GET | [ ] | +| `/api/goals` | POST | [ ] | +| `/api/goals/:id` | PATCH | [ ] | +| `/api/habits` | GET | [ ] | +| `/api/habits/today` | GET | [ ] | +| `/api/habits` | POST | [ ] | +| `/api/habits/:id/complete` | POST | [ ] | +| `/api/habits/stats/overview` | GET | [ ] | +| `/api/jobs` | GET | [ ] | +| `/api/jobs/discover` | GET | [ ] | +| `/api/jobs/:id/bookmark` | POST | [ ] | +| `/api/jobs/bookmarks/me` | GET | [ ] | +| `/api/portfolio/items` | GET | [ ] | +| `/api/skills/verified` | GET | [ ] | +| `/api/radar/market-fit` | GET | [ ] | +| `/api/radar/skill-deltas` | GET | [ ] | +| `/api/subscription/status` | GET | [ ] | +| `/api/notifications/me` | GET | [ ] | +| `/api/chatbot/agent` | POST | [ ] | + +- [ ] Replace `any` return types with concrete TypeScript interfaces +- [ ] Add error handling for 401 (redirect to login), 429 (rate limit backoff), 5xx (retry with exponential backoff) +- [ ] Add offline detection and queue mutations for retry + +### 2.3 Navi chat — streaming upgrade + +- [ ] Implement SSE or WebSocket streaming for `/api/chatbot/agent` +- [ ] Update `useNaviChat` hook to consume streaming tokens +- [ ] Display tokens as they arrive (already have streaming UI scaffolding) +- [ ] Add conversation persistence (thread ID stored in `tauri-plugin-store`) +- [ ] Add conversation history loading on Chat page mount + +### 2.4 Career data display in Dashboard + +- [ ] Add a "Career Overview" card to Dashboard (CoeAdapt mode only): + - Active plan name + progress % + - Today's tasks (count completed / total) + - Current streak (habits) + - Next job application deadline +- [ ] Wire to `api.getPlans()`, `api.getHabitsToday()`, `api.getTasks()`, `api.getJobs()` +- [ ] Add loading skeletons for async data +- [ ] Handle empty states ("No plan yet — chat with Navi to get started") + +### 2.5 Device token handoff to MCP sidecar + +- [ ] Pass device token to MCP sidecar as environment variable on launch +- [ ] MCP sidecar attaches token to requests forwarded to CoeAdapt API +- [ ] This enables CareerClaw (inside workspace) to make authenticated API calls through the MCP bridge +- [ ] Verify token rotation propagates to running sidecar + +### 2.6 Subscription gating + +- [ ] Fetch subscription status on login (`api.getSubscription()`) +- [ ] Gate premium features based on `features` map from subscription response +- [ ] Show upgrade prompt for gated features +- [ ] Handle free tier gracefully (no degraded UX for free users on core features) + +--- + +## Phase 3: Workspace Image & CareerClaw + +**Goal:** Build and publish the `coeadapt/workspace:latest` Docker image with CareerClaw baked in, and verify the full AI agent loop works. + +**Duration:** 2–3 weeks + +### 3.1 Workspace image build pipeline + +- [ ] Create `dockerfile-kasm-coeadapt-workspace` (the "career workspace" image): + - Based on `dockerfile-kasm-ubuntu-noble-desktop` or `dockerfile-kasm-zorin-deluxe` + - Includes CareerClaw install (`src/ubuntu/install/careerclaw/`) + - Includes key career tools: VS Code, Firefox/Chrome, LibreOffice, terminal + - Applies all security hardening from SECURITY.md +- [ ] Add GitHub Actions workflow to build and push to Docker Hub / GHCR: + - `coeadapt/workspace:latest` — stable + - `coeadapt/workspace:dev` — from develop branch + - `coeadapt/workspace:v1.0.0` — tagged releases +- [ ] Optimize image size (target < 5 GB compressed) +- [ ] Test image pull, create, start, open cycle end-to-end + +### 3.2 CareerClaw gateway + +- [ ] Verify CareerClaw gateway starts on port 18789 inside the container +- [ ] Verify MCP sidecar (port 3100 on host) can reach gateway (port 18789 in container) +- [ ] Test full tool loop: Claude Desktop → MCP sidecar → docker exec → CareerClaw → workspace +- [ ] Document the network topology for contributors + +### 3.3 CareerClaw + CoeAdapt API bridge + +- [ ] CareerClaw receives device token from MCP sidecar environment +- [ ] CareerClaw can call CoeAdapt API endpoints: + - Update task status + - Submit evidence + - Log skill practice + - Report habit completion +- [ ] Navi (in web app) can trigger CareerClaw actions in workspace: + - Open a URL + - Run a command + - Take a screenshot + - Open an application +- [ ] Verify bidirectional communication: web app ↔ API ↔ MCP sidecar ↔ CareerClaw + +### 3.4 Workspace branding + +- [ ] Apply CoeAdapt branding to workspace desktop: + - Custom wallpaper + - Branded panel/dock + - Welcome window on first launch +- [ ] Add career-specific desktop shortcuts (Resume Builder, Portfolio, Job Tracker) +- [ ] These shortcuts open browser to CoeAdapt web app at the relevant page + +--- + +## Phase 4: Distribution & Auto-Update + +**Goal:** Users can download, install, and receive updates seamlessly on Windows, macOS, and Linux. + +**Duration:** 1–2 weeks (parallel with Phases 5–6) + +### 4.1 Platform installers + +- [ ] **Windows:** `.msi` installer + - Test on Windows 10 and 11 + - Verify Docker Desktop detection works + - Code signing certificate (optional for v1, recommended) +- [ ] **macOS:** `.dmg` + - Test on Intel and Apple Silicon + - Handle Gatekeeper / notarization (required for unsigned apps) + - Verify Podman detection works alongside Docker +- [ ] **Linux:** `.AppImage` + `.deb` + - Test on Ubuntu 22.04, 24.04, Fedora 40+ + - Verify `xdg-open` for workspace browser launch + +### 4.2 Auto-update flow + +- [ ] Tauri updater checks `releases.coeadapt.com` (or GitHub Releases) on launch +- [ ] User sees "Update available" notification +- [ ] One-click update, download, restart +- [ ] Test update from v0.1.0 → v1.0.0 +- [ ] Rollback plan if update breaks (user can re-download from releases page) + +### 4.3 First-run experience + +- [ ] Installer opens the app after install +- [ ] Setup wizard detects missing Docker and offers one-click install link +- [ ] First image pull shows time estimate and progress +- [ ] After setup, dashboard opens — user sees "Open Workspace" button +- [ ] Entire flow from download to open workspace: < 10 minutes (excluding Docker install) + +### 4.4 Release page + +- [ ] GitHub Releases page with: + - Platform-specific download links + - Release notes (auto-generated from conventional commits) + - SHA256 checksums +- [ ] Optional: landing page at `coeadapt.com/download` with download buttons + +--- + +## Phase 5: Quality & Security + +**Goal:** Ship with confidence. No known security vulnerabilities, no data loss, no crashes. + +**Duration:** 2 weeks (parallel with Phases 4, 6) + +### 5.1 Testing + +- [ ] **Unit tests (frontend):** + - Mode detection (`lib/mode.ts`) + - API client error handling (`lib/api.ts`) + - Hook behavior (`useContainer`, `useClaudeConnection`) +- [ ] **Unit tests (backend):** + - Docker info parsing (`docker.rs`) + - Container status parsing (`container.rs`) + - Disk space calculation (`disk.rs`) + - Claude config detection and injection (`claude.rs`) +- [ ] **Integration tests:** + - Full setup wizard flow (mock Docker) + - Container lifecycle: pull → create → start → stop → reset + - MCP sidecar start/stop/health + - Claude Desktop config injection + backup +- [ ] **E2E tests (optional for v1, required for v1.1):** + - Playwright or similar for Tauri webview + - Full flow: launch → setup → dashboard → open workspace + +### 5.2 Security review + +- [ ] Verify all SECURITY.md fixes are applied in the published workspace image +- [ ] Audit CSP policy in `tauri.conf.json` — no unnecessary `unsafe-*` directives +- [ ] Verify MCP sidecar only listens on `127.0.0.1` (not `0.0.0.0`) +- [ ] Verify device tokens are stored encrypted at rest (via `tauri-plugin-store`) +- [ ] Verify no secrets in git history (Clerk keys, API keys, signing keys) +- [ ] Review Tauri capabilities (`capabilities/default.json`) — principle of least privilege +- [ ] Verify Docker socket access is properly scoped +- [ ] Run `cargo audit` and `bun audit` for dependency vulnerabilities + +### 5.3 Error handling audit + +- [ ] Every Tauri command returns `Result` — verify all error paths are handled +- [ ] Every API call in the frontend has error handling +- [ ] MCP sidecar handles `docker exec` timeouts (currently 30s — verify) +- [ ] Launcher handles Docker daemon not running (user-friendly message) +- [ ] Launcher handles workspace image pull failure (retry button) +- [ ] Launcher handles disk full during pull (clear message + prune suggestion) + +### 5.4 Performance + +- [ ] Launcher startup time < 2 seconds to window visible +- [ ] Container status polling interval tuned (currently what? — verify) +- [ ] MCP health polling at 15s in tray — verify no resource leak +- [ ] Disk monitoring at 30 min — verify sysinfo doesn't spike CPU +- [ ] Workspace browser loads within 5 seconds of container start + +--- + +## Phase 6: Launch Operations + +**Goal:** Everything needed for the actual launch day and the weeks that follow. + +**Duration:** 1 week (parallel with Phases 4, 5) + +### 6.1 Documentation + +- [ ] Verify README.md is accurate for v1.0 +- [ ] Verify CONTRIBUTING.md has correct setup instructions +- [ ] Write `docs/QUICKSTART.md` — 5-step guide for end users +- [ ] Write `docs/TROUBLESHOOTING.md`: + - Docker not detected + - Image pull fails + - Workspace won't start + - AI copilot disconnected + - Certificate trust issues + - Port conflicts (6901, 3100) +- [ ] Write `docs/COEADAPT_INTEGRATION.md` — detailed guide for CoeAdapt features +- [ ] Update `coeadapt-launcher/README.md` with final architecture + +### 6.2 Telemetry & monitoring (optional for v1) + +- [ ] Anonymous usage telemetry (opt-in): + - Launcher opens / workspace starts / AI connections + - No PII, no workspace content, no commands +- [ ] Crash reporting (Sentry or similar): + - Frontend errors + - Rust panics + - MCP sidecar crashes +- [ ] CoeAdapt API monitoring: + - Track Career-Box API call volume + - Monitor error rates + - Alert on device token failures + +### 6.3 Support infrastructure + +- [ ] GitHub Issues templates: + - Bug report (with system info: OS, Docker version, app version) + - Feature request + - Security vulnerability (private advisory) +- [ ] GitHub Discussions enabled for community Q&A +- [ ] CoeAdapt support email for authenticated users +- [ ] FAQ section on coeadapt.com/career-box + +### 6.4 Launch checklist + +Pre-launch (T-7 days): +- [ ] All Phase 1–3 deliverables complete +- [ ] Workspace image published to Docker Hub / GHCR +- [ ] Platform installers tested on Windows, macOS, Linux +- [ ] Auto-update tested from v0.9.0 → v1.0.0 +- [ ] All SECURITY.md fixes verified in published image +- [ ] Release notes drafted +- [ ] Landing page / download page live + +Launch day (T-0): +- [ ] Tag `v1.0.0` on `main` +- [ ] CI builds and publishes installers to GitHub Releases +- [ ] CI builds and pushes `coeadapt/workspace:v1.0.0` and `:latest` +- [ ] Update manifest published for auto-updater +- [ ] Announce on CoeAdapt channels +- [ ] Monitor error rates and support channels for first 24 hours + +Post-launch (T+7 days): +- [ ] Review crash reports and error logs +- [ ] Triage and fix any critical bugs → v1.0.1 patch +- [ ] Gather user feedback +- [ ] Plan v1.1 based on feedback and remaining roadmap items + +--- + +## Risk Register + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Docker Desktop license concerns for enterprise users | Medium | Medium | Support Podman as alternative (already implemented) | +| Kasm upstream breaks our customizations | High | Low | Pin upstream version, document all patches in SECURITY.md | +| Clerk auth doesn't work reliably in Tauri webview | High | Medium | Test early (Phase 2.1), have fallback to external browser auth | +| Workspace image too large for first download | Medium | High | Optimize layers, show accurate progress, support resume | +| CareerClaw gateway authentication vulnerabilities | Critical | Low | Security audit in Phase 5, removed `--allow-unconfigured` | +| Auto-update breaks installations | High | Medium | Signed updates, rollback via re-download, staged rollouts | +| CoeAdapt API availability affects standalone users | Medium | Low | Standalone mode never calls CoeAdapt API — fully offline | +| macOS Gatekeeper blocks unsigned app | High | High | Plan for notarization or guide users through "Open Anyway" | +| Port conflicts (6901, 3100) with other software | Medium | Medium | Add port conflict detection in setup wizard | +| Large Docker images consume user disk space over time | Medium | High | "Clean Up Old Images" already implemented, add auto-prune | + +--- + +## Success Metrics + +### Launch (first 30 days) + +| Metric | Target | +|--------|--------| +| Downloads | 500+ | +| Successful setups (workspace running) | 70% of downloads | +| CoeAdapt account connections | 30% of successful setups | +| Navi conversations started | 50% of connected users | +| Critical bugs reported | < 5 | +| Average setup time (download → workspace open) | < 10 min | + +### Growth (first 90 days) + +| Metric | Target | +|--------|--------| +| Monthly active workspaces | 200+ | +| Career plans created via Navi | 100+ | +| Tasks completed in workspace | 500+ | +| Community contributions (PRs) | 10+ | +| GitHub stars | 100+ | + +--- + +## Open Questions + +1. **Workspace image hosting:** Docker Hub (free tier limits) vs. GHCR (unlimited for public repos) vs. self-hosted registry? + +2. **macOS notarization:** Do we pay for Apple Developer Program ($99/year) for v1.0, or ship unsigned with "Open Anyway" instructions? + +3. **Navi streaming protocol:** Does the CoeAdapt API support SSE for `/api/chatbot/agent`? Or do we need WebSockets? Or is polling acceptable for v1? + +4. **Subscription model:** What features are free vs. premium? This affects subscription gating implementation in Phase 2.6. + +5. **CareerClaw version:** Which OpenClaw release does CareerClaw track? Is the fork up-to-date? + +6. **Telemetry framework:** Posthog, Mixpanel, custom, or skip for v1? + +7. **Windows code signing:** Required for v1.0? SmartScreen will warn users without it. + +8. **CoeAdapt API staging environment:** Is there a staging API for development/testing, or do we test against production? + +--- + +## Dependency Graph + +``` +Phase 1 (Foundation) + ├── 1.1 Environment config + ├── 1.2 GitHub Actions CI ──────────────────────┐ + ├── 1.3 Tauri updater config │ + ├── 1.4 MCP sidecar cross-compile │ + └── 1.5 Error boundaries │ + │ +Phase 2 (CoeAdapt Integration) ◄── requires 1.1 │ + ├── 2.1 Auth flow │ + ├── 2.2 API contract validation │ + ├── 2.3 Navi streaming │ + ├── 2.4 Career data in Dashboard │ + ├── 2.5 Device token → MCP │ + └── 2.6 Subscription gating │ + │ +Phase 3 (Workspace & CareerClaw) ◄── requires 1.2│ + ├── 3.1 Image build pipeline ◄────────────────────┘ + ├── 3.2 Gateway verification + ├── 3.3 CareerClaw ↔ CoeAdapt bridge ◄── requires 2.5 + └── 3.4 Workspace branding + +Phase 4 (Distribution) ◄── requires 1.2, 1.3, 1.4 +Phase 5 (Quality) ◄── requires 2.*, 3.* +Phase 6 (Launch) ◄── requires 4.*, 5.* +``` + +--- + +## Version History + +| Date | Version | Change | +|------|---------|--------| +| 2026-02-25 | 0.1 | Initial draft — full assessment, six-phase plan | diff --git a/LICENSE.md b/LICENSE.md index a26705a44..fed5ff258 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,11 +1,21 @@ -# Disclaimer +# License -This license applies only to the source code that is directly maintained in this git repository, it does not extend to dependencies from outside of this repository, to include other projects owned and/or maintained by Kasm Technologies. +This project contains work from multiple authors, all licensed under the MIT License. + +## Career-Box / Coeadapt -## License +Copyright 2025-2026 Coeadapt + +## Kasm Workspaces Images Copyright 2022 Kasm Technologies Inc +This license applies only to the source code that is directly maintained in this git repository, it does not extend to dependencies from outside of this repository, to include other projects owned and/or maintained by Kasm Technologies. + +--- + +## MIT License + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. diff --git a/README.md b/README.md index ae2ac9bee..b30cc8111 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,370 @@ -![Logo][logo] -# Workspaces Images -This repository contains several example of desktop and application Workspaces images. -Administrators may leverage these images directly or use them as a starting point for their own custom images. -Each of these images is based off one of the [**Workspaces Core Images**](https://github.com/kasmtech/workspaces-core-images?utm_campaign=Github&utm_source=github) which contain the necessary wiring to work within the Kasm Workspaces platform. +# Career-Box +**A containerized career workspace powered by AI.** -For more information about building custom images please review the [**How To Guide**](https://kasmweb.com/docs/latest/how_to/building_images.html?utm_campaign=Github&utm_source=github) +Career-Box gives you a full Linux desktop pre-loaded with career development tools — browsers, IDEs, office suites, security tools, and more — all running in a Docker container on your machine. A desktop launcher manages everything so you never touch a terminal. CareerClaw, an AI agent running inside the workspace, connects to your career coach so it can see your screen, run commands, open applications, and help you do real work. -The Kasm team publishes applications and desktop images for use inside the platform. More information, including source can be found in the [**Default Images List**](https://kasmweb.com/docs/latest/guide/custom_images.html?utm_campaign=Github&utm_source=github) +No Docker knowledge. No Linux experience. No account required. Just launch and go. +

+ Setup wizard + Dashboard +

-# Manual Deployment +--- -To build the provided images: +## What is Career-Box? - sudo docker build -t kasmweb/firefox:dev -f dockerfile-kasm-firefox . +Career-Box is an open-source, containerized career development workspace. It gives you a full Linux desktop pre-loaded with 80+ career tools, managed by a desktop launcher, with an AI agent gateway that lets assistants interact with your workspace directly. +It brings together two powerful open-source projects: -While these image are primarily built to run inside the Workspaces platform, they can also be executed manually. Please note that certain functionality, such as audio, uploads, downloads, and microphone pass-through are only available within the Kasm platform. +- **[Kasm Workspaces](https://github.com/kasmtech/workspaces-images)** — a container streaming platform that delivers full Linux desktops and applications through your browser. Kasm provides the isolated, reproducible environment where career work happens. +- **[OpenClaw](https://github.com/openclaw/openclaw)** — an AI agent framework with built-in tools for shell execution, web search, browser automation, file management, and persistent memory. OpenClaw provides the intelligence layer that turns the workspace into an AI-powered career assistant. +**Why combine them?** Career development requires both *doing* and *thinking*. Kasm gives you a safe, disposable desktop where you can practice coding, build portfolios, and run tools without messing up your main machine. OpenClaw gives an AI assistant the ability to see your workspace, run commands, open applications, and interact with the tools inside it. Together, they create a career workspace where AI doesn't just advise — it *works alongside you*. + +### Optional: CoeAdapt platform integration + +> Career-Box works great on its own with any MCP-compatible AI assistant. For users who want more, it also integrates with the [CoeAdapt platform](https://coeadapt.com) for: +> +> - **Navi** — an AI career companion with career coaching, assessments, and personalized guidance +> - **Career tracking** — plans, tasks, goals, habits, job applications, portfolio, and skill verification +> - **Cloud sync** — your career data accessible from anywhere +> +> See [Connecting to CoeAdapt](#connecting-to-coeadapt) below. + +### How it works + +**Standalone mode** (default — no account needed): + +``` +┌────────────────────────────────────────────────────┐ +│ Your Machine │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Career-Box Launcher (system tray) │ │ +│ │ Manages container lifecycle │ │ +│ └──────┬───────────────────────────────────────┘ │ +│ │ │ +│ ┌────▼──────────────────────────┐ │ +│ │ Docker Container │ │ +│ │ :6901 — Kasm Desktop │ │ +│ │ :18789 — CareerClaw Gateway │ │ +│ │ (OpenClaw MCP bridge) │ │ +│ └────────────────▲───────────────┘ │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ Claude Desktop / │ │ +│ │ Any MCP-compatible │ │ +│ │ AI assistant │ │ +│ └─────────────────────┘ │ +└────────────────────────────────────────────────────┘ +``` + +**With CoeAdapt** (optional — adds Navi, career tracking, cloud sync): + +``` +┌────────────────────────────────────────────────────────────┐ +│ Navi (coeadapt.com/navi) │ +│ AI career companion — powered by OpenClaw agent framework │ +└────────────────────┬───────────────────────────────────────┘ + │ Cloud API + │ + ┌────────────▼───────────────────────────────┐ + │ Your Machine │ + │ │ + │ ┌──────────────────────────────────────┐ │ + │ │ Coeadapt Launcher (system tray) │ │ + │ │ Manages container lifecycle │ │ + │ └──────┬──────────────────────────────┘ │ + │ │ │ + │ ┌────▼──────────────────────────┐ │ + │ │ Docker Container │ │ + │ │ :6901 — Kasm Desktop │ │ + │ │ :18789 — CareerClaw Gateway │ │ + │ │ (OpenClaw MCP bridge) │ │ + │ └────────────────────────────────┘ │ + └────────────────────────────────────────────┘ +``` + +The **Coeadapt Launcher** is a cross-platform desktop app (built with Tauri v2) that: +- Detects Docker/Podman and guides you through setup +- Pulls and manages the Kasm workspace container +- Lives in your system tray with start/stop/open controls + +Once the workspace is running, **CareerClaw** (the OpenClaw-based AI agent inside the container) provides the MCP gateway on port 18789 — giving AI assistants full access to the workspace tools. + +--- + +## What's inside the box + +Career-Box includes **80+ containerized application images** inherited from [Kasm Workspaces](https://kasmweb.com), each built to stream a desktop or application through your browser: + +| Category | Applications | +|----------|-------------| +| **Browsers** | Firefox, Chrome, Chromium, Brave, Edge, Vivaldi, Tor Browser | +| **Development** | VS Code, Atom, Sublime Text, Java Dev, Unity Hub | +| **Office & Productivity** | LibreOffice, OnlyOffice, Obsidian, Thunderbird | +| **Communication** | Slack, Discord, Teams, Telegram, Signal, Zoom | +| **Creative** | GIMP, Inkscape, Pinta, Blender, Audacity | +| **Security & OSINT** | Kali Linux, ParrotOS, SpiderFoot, Nessus, Hunchly, Maltego, Forensic-OSINT | +| **Desktops** | Ubuntu (Jammy/Noble), Debian, Fedora, AlmaLinux, Rocky, Alpine, openSUSE, Oracle | +| **Utilities** | Remmina, FileZilla, VLC, Deluge, qBittorrent | + +Each image is a self-contained Docker container with KasmVNC for browser-based access. The career workspace image bundles the most useful tools into a single desktop environment. + +--- + +## Career capabilities + +When connected to AI (Claude, Navi, or any MCP-compatible assistant), Career-Box becomes an intelligent workspace for: + +- **Resume building** — AI reads your drafts, tailors them to job postings, and writes cover letters +- **Interview prep** — Practice answers out loud, get feedback, run mock interviews +- **Job tracking** — Maintain application pipelines with company research auto-populated +- **Skill development** — Follow structured learning paths with hands-on practice in the workspace +- **Career journaling** — Structured reflection with AI-powered theme analysis +- **Portfolio building** — Build projects, generate documentation, publish to GitHub +- **Networking** — Draft outreach emails, track contacts, manage follow-ups +- **Market research** — Analyze job markets, salary benchmarks, and emerging opportunities + +All powered by CareerClaw's OpenClaw gateway — providing shell execution, browser automation, file management, web search, and persistent memory inside the workspace. + +--- + +## Quick start + +### Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [Podman Desktop](https://podman-desktop.io/) +- 15 GB free disk space (25 GB recommended) + +### Install + +Download the latest release for your platform from the [Releases](https://github.com/coeadapt/Career-Box/releases) page: + +| Platform | Download | +|----------|----------| +| Windows | `.msi` installer | +| macOS | `.dmg` | +| Linux | `.AppImage` or `.deb` | + +### Run (standalone) + +1. Launch **Career-Box** from your applications +2. The setup wizard walks you through everything (Docker check, image download, workspace start) +3. Click **Open Workspace** to access your career desktop in the browser +4. CareerClaw AI gateway starts automatically inside the workspace + +No account required. No sign-up. Just launch and go. + +### Run (with CoeAdapt) + +1. Create an account at [coeadapt.com](https://coeadapt.com) +2. Configure your Clerk key (see [Connecting to CoeAdapt](#connecting-to-coeadapt)) +3. Launch the app and sign in +4. Get access to Navi, career tracking, and cloud sync + +--- + +## Connecting to CoeAdapt + +> **This section is optional.** Career-Box works fully without CoeAdapt. + +To connect Career-Box to the CoeAdapt platform for Navi, career tracking, and cloud sync: + +1. Create an account at [coeadapt.com](https://coeadapt.com) +2. Copy your Clerk publishable key from your CoeAdapt dashboard +3. Edit `coeadapt-launcher/.env`: + ```env + VITE_CLERK_PUBLISHABLE_KEY=pk_live_YOUR_KEY_HERE + VITE_COEADAPT_API_URL=https://api.coeadapt.com + ``` +4. Restart the launcher + +This enables: +- Sign-in with your CoeAdapt account +- Navi AI career companion in the Dashboard +- Career tracking and progress sync +- Device token management for API access + +When no valid Clerk key is configured (the default), standalone mode activates automatically. See [Environment configuration](#environment-configuration) for details. + +--- + +## Project structure + +``` +Career-Box/ +├── coeadapt-launcher/ # Tauri v2 desktop launcher app +│ ├── src/ # React frontend (TypeScript + Tailwind) +│ └── src-tauri/ # Rust backend (container mgmt, health checks) +├── src/ +│ ├── ubuntu/install/ # Ubuntu application install scripts +│ ├── alpine/install/ # Alpine install scripts +│ ├── opensuse/install/ # openSUSE install scripts +│ └── common/ # Shared resources +├── docs/ # Per-application documentation (80+ READMEs) +├── ci-scripts/ # CI/CD pipeline scripts +├── dockerfile-kasm-* # Dockerfiles for each application image +├── CONTRIBUTING.md # Contributor guide +├── SECURITY.md # Security hardening documentation +└── LICENSE.md # MIT License +``` + +For detailed launcher documentation, see [coeadapt-launcher/README.md](coeadapt-launcher/README.md). + +--- + +## For developers + +Welcome. This project has two distinct halves, and you can contribute to either without understanding the other. + +### The launcher (`coeadapt-launcher/`) + +A Tauri v2 desktop app — React frontend, Rust backend. This is where most active development happens. If you've worked with React, TypeScript, or Rust, you'll feel at home here. + +**Setup:** + +```bash +cd coeadapt-launcher + +# Install dependencies +bun install + +# Run in dev mode (Vite HMR + Tauri window) +bun run tauri dev ``` -sudo docker run --rm -it --shm-size=512m -p 6901:6901 -e VNC_PW=password kasmweb/firefox:dev + +**Requirements:** [Bun](https://bun.sh/), [Rust](https://rustup.rs/), [Docker Desktop](https://www.docker.com/products/docker-desktop/) + +**Build for production:** + +```bash +# Build the Tauri app (produces platform installers in src-tauri/target/release/bundle/) +bun run tauri build ``` -The container is now accessible via a browser : `https://:6901` +For the full launcher architecture, see [coeadapt-launcher/README.md](coeadapt-launcher/README.md). + +### The workspace images (`src/`, `dockerfile-kasm-*`) + +80+ Dockerfiles and install scripts inherited from [Kasm Workspaces](https://github.com/kasmtech/workspaces-images). Each image defines a containerized application or desktop environment. + +**To build an image:** + +```bash +sudo docker build -t kasmweb/firefox:dev -f dockerfile-kasm-firefox . +``` + +**To run it standalone (browser access at `https://localhost:6901`):** + +```bash +sudo docker run --rm -it --shm-size=512m -p 6901:6901 -e VNC_PW=password kasmweb/firefox:dev +``` + +Each image has an install script in `src/ubuntu/install//` and documentation in `docs//README.md`. Follow the existing patterns when adding or modifying images. For the full image building guide, see Kasm's [How To Guide](https://kasmweb.com/docs/latest/how_to/building_images.html). + +### Environment configuration + +Career-Box has two modes, auto-detected from `.env`: + +| Mode | When | What you get | +|------|------|-------------| +| **Standalone** (default) | No Clerk key, or `pk_test_REPLACE_ME` | Workspace + CareerClaw AI gateway + any MCP-compatible AI | +| **CoeAdapt** | Valid Clerk publishable key | + Navi chat, career tracking, account management, cloud sync | + +The default `.env` ships with `pk_test_REPLACE_ME`, which activates standalone mode. You can develop and test all workspace, container, and AI features without a CoeAdapt account. + +To develop CoeAdapt-specific features (Navi chat, career tracking, account management), you'll need a valid Clerk key. Contact the maintainers or sign up at [coeadapt.com](https://coeadapt.com). + +The mode detection lives in `coeadapt-launcher/src/lib/mode.ts`. + +### Where to start + +| Interest | Start here | +|----------|-----------| +| Frontend / UI | `coeadapt-launcher/src/pages/` and `src/components/` — React + Tailwind | +| Backend / Systems | `coeadapt-launcher/src-tauri/src/` — Rust, Docker management, health checks | +| AI / CareerClaw | `src/ubuntu/install/careerclaw/` — OpenClaw integration and gateway config | +| Container images | `src/ubuntu/install/` — add new apps or fix existing install scripts | +| Security | [SECURITY.md](SECURITY.md) — review the audit, fix remaining issues | +| Documentation | `docs/` — 80+ app READMEs, or improve this README | + +--- + +## Built on open source + +Career-Box stands on the shoulders of two major open-source projects: + +### Kasm Workspaces + +This repository is a fork of [kasmtech/workspaces-images](https://github.com/kasmtech/workspaces-images) by [Kasm Technologies](https://kasmweb.com). Kasm Workspaces is a container streaming platform that delivers browser-based access to desktops and applications using [KasmVNC](https://github.com/kasmtech/KasmVNC). The 80+ Dockerfiles and install scripts in this repo come from Kasm's upstream project. + +- **Upstream repo:** [github.com/kasmtech/workspaces-images](https://github.com/kasmtech/workspaces-images) +- **Core images:** [github.com/kasmtech/workspaces-core-images](https://github.com/kasmtech/workspaces-core-images) +- **KasmVNC:** [github.com/kasmtech/KasmVNC](https://github.com/kasmtech/KasmVNC) +- **Docs:** [kasmweb.com/docs](https://kasmweb.com/docs/latest/how_to/building_images.html) + +### OpenClaw + +[OpenClaw](https://github.com/openclaw/openclaw) is an open-source AI agent framework that gives assistants real tools — shell execution, web search, browser automation, file system access, persistent memory, and proactive scheduling. Career-Box uses OpenClaw's architecture to power CareerClaw, the career-specific agent layer that turns the Kasm workspace into an intelligent career development environment. + +- **Upstream repo:** [github.com/openclaw/openclaw](https://github.com/openclaw/openclaw) +- **CareerClaw fork:** [github.com/alexander-acker/careerclaw](https://github.com/alexander-acker/careerclaw) +- **Skills hub:** [github.com/openclaw/clawhub](https://github.com/openclaw/clawhub) + +### What Career-Box adds + +- The **Career-Box Launcher** — a Tauri v2 desktop app for managing workspace containers without touching Docker +- **CareerClaw** — career-specific OpenClaw agent with gateway, skills for coaching, assessments, resume building, interview prep, and job tracking +- **Security hardening** — patches to upstream Kasm scripts (see [SECURITY.md](SECURITY.md) for the full audit) +- **Optional CoeAdapt integration** — connect to [coeadapt.com](https://coeadapt.com) for Navi AI coaching, career tracking, and cloud sync + +The Kasm workspace images and install scripts in this repository are used as-is or with security patches documented in SECURITY.md. + +--- + +## Tech stack + +| Component | Technology | +|-----------|-----------| +| Desktop launcher | Tauri v2, React 19, TypeScript, Tailwind CSS v4 | +| Launcher backend | Rust (tokio, reqwest, sysinfo) | +| AI agent | CareerClaw (OpenClaw fork), Node.js | +| Container runtime | Docker / Podman | +| Desktop streaming | KasmVNC (via Kasm Workspaces) | +| Package manager | Bun | + +--- + +## Why we're building this + +The world is adapting. AI is reshaping industries, automating tasks, and redefining what it means to have a career. Roles that existed for decades are changing overnight. New ones are appearing faster than anyone can track. + +This is not something to fear. But it is something we have a responsibility to face honestly. + +Not everyone has a software engineering background. Not everyone has a mentor, a network, or the time to figure out what's next on their own. But everyone deserves to be equipped for what's coming. No one should be left behind because they didn't have the right tools or the right guidance at the right moment. + +Tasks will be automated. Roles will change. But the deeply personal endeavour of contributing to something meaningful — of finding work that matters to you and building a life around it — that will never change. That's the part worth protecting. + +Career-Box exists because we believe AI should be the great equalizer, not the great divider. We're building a tool that puts an AI career coach and a fully equipped workspace in the hands of anyone who needs it — not just the people who already know how to code or already have access to the best opportunities. + +If you're here, you're part of that mission. Welcome to the community. What we're building together has the potential to genuinely change lives — to help people navigate the most uncertain career landscape in a generation, and to come out the other side doing work they care about. - - **User** : `kasm_user` - - **Password**: `password` +We're called to adapt. Let's make sure no one has to do it alone. +--- -# About Workspaces -Kasm Workspaces is a docker container streaming platform that enables you to deliver browser-based access to desktops, applications, and web services. Kasm uses a modern DevOps approach for programmatic delivery of services via Containerized Desktop Infrastructure (CDI) technology to create on-demand, disposable, docker containers that are accessible via web browser. The rendering of the graphical-based containers is powered by the open-source project [**KasmVNC**](https://github.com/kasmtech/KasmVNC?utm_campaign=Github&utm_source=github) +## Contributing -![Screenshot][Kasm_Workflow] +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, guidelines, and how to submit changes. -Kasm Workspaces was developed to meet the most demanding secure collaboration requirements that is highly scalable, customizable, and easy to maintain. Most importantly, Kasm provides a solution, rather than a service, so it is infinitely customizable to your unique requirements and includes a developer API so that it can be integrated with, rather than replace, your existing applications and workflows. Kasm can be deployed in the cloud (Public or Private), on-premise (Including Air-Gapped Networks), or in a hybrid configuration. +## Security -# Live Demo -A self-guided on-demand demo is available at [**kasmweb.com**](https://www.kasmweb.com/demo.html?utm_campaign=Github&utm_source=github) +See [SECURITY.md](SECURITY.md) for the security audit, hardening documentation, and how to report vulnerabilities. +## License -[logo]: https://cdn2.hubspot.net/hubfs/5856039/dockerhub/kasm_logo.png "Kasm Logo" -[Kasm_Workflow]: https://cdn2.hubspot.net/hubfs/5856039/dockerhub/kasm_workflow_960.gif "Kasm Workflow" +[MIT License](LICENSE.md). Workspace image scripts originally by [Kasm Technologies Inc](https://kasmweb.com), with additional work by [Coeadapt](https://coeadapt.com). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..ac6d64551 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,243 @@ +# Security Hardening + +Security audit and hardening patches applied to the Career-Box workspace environment. This document covers vulnerabilities found in upstream Kasm workspace scripts and in Career-Box's own components (Coeadapt Launcher, OpenClaw gateway integration), along with the fixes applied. + +All fixes preserve full functionality while closing security gaps. If you discover a vulnerability not listed here, please open a private security advisory on this repository rather than a public issue. + +--- + +## Critical Fixes + +### 1. Gateway auth bypass removed +**File:** `src/ubuntu/install/careerclaw/install_careerclaw.sh`, `src/ubuntu/install/careerclaw/custom_startup.sh` + +The `--allow-unconfigured` flag was passed to the OpenClaw gateway, allowing it to accept connections without requiring proper configuration or authentication setup. Removed from both the launcher script and the respawn loop. + +```diff +- ARGS=${APP_ARGS:-"gateway --allow-unconfigured"} ++ ARGS=${APP_ARGS:-"gateway"} +``` + +### 2. Passwordless sudo eliminated +**File:** `src/ubuntu/install/dind/install_dind.sh` + +`kasm-user` had unrestricted `NOPASSWD: ALL` sudo access — equivalent to full root. Replaced with a random password and scoped sudo to only the binaries that actually need it. + +```diff +- echo 'kasm-user:kasm-user' | chpasswd +- echo 'kasm-user ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers ++ KASM_PASS=$(openssl rand -base64 16) ++ echo "kasm-user:${KASM_PASS}" | chpasswd ++ echo 'kasm-user ALL=(ALL) NOPASSWD: /usr/bin/dockerd, /usr/local/bin/dind, /usr/local/bin/dockerd-entrypoint.sh, /usr/sbin/iptables, /usr/sbin/ip6tables' >> /etc/sudoers +``` + +### 3. VPN credentials secured +**File:** `src/ubuntu/install/vpn/start_vpn.sh` + +OpenVPN credentials were written to world-readable files. Now created with `chmod 600` before any secrets are written. + +```diff ++ install -m 600 -o kasm-user -g kasm-user /dev/null /home/kasm-user/vpn_credentials + echo ${USER} > /home/kasm-user/vpn_credentials + echo ${PASS} >> /home/kasm-user/vpn_credentials +``` + +--- + +## High-Priority Fixes + +### 4. TLS certificate validation enforced +**File:** `src/ubuntu/install/remmina/install_remmina.sh` + +Both VNC and RDP default connection profiles had `ignore-tls-errors=1`, disabling certificate validation and allowing man-in-the-middle attacks on remote desktop connections. + +```diff +- ignore-tls-errors=1 ++ ignore-tls-errors=0 +``` + +Applied to both `default.vnc.remmina` and `default.rdp.remmina` profiles. + +### 5. SMB locked to localhost +**File:** `src/ubuntu/install/smb/install_smb.sh` + +Samba was bound to all network interfaces with guest access enabled via `map to guest = bad user`. Three changes: + +```diff +- ; interfaces = 127.0.0.0/8 eth0 +- ; bind interfaces only = yes ++ interfaces = 127.0.0.0/8 ++ bind interfaces only = yes +``` +```diff +- map to guest = bad user ++ map to guest = never +``` +```diff +- usershare allow guests = yes ++ usershare allow guests = no +``` + +### 6. ADB port restricted to localhost +**File:** `src/ubuntu/install/redroid/custom_startup.sh` + +Android Debug Bridge was exposed on `0.0.0.0:5555`, giving any machine on the network a full shell into the Android emulator. + +```diff +- -p 5555:5555 \ ++ -p 127.0.0.1:5555:5555 \ +``` + +### 7. Tauri updater signature enforcement +**File:** `coeadapt-launcher/src-tauri/tauri.conf.json` + +The updater `pubkey` was empty, meaning update packages were not signature-verified. A placeholder now forces a real key to be set before release. + +```diff +- "pubkey": "" ++ "pubkey": "REPLACE_WITH_REAL_PUBKEY_BEFORE_RELEASE" +``` + +> **Action required:** Generate a real keypair with `tauri signer generate` and replace the placeholder before shipping. + +--- + +## Medium-Priority Fixes + +### 8. Pipe-to-bash patterns removed +**Files:** `src/ubuntu/install/careerclaw/install_careerclaw.sh`, `src/ubuntu/install/dind/install_dind.sh` + +`curl | bash` executes remote code without inspection. Changed to download-then-execute so the script can be audited or cached. + +```diff +- curl -fsSL https://deb.nodesource.com/setup_22.x | bash - ++ NODESOURCE_SCRIPT=$(mktemp) ++ curl -fsSL https://deb.nodesource.com/setup_22.x -o "$NODESOURCE_SCRIPT" ++ bash "$NODESOURCE_SCRIPT" ++ rm -f "$NODESOURCE_SCRIPT" +``` + +```diff +- wget -q -O - https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash ++ K3D_SCRIPT=$(mktemp) ++ wget -q -O "$K3D_SCRIPT" https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh ++ bash "$K3D_SCRIPT" ++ rm -f "$K3D_SCRIPT" +``` + +### 9. Gateway localhost binding enforced at runtime +**File:** `src/ubuntu/install/careerclaw/install_careerclaw.sh` + +The launcher now reads `openclaw.json` and refuses to start if `gateway.bind` is anything other than `127.0.0.1`. Prevents config tampering from exposing the gateway to the network. + +### 10. OpenClaw config directory hardened +**File:** `src/ubuntu/install/careerclaw/install_careerclaw.sh` + +- `~/.openclaw/` set to `chmod 700` (owner-only access) +- `~/.openclaw/openclaw.json` set to `chmod 600` (owner-only read/write) +- CORS restricted to `localhost:18789` and `127.0.0.1:18789` origins only + +### 11. Dev dependencies stripped from production image +**File:** `src/ubuntu/install/careerclaw/install_careerclaw.sh` + +Added `pnpm prune --prod` and `rm -rf .git` after build to reduce attack surface and image size. + +--- + +## Round 2 — Additional Findings + +### 12. Hunchly license key removed from repository +**File:** `src/ubuntu/install/hunchly/license.key` + +A license key file was committed to git history. Removed from tracking and added to `.gitignore`. The key still exists in git history — run `git filter-branch` or BFG Repo-Cleaner to purge it before making the repo public. + +### 13. CI script SSH key set to chmod 777 +**File:** `ci-scripts/test.sh` + +The SSH private key used for CI test instances was set world-readable/writable. Fixed to `chmod 600`. + +```diff +- chmod 777 $(dirname ${CI_PROJECT_DIR})/sshkey ++ chmod 600 $(dirname ${CI_PROJECT_DIR})/sshkey +``` + +### 14. CI script disabled SSH host key verification +**File:** `ci-scripts/test.sh` + +Six SSH calls used `StrictHostKeyChecking=no`, allowing MITM attacks on CI test infrastructure. Changed to `accept-new` (trusts first connect, rejects changed keys). + +```diff +- -oStrictHostKeyChecking=no ++ -oStrictHostKeyChecking=accept-new +``` + +### 15. Chrome repo on OpenSUSE used HTTP +**File:** `src/ubuntu/install/chrome/install_chrome.sh` + +The zypper repository for Chrome used unencrypted HTTP, enabling package tampering via MITM. + +```diff +- zypper ar http://dl.google.com/linux/chrome/rpm/stable/x86_64 Google-Chrome ++ zypper ar https://dl.google.com/linux/chrome/rpm/stable/x86_64 Google-Chrome +``` + +### 16. Remmina RDP gateway transport defaulted to HTTP +**File:** `src/ubuntu/install/remmina/install_remmina.sh` + +The default RDP profile forced gateway transport over unencrypted HTTP. Changed to `auto` which prefers HTTPS when available. + +```diff +- gwtransp=http ++ gwtransp=auto +``` + +### 17. CareerClaw bind check hardened against config tampering +**File:** `src/ubuntu/install/careerclaw/install_careerclaw.sh` + +The previous check refused to start if bind wasn't `127.0.0.1`, but a TOCTOU race could allow bypass. Now the launcher auto-repairs the config back to `127.0.0.1` before starting, closing the race window. + +### 18. OwnCloud HTTP package repository +**File:** `src/ubuntu/install/owncloud/install_owncloud.sh` + +Package repository used unencrypted HTTP, allowing MITM package injection. + +```diff +- deb http://download.opensuse.org/repositories/isv:/ownCloud:/desktop/Ubuntu_16.04/ / ++ deb https://download.opensuse.org/repositories/isv:/ownCloud:/desktop/Ubuntu_16.04/ / +``` + +--- + +## Known Remaining Issues (upstream / not patchable here) + +| Issue | Location | Notes | +|-------|----------|-------| +| Unverified binary downloads (no checksums) | blender, eclipse, gimp, horizon, postman, hunchly install scripts | Upstream Kasm scripts — add SHA256 checks when pinning versions | +| Pipe-to-gpg key imports | signal, terraform, vivaldi, sublime, atom, unityhub, dind install scripts | Standard distro packaging pattern — lower risk since GPG verifies the key itself; modern `signed-by` keyring already used where supported | +| AWS credentials passed as CLI args in CI | `ci-scripts/test.sh` | Visible in `ps aux` — migrate to IAM roles or CI secret masking | +| OwnCloud config points to `http://192.168.117.130:9999` | `src/ubuntu/install/owncloud/install_owncloud.cfg` | Test/template config — users should override with HTTPS endpoint | +| Deprecated `apt-key add` in 7 scripts | atom, dind, dind_rootless, sublime_text, unityhub, signal, terraform | Upstream Kasm convention; fallback for older Ubuntu; newer distros use keyring path | + +--- + +## Accepted Risks + +| Item | Reason | +|------|--------| +| `--no-sandbox` on Chrome/Chromium | Required inside Kasm containers — the container itself is the sandbox boundary | +| `--privileged` on Redroid container | Required for `binder_linux` kernel access; Android emulation won't function without it | +| KasmVNC on `0.0.0.0:6901` | Intentional — this is the user entry point to the remote desktop, protected by VNC password | +| Docker group membership for kasm-user | Required for DinD functionality; mitigated by scoped sudo and container isolation | + +--- + +## Port Map + +| Port | Service | Binding | Auth | +|------|---------|---------|------| +| 18789 | OpenClaw Gateway | `127.0.0.1` | Configured (no longer unconfigured) | +| 6901 | KasmVNC | `0.0.0.0` | VNC password | +| 5555 | ADB (Redroid) | `127.0.0.1` | None (localhost only) | +| 5000 | Cyberbro | `127.0.0.1` | None (localhost only) | +| 5002 | SpiderFoot | `127.0.0.1` | None (localhost only) | +| 8834 | Nessus | `localhost` | HTTPS + Nessus auth | diff --git a/ci-scripts/gitlab-ci.template b/ci-scripts/gitlab-ci.template index 6e248a837..07411aa10 100644 --- a/ci-scripts/gitlab-ci.template +++ b/ci-scripts/gitlab-ci.template @@ -26,9 +26,6 @@ before_script: - if [ "$CI_COMMIT_REF_PROTECTED" == "true" ]; then docker login --username $GHCR_USERNAME --password $GHCR_PASSWORD ghcr.io; fi - export SANITIZED_BRANCH="$(echo $CI_COMMIT_REF_NAME | sed -r 's#^release/##' | sed 's/\//_/g')" -include: - - component: $CI_SERVER_FQDN/kasm-technologies/internal/qa-infrastructure/monitor-in-grafana@develop - .run_rules: rules: - if: > @@ -45,9 +42,7 @@ include: {% for IMAGE in multiImages %} build_{{ IMAGE.name }}: stage: build - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -75,9 +70,7 @@ build_{{ IMAGE.name }}: {% for IMAGE in singleImages %} build_{{ IMAGE.name }}: stage: build - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -105,9 +98,7 @@ build_{{ IMAGE.name }}: {% for IMAGE in multiImages %} test_{{ IMAGE.name }}: stage: test - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -136,9 +127,7 @@ test_{{ IMAGE.name }}: {% for IMAGE in singleImages %} test_{{ IMAGE.name }}: stage: test - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -167,9 +156,7 @@ test_{{ IMAGE.name }}: {% for IMAGE in multiImages %} manifest_{{ IMAGE.name }}: stage: manifest - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -201,9 +188,7 @@ manifest_{{ IMAGE.name }}: {% for IMAGE in singleImages %} manifest_{{ IMAGE.name }}: stage: manifest - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET != "{{ IMAGE.runset }}" @@ -239,9 +224,7 @@ manifest_{{ IMAGE.name }}: {% for IMAGE in multiImages %} weekly_manifest_{{ IMAGE.name }}: stage: manifest - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET == "schedule" @@ -258,9 +241,7 @@ weekly_manifest_{{ IMAGE.name }}: {% for IMAGE in singleImages %} weekly_manifest_{{ IMAGE.name }}: stage: manifest - extends: - - .run_rules - - .monitor-in-grafana + extends: .run_rules rules: - !reference [.run_rules, rules] - if: $PARENT_PIPELINE_SOURCE == "schedule" && $RUN_SET == "schedule" diff --git a/coeadapt-launcher/.gitignore b/coeadapt-launcher/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/coeadapt-launcher/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/coeadapt-launcher/.vscode/extensions.json b/coeadapt-launcher/.vscode/extensions.json new file mode 100644 index 000000000..24d7cc6de --- /dev/null +++ b/coeadapt-launcher/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/coeadapt-launcher/README.md b/coeadapt-launcher/README.md new file mode 100644 index 000000000..a6ebbfa3c --- /dev/null +++ b/coeadapt-launcher/README.md @@ -0,0 +1,231 @@ +# Coeadapt Launcher + +Cross-platform desktop app that manages the Coeadapt career workspace. Built with Tauri v2 + React + TypeScript. + +> This is a subproject of [Career-Box](../README.md). See the root README for the full project overview. + +## What It Does + +- Detects Docker/Podman and guides non-technical users through setup +- Pulls and manages a Kasm Workspaces container (`coeadapt/workspace:latest`) +- Runs an MCP server (port 3100) so Claude can interact with the workspace +- Auto-configures Claude Desktop for one-click AI connection +- Lives in the system tray with start/stop/open controls + +## Architecture + +``` +Coeadapt Tauri App (system tray + window) +├── React UI (Vite + Tailwind v4) +│ ├── Setup wizard (onboarding) +│ ├── Dashboard (status + controls) +│ ├── Claude Setup (AI connection) +│ └── Settings (workspace, AI, account) +├── Rust Backend (Tauri commands) +│ ├── Docker/Podman detection +│ ├── Container lifecycle (pull/create/start/stop) +│ ├── Disk space monitoring +│ ├── Health checks (workspace + MCP) +│ └── Claude Desktop config injection +└── MCP Server (Node.js sidecar, port 3100) + ├── workspace_status + ├── run_command + ├── read_file / write_file / list_files + ├── take_screenshot + ├── open_application + └── get_user_progress +``` + +## Prerequisites + +- [Bun](https://bun.sh/) (package manager + bundler) +- [Rust](https://rustup.rs/) (for Tauri backend) +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [Podman Desktop](https://podman-desktop.io/) + +## Development + +```bash +# Install frontend dependencies +bun install + +# Install MCP server dependencies +cd mcp-server && bun install && cd .. + +# Run in dev mode (Vite HMR + Tauri window) +bun run tauri dev +``` + +## Building + +```bash +# 1. Build MCP sidecar binary +cd mcp-server && bun run build && cd .. + +# 2. Build the Tauri app (produces platform installers) +bun run tauri build +``` + +### Build Outputs + +| Platform | Artifacts | +|----------|-----------| +| Windows | `.msi` installer, `.exe` (NSIS) | +| macOS | `.dmg` | +| Linux | `.AppImage`, `.deb` | + +## Project Structure + +``` +coeadapt-launcher/ +├── src/ # React frontend +│ ├── pages/ +│ │ ├── Setup.tsx # Multi-step onboarding wizard +│ │ ├── Dashboard.tsx # Workspace status + controls +│ │ ├── ClaudeSetup.tsx # Claude Desktop connection flow +│ │ └── Settings.tsx # Tabbed settings (Account, AI, Workspace, General) +│ ├── components/ +│ │ ├── DiskWarningBanner.tsx # Persistent low-space warning (<5GB) +│ │ ├── DiskUsage.tsx # Disk space progress bar +│ │ ├── ProgressBar.tsx # Image pull progress (indeterminate support) +│ │ ├── Spinner.tsx # Loading spinner (sm/md/lg) +│ │ ├── StatusIndicator.tsx # Colored dot + label +│ │ └── WorkspaceControls.tsx # Context-aware Start/Stop/Open buttons +│ ├── hooks/ +│ │ ├── useDocker.ts # Docker/Podman runtime detection +│ │ ├── useContainer.ts # Container lifecycle + status polling +│ │ ├── useClaudeConnection.ts # Claude Desktop detection + MCP health +│ │ └── useDiskSpace.ts # Disk monitoring + warning events +│ └── lib/ +│ ├── tauri.ts # 17 Tauri command wrappers +│ ├── types.ts # TypeScript interfaces (mirrors Rust structs) +│ └── constants.ts # User-facing strings (no jargon) +├── src-tauri/ # Rust backend +│ ├── src/ +│ │ ├── main.rs # Entry point +│ │ ├── lib.rs # App setup, tray, plugins, event loops +│ │ ├── state.rs # Serializable structs + constants +│ │ ├── commands.rs # 17 Tauri command handlers +│ │ ├── docker.rs # Docker CLI wrapper + streaming pull +│ │ ├── container.rs # Container create/start/stop/remove +│ │ ├── disk.rs # Disk space checks (sysinfo crate) +│ │ ├── health.rs # Workspace + MCP health polling +│ │ └── claude.rs # Claude Desktop config detection + injection +│ ├── Cargo.toml # Rust dependencies +│ ├── tauri.conf.json # App config, window, CSP, updater +│ └── capabilities/default.json # Tauri security permissions +└── mcp-server/ # Node.js MCP server (sidecar) + ├── src/ + │ ├── index.ts # HTTP server + MCP transport setup + │ ├── docker-exec.ts # docker exec wrapper (30s timeout) + │ └── tools/ # MCP tool implementations + │ ├── workspace.ts # workspace_status + │ ├── commands.ts # run_command + │ ├── filesystem.ts # read_file, write_file, list_files + │ ├── screenshot.ts # take_screenshot + │ ├── applications.ts # open_application + │ └── progress.ts # get_user_progress + ├── build.ts # Bun compile to sidecar binary + ├── package.json + └── tsconfig.json +``` + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Framework | Tauri v2 (stable) | +| Frontend | React 19, TypeScript 5.8, Vite 7 | +| Styling | Tailwind CSS v4 (navy + coral theme) | +| Backend | Rust (2021 edition) | +| MCP Server | `@modelcontextprotocol/sdk` v1.12, Zod | +| HTTP Client | reqwest 0.12 (rustls-tls) | +| System Info | sysinfo 0.34 | + +### Tauri Plugins + +- `tauri-plugin-opener` - Open URLs in default browser +- `tauri-plugin-shell` - Execute shell commands +- `tauri-plugin-store` - Persistent key-value storage +- `tauri-plugin-updater` - Auto-update support +- `tauri-plugin-autostart` - Launch on system boot + +## Container Configuration + +| Setting | Value | +|---------|-------| +| Container name | `coeadapt-workspace` | +| Image | `coeadapt/workspace:latest` | +| Data volume | `coeadapt-data` (mounted at `/home/kasm-user`) | +| Workspace port | `6901` (KasmVNC) | +| MCP server port | `3100` | +| Shared memory | `512MB` | +| Restart policy | `unless-stopped` | + +## Disk Space Requirements + +| Threshold | Value | Behavior | +|-----------|-------|----------| +| Minimum | 15 GB free | Blocks setup | +| Recommended | 25 GB free | Warning shown | +| Low space | < 5 GB free | Persistent banner | + +## MCP Server + +The MCP server bridges Claude to the workspace container via `docker exec`. It exposes 8 tools: + +| Tool | Description | +|------|-------------| +| `workspace_status` | Check if workspace is running | +| `run_command` | Execute shell commands in the workspace | +| `read_file` | Read file contents from workspace | +| `write_file` | Write content to a file in workspace | +| `list_files` | List directory contents | +| `take_screenshot` | Capture desktop screenshot | +| `open_application` | Launch apps (firefox, terminal, vscode, etc.) | +| `get_user_progress` | Read career progress data | + +### Endpoints + +- `http://127.0.0.1:3100/mcp` - MCP Streamable HTTP transport +- `http://127.0.0.1:3100/health` - Health check (status, lastToolCall, uptime) + +## Claude Desktop Integration + +The app auto-detects Claude Desktop and injects MCP config into `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "coeadapt": { + "command": "npx", + "args": ["mcp-remote", "http://localhost:3100/mcp"], + "env": {} + } + } +} +``` + +Config file locations: +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Linux:** `~/.config/Claude/claude_desktop_config.json` + +A backup (`.json.bak`) is created before the first modification. + +## Current Status + +### Complete +- Docker/Podman detection and daemon monitoring +- Full container lifecycle (pull with streaming progress, create, start, stop, reset) +- Disk space monitoring with thresholds and warning banner +- Multi-step setup wizard with auto-advance +- Dashboard with workspace status, controls, and disk usage +- Claude Desktop auto-detection and config injection +- MCP server with all 8 tools +- System tray (start/stop/open/show/quit) +- Hide-to-tray on window close +- Settings page (AI Connection + Workspace tabs functional) + +### Roadmap + +See [GitHub Issues](https://github.com/coeadapt/Career-Box/issues) for planned work and known issues. diff --git a/coeadapt-launcher/bun.lock b/coeadapt-launcher/bun.lock new file mode 100644 index 000000000..6d1453b3a --- /dev/null +++ b/coeadapt-launcher/bun.lock @@ -0,0 +1,391 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "coeadapt-launcher", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "^2.10.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.3", + "vite": "^7.0.4", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.0", "", { "os": "linux", "cpu": "none" }, "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="], + + "@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], + + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], + + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], + + "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="], + + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/coeadapt-launcher/index.html b/coeadapt-launcher/index.html new file mode 100644 index 000000000..791e8ca50 --- /dev/null +++ b/coeadapt-launcher/index.html @@ -0,0 +1,16 @@ + + + + + + + Coeadapt + + + + + +
+ + + diff --git a/coeadapt-launcher/mcp-server/build.ts b/coeadapt-launcher/mcp-server/build.ts new file mode 100644 index 000000000..f0b9fe8e2 --- /dev/null +++ b/coeadapt-launcher/mcp-server/build.ts @@ -0,0 +1,47 @@ +import { execSync } from "node:child_process"; +import { mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = join(__dirname, "..", "src-tauri", "binaries"); + +// Determine target triple for Tauri sidecar naming +function getTargetTriple(): string { + const platform = process.platform; + const arch = process.arch; + + if (platform === "win32") { + return arch === "arm64" + ? "aarch64-pc-windows-msvc" + : "x86_64-pc-windows-msvc"; + } + if (platform === "darwin") { + return arch === "arm64" + ? "aarch64-apple-darwin" + : "x86_64-apple-darwin"; + } + // Linux + return arch === "arm64" + ? "aarch64-unknown-linux-gnu" + : "x86_64-unknown-linux-gnu"; +} + +const triple = getTargetTriple(); +const ext = process.platform === "win32" ? ".exe" : ""; +const outFile = join(outDir, `coeadapt-mcp-${triple}${ext}`); + +// Ensure output directory exists +if (!existsSync(outDir)) { + mkdirSync(outDir, { recursive: true }); +} + +console.log(`Building MCP server sidecar for ${triple}...`); +console.log(`Output: ${outFile}`); + +execSync( + `bun build src/index.ts --compile --outfile "${outFile}"`, + { cwd: __dirname, stdio: "inherit" }, +); + +console.log("Build complete!"); diff --git a/coeadapt-launcher/mcp-server/bun.lock b/coeadapt-launcher/mcp-server/bun.lock new file mode 100644 index 000000000..6f21ae691 --- /dev/null +++ b/coeadapt-launcher/mcp-server/bun.lock @@ -0,0 +1,269 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "coeadapt-mcp", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0", + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + + "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + } +} diff --git a/coeadapt-launcher/mcp-server/package.json b/coeadapt-launcher/mcp-server/package.json new file mode 100644 index 000000000..c4e5c4fa6 --- /dev/null +++ b/coeadapt-launcher/mcp-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "coeadapt-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsx build.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "typescript": "^5.8.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/coeadapt-launcher/mcp-server/src/docker-exec.ts b/coeadapt-launcher/mcp-server/src/docker-exec.ts new file mode 100644 index 000000000..909ba4102 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/docker-exec.ts @@ -0,0 +1,29 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const CONTAINER_NAME = "coeadapt-workspace"; + +export async function dockerExec( + command: string, +): Promise<{ stdout: string; stderr: string }> { + return execFileAsync( + "docker", + ["exec", CONTAINER_NAME, "bash", "-c", command], + { timeout: 30000, maxBuffer: 10 * 1024 * 1024 }, + ); +} + +export async function isContainerRunning(): Promise { + try { + const { stdout } = await execFileAsync("docker", [ + "inspect", + "-f", + "{{.State.Running}}", + CONTAINER_NAME, + ]); + return stdout.trim() === "true"; + } catch { + return false; + } +} diff --git a/coeadapt-launcher/mcp-server/src/index.ts b/coeadapt-launcher/mcp-server/src/index.ts new file mode 100644 index 000000000..91420f6d9 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/index.ts @@ -0,0 +1,144 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer } from "node:http"; + +import { registerWorkspaceStatus } from "./tools/workspace.js"; +import { registerFilesystemTools } from "./tools/filesystem.js"; +import { registerRunCommand } from "./tools/commands.js"; +import { registerScreenshot } from "./tools/screenshot.js"; +import { registerOpenApplication } from "./tools/applications.js"; +import { registerGetProgress } from "./tools/progress.js"; +import { registerComputerUseTools } from "./tools/computer-use.js"; +import { dockerExec } from "./docker-exec.js"; + +const PORT = 3100; +const HOST = "127.0.0.1"; + +let lastToolCall = Date.now(); + +function onToolCall() { + lastToolCall = Date.now(); +} + +// Create MCP server +const server = new McpServer({ + name: "coeadapt", + version: "0.1.0", +}); + +// Register all tools +registerWorkspaceStatus(server, onToolCall); +registerFilesystemTools(server, onToolCall); +registerRunCommand(server, onToolCall); +registerScreenshot(server, onToolCall); +registerOpenApplication(server, onToolCall); +registerGetProgress(server, onToolCall); +registerComputerUseTools(server, onToolCall); + +// Create HTTP server with Streamable HTTP transport +const httpServer = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`); + + // Health endpoint + if (url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "ok", + lastToolCall, + uptime: process.uptime(), + }), + ); + return; + } + + // Progress summary proxy — fetches from in-VM progress tracker for the dashboard UI + if (url.pathname === "/progress-summary") { + try { + const { stdout } = await dockerExec( + "curl -sf -m 3 http://127.0.0.1:7700/progress/summary 2>/dev/null || " + + 'echo \'{"progress_percent":0,"streak_days":0,"total_activities":0,"total_goals":0,"completed_goals":0,"total_skills":0,"total_milestones":0,"last_activity_at":null}\'', + ); + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(stdout); + } catch { + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify({ + progress_percent: 0, + streak_days: 0, + total_activities: 0, + total_goals: 0, + completed_goals: 0, + total_skills: 0, + total_milestones: 0, + last_activity_at: null, + })); + } + return; + } + + // Agent health proxy — reports status of in-VM services + if (url.pathname === "/agent-health") { + let progressOk = false; + let computerOk = false; + try { + const { stdout } = await dockerExec( + "curl -sf -m 2 http://127.0.0.1:7700/health >/dev/null 2>&1 && echo ok || echo down", + ); + progressOk = stdout.trim() === "ok"; + } catch {} + try { + const { stdout } = await dockerExec( + "curl -sf -m 2 http://127.0.0.1:7701/health >/dev/null 2>&1 && echo ok || echo down", + ); + computerOk = stdout.trim() === "ok"; + } catch {} + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify({ + progress_tracker: progressOk ? "ok" : "down", + computer_use: computerOk ? "ok" : "down", + })); + return; + } + + // MCP endpoint + if (url.pathname === "/mcp") { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } + + // 404 for everything else + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); +}); + +httpServer.listen(PORT, HOST, () => { + console.log(`Coeadapt MCP server listening on http://${HOST}:${PORT}`); + console.log(` MCP endpoint: http://${HOST}:${PORT}/mcp`); + console.log(` Health check: http://${HOST}:${PORT}/health`); +}); + +// Graceful shutdown +process.on("SIGINT", () => { + console.log("Shutting down MCP server..."); + httpServer.close(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + httpServer.close(); + process.exit(0); +}); diff --git a/coeadapt-launcher/mcp-server/src/tools/applications.ts b/coeadapt-launcher/mcp-server/src/tools/applications.ts new file mode 100644 index 000000000..055dc28c1 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/applications.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +const APP_MAP: Record = { + firefox: "firefox", + chrome: "google-chrome", + "google-chrome": "google-chrome", + terminal: "xfce4-terminal", + "file-manager": "thunar", + "text-editor": "xfce4-terminal -e nano", + vscode: "code", + "vs-code": "code", + gimp: "gimp", + thunderbird: "thunderbird", + onlyoffice: "onlyoffice-desktopeditors", + vlc: "vlc", +}; + +export function registerOpenApplication( + server: McpServer, + onToolCall: () => void, +) { + server.tool( + "open_application", + "Launch an application in the workspace", + { app_name: z.string().describe("Application name (e.g., firefox, chrome, terminal, vscode)") }, + async ({ app_name }) => { + onToolCall(); + const cmd = APP_MAP[app_name.toLowerCase()] || app_name; + try { + await dockerExec(`DISPLAY=:1 nohup ${cmd} > /dev/null 2>&1 &`); + return { + content: [ + { type: "text" as const, text: `Launched ${app_name}` }, + ], + }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error launching ${app_name}: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/commands.ts b/coeadapt-launcher/mcp-server/src/tools/commands.ts new file mode 100644 index 000000000..054e4ffd2 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/commands.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +export function registerRunCommand( + server: McpServer, + onToolCall: () => void, +) { + server.tool( + "run_command", + "Execute a shell command inside the workspace", + { command: z.string().describe("Shell command to execute") }, + async ({ command }) => { + onToolCall(); + try { + const { stdout, stderr } = await dockerExec(command); + const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : ""); + return { content: [{ type: "text" as const, text: output }] }; + } catch (err: any) { + return { + content: [ + { + type: "text" as const, + text: `Error (exit ${err.code}): ${err.stderr || err.message}`, + }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/computer-use.ts b/coeadapt-launcher/mcp-server/src/tools/computer-use.ts new file mode 100644 index 000000000..dde583352 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/computer-use.ts @@ -0,0 +1,398 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +/** + * Helper to call the in-VM computer-use HTTP service. + * Falls back to direct xdotool commands if the service is unavailable. + */ +async function curlAgent( + method: "GET" | "POST", + path: string, + body?: Record, +): Promise { + const curlArgs = [ + "curl", "-sf", "-m", "10", + "-H", "Content-Type: application/json", + ]; + if (method === "POST" && body) { + curlArgs.push("-X", "POST", "-d", JSON.stringify(body)); + } + curlArgs.push(`http://127.0.0.1:7701${path}`); + const { stdout } = await dockerExec(curlArgs.join(" ")); + return stdout; +} + +export function registerComputerUseTools( + server: McpServer, + onToolCall: () => void, +) { + // ----------------------------------------------------------------------- + // Screenshot + // ----------------------------------------------------------------------- + server.tool( + "computer_screenshot", + "Capture a screenshot of the workspace desktop. Returns a base64-encoded PNG image.", + { + region: z + .object({ + x: z.number().describe("Left edge X coordinate"), + y: z.number().describe("Top edge Y coordinate"), + width: z.number().describe("Width in pixels"), + height: z.number().describe("Height in pixels"), + }) + .optional() + .describe("Optional region to capture. Omit for full screen."), + }, + async ({ region }) => { + onToolCall(); + try { + const body = region || {}; + const raw = await curlAgent("POST", "/screen/screenshot", body); + const parsed = JSON.parse(raw); + if (parsed.image) { + return { + content: [ + { + type: "image" as const, + data: parsed.image, + mimeType: "image/png", + }, + ], + }; + } + return { + content: [{ type: "text" as const, text: "Screenshot capture failed" }], + isError: true, + }; + } catch (err: any) { + // Fallback to direct import command + try { + await dockerExec( + "DISPLAY=:1 import -window root /tmp/screenshot.png 2>/dev/null", + ); + const { stdout } = await dockerExec( + "base64 -w 0 /tmp/screenshot.png 2>/dev/null", + ); + if (stdout && stdout !== "") { + return { + content: [ + { type: "image" as const, data: stdout, mimeType: "image/png" }, + ], + }; + } + } catch {} + return { + content: [ + { + type: "text" as const, + text: `Screenshot error: ${err.message || err}`, + }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Screen info + // ----------------------------------------------------------------------- + server.tool( + "computer_screen_size", + "Get the screen dimensions (width, height) of the workspace desktop", + {}, + async () => { + onToolCall(); + try { + const raw = await curlAgent("GET", "/screen/size"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Mouse: move + // ----------------------------------------------------------------------- + server.tool( + "computer_mouse_move", + "Move the mouse cursor to the specified screen coordinates", + { + x: z.number().describe("X coordinate (pixels from left)"), + y: z.number().describe("Y coordinate (pixels from top)"), + }, + async ({ x, y }) => { + onToolCall(); + try { + const raw = await curlAgent("POST", "/mouse/move", { x, y }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Mouse: click + // ----------------------------------------------------------------------- + server.tool( + "computer_click", + "Click at the current mouse position or at specific coordinates", + { + x: z.number().optional().describe("X coordinate to click at (moves mouse first if provided)"), + y: z.number().optional().describe("Y coordinate to click at (moves mouse first if provided)"), + button: z + .enum(["left", "right", "middle"]) + .default("left") + .describe("Mouse button: left (1), right (3), or middle (2)"), + double: z + .boolean() + .default(false) + .describe("If true, perform a double-click"), + }, + async ({ x, y, button, double }) => { + onToolCall(); + const btn = button === "right" ? 3 : button === "middle" ? 2 : 1; + try { + if (x !== undefined && y !== undefined) { + const endpoint = double ? "/action/double_click_at" : "/action/click_at"; + const raw = await curlAgent("POST", endpoint, { x, y, button: btn }); + return { content: [{ type: "text" as const, text: raw }] }; + } + const endpoint = double ? "/mouse/double_click" : "/mouse/click"; + const raw = await curlAgent("POST", endpoint, { button: btn }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Mouse: scroll + // ----------------------------------------------------------------------- + server.tool( + "computer_scroll", + "Scroll the mouse wheel up or down", + { + direction: z.enum(["up", "down"]).describe("Scroll direction"), + clicks: z + .number() + .default(3) + .describe("Number of scroll clicks (default 3)"), + }, + async ({ direction, clicks }) => { + onToolCall(); + try { + const raw = await curlAgent("POST", "/mouse/scroll", { direction, clicks }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Mouse: drag + // ----------------------------------------------------------------------- + server.tool( + "computer_drag", + "Click-and-drag from one position to another", + { + x1: z.number().describe("Start X coordinate"), + y1: z.number().describe("Start Y coordinate"), + x2: z.number().describe("End X coordinate"), + y2: z.number().describe("End Y coordinate"), + }, + async ({ x1, y1, x2, y2 }) => { + onToolCall(); + try { + const raw = await curlAgent("POST", "/mouse/drag", { x1, y1, x2, y2 }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Keyboard: type text + // ----------------------------------------------------------------------- + server.tool( + "computer_type", + "Type text using the keyboard. For special keys, use computer_key_press instead.", + { + text: z.string().describe("Text to type"), + x: z.number().optional().describe("X coordinate to click before typing"), + y: z.number().optional().describe("Y coordinate to click before typing"), + }, + async ({ text, x, y }) => { + onToolCall(); + try { + if (x !== undefined && y !== undefined) { + const raw = await curlAgent("POST", "/action/type_at", { x, y, text }); + return { content: [{ type: "text" as const, text: raw }] }; + } + const raw = await curlAgent("POST", "/keyboard/type", { text }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Keyboard: press key(s) + // ----------------------------------------------------------------------- + server.tool( + "computer_key_press", + "Press a key or key combination. Examples: 'Return', 'ctrl+c', 'alt+F4', 'ctrl+shift+t', 'BackSpace', 'Tab', 'Escape'", + { + keys: z + .string() + .describe( + "Key combo using xdotool syntax (e.g. 'Return', 'ctrl+c', 'alt+Tab', 'super')", + ), + }, + async ({ keys }) => { + onToolCall(); + try { + const raw = await curlAgent("POST", "/keyboard/press", { keys }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Window: get active window info + // ----------------------------------------------------------------------- + server.tool( + "computer_active_window", + "Get information about the currently active (focused) window", + {}, + async () => { + onToolCall(); + try { + const raw = await curlAgent("GET", "/window/active"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Window: list windows + // ----------------------------------------------------------------------- + server.tool( + "computer_list_windows", + "List all visible windows on the desktop", + {}, + async () => { + onToolCall(); + try { + const raw = await curlAgent("GET", "/window/list"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Window: focus a specific window + // ----------------------------------------------------------------------- + server.tool( + "computer_focus_window", + "Bring a specific window to the foreground by its window ID", + { + window_id: z.string().describe("The window ID (from computer_list_windows)"), + }, + async ({ window_id }) => { + onToolCall(); + try { + const raw = await curlAgent("POST", "/window/focus", { window_id }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Mouse: get position + // ----------------------------------------------------------------------- + server.tool( + "computer_mouse_position", + "Get the current mouse cursor position", + {}, + async () => { + onToolCall(); + try { + const raw = await curlAgent("GET", "/mouse/position"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/filesystem.ts b/coeadapt-launcher/mcp-server/src/tools/filesystem.ts new file mode 100644 index 000000000..2ddc41f0f --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/filesystem.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +export function registerFilesystemTools( + server: McpServer, + onToolCall: () => void, +) { + server.tool( + "read_file", + "Read a file from the workspace filesystem", + { path: z.string().describe("Absolute path to the file") }, + async ({ path }) => { + onToolCall(); + try { + const { stdout } = await dockerExec(`cat "${path}"`); + return { content: [{ type: "text" as const, text: stdout }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + }, + ); + + server.tool( + "write_file", + "Write content to a file in the workspace", + { + path: z.string().describe("Absolute path to write"), + content: z.string().describe("Content to write"), + }, + async ({ path, content }) => { + onToolCall(); + try { + const b64 = Buffer.from(content).toString("base64"); + await dockerExec(`echo "${b64}" | base64 -d > "${path}"`); + return { + content: [{ type: "text" as const, text: `Written to ${path}` }], + }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + }, + ); + + server.tool( + "list_files", + "List files and directories at a given path", + { + path: z + .string() + .describe("Directory path to list") + .default("/home/kasm-user"), + }, + async ({ path }) => { + onToolCall(); + try { + const { stdout } = await dockerExec(`ls -la "${path}"`); + return { content: [{ type: "text" as const, text: stdout }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/progress.ts b/coeadapt-launcher/mcp-server/src/tools/progress.ts new file mode 100644 index 000000000..74f4daec9 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/progress.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +/** + * Helper to call the in-VM progress tracker HTTP service. + * Falls back to direct file reads if the service is unavailable. + */ +async function progressApi( + method: "GET" | "POST" | "PUT", + path: string, + body?: Record, +): Promise { + const curlArgs = [ + "curl", "-sf", "-m", "5", + "-H", "Content-Type: application/json", + ]; + if (method === "POST" || method === "PUT") { + curlArgs.push("-X", method); + if (body) { + curlArgs.push("-d", JSON.stringify(body)); + } + } + curlArgs.push(`http://127.0.0.1:7700${path}`); + const { stdout } = await dockerExec(curlArgs.join(" ")); + return stdout; +} + +export function registerGetProgress( + server: McpServer, + onToolCall: () => void, +) { + // ----------------------------------------------------------------------- + // Get full progress data + // ----------------------------------------------------------------------- + server.tool( + "get_user_progress", + "Get the user's complete career development progress including activities, goals, skills, and milestones", + {}, + async () => { + onToolCall(); + try { + const raw = await progressApi("GET", "/progress"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch { + // Fallback: read file directly + try { + const { stdout } = await dockerExec( + "cat /home/kasm-user/.coeadapt/progress.json 2>/dev/null || " + + 'echo \'{"activities":[],"assessments":[],"goals":[],"skills":[],"milestones":[],"progress_percent":0}\'', + ); + return { content: [{ type: "text" as const, text: stdout }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + } + }, + ); + + // ----------------------------------------------------------------------- + // Get progress summary (lightweight) + // ----------------------------------------------------------------------- + server.tool( + "get_progress_summary", + "Get a lightweight summary of the user's career progress: completion percentage, streak, counts", + {}, + async () => { + onToolCall(); + try { + const raw = await progressApi("GET", "/progress/summary"); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Log an activity + // ----------------------------------------------------------------------- + server.tool( + "log_activity", + "Log a career development activity (e.g. completed a tutorial, attended a workshop, practiced a skill)", + { + title: z.string().describe("Title of the activity"), + type: z + .enum([ + "tutorial", "workshop", "practice", "project", "assessment", + "reading", "networking", "application", "interview", "general", + ]) + .default("general") + .describe("Type of activity"), + description: z.string().optional().describe("Description of what was done"), + duration_minutes: z.number().optional().describe("How long the activity took in minutes"), + tags: z.array(z.string()).optional().describe("Tags/labels for the activity"), + }, + async ({ title, type, description, duration_minutes, tags }) => { + onToolCall(); + try { + const raw = await progressApi("POST", "/progress/activities", { + title, + type, + description: description || "", + duration_minutes: duration_minutes || 0, + tags: tags || [], + }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Create a goal + // ----------------------------------------------------------------------- + server.tool( + "create_goal", + "Create a new career development goal for the user to work toward", + { + title: z.string().describe("Goal title"), + description: z.string().optional().describe("Detailed description of the goal"), + category: z + .enum([ + "skill", "certification", "project", "job-search", + "networking", "education", "portfolio", "general", + ]) + .default("general") + .describe("Goal category"), + target_date: z.string().optional().describe("Target completion date (ISO 8601)"), + sub_goals: z + .array(z.string()) + .optional() + .describe("List of sub-goal descriptions"), + }, + async ({ title, description, category, target_date, sub_goals }) => { + onToolCall(); + try { + const raw = await progressApi("POST", "/progress/goals", { + title, + description: description || "", + category, + target_date, + sub_goals: sub_goals || [], + }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Update a goal + // ----------------------------------------------------------------------- + server.tool( + "update_goal", + "Update an existing goal's status, description, or details", + { + goal_id: z.number().describe("ID of the goal to update"), + status: z + .enum(["active", "completed", "paused", "abandoned"]) + .optional() + .describe("New goal status"), + title: z.string().optional().describe("Updated title"), + description: z.string().optional().describe("Updated description"), + }, + async ({ goal_id, status, title, description }) => { + onToolCall(); + const updates: Record = {}; + if (status) updates.status = status; + if (title) updates.title = title; + if (description) updates.description = description; + try { + const raw = await progressApi("PUT", `/progress/goals/${goal_id}`, updates); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Record a skill + // ----------------------------------------------------------------------- + server.tool( + "record_skill", + "Record or update a skill the user has demonstrated or is developing", + { + name: z.string().describe("Skill name (e.g. 'Python', 'Public Speaking', 'React')"), + category: z + .enum([ + "technical", "soft-skill", "tool", "language", + "framework", "methodology", "domain", "general", + ]) + .default("general") + .describe("Skill category"), + level: z + .enum(["beginner", "intermediate", "advanced", "expert"]) + .default("beginner") + .describe("Current proficiency level"), + evidence: z + .array(z.string()) + .optional() + .describe("Evidence of the skill (project URLs, descriptions, etc.)"), + }, + async ({ name, category, level, evidence }) => { + onToolCall(); + try { + const raw = await progressApi("POST", "/progress/skills", { + name, + category, + level, + evidence: evidence || [], + }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Update a skill + // ----------------------------------------------------------------------- + server.tool( + "update_skill", + "Update a skill's level or add new evidence", + { + skill_id: z.number().describe("ID of the skill to update"), + level: z + .enum(["beginner", "intermediate", "advanced", "expert"]) + .optional() + .describe("Updated proficiency level"), + evidence: z + .array(z.string()) + .optional() + .describe("New evidence entries to add"), + verified: z.boolean().optional().describe("Mark as verified by assessment"), + }, + async ({ skill_id, level, evidence, verified }) => { + onToolCall(); + const updates: Record = {}; + if (level) updates.level = level; + if (evidence) updates.evidence = evidence; + if (verified !== undefined) updates.verified = verified; + try { + const raw = await progressApi("PUT", `/progress/skills/${skill_id}`, updates); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Add a milestone + // ----------------------------------------------------------------------- + server.tool( + "add_milestone", + "Record a career milestone or achievement (e.g. 'Got first interview', 'Completed Python course')", + { + title: z.string().describe("Milestone title"), + description: z.string().optional().describe("Details about the milestone"), + achieved: z + .boolean() + .default(true) + .describe("Whether the milestone is already achieved"), + }, + async ({ title, description, achieved }) => { + onToolCall(); + try { + const raw = await progressApi("POST", "/progress/milestones", { + title, + description: description || "", + achieved, + }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); + + // ----------------------------------------------------------------------- + // Record an assessment result + // ----------------------------------------------------------------------- + server.tool( + "record_assessment", + "Record the result of a skills assessment or quiz", + { + skill: z.string().describe("Skill being assessed"), + score: z.number().describe("Score achieved"), + max_score: z.number().default(100).describe("Maximum possible score"), + type: z + .enum(["self", "quiz", "project-review", "peer", "ai"]) + .default("self") + .describe("Type of assessment"), + notes: z.string().optional().describe("Notes about the assessment"), + }, + async ({ skill, score, max_score, type, notes }) => { + onToolCall(); + try { + const raw = await progressApi("POST", "/progress/assessments", { + skill, + score, + max_score, + type, + notes: notes || "", + }); + return { content: [{ type: "text" as const, text: raw }] }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.message || err}` }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/screenshot.ts b/coeadapt-launcher/mcp-server/src/tools/screenshot.ts new file mode 100644 index 000000000..82f8b9b1e --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/screenshot.ts @@ -0,0 +1,75 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dockerExec } from "../docker-exec.js"; + +export function registerScreenshot( + server: McpServer, + onToolCall: () => void, +) { + server.tool( + "take_screenshot", + "Capture a screenshot of the current workspace desktop (simple version — use computer_screenshot for region capture)", + {}, + async () => { + onToolCall(); + try { + // Try the computer-use service first for better reliability + const { stdout: serviceResult } = await dockerExec( + 'curl -sf -m 5 http://127.0.0.1:7701/screen/screenshot 2>/dev/null || echo ""', + ); + if (serviceResult && serviceResult.startsWith("{")) { + const parsed = JSON.parse(serviceResult); + if (parsed.image) { + return { + content: [ + { + type: "image" as const, + data: parsed.image, + mimeType: "image/png", + }, + ], + }; + } + } + } catch { + // Service not available, fall through to direct capture + } + + try { + // Fallback: direct imagemagick capture + await dockerExec( + "DISPLAY=:1 import -window root /tmp/screenshot.png 2>/dev/null || " + + "DISPLAY=:1 xdotool key --delay 100 Print && sleep 1", + ); + const { stdout } = await dockerExec( + "base64 -w 0 /tmp/screenshot.png 2>/dev/null || echo 'NO_SCREENSHOT'", + ); + if (stdout === "NO_SCREENSHOT") { + return { + content: [ + { + type: "text" as const, + text: "Screenshot capture not available. Install imagemagick in the workspace.", + }, + ], + }; + } + return { + content: [ + { + type: "image" as const, + data: stdout, + mimeType: "image/png", + }, + ], + }; + } catch (err: any) { + return { + content: [ + { type: "text" as const, text: `Error: ${err.stderr || err.message}` }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/src/tools/workspace.ts b/coeadapt-launcher/mcp-server/src/tools/workspace.ts new file mode 100644 index 000000000..46acd50a9 --- /dev/null +++ b/coeadapt-launcher/mcp-server/src/tools/workspace.ts @@ -0,0 +1,29 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { isContainerRunning } from "../docker-exec.js"; + +export function registerWorkspaceStatus( + server: McpServer, + onToolCall: () => void, +) { + server.tool( + "workspace_status", + "Check if the Coeadapt workspace is running and healthy", + {}, + async () => { + onToolCall(); + const running = await isContainerRunning(); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + running, + url: running ? "https://localhost:6901" : null, + status: running ? "healthy" : "stopped", + }), + }, + ], + }; + }, + ); +} diff --git a/coeadapt-launcher/mcp-server/tsconfig.json b/coeadapt-launcher/mcp-server/tsconfig.json new file mode 100644 index 000000000..ac12238df --- /dev/null +++ b/coeadapt-launcher/mcp-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/coeadapt-launcher/package-lock.json b/coeadapt-launcher/package-lock.json new file mode 100644 index 000000000..44d9d4a63 --- /dev/null +++ b/coeadapt-launcher/package-lock.json @@ -0,0 +1,1343 @@ +{ + "name": "coeadapt-launcher", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coeadapt-launcher", + "version": "0.1.0", + "dependencies": { + "@clerk/clerk-react": "^5", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "^2.10.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@clerk/clerk-react": { + "version": "5.60.1", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.60.1.tgz", + "integrity": "sha512-Z7LAJKvcieGSFB3Q0f840o8Lh7VauvBjc+aDElIt6OHaJRjWcjhFcRiL2Fvg9YkRG942IZN8RHl7iKUzAU5SIQ==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.45.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + } + }, + "node_modules/@clerk/shared": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.45.0.tgz", + "integrity": "sha512-u4MlyEQy+QnGiQqwwqznplJ59el3k05tgaRyh9O3KSxWa84Br4JCXRuV9yYhA0+7bvgUPE7nLlX2byWmf7QOAA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "csstype": "3.1.3", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0", + "swr": "2.3.4" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/shared/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tauri-apps/plugin-store": { + "version": "2.4.2", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.0", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/swr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz", + "integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/coeadapt-launcher/package.json b/coeadapt-launcher/package.json new file mode 100644 index 000000000..1504a8c64 --- /dev/null +++ b/coeadapt-launcher/package.json @@ -0,0 +1,34 @@ +{ + "name": "coeadapt-launcher", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@clerk/clerk-react": "^5", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "^2.10.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/coeadapt-launcher/public/logo-color.png b/coeadapt-launcher/public/logo-color.png new file mode 100644 index 000000000..f5b4b72d4 Binary files /dev/null and b/coeadapt-launcher/public/logo-color.png differ diff --git a/coeadapt-launcher/public/logo-white.png b/coeadapt-launcher/public/logo-white.png new file mode 100644 index 000000000..ccab8ea62 Binary files /dev/null and b/coeadapt-launcher/public/logo-white.png differ diff --git a/coeadapt-launcher/public/tauri.svg b/coeadapt-launcher/public/tauri.svg new file mode 100644 index 000000000..31b62c928 --- /dev/null +++ b/coeadapt-launcher/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/coeadapt-launcher/public/vite.svg b/coeadapt-launcher/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/coeadapt-launcher/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/coeadapt-launcher/public/wordmark-color.png b/coeadapt-launcher/public/wordmark-color.png new file mode 100644 index 000000000..0d0d85f25 Binary files /dev/null and b/coeadapt-launcher/public/wordmark-color.png differ diff --git a/coeadapt-launcher/public/wordmark-white.png b/coeadapt-launcher/public/wordmark-white.png new file mode 100644 index 000000000..a09db1d09 Binary files /dev/null and b/coeadapt-launcher/public/wordmark-white.png differ diff --git a/coeadapt-launcher/src-tauri/.gitignore b/coeadapt-launcher/src-tauri/.gitignore new file mode 100644 index 000000000..b21bd681d --- /dev/null +++ b/coeadapt-launcher/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/coeadapt-launcher/src-tauri/Cargo.toml b/coeadapt-launcher/src-tauri/Cargo.toml new file mode 100644 index 000000000..26e3977f2 --- /dev/null +++ b/coeadapt-launcher/src-tauri/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "coeadapt-launcher" +version = "0.1.0" +description = "Coeadapt - Turn-key career workspace launcher" +authors = ["Coeadapt"] +edition = "2021" + +[lib] +name = "coeadapt_launcher_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-opener = "2" +tauri-plugin-shell = "2" +tauri-plugin-store = "2" +tauri-plugin-updater = "2" +tauri-plugin-autostart = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["full"] } +dirs = "6" +sysinfo = "0.34" diff --git a/src/ubuntu/install/hunchly/license.key b/coeadapt-launcher/src-tauri/binaries/.gitkeep similarity index 100% rename from src/ubuntu/install/hunchly/license.key rename to coeadapt-launcher/src-tauri/binaries/.gitkeep diff --git a/coeadapt-launcher/src-tauri/build.rs b/coeadapt-launcher/src-tauri/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/coeadapt-launcher/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/coeadapt-launcher/src-tauri/capabilities/default.json b/coeadapt-launcher/src-tauri/capabilities/default.json new file mode 100644 index 000000000..081f8d77e --- /dev/null +++ b/coeadapt-launcher/src-tauri/capabilities/default.json @@ -0,0 +1,18 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Coeadapt default capabilities", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "shell:allow-spawn", + "shell:allow-execute", + "shell:allow-open", + "store:default", + "updater:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled" + ] +} diff --git a/coeadapt-launcher/src-tauri/icons/128x128.png b/coeadapt-launcher/src-tauri/icons/128x128.png new file mode 100644 index 000000000..6be5e50e9 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/128x128.png differ diff --git a/coeadapt-launcher/src-tauri/icons/128x128@2x.png b/coeadapt-launcher/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..e81becee5 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/128x128@2x.png differ diff --git a/coeadapt-launcher/src-tauri/icons/32x32.png b/coeadapt-launcher/src-tauri/icons/32x32.png new file mode 100644 index 000000000..a437dd517 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/32x32.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square107x107Logo.png b/coeadapt-launcher/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 000000000..0ca4f2719 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square107x107Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square142x142Logo.png b/coeadapt-launcher/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 000000000..b81f82039 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square142x142Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square150x150Logo.png b/coeadapt-launcher/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 000000000..624c7bfba Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square150x150Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square284x284Logo.png b/coeadapt-launcher/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 000000000..c021d2ba7 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square284x284Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square30x30Logo.png b/coeadapt-launcher/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 000000000..621970023 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square30x30Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square310x310Logo.png b/coeadapt-launcher/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 000000000..f9bc04839 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square310x310Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square44x44Logo.png b/coeadapt-launcher/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 000000000..d5fbfb2ab Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square44x44Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square71x71Logo.png b/coeadapt-launcher/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 000000000..63440d798 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square71x71Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/Square89x89Logo.png b/coeadapt-launcher/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 000000000..f3f705af2 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/Square89x89Logo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/StoreLogo.png b/coeadapt-launcher/src-tauri/icons/StoreLogo.png new file mode 100644 index 000000000..455638826 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/StoreLogo.png differ diff --git a/coeadapt-launcher/src-tauri/icons/icon.icns b/coeadapt-launcher/src-tauri/icons/icon.icns new file mode 100644 index 000000000..12a5bcee2 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/icon.icns differ diff --git a/coeadapt-launcher/src-tauri/icons/icon.ico b/coeadapt-launcher/src-tauri/icons/icon.ico new file mode 100644 index 000000000..b3636e4b2 Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/icon.ico differ diff --git a/coeadapt-launcher/src-tauri/icons/icon.png b/coeadapt-launcher/src-tauri/icons/icon.png new file mode 100644 index 000000000..e1cd2619e Binary files /dev/null and b/coeadapt-launcher/src-tauri/icons/icon.png differ diff --git a/coeadapt-launcher/src-tauri/src/claude.rs b/coeadapt-launcher/src-tauri/src/claude.rs new file mode 100644 index 000000000..fde3467fd --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/claude.rs @@ -0,0 +1,137 @@ +use std::path::PathBuf; + +use crate::state::ClaudeStatus; + +pub fn claude_config_path() -> Option { + if cfg!(target_os = "macos") { + dirs::home_dir() + .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json")) + } else if cfg!(target_os = "windows") { + std::env::var("APPDATA") + .ok() + .map(|a| PathBuf::from(a).join("Claude").join("claude_desktop_config.json")) + } else { + dirs::config_dir().map(|c| c.join("Claude/claude_desktop_config.json")) + } +} + +pub fn is_claude_installed() -> bool { + claude_config_path() + .map(|p| { + p.parent() + .map(|d| d.exists()) + .unwrap_or(false) + }) + .unwrap_or(false) +} + +pub fn is_coeadapt_configured() -> bool { + let Some(config_path) = claude_config_path() else { + return false; + }; + if !config_path.exists() { + return false; + } + let Ok(contents) = std::fs::read_to_string(&config_path) else { + return false; + }; + let Ok(config) = serde_json::from_str::(&contents) else { + return false; + }; + config + .get("mcpServers") + .and_then(|s| s.get("coeadapt")) + .is_some() +} + +pub fn inject_coeadapt_config() -> Result<(), String> { + let config_path = claude_config_path().ok_or("Cannot determine Claude config path")?; + + // Create backup before first edit + if config_path.exists() { + let backup = config_path.with_extension("json.bak"); + if !backup.exists() { + std::fs::copy(&config_path, &backup).map_err(|e| e.to_string())?; + } + } + + let mut config: serde_json::Value = if config_path.exists() { + let contents = std::fs::read_to_string(&config_path).map_err(|e| e.to_string())?; + serde_json::from_str(&contents).map_err(|e| { + format!( + "Claude config JSON parse error: {}. Not modifying file.", + e + ) + })? + } else { + // Create parent directory if needed + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + serde_json::json!({}) + }; + + // Ensure mcpServers exists + if config.get("mcpServers").is_none() { + config["mcpServers"] = serde_json::json!({}); + } + + // Only add if not already present + if config["mcpServers"].get("coeadapt").is_none() { + config["mcpServers"]["coeadapt"] = serde_json::json!({ + "command": "npx", + "args": ["mcp-remote", "http://localhost:3100/mcp"], + "env": {} + }); + } + + let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + std::fs::write(&config_path, formatted).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn remove_coeadapt_config() -> Result<(), String> { + let config_path = claude_config_path().ok_or("Cannot determine Claude config path")?; + if !config_path.exists() { + return Ok(()); + } + + let contents = std::fs::read_to_string(&config_path).map_err(|e| e.to_string())?; + let mut config: serde_json::Value = + serde_json::from_str(&contents).map_err(|e| e.to_string())?; + + if let Some(servers) = config.get_mut("mcpServers") { + if let Some(obj) = servers.as_object_mut() { + obj.remove("coeadapt"); + } + } + + let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + std::fs::write(&config_path, formatted).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn get_claude_status() -> ClaudeStatus { + let installed = is_claude_installed(); + let config_path = claude_config_path().map(|p| p.to_string_lossy().to_string()); + let configured = is_coeadapt_configured(); + + ClaudeStatus { + is_installed: installed, + config_path, + is_configured: configured, + needs_restart: false, + } +} + +pub fn verify_and_repair_config() -> Result { + if !is_claude_installed() { + return Ok(false); + } + if is_coeadapt_configured() { + return Ok(false); // Already configured, no repair needed + } + // Config missing or coeadapt entry removed (e.g., Claude update) + inject_coeadapt_config()?; + Ok(true) // Repaired +} diff --git a/coeadapt-launcher/src-tauri/src/commands.rs b/coeadapt-launcher/src-tauri/src/commands.rs new file mode 100644 index 000000000..dc341e87f --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/commands.rs @@ -0,0 +1,182 @@ +use crate::{claude, container, disk, docker, health, mcp, ssl}; +use std::time::Duration; + +// --- Docker Detection --- + +#[tauri::command] +pub fn detect_container_runtime() -> crate::state::DockerInfo { + docker::get_docker_info().unwrap_or(crate::state::DockerInfo { + runtime: crate::state::ContainerRuntime::None, + version: String::new(), + is_daemon_running: false, + }) +} + +#[tauri::command] +pub fn check_wsl2_status() -> bool { + docker::is_wsl2_enabled() +} + +// --- Disk Space --- + +#[tauri::command] +pub fn check_disk_space() -> crate::state::DiskStatus { + disk::check_disk_space() +} + +#[tauri::command] +pub fn get_docker_disk_usage() -> Result { + disk::get_docker_disk_usage() +} + +#[tauri::command] +pub fn prune_docker_images() -> Result { + container::prune_images() +} + +// --- Container Lifecycle --- + +#[tauri::command] +pub fn get_workspace_status() -> crate::state::ContainerStatus { + container::get_container_status() +} + +#[tauri::command] +pub fn check_image_exists() -> bool { + container::image_exists() +} + +#[tauri::command] +pub async fn pull_workspace_image(app: tauri::AppHandle) -> Result<(), String> { + // Run in a blocking thread since docker_pull_streaming uses std::process + let app_clone = app.clone(); + tokio::task::spawn_blocking(move || { + docker::docker_pull_streaming(crate::state::IMAGE_NAME, app_clone) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub fn create_workspace(app: tauri::AppHandle) -> Result { + use tauri_plugin_store::StoreExt; + + let (memory_mb, vnc_password) = if let Ok(store) = app.store("settings.json") { + let mem = store + .get("containerMemoryMb") + .and_then(|v| v.as_u64()) + .unwrap_or(2048); + let pw = store + .get("vncPassword") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "coeadapt".to_string()); + (mem, pw) + } else { + (2048, "coeadapt".to_string()) + }; + + container::create_container_with_config(memory_mb, &vnc_password) +} + +#[tauri::command] +pub fn start_workspace() -> Result<(), String> { + container::start_container() +} + +#[tauri::command] +pub fn stop_workspace() -> Result<(), String> { + container::stop_container() +} + +#[tauri::command] +pub fn reset_workspace() -> Result<(), String> { + container::remove_container()?; + container::remove_workspace_data()?; + Ok(()) +} + +// --- Health --- + +#[tauri::command] +pub async fn wait_for_workspace_ready(app: tauri::AppHandle) -> Result<(), String> { + health::wait_for_workspace(app, Duration::from_secs(120)).await +} + +// --- MCP --- + +#[tauri::command] +pub async fn check_mcp_status() -> bool { + health::check_mcp_health().await +} + +#[tauri::command] +pub async fn get_mcp_health() -> crate::state::McpHealthInfo { + health::get_mcp_health_info().await +} + +#[tauri::command] +pub fn start_mcp(app: tauri::AppHandle) -> Result<(), String> { + mcp::start_mcp_sidecar(&app) +} + +#[tauri::command] +pub fn stop_mcp() -> Result<(), String> { + mcp::stop_mcp_sidecar() +} + +// --- Claude Connection --- + +#[tauri::command] +pub fn get_claude_status() -> crate::state::ClaudeStatus { + claude::get_claude_status() +} + +#[tauri::command] +pub fn configure_claude() -> Result<(), String> { + claude::inject_coeadapt_config() +} + +// --- SSL / Certificate Trust --- + +#[tauri::command] +pub fn check_ssl_trust() -> bool { + ssl::is_ca_installed() +} + +#[tauri::command] +pub fn install_ssl_certificate() -> Result<(), String> { + ssl::install_ca_cert() +} + +#[tauri::command] +pub fn uninstall_ssl_certificate() -> Result<(), String> { + ssl::uninstall_ca_cert() +} + +// --- Workspace Browser --- + +#[tauri::command] +pub fn open_workspace_browser() -> Result<(), String> { + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "https://localhost:6901"]) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("https://localhost:6901") + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg("https://localhost:6901") + .spawn() + .map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/coeadapt-launcher/src-tauri/src/container.rs b/coeadapt-launcher/src-tauri/src/container.rs new file mode 100644 index 000000000..7db40a36d --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/container.rs @@ -0,0 +1,125 @@ +use crate::docker::docker_cmd; +use crate::state::{ContainerState, ContainerStatus, CONTAINER_NAME, IMAGE_NAME, VOLUME_NAME}; + +pub fn get_container_status() -> ContainerStatus { + let result = docker_cmd(&[ + "inspect", + "--format", + "{{.State.Status}}|{{.Id}}|{{.State.StartedAt}}|{{.Config.Image}}", + CONTAINER_NAME, + ]); + + match result { + Ok(output) => { + let parts: Vec<&str> = output.split('|').collect(); + if parts.len() >= 4 { + let state = match parts[0] { + "running" => ContainerState::Running, + "exited" | "dead" => ContainerState::Stopped, + "created" | "restarting" => ContainerState::Starting, + _ => ContainerState::Stopped, + }; + let container_id = Some(parts[1][..12.min(parts[1].len())].to_string()); + let uptime = if state == ContainerState::Running { + Some(parts[2].to_string()) + } else { + None + }; + ContainerStatus { + state, + container_id, + uptime, + image: parts[3].to_string(), + } + } else { + ContainerStatus { + state: ContainerState::Error("Failed to parse container info".to_string()), + container_id: None, + uptime: None, + image: IMAGE_NAME.to_string(), + } + } + } + Err(_) => ContainerStatus { + state: ContainerState::NotFound, + container_id: None, + uptime: None, + image: IMAGE_NAME.to_string(), + }, + } +} + +pub fn create_container() -> Result { + create_container_with_config(2048, "coeadapt") +} + +pub fn create_container_with_config(memory_mb: u64, vnc_password: &str) -> Result { + let memory_flag = format!("--memory={}m", memory_mb); + let vnc_env = format!("VNC_PW={}", vnc_password); + + docker_cmd(&[ + "run", + "-d", + "--name", + CONTAINER_NAME, + "--shm-size=512m", + &memory_flag, + "-p", + "6901:6901", + "-v", + &format!("{}:/home/kasm-user", VOLUME_NAME), + "-e", + &vnc_env, + "--restart", + "unless-stopped", + IMAGE_NAME, + ]) +} + +pub fn start_container() -> Result<(), String> { + docker_cmd(&["start", CONTAINER_NAME])?; + Ok(()) +} + +pub fn stop_container() -> Result<(), String> { + docker_cmd(&["stop", CONTAINER_NAME])?; + Ok(()) +} + +pub fn remove_container() -> Result<(), String> { + // Stop first if running + let _ = docker_cmd(&["stop", CONTAINER_NAME]); + docker_cmd(&["rm", CONTAINER_NAME])?; + Ok(()) +} + +pub fn image_exists() -> bool { + docker_cmd(&["image", "inspect", IMAGE_NAME]).is_ok() +} + +pub fn check_for_image_update() -> Result { + // Get the current image digest + let _current_digest = docker_cmd(&[ + "image", + "inspect", + "--format", + "{{index .RepoDigests 0}}", + IMAGE_NAME, + ]) + .unwrap_or_default(); + + // Pull latest + let pull_output = docker_cmd(&["pull", IMAGE_NAME])?; + + // Check if "Status: Image is up to date" is in the output + Ok(!pull_output.contains("Image is up to date")) +} + +pub fn remove_workspace_data() -> Result<(), String> { + docker_cmd(&["volume", "rm", VOLUME_NAME])?; + Ok(()) +} + +pub fn prune_images() -> Result { + docker_cmd(&["image", "prune", "-f"]) +} diff --git a/coeadapt-launcher/src-tauri/src/disk.rs b/coeadapt-launcher/src-tauri/src/disk.rs new file mode 100644 index 000000000..13c7693d0 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/disk.rs @@ -0,0 +1,70 @@ +use sysinfo::Disks; + +use crate::docker::docker_cmd; +use crate::state::{DiskStatus, DockerDiskUsage}; + +pub fn check_disk_space() -> DiskStatus { + let disks = Disks::new_with_refreshed_list(); + + for disk in disks.list() { + let mount = disk.mount_point().to_str().unwrap_or(""); + + // On Windows, check C:\. On macOS/Linux, check / + let is_target = if cfg!(target_os = "windows") { + mount.starts_with("C:") + } else { + mount == "/" + }; + + if is_target { + let available_gb = disk.available_space() as f64 / 1_073_741_824.0; + let total_gb = disk.total_space() as f64 / 1_073_741_824.0; + return DiskStatus { + available_gb: (available_gb * 10.0).round() / 10.0, + total_gb: (total_gb * 10.0).round() / 10.0, + meets_minimum: available_gb >= 15.0, + meets_recommended: available_gb >= 25.0, + is_low: available_gb < 5.0, + }; + } + } + + // Fallback if we can't determine + DiskStatus { + available_gb: 100.0, + total_gb: 500.0, + meets_minimum: true, + meets_recommended: true, + is_low: false, + } +} + +pub fn get_docker_disk_usage() -> Result { + let output = docker_cmd(&["system", "df", "--format", "{{.Type}}\t{{.Size}}"])?; + + let mut images_size = String::from("0B"); + let mut containers_size = String::from("0B"); + let mut volumes_size = String::from("0B"); + + for line in output.lines() { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 2 { + match parts[0] { + "Images" => images_size = parts[1].to_string(), + "Containers" => containers_size = parts[1].to_string(), + "Local Volumes" => volumes_size = parts[1].to_string(), + _ => {} + } + } + } + + Ok(DockerDiskUsage { + total_size: format!( + "Images: {}, Containers: {}, Volumes: {}", + images_size, containers_size, volumes_size + ), + images_size, + containers_size, + volumes_size, + }) +} diff --git a/coeadapt-launcher/src-tauri/src/docker.rs b/coeadapt-launcher/src-tauri/src/docker.rs new file mode 100644 index 000000000..7b214f040 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/docker.rs @@ -0,0 +1,155 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use tauri::Emitter; + +use crate::state::{ContainerRuntime, DockerInfo, PullProgress}; + +pub fn detect_runtime() -> ContainerRuntime { + if docker_cmd(&["info"]).is_ok() { + ContainerRuntime::Docker + } else if Command::new("podman") + .arg("info") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + ContainerRuntime::Podman + } else { + ContainerRuntime::None + } +} + +pub fn get_docker_info() -> Result { + let runtime = detect_runtime(); + match runtime { + ContainerRuntime::None => Ok(DockerInfo { + runtime: ContainerRuntime::None, + version: String::new(), + is_daemon_running: false, + }), + _ => { + let version = docker_cmd(&["version", "--format", "{{.Server.Version}}"]) + .unwrap_or_else(|_| "unknown".to_string()); + Ok(DockerInfo { + runtime: runtime.clone(), + version, + is_daemon_running: runtime != ContainerRuntime::None, + }) + } + } +} + +pub fn docker_cmd(args: &[&str]) -> Result { + let output = Command::new("docker") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("Failed to execute docker: {}", e))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +pub fn docker_pull_streaming( + image: &str, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let mut child = Command::new("docker") + .args(["pull", image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn docker pull: {}", e))?; + + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + + for line in reader.lines() { + if let Ok(line) = line { + let progress = parse_pull_line(&line); + let _ = app_handle.emit("docker-pull-progress", &progress); + } + } + + let status = child.wait().map_err(|e| e.to_string())?; + if !status.success() { + return Err("Image pull failed".to_string()); + } + Ok(()) +} + +fn parse_pull_line(line: &str) -> PullProgress { + // Docker pull output looks like: + // "abc123: Pulling fs layer" + // "abc123: Downloading [===> ] 12.5MB/100MB" + // "abc123: Pull complete" + // "Digest: sha256:..." + // "Status: Downloaded newer image for ..." + let percent = if line.contains("Pull complete") || line.contains("Already exists") { + 100.0 + } else if line.contains('/') && (line.contains("Downloading") || line.contains("Extracting")) { + // Try to parse "12.5MB/100MB" style progress + if let Some(bracket_start) = line.find(']') { + if let Some(sizes) = line[bracket_start..].split_whitespace().nth(1) { + let parts: Vec<&str> = sizes.split('/').collect(); + if parts.len() == 2 { + let current = parse_size(parts[0]); + let total = parse_size(parts[1]); + if total > 0.0 { + return PullProgress { + status: line.to_string(), + progress: Some(sizes.to_string()), + percent: (current / total * 100.0).min(100.0), + }; + } + } + } + } + -1.0 + } else { + -1.0 + }; + + PullProgress { + status: line.to_string(), + progress: None, + percent, + } +} + +fn parse_size(s: &str) -> f64 { + let s = s.trim(); + if let Some(num) = s.strip_suffix("GB") { + num.parse::().unwrap_or(0.0) * 1024.0 + } else if let Some(num) = s.strip_suffix("MB") { + num.parse::().unwrap_or(0.0) + } else if let Some(num) = s.strip_suffix("kB") { + num.parse::().unwrap_or(0.0) / 1024.0 + } else if let Some(num) = s.strip_suffix("B") { + num.parse::().unwrap_or(0.0) / 1024.0 / 1024.0 + } else { + s.parse::().unwrap_or(0.0) + } +} + +#[cfg(target_os = "windows")] +pub fn is_wsl2_enabled() -> bool { + Command::new("wsl") + .args(["--list", "--verbose"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "windows"))] +pub fn is_wsl2_enabled() -> bool { + true // Not applicable on non-Windows +} diff --git a/coeadapt-launcher/src-tauri/src/health.rs b/coeadapt-launcher/src-tauri/src/health.rs new file mode 100644 index 000000000..cfc57d614 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/health.rs @@ -0,0 +1,76 @@ +use std::time::Duration; +use tauri::Emitter; + +pub async fn wait_for_workspace( + app: tauri::AppHandle, + timeout: Duration, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| e.to_string())?; + + let start = std::time::Instant::now(); + let mut attempt = 0u32; + + while start.elapsed() < timeout { + attempt += 1; + let _ = app.emit( + "health-check", + serde_json::json!({ + "attempt": attempt, + "elapsed_secs": start.elapsed().as_secs(), + }), + ); + + match client.get("https://localhost:6901").send().await { + Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => { + let _ = app.emit("workspace-ready", true); + return Ok(()); + } + _ => { + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + + Err(format!( + "Workspace not ready after {}s", + timeout.as_secs() + )) +} + +pub async fn check_mcp_health() -> bool { + get_mcp_health_info().await.is_running +} + +pub async fn get_mcp_health_info() -> crate::state::McpHealthInfo { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .unwrap_or_default(); + + match client.get("http://127.0.0.1:3100/health").send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(body) = resp.json::().await { + crate::state::McpHealthInfo { + is_running: true, + last_tool_call: body.get("lastToolCall").and_then(|v| v.as_u64()), + uptime_secs: body.get("uptime").and_then(|v| v.as_f64()), + } + } else { + crate::state::McpHealthInfo { + is_running: true, + last_tool_call: None, + uptime_secs: None, + } + } + } + _ => crate::state::McpHealthInfo { + is_running: false, + last_tool_call: None, + uptime_secs: None, + }, + } +} diff --git a/coeadapt-launcher/src-tauri/src/lib.rs b/coeadapt-launcher/src-tauri/src/lib.rs new file mode 100644 index 000000000..684a3de3d --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/lib.rs @@ -0,0 +1,226 @@ +mod claude; +mod commands; +mod container; +mod disk; +mod docker; +mod health; +mod mcp; +mod ssl; +mod state; + +use tauri::{ + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Emitter, Manager, +}; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) + .setup(|app| { + // Build tray menu + let open_workspace = MenuItem::with_id(app, "open_workspace", "Open Workspace", true, None::<&str>)?; + let start = MenuItem::with_id(app, "start", "Start Workspace", true, None::<&str>)?; + let stop = MenuItem::with_id(app, "stop", "Stop Workspace", true, None::<&str>)?; + + let sep1 = PredefinedMenuItem::separator(app)?; + + let ai_status = MenuItem::with_id(app, "ai_status", "AI Copilot: Checking...", false, None::<&str>)?; + let reconnect_claude = MenuItem::with_id(app, "reconnect_claude", "Reconnect to Claude", true, None::<&str>)?; + + let sep2 = PredefinedMenuItem::separator(app)?; + + let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?; + let show_window = MenuItem::with_id(app, "show", "Show Dashboard", true, None::<&str>)?; + + let sep3 = PredefinedMenuItem::separator(app)?; + + let quit = MenuItem::with_id(app, "quit", "Quit Coeadapt", true, None::<&str>)?; + + let menu = Menu::with_items( + app, + &[ + &open_workspace, &start, &stop, + &sep1, + &ai_status, &reconnect_claude, + &sep2, + &settings, &show_window, + &sep3, + &quit, + ], + )?; + + // Clone ai_status for the polling task + let ai_status_item = ai_status.clone(); + + let _tray = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .tooltip("Coeadapt") + .on_menu_event(move |app, event| match event.id.as_ref() { + "open_workspace" => { + let _ = commands::open_workspace_browser(); + } + "start" => { + let status = container::get_container_status(); + match status.state { + state::ContainerState::NotFound => { + let _ = container::create_container(); + } + state::ContainerState::Stopped => { + let _ = container::start_container(); + } + _ => {} + } + } + "stop" => { + let _ = container::stop_container(); + } + "reconnect_claude" => { + let _ = claude::verify_and_repair_config(); + } + "settings" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = app.emit("navigate", "/settings"); + } + } + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "quit" => { + let _ = mcp::stop_mcp_sidecar(); + let _ = container::stop_container(); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + // Dynamic AI status in tray (every 15 seconds) + tauri::async_runtime::spawn(async move { + loop { + let healthy = health::check_mcp_health().await; + let text = if healthy { + "AI Copilot: Connected" + } else { + "AI Copilot: Disconnected" + }; + let _ = ai_status_item.set_text(text); + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + } + }); + + // On launch: verify Claude config + let _ = claude::verify_and_repair_config(); + + // Start MCP sidecar + let handle_for_mcp = app.handle().clone(); + if let Err(e) = mcp::start_mcp_sidecar(&handle_for_mcp) { + eprintln!("[mcp] Warning: Failed to start MCP sidecar: {}", e); + } + + // Auto-start workspace if setting is enabled + { + use tauri_plugin_store::StoreExt; + let handle_for_autostart = app.handle().clone(); + if let Ok(store) = handle_for_autostart.store("settings.json") { + let auto_start = store + .get("autoStartWorkspace") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if auto_start { + tauri::async_runtime::spawn(async move { + // Delay to let Docker daemon initialize + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let status = container::get_container_status(); + match status.state { + crate::state::ContainerState::Stopped => { + let _ = container::start_container(); + } + crate::state::ContainerState::NotFound => { + if container::image_exists() { + let _ = container::create_container(); + } + } + _ => {} + } + }); + } + } + } + + // Start disk monitoring (every 30 min) + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + let status = disk::check_disk_space(); + if status.is_low { + let _ = handle.emit("disk-warning", &status); + } + tokio::time::sleep(std::time::Duration::from_secs(1800)).await; + } + }); + + Ok(()) + }) + .on_window_event(|window, event| { + // Hide to tray on close instead of quitting + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let _ = window.hide(); + api.prevent_close(); + } + }) + .invoke_handler(tauri::generate_handler![ + commands::detect_container_runtime, + commands::check_wsl2_status, + commands::check_disk_space, + commands::get_docker_disk_usage, + commands::prune_docker_images, + commands::get_workspace_status, + commands::check_image_exists, + commands::pull_workspace_image, + commands::create_workspace, + commands::start_workspace, + commands::stop_workspace, + commands::reset_workspace, + commands::wait_for_workspace_ready, + commands::check_mcp_status, + commands::get_mcp_health, + commands::get_claude_status, + commands::configure_claude, + commands::open_workspace_browser, + commands::check_ssl_trust, + commands::install_ssl_certificate, + commands::uninstall_ssl_certificate, + commands::start_mcp, + commands::stop_mcp, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/coeadapt-launcher/src-tauri/src/main.rs b/coeadapt-launcher/src-tauri/src/main.rs new file mode 100644 index 000000000..fa63e10fc --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + coeadapt_launcher_lib::run() +} diff --git a/coeadapt-launcher/src-tauri/src/mcp.rs b/coeadapt-launcher/src-tauri/src/mcp.rs new file mode 100644 index 000000000..e9f1b1586 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/mcp.rs @@ -0,0 +1,101 @@ +use std::sync::Mutex; +use tauri::Manager; +use tauri_plugin_shell::ShellExt; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_store::StoreExt; + +/// Global singleton holding the running sidecar child process. +/// Option so we can .take() it when killing (kill consumes self). +static MCP_CHILD: Mutex> = Mutex::new(None); + +/// Spawn the MCP sidecar binary. Idempotent — does nothing if already running. +pub fn start_mcp_sidecar(app: &tauri::AppHandle) -> Result<(), String> { + let mut guard = MCP_CHILD.lock().map_err(|e| e.to_string())?; + + // Already have a child handle — assume it's still running + if guard.is_some() { + return Ok(()); + } + + // Read device token and API URL from Tauri store for Navi API access + let device_token = app + .store("auth.json") + .ok() + .and_then(|store| store.get("deviceToken")) + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(); + + let api_url = std::env::var("COEADAPT_API_URL") + .unwrap_or_else(|_| "https://api.coeadapt.com".to_string()); + + let sidecar_command = app + .shell() + .sidecar("coeadapt-mcp") + .map_err(|e| format!("Failed to create sidecar command: {}", e))? + .env("COEADAPT_DEVICE_TOKEN", &device_token) + .env("COEADAPT_API_URL", &api_url); + + let (mut rx, child) = sidecar_command + .spawn() + .map_err(|e| format!("Failed to spawn MCP sidecar: {}", e))?; + + eprintln!("[mcp] Sidecar started (pid {})", child.pid()); + + *guard = Some(child); + drop(guard); // Release lock before spawning the reader task + + // Drain stdout/stderr and auto-restart on termination + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + let line = String::from_utf8_lossy(&bytes); + eprintln!("[mcp-stdout] {}", line.trim()); + } + CommandEvent::Stderr(bytes) => { + let line = String::from_utf8_lossy(&bytes); + eprintln!("[mcp-stderr] {}", line.trim()); + } + CommandEvent::Terminated(payload) => { + eprintln!( + "[mcp] Sidecar terminated (code: {:?}, signal: {:?})", + payload.code, payload.signal + ); + // Clear the stored child + if let Ok(mut guard) = MCP_CHILD.lock() { + *guard = None; + } + // Auto-restart after a short delay + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + eprintln!("[mcp] Auto-restarting sidecar..."); + if let Err(e) = start_mcp_sidecar(&app_handle) { + eprintln!("[mcp] Auto-restart failed: {}", e); + } + break; + } + _ => {} + } + } + }); + + Ok(()) +} + +/// Stop the MCP sidecar. Idempotent — does nothing if not running. +pub fn stop_mcp_sidecar() -> Result<(), String> { + let mut guard = MCP_CHILD.lock().map_err(|e| e.to_string())?; + if let Some(child) = guard.take() { + child.kill().map_err(|e| format!("Failed to kill MCP sidecar: {}", e))?; + eprintln!("[mcp] Sidecar stopped"); + } + Ok(()) +} + +/// Check if the MCP sidecar child handle is present. +pub fn is_mcp_running() -> bool { + MCP_CHILD + .lock() + .map(|guard| guard.is_some()) + .unwrap_or(false) +} diff --git a/coeadapt-launcher/src-tauri/src/ssl.rs b/coeadapt-launcher/src-tauri/src/ssl.rs new file mode 100644 index 000000000..805bd0af9 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/ssl.rs @@ -0,0 +1,235 @@ +use std::path::PathBuf; +use std::process::Command; + +use crate::docker::docker_cmd; +use crate::state::CONTAINER_NAME; + +const CA_CONTAINER_PATH: &str = "/usr/share/coeadapt/ca.crt"; +const CA_CERT_FILENAME: &str = "coeadapt-workspace-ca.crt"; +const CA_SUBJECT_NAME: &str = "Coeadapt Workspace CA"; + +/// Local directory where we store the extracted CA cert on the host. +fn ca_data_dir() -> PathBuf { + let dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("coeadapt-launcher"); + std::fs::create_dir_all(&dir).ok(); + dir +} + +/// Full path to the CA cert on the host. +fn ca_cert_path() -> PathBuf { + ca_data_dir().join(CA_CERT_FILENAME) +} + +/// Extract the CA certificate from the running container to the host. +pub fn extract_ca_cert() -> Result { + let dest = ca_cert_path(); + let dest_str = dest.to_str().ok_or("Invalid path")?; + let src = format!("{}:{}", CONTAINER_NAME, CA_CONTAINER_PATH); + + docker_cmd(&["cp", &src, dest_str])?; + + if dest.exists() { + Ok(dest) + } else { + Err("CA cert was not extracted".to_string()) + } +} + +/// Check whether the Coeadapt CA is already trusted by the host OS. +pub fn is_ca_installed() -> bool { + #[cfg(target_os = "windows")] + return is_ca_installed_windows(); + + #[cfg(target_os = "macos")] + return is_ca_installed_macos(); + + #[cfg(target_os = "linux")] + return is_ca_installed_linux(); + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + return false; +} + +/// Install the CA certificate into the host OS trust store. +/// On Windows this triggers a security confirmation dialog. +pub fn install_ca_cert() -> Result<(), String> { + let cert_path = ca_cert_path(); + + // Extract first if not already on disk + if !cert_path.exists() { + extract_ca_cert()?; + } + + #[cfg(any(target_os = "windows", target_os = "macos"))] + let cert_str = cert_path.to_str().ok_or("Invalid cert path")?; + + #[cfg(target_os = "windows")] + return install_ca_windows(cert_str); + + #[cfg(target_os = "macos")] + return install_ca_macos(cert_str); + + #[cfg(target_os = "linux")] + return install_ca_linux(&cert_path); + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + return Err("Unsupported platform".to_string()); +} + +/// Remove the Coeadapt CA from the host trust store. +pub fn uninstall_ca_cert() -> Result<(), String> { + #[cfg(target_os = "windows")] + uninstall_ca_windows()?; + + #[cfg(target_os = "macos")] + uninstall_ca_macos()?; + + #[cfg(target_os = "linux")] + uninstall_ca_linux(); + + // Remove local copy + let _ = std::fs::remove_file(ca_cert_path()); + Ok(()) +} + +// --- Platform-specific implementations --- + +#[cfg(target_os = "windows")] +fn is_ca_installed_windows() -> bool { + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!( + "Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object {{ $_.Subject -like '*{}*' }} | Measure-Object | Select-Object -ExpandProperty Count", + CA_SUBJECT_NAME + ), + ]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let count = String::from_utf8_lossy(&out.stdout).trim().to_string(); + count != "0" + } + _ => false, + } +} + +#[cfg(target_os = "windows")] +fn install_ca_windows(cert_str: &str) -> Result<(), String> { + let status = Command::new("certutil") + .args(["-addstore", "-user", "Root", cert_str]) + .status() + .map_err(|e| format!("Failed to run certutil: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err("Certificate installation failed. Please accept the security prompt.".to_string()) + } +} + +#[cfg(target_os = "windows")] +fn uninstall_ca_windows() -> Result<(), String> { + let status = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!( + "Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object {{ $_.Subject -like '*{}*' }} | Remove-Item", + CA_SUBJECT_NAME + ), + ]) + .status() + .map_err(|e| format!("Failed to remove CA: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to remove CA certificate from trust store".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn is_ca_installed_macos() -> bool { + let output = Command::new("security") + .args(["find-certificate", "-c", CA_SUBJECT_NAME, "-a"]) + .output(); + + matches!(output, Ok(out) if out.status.success()) +} + +#[cfg(target_os = "macos")] +fn install_ca_macos(cert_str: &str) -> Result<(), String> { + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + let keychain = format!("{}/Library/Keychains/login.keychain-db", home); + + let status = Command::new("security") + .args([ + "add-trusted-cert", + "-r", + "trustRoot", + "-k", + &keychain, + cert_str, + ]) + .status() + .map_err(|e| format!("Failed to install CA: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err("Certificate installation failed".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn uninstall_ca_macos() -> Result<(), String> { + let cert_path = ca_cert_path(); + if cert_path.exists() { + if let Some(cert_str) = cert_path.to_str() { + let _ = Command::new("security") + .args(["remove-trusted-cert", cert_str]) + .status(); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn is_ca_installed_linux() -> bool { + std::path::Path::new(&format!( + "/usr/local/share/ca-certificates/{}", + CA_CERT_FILENAME + )) + .exists() +} + +#[cfg(target_os = "linux")] +fn install_ca_linux(cert_path: &std::path::Path) -> Result<(), String> { + let dest = format!("/usr/local/share/ca-certificates/{}", CA_CERT_FILENAME); + let cert_str = cert_path.to_str().unwrap_or_default(); + + // Use pkexec for privilege elevation (shows a graphical password prompt) + let status = Command::new("pkexec") + .args(["bash", "-c", &format!("cp '{}' '{}' && update-ca-certificates", cert_str, dest)]) + .status() + .map_err(|e| format!("Failed to install CA cert: {}", e))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to install CA cert (authentication required)".to_string()) + } +} + +#[cfg(target_os = "linux")] +fn uninstall_ca_linux() { + let sys_cert = format!("/usr/local/share/ca-certificates/{}", CA_CERT_FILENAME); + let _ = std::fs::remove_file(&sys_cert); + let _ = Command::new("update-ca-certificates").status(); +} diff --git a/coeadapt-launcher/src-tauri/src/state.rs b/coeadapt-launcher/src-tauri/src/state.rs new file mode 100644 index 000000000..5d9060ed4 --- /dev/null +++ b/coeadapt-launcher/src-tauri/src/state.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ContainerRuntime { + Docker, + Podman, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerInfo { + pub runtime: ContainerRuntime, + pub version: String, + pub is_daemon_running: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ContainerState { + NotFound, + Running, + Stopped, + Starting, + Pulling, + Error(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContainerStatus { + pub state: ContainerState, + pub container_id: Option, + pub uptime: Option, + pub image: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiskStatus { + pub available_gb: f64, + pub total_gb: f64, + pub meets_minimum: bool, + pub meets_recommended: bool, + pub is_low: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerDiskUsage { + pub images_size: String, + pub containers_size: String, + pub volumes_size: String, + pub total_size: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeStatus { + pub is_installed: bool, + pub config_path: Option, + pub is_configured: bool, + pub needs_restart: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullProgress { + pub status: String, + pub progress: Option, + pub percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpHealthInfo { + pub is_running: bool, + pub last_tool_call: Option, + pub uptime_secs: Option, +} + +pub const CONTAINER_NAME: &str = "coeadapt-workspace"; +pub const IMAGE_NAME: &str = "coeadapt/workspace:latest"; +pub const VOLUME_NAME: &str = "coeadapt-data"; diff --git a/coeadapt-launcher/src-tauri/tauri.conf.json b/coeadapt-launcher/src-tauri/tauri.conf.json new file mode 100644 index 000000000..3131b8695 --- /dev/null +++ b/coeadapt-launcher/src-tauri/tauri.conf.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Coeadapt", + "version": "0.1.0", + "identifier": "com.coeadapt.launcher", + "build": { + "beforeDevCommand": "bun run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "bun run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Coeadapt", + "width": 800, + "height": 600, + "resizable": true, + "visible": true + } + ], + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true, + "tooltip": "Coeadapt" + }, + "security": { + "csp": "default-src 'self'; connect-src 'self' https://localhost:6901 http://127.0.0.1:3100 https://api.coeadapt.com http://localhost:5000 https://*.clerk.accounts.dev https://*.clerk.com; img-src 'self' data: https://*.clerk.accounts.dev https://*.clerk.com https://img.clerk.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; frame-src https://*.clerk.accounts.dev https://*.clerk.com; script-src 'self' https://*.clerk.accounts.dev https://*.clerk.com" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "binaries/coeadapt-mcp" + ] + }, + "plugins": { + "updater": { + "endpoints": [ + "https://releases.coeadapt.com/tauri/{{target}}/{{arch}}/{{current_version}}" + ], + "pubkey": "REPLACE_WITH_REAL_PUBKEY_BEFORE_RELEASE" + } + } +} diff --git a/coeadapt-launcher/src/App.css b/coeadapt-launcher/src/App.css new file mode 100644 index 000000000..e0598b63e --- /dev/null +++ b/coeadapt-launcher/src/App.css @@ -0,0 +1 @@ +/* Unused - styles are in index.css with Tailwind */ diff --git a/coeadapt-launcher/src/App.tsx b/coeadapt-launcher/src/App.tsx new file mode 100644 index 000000000..c333044d6 --- /dev/null +++ b/coeadapt-launcher/src/App.tsx @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom"; +import { useAuth } from "@clerk/clerk-react"; +import { STANDALONE_MODE } from "./lib/mode"; +import { DiskWarningBanner } from "./components/DiskWarningBanner"; +import { AuthGuard } from "./components/AuthGuard"; +import { safeListen } from "./lib/tauri"; +import { setAuthProvider } from "./lib/api"; +import { useDeviceToken } from "./hooks/useDeviceToken"; +import Login from "./pages/Login"; +import Setup from "./pages/Setup"; +import Dashboard from "./pages/Dashboard"; +import ClaudeSetup from "./pages/ClaudeSetup"; +import Settings from "./pages/Settings"; +import Chat from "./pages/Chat"; + +/** Listens for "navigate" events from the Rust tray menu and routes accordingly. */ +function TrayNavigationListener() { + const navigate = useNavigate(); + useEffect(() => { + const unlisten = safeListen("navigate", (event) => { + navigate(event.payload); + }); + return () => { unlisten.then((fn) => fn()); }; + }, [navigate]); + return null; +} + +/** Wires Clerk auth into the API client and auto-generates device token. */ +function AuthWiring() { + const { getToken, isSignedIn } = useAuth(); + useDeviceToken(); // Auto-generates and stores device token after sign-in + + useEffect(() => { + if (isSignedIn) { + setAuthProvider(getToken); + } + }, [isSignedIn, getToken]); + + return null; +} + +function App() { + return ( + + + {!STANDALONE_MODE && } +
+ + + {!STANDALONE_MODE && } />} + } /> + } /> + } /> + } /> + {!STANDALONE_MODE && } />} + } /> + +
+
+ ); +} + +export default App; diff --git a/coeadapt-launcher/src/assets/react.svg b/coeadapt-launcher/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/coeadapt-launcher/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/coeadapt-launcher/src/components/AuthGuard.tsx b/coeadapt-launcher/src/components/AuthGuard.tsx new file mode 100644 index 000000000..0e24d3560 --- /dev/null +++ b/coeadapt-launcher/src/components/AuthGuard.tsx @@ -0,0 +1,32 @@ +import { useAuth } from "@clerk/clerk-react"; +import { Navigate } from "react-router-dom"; +import { STANDALONE_MODE } from "../lib/mode"; +import { Spinner } from "./Spinner"; + +/** + * In CoeAdapt mode, delegates to ClerkAuthGuard which uses the useAuth hook. + * In standalone mode, renders children immediately with no auth check. + */ +export function AuthGuard({ children }: { children: React.ReactNode }) { + if (STANDALONE_MODE) return <>{children}; + return {children}; +} + +/** Separated component so useAuth hook is always called (Rules of Hooks). */ +function ClerkAuthGuard({ children }: { children: React.ReactNode }) { + const { isSignedIn, isLoaded } = useAuth(); + + if (!isLoaded) { + return ( +
+ +
+ ); + } + + if (!isSignedIn) { + return ; + } + + return <>{children}; +} diff --git a/coeadapt-launcher/src/components/DiskUsage.tsx b/coeadapt-launcher/src/components/DiskUsage.tsx new file mode 100644 index 000000000..f3072dbb8 --- /dev/null +++ b/coeadapt-launcher/src/components/DiskUsage.tsx @@ -0,0 +1,33 @@ +import type { DiskStatus } from "../lib/types"; + +interface Props { + status: DiskStatus; +} + +export function DiskUsage({ status }: Props) { + const usedGb = status.total_gb - status.available_gb; + const usedPercent = (usedGb / status.total_gb) * 100; + + const barColor = status.is_low + ? "bg-danger" + : !status.meets_recommended + ? "bg-warning" + : "bg-success"; + + return ( +
+
+ Storage + + {status.available_gb} GB free of {status.total_gb} GB + +
+
+
+
+
+ ); +} diff --git a/coeadapt-launcher/src/components/DiskWarningBanner.tsx b/coeadapt-launcher/src/components/DiskWarningBanner.tsx new file mode 100644 index 000000000..42a83e9fc --- /dev/null +++ b/coeadapt-launcher/src/components/DiskWarningBanner.tsx @@ -0,0 +1,28 @@ +import { useDiskSpace } from "../hooks/useDiskSpace"; + +export function DiskWarningBanner() { + const { status, showWarning, dismissWarning } = useDiskSpace(); + + if (!showWarning || !status) return null; + + return ( +
+
+ + + + + Low disk space ({status.available_gb} GB remaining) + +
+ +
+ ); +} diff --git a/coeadapt-launcher/src/components/ProgressBar.tsx b/coeadapt-launcher/src/components/ProgressBar.tsx new file mode 100644 index 000000000..787682f41 --- /dev/null +++ b/coeadapt-launcher/src/components/ProgressBar.tsx @@ -0,0 +1,32 @@ +interface Props { + percent: number; + label?: string; + indeterminate?: boolean; +} + +export function ProgressBar({ percent, label, indeterminate }: Props) { + return ( +
+ {label && ( +
+ {label} + {!indeterminate && ( + + {Math.round(percent)}% + + )} +
+ )} +
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/coeadapt-launcher/src/components/ProgressCard.tsx b/coeadapt-launcher/src/components/ProgressCard.tsx new file mode 100644 index 000000000..c33bcda1c --- /dev/null +++ b/coeadapt-launcher/src/components/ProgressCard.tsx @@ -0,0 +1,107 @@ +import type { ProgressSummary, AgentHealthInfo } from "../lib/types"; +import { ProgressBar } from "./ProgressBar"; +import { StatusIndicator } from "./StatusIndicator"; + +interface ProgressCardProps { + summary: ProgressSummary | null; + agentHealth: AgentHealthInfo | null; + loading: boolean; +} + +export function ProgressCard({ summary, agentHealth, loading }: ProgressCardProps) { + if (loading && !summary) { + return ( +
+
+
+ + + +
+
+

Career Progress

+

Loading...

+
+
+
+ ); + } + + if (!summary) { + return null; + } + + const hasActivity = summary.total_activities > 0 || summary.total_goals > 0; + + return ( +
+ {/* Header */} +
+
+ + + +
+
+

Career Progress

+ {hasActivity ? ( +

+ {summary.progress_percent}% complete +

+ ) : ( +

Get started with your first activity

+ )} +
+ {summary.streak_days > 0 && ( +
+ {summary.streak_days} +

day streak

+
+ )} +
+ + {/* Progress bar */} + {hasActivity && ( + + )} + + {/* Stats grid */} +
+ + + + +
+ + {/* Agent services status (compact) */} + {agentHealth && ( +
+ + +
+ )} + + {/* Empty state */} + {!hasActivity && ( +

+ Use the AI copilot to log activities, set goals, and track your career development. +

+ )} +
+ ); +} + +function StatBox({ label, value }: { label: string; value: string | number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/coeadapt-launcher/src/components/Spinner.tsx b/coeadapt-launcher/src/components/Spinner.tsx new file mode 100644 index 000000000..bf7a12f58 --- /dev/null +++ b/coeadapt-launcher/src/components/Spinner.tsx @@ -0,0 +1,22 @@ +export function Spinner({ size = "md" }: { size?: "sm" | "md" | "lg" }) { + const dims = { sm: "w-5 h-5", md: "w-8 h-8", lg: "w-12 h-12" }; + return ( +
+ + + + + + + + + + +
+ ); +} diff --git a/coeadapt-launcher/src/components/StatusIndicator.tsx b/coeadapt-launcher/src/components/StatusIndicator.tsx new file mode 100644 index 000000000..9b896be13 --- /dev/null +++ b/coeadapt-launcher/src/components/StatusIndicator.tsx @@ -0,0 +1,25 @@ +interface Props { + status: "running" | "starting" | "stopped" | "error"; + label: string; +} + +export function StatusIndicator({ status, label }: Props) { + const dotColor = { + running: "bg-success", + starting: "bg-warning animate-pulse", + stopped: "bg-surface-500", + error: "bg-danger", + }; + + return ( +
+ + {status === "running" && ( + + )} + + + {label} +
+ ); +} diff --git a/coeadapt-launcher/src/components/ToggleSwitch.tsx b/coeadapt-launcher/src/components/ToggleSwitch.tsx new file mode 100644 index 000000000..a9c5707a9 --- /dev/null +++ b/coeadapt-launcher/src/components/ToggleSwitch.tsx @@ -0,0 +1,33 @@ +interface Props { + label: string; + description?: string; + checked: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; +} + +export function ToggleSwitch({ label, description, checked, onChange, disabled }: Props) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ +
+ ); +} diff --git a/coeadapt-launcher/src/components/WorkspaceControls.tsx b/coeadapt-launcher/src/components/WorkspaceControls.tsx new file mode 100644 index 000000000..db3317309 --- /dev/null +++ b/coeadapt-launcher/src/components/WorkspaceControls.tsx @@ -0,0 +1,58 @@ +interface Props { + isRunning: boolean; + isStopped: boolean; + loading: boolean; + onStart: () => void; + onStop: () => void; + onOpen: () => void; +} + +export function WorkspaceControls({ + isRunning, + isStopped, + loading, + onStart, + onStop, + onOpen, +}: Props) { + return ( +
+ {isRunning && ( + <> + + + + )} + {isStopped && ( + + )} +
+ ); +} diff --git a/coeadapt-launcher/src/hooks/useClaudeConnection.ts b/coeadapt-launcher/src/hooks/useClaudeConnection.ts new file mode 100644 index 000000000..41f508332 --- /dev/null +++ b/coeadapt-launcher/src/hooks/useClaudeConnection.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, useCallback } from "react"; +import { tauri } from "../lib/tauri"; +import type { ClaudeStatus, McpHealthInfo } from "../lib/types"; + +export function useClaudeConnection() { + const [status, setStatus] = useState(null); + const [mcpConnected, setMcpConnected] = useState(false); + const [mcpHealth, setMcpHealth] = useState(null); + const [configuring, setConfiguring] = useState(false); + + const refresh = useCallback(async () => { + try { + const claudeStatus = await tauri.getClaudeStatus(); + setStatus(claudeStatus); + const health = await tauri.getMcpHealth(); + setMcpConnected(health.is_running); + setMcpHealth(health); + } catch { + // Ignore + } + }, []); + + useEffect(() => { + refresh(); + const interval = setInterval(refresh, 30000); + return () => clearInterval(interval); + }, [refresh]); + + const configureClaude = useCallback(async () => { + setConfiguring(true); + try { + await tauri.configureClaude(); + await refresh(); + } catch { + // Ignore + } finally { + setConfiguring(false); + } + }, [refresh]); + + // MCP is connected but no tool calls for 5+ minutes + const isIdle = + mcpConnected && + mcpHealth?.last_tool_call != null && + Date.now() - mcpHealth.last_tool_call > 5 * 60 * 1000; + + return { status, mcpConnected, mcpHealth, isIdle, configuring, configureClaude, refresh }; +} diff --git a/coeadapt-launcher/src/hooks/useContainer.ts b/coeadapt-launcher/src/hooks/useContainer.ts new file mode 100644 index 000000000..b4cf6410e --- /dev/null +++ b/coeadapt-launcher/src/hooks/useContainer.ts @@ -0,0 +1,141 @@ +import { useState, useEffect, useCallback } from "react"; +import { tauri, safeListen } from "../lib/tauri"; +import type { ContainerStatus, PullProgress } from "../lib/types"; + +export function useContainer() { + const [status, setStatus] = useState(null); + const [pullProgress, setPullProgress] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [sslTrusted, setSslTrusted] = useState(null); + const [sslInstalling, setSslInstalling] = useState(false); + + const refresh = useCallback(async () => { + try { + const s = await tauri.getWorkspaceStatus(); + setStatus(s); + setError(null); + } catch { + // Not in Tauri or command failed + } + }, []); + + const checkSsl = useCallback(async () => { + try { + const trusted = await tauri.checkSslTrust(); + setSslTrusted(trusted); + return trusted; + } catch { + setSslTrusted(null); + return false; + } + }, []); + + useEffect(() => { + refresh(); + + const unlistenPull = safeListen("docker-pull-progress", (event) => { + setPullProgress(event.payload); + }); + const unlistenReady = safeListen("workspace-ready", () => { + refresh(); + checkSsl(); + }); + + const interval = setInterval(refresh, 10000); + + return () => { + unlistenPull.then((fn) => fn()); + unlistenReady.then((fn) => fn()); + clearInterval(interval); + }; + }, [refresh, checkSsl]); + + const pullImage = useCallback(async () => { + setLoading(true); + setError(null); + try { + await tauri.pullWorkspaceImage(); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + setPullProgress(null); + refresh(); + } + }, [refresh]); + + const createWorkspace = useCallback(async () => { + setLoading(true); + setError(null); + try { + await tauri.createWorkspace(); + refresh(); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [refresh]); + + const startWorkspace = useCallback(async () => { + setLoading(true); + setError(null); + try { + await tauri.startWorkspace(); + refresh(); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [refresh]); + + const stopWorkspace = useCallback(async () => { + setLoading(true); + try { + await tauri.stopWorkspace(); + refresh(); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [refresh]); + + const openWorkspace = useCallback(async () => { + try { + await tauri.openWorkspaceBrowser(); + } catch (e) { + setError(String(e)); + } + }, []); + + const installSslCertificate = useCallback(async () => { + setSslInstalling(true); + setError(null); + try { + await tauri.installSslCertificate(); + setSslTrusted(true); + } catch (e) { + setError(String(e)); + } finally { + setSslInstalling(false); + } + }, []); + + const isRunning = status?.state === "Running"; + const isStopped = status?.state === "Stopped" || status?.state === "NotFound"; + + // Check SSL trust when container becomes running + useEffect(() => { + if (isRunning) checkSsl(); + }, [isRunning, checkSsl]); + + return { + status, pullProgress, loading, error, isRunning, isStopped, + sslTrusted, sslInstalling, + pullImage, createWorkspace, startWorkspace, stopWorkspace, openWorkspace, refresh, + installSslCertificate, + }; +} diff --git a/coeadapt-launcher/src/hooks/useDeviceToken.ts b/coeadapt-launcher/src/hooks/useDeviceToken.ts new file mode 100644 index 000000000..e02f759f1 --- /dev/null +++ b/coeadapt-launcher/src/hooks/useDeviceToken.ts @@ -0,0 +1,72 @@ +import { useAuth } from "@clerk/clerk-react"; +import { useState, useEffect, useCallback } from "react"; +import { api, setDeviceToken } from "../lib/api"; + +export function useDeviceToken() { + const { isSignedIn } = useAuth(); + const [deviceToken, setDeviceTokenState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadOrGenerate = useCallback(async () => { + if (!isSignedIn) return; + setLoading(true); + setError(null); + + try { + // Try to load existing token from Tauri store + const { Store } = await import("@tauri-apps/plugin-store"); + const store = await Store.load("auth.json"); + const existing = await store.get("deviceToken"); + + if (existing) { + // Verify it's still valid + setDeviceToken(existing); + try { + const result = await api.verifyToken(); + if (result.valid) { + setDeviceTokenState(existing); + setLoading(false); + return; + } + } catch { + // Token invalid, fall through to generate new one + } + } + + // Generate new device token + const hostname = `Career-Box-${Date.now().toString(36)}`; + const result = await api.generateDeviceToken(hostname); + await store.set("deviceToken", result.token); + await store.save(); + setDeviceToken(result.token); + setDeviceTokenState(result.token); + } catch (err: any) { + console.error("Failed to setup device token:", err); + setError(err.message || "Failed to connect to Coeadapt"); + } finally { + setLoading(false); + } + }, [isSignedIn]); + + useEffect(() => { + loadOrGenerate(); + }, [loadOrGenerate]); + + const regenerate = useCallback(async () => { + // Clear existing token and generate fresh + try { + const { Store } = await import("@tauri-apps/plugin-store"); + const store = await Store.load("auth.json"); + await store.delete("deviceToken"); + await store.save(); + } catch { + // Ignore store errors + } + setDeviceTokenState(null); + setDeviceToken(null); + await loadOrGenerate(); + }, [loadOrGenerate]); + + return { deviceToken, loading, error, regenerate }; +} diff --git a/coeadapt-launcher/src/hooks/useDiskSpace.ts b/coeadapt-launcher/src/hooks/useDiskSpace.ts new file mode 100644 index 000000000..e7727bf0c --- /dev/null +++ b/coeadapt-launcher/src/hooks/useDiskSpace.ts @@ -0,0 +1,30 @@ +import { useState, useEffect, useCallback } from "react"; +import { tauri, safeListen } from "../lib/tauri"; +import type { DiskStatus } from "../lib/types"; + +export function useDiskSpace() { + const [status, setStatus] = useState(null); + const [showWarning, setShowWarning] = useState(false); + + const check = useCallback(async () => { + try { + const result = await tauri.checkDiskSpace(); + setStatus(result); + setShowWarning(result.is_low); + } catch { + // Not in Tauri or command failed + } + }, []); + + useEffect(() => { + check(); + const unlisten = safeListen("disk-warning", (event) => { + setStatus(event.payload); + setShowWarning(true); + }); + return () => { unlisten.then((fn) => fn()); }; + }, [check]); + + const dismissWarning = useCallback(() => setShowWarning(false), []); + return { status, showWarning, dismissWarning, refresh: check }; +} diff --git a/coeadapt-launcher/src/hooks/useDocker.ts b/coeadapt-launcher/src/hooks/useDocker.ts new file mode 100644 index 000000000..5f653021a --- /dev/null +++ b/coeadapt-launcher/src/hooks/useDocker.ts @@ -0,0 +1,28 @@ +import { useState, useEffect, useCallback } from "react"; +import { tauri } from "../lib/tauri"; +import type { DockerInfo } from "../lib/types"; + +export function useDocker() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + + const check = useCallback(async () => { + setLoading(true); + try { + const result = await tauri.detectRuntime(); + setInfo(result); + } catch { + setInfo(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + check(); + }, [check]); + + const isAvailable = info?.runtime !== "None" && info?.is_daemon_running; + + return { info, loading, isAvailable, refresh: check }; +} diff --git a/coeadapt-launcher/src/hooks/useNaviChat.ts b/coeadapt-launcher/src/hooks/useNaviChat.ts new file mode 100644 index 000000000..398b93002 --- /dev/null +++ b/coeadapt-launcher/src/hooks/useNaviChat.ts @@ -0,0 +1,135 @@ +import { useState, useCallback, useRef } from "react"; +import { useAuth } from "@clerk/clerk-react"; +import { getDeviceToken } from "../lib/api"; + +const API_BASE = import.meta.env.VITE_COEADAPT_API_URL || "http://localhost:5000"; + +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: string; +} + +export function useNaviChat(threadId: string = "default") { + const { getToken } = useAuth(); + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const getAuthHeader = useCallback(async (): Promise => { + try { + const clerkToken = await getToken(); + if (clerkToken) return `Bearer ${clerkToken}`; + } catch { + // Fall through to device token + } + const deviceToken = getDeviceToken(); + if (deviceToken) return `Bearer ${deviceToken}`; + throw new Error("Not authenticated"); + }, [getToken]); + + const sendMessage = useCallback( + async (text: string) => { + setError(null); + + const userMsg: ChatMessage = { + id: `user-${Date.now()}`, + role: "user", + content: text, + timestamp: new Date().toISOString(), + }; + + const assistantMsg: ChatMessage = { + id: `assistant-${Date.now()}`, + role: "assistant", + content: "", + timestamp: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, userMsg, assistantMsg]); + setIsStreaming(true); + + try { + const authHeader = await getAuthHeader(); + const controller = new AbortController(); + abortRef.current = controller; + + const response = await fetch(`${API_BASE}/api/chatbot/agent/stream?message=${encodeURIComponent(text)}&threadId=${encodeURIComponent(threadId)}`, { + headers: { Authorization: authHeader }, + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || body.message || `HTTP ${response.status}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) throw new Error("No response body"); + + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === "content" || data.content) { + const chunk = data.content || data.text || ""; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + last.content += chunk; + } + return updated; + }); + } else if (data.type === "error") { + setError(data.error || "An error occurred"); + } + } catch { + // Skip malformed SSE lines + } + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + setError(err.message || "Failed to send message"); + // Remove empty assistant message on error + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "assistant" && !last.content) { + return prev.slice(0, -1); + } + return prev; + }); + } + } finally { + setIsStreaming(false); + abortRef.current = null; + } + }, + [getAuthHeader, threadId], + ); + + const stopStreaming = useCallback(() => { + abortRef.current?.abort(); + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + setError(null); + }, []); + + return { messages, isStreaming, error, sendMessage, stopStreaming, clearMessages }; +} diff --git a/coeadapt-launcher/src/hooks/useProgress.ts b/coeadapt-launcher/src/hooks/useProgress.ts new file mode 100644 index 000000000..98c95e0c2 --- /dev/null +++ b/coeadapt-launcher/src/hooks/useProgress.ts @@ -0,0 +1,81 @@ +import { useState, useEffect, useCallback } from "react"; +import type { ProgressSummary, AgentHealthInfo } from "../lib/types"; + +const PROGRESS_POLL_INTERVAL = 30_000; // 30 seconds + +/** + * Fetches progress summary from the in-VM agent via the MCP server health endpoint, + * or falls back to docker exec for direct reads. In both cases, the data comes + * from the progress tracker running inside the Kasm container. + * + * The MCP server is already running on the host at port 3100. We ask it to + * proxy the progress summary request to the container. + */ +async function fetchProgressSummary(): Promise { + try { + // The MCP health endpoint is always available on the host. + // We piggyback a progress fetch by hitting the progress tracker + // inside the container via the MCP server's tool mechanism. + // However, for the dashboard we use a simpler direct approach: + // hit the MCP server's health, then fetch progress via a lightweight + // sidecar endpoint. + const res = await fetch("http://127.0.0.1:3100/progress-summary", { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return res.json(); + } + } catch { + // MCP progress endpoint not available — this is expected before + // we add the proxy route. Return null to show "no data" state. + } + return null; +} + +async function fetchAgentHealth(): Promise { + try { + const res = await fetch("http://127.0.0.1:3100/agent-health", { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + return res.json(); + } + } catch { + // Not available + } + return null; +} + +export function useProgress(isContainerRunning: boolean) { + const [summary, setSummary] = useState(null); + const [agentHealth, setAgentHealth] = useState(null); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!isContainerRunning) { + setSummary(null); + setAgentHealth(null); + return; + } + setLoading(true); + try { + const [summaryData, healthData] = await Promise.all([ + fetchProgressSummary(), + fetchAgentHealth(), + ]); + setSummary(summaryData); + setAgentHealth(healthData); + } finally { + setLoading(false); + } + }, [isContainerRunning]); + + useEffect(() => { + refresh(); + if (!isContainerRunning) return; + const interval = setInterval(refresh, PROGRESS_POLL_INTERVAL); + return () => clearInterval(interval); + }, [refresh, isContainerRunning]); + + return { summary, agentHealth, loading, refresh }; +} diff --git a/coeadapt-launcher/src/hooks/useSettings.ts b/coeadapt-launcher/src/hooks/useSettings.ts new file mode 100644 index 000000000..77d74d60c --- /dev/null +++ b/coeadapt-launcher/src/hooks/useSettings.ts @@ -0,0 +1,112 @@ +import { useState, useEffect, useCallback } from "react"; + +interface Settings { + autoStartApp: boolean; + autoStartWorkspace: boolean; + autoUpdateImage: boolean; + containerMemoryMb: number; + vncPassword: string; +} + +const DEFAULTS: Settings = { + autoStartApp: false, + autoStartWorkspace: false, + autoUpdateImage: false, + containerMemoryMb: 2048, + vncPassword: "coeadapt", +}; + +let storeInstance: Awaited> | null = null; + +async function getStore() { + if (!storeInstance) { + const { Store } = await import("@tauri-apps/plugin-store"); + storeInstance = await Store.load("settings.json", { + defaults: { + autoStartWorkspace: false, + autoUpdateImage: false, + containerMemoryMb: 2048, + vncPassword: "coeadapt", + }, + autoSave: true, + }); + } + return storeInstance; +} + +export function useSettings() { + const [settings, setSettings] = useState(DEFAULTS); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + // Autostart plugin + const { isEnabled } = await import("@tauri-apps/plugin-autostart"); + const autoStartApp = await isEnabled(); + + // Store-backed settings + const store = await getStore(); + const autoStartWorkspace = (await store.get("autoStartWorkspace")) ?? DEFAULTS.autoStartWorkspace; + const autoUpdateImage = (await store.get("autoUpdateImage")) ?? DEFAULTS.autoUpdateImage; + const containerMemoryMb = (await store.get("containerMemoryMb")) ?? DEFAULTS.containerMemoryMb; + const vncPassword = (await store.get("vncPassword")) ?? DEFAULTS.vncPassword; + + setSettings({ autoStartApp, autoStartWorkspace, autoUpdateImage, containerMemoryMb, vncPassword }); + } catch { + // Not in Tauri context + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const setAutoStartApp = useCallback(async (value: boolean) => { + try { + const autostart = await import("@tauri-apps/plugin-autostart"); + if (value) { + await autostart.enable(); + } else { + await autostart.disable(); + } + setSettings((prev) => ({ ...prev, autoStartApp: value })); + } catch { /* ignore */ } + }, []); + + const setAutoStartWorkspace = useCallback(async (value: boolean) => { + const store = await getStore(); + await store.set("autoStartWorkspace", value); + setSettings((prev) => ({ ...prev, autoStartWorkspace: value })); + }, []); + + const setAutoUpdateImage = useCallback(async (value: boolean) => { + const store = await getStore(); + await store.set("autoUpdateImage", value); + setSettings((prev) => ({ ...prev, autoUpdateImage: value })); + }, []); + + const setContainerMemory = useCallback(async (value: number) => { + const store = await getStore(); + await store.set("containerMemoryMb", value); + setSettings((prev) => ({ ...prev, containerMemoryMb: value })); + }, []); + + const setVncPassword = useCallback(async (value: string) => { + const store = await getStore(); + await store.set("vncPassword", value); + setSettings((prev) => ({ ...prev, vncPassword: value })); + }, []); + + return { + settings, + loading, + refresh, + setAutoStartApp, + setAutoStartWorkspace, + setAutoUpdateImage, + setContainerMemory, + setVncPassword, + }; +} diff --git a/coeadapt-launcher/src/index.css b/coeadapt-launcher/src/index.css new file mode 100644 index 000000000..1511fce8a --- /dev/null +++ b/coeadapt-launcher/src/index.css @@ -0,0 +1,176 @@ +@import "tailwindcss"; + +@theme { + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-400: #818cf8; + --color-brand-500: #4f46e5; + --color-brand-600: #4338ca; + --color-brand-700: #3730a3; + + --color-surface-0: #0a0a0a; + --color-surface-50: #111111; + --color-surface-100: #171717; + --color-surface-200: #1e1e1e; + --color-surface-300: #262626; + --color-surface-400: #333333; + --color-surface-500: #404040; + + --color-text-primary: #ffffff; + --color-text-secondary: #a3a3a3; + --color-text-tertiary: #8a8a8a; + --color-text-muted: #737373; + --color-text-faint: #525252; + + --color-accent: #4f46e5; + + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-danger: #ef4444; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + padding: 0; + font-family: "Inter", system-ui, -apple-system, sans-serif; + background-color: #0a0a0a; + color: #ffffff; + overflow: hidden; + min-height: 100vh; +} + +#root { + min-height: 100vh; +} + +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #404040; } + +@keyframes fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes slide-up { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes pulse-glow { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} +@keyframes breathe { + 0%, 100% { transform: scale(1); opacity: 0.6; } + 50% { transform: scale(1.05); opacity: 1; } +} + +.animate-fade-in { animation: fade-in 0.4s ease-out both; } +.animate-slide-up { animation: slide-up 0.5s ease-out both; } +.animate-breathe { animation: breathe 3s ease-in-out infinite; } +.delay-100 { animation-delay: 100ms; } +.delay-200 { animation-delay: 200ms; } +.delay-300 { animation-delay: 300ms; } +.delay-400 { animation-delay: 400ms; } + +.glass-card { + background: rgba(23, 23, 23, 0.7); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; +} + +.brand-gradient { + background: linear-gradient(135deg, #4338ca 0%, #3b82f6 100%); +} + +.brand-gradient-text { + background: linear-gradient(135deg, #818cf8 0%, #60a5fa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 28px; + background: linear-gradient(135deg, #4338ca 0%, #3b82f6 100%); + color: white; + font-weight: 600; + font-size: 15px; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.2s ease; +} +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 8px 25px rgba(67, 56, 202, 0.35); +} +.btn-primary:active { transform: translateY(0); } +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + background: rgba(255, 255, 255, 0.06); + color: #a3a3a3; + font-weight: 500; + font-size: 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + cursor: pointer; + transition: all 0.2s ease; +} +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + border-color: rgba(255, 255, 255, 0.15); +} +.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + font-weight: 500; + font-size: 14px; + border-radius: 10px; + border: 1px solid rgba(239, 68, 68, 0.2); + cursor: pointer; + transition: all 0.2s ease; +} +.btn-danger:hover { background: rgba(239, 68, 68, 0.2); } +.btn-danger:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/coeadapt-launcher/src/lib/api.ts b/coeadapt-launcher/src/lib/api.ts new file mode 100644 index 000000000..1025f4647 --- /dev/null +++ b/coeadapt-launcher/src/lib/api.ts @@ -0,0 +1,170 @@ +/** + * Typed API client for Coeadapt platform communication. + * + * Automatically attaches auth headers (Clerk JWT or device token). + * Handles 401, 429, and 500+ errors with appropriate strategies. + */ + +const API_BASE = import.meta.env.VITE_COEADAPT_API_URL || "http://localhost:5000"; + +type GetTokenFn = () => Promise; + +let _getToken: GetTokenFn | null = null; +let _deviceToken: string | null = null; + +/** Called once from AuthWiring to inject Clerk's getToken function. */ +export function setAuthProvider(getToken: GetTokenFn) { + _getToken = getToken; +} + +/** Called after login to set the device token for background use. */ +export function setDeviceToken(token: string | null) { + _deviceToken = token; +} + +/** Get the current device token (for passing to MCP sidecar). */ +export function getDeviceToken(): string | null { + return _deviceToken; +} + +async function getAuthHeader(): Promise> { + // Prefer Clerk JWT for interactive requests + if (_getToken) { + try { + const token = await _getToken(); + if (token) { + return { Authorization: `Bearer ${token}` }; + } + } catch { + // Clerk not available, fall through + } + } + // Fall back to device token + if (_deviceToken) { + return { Authorization: `Bearer ${_deviceToken}` }; + } + return {}; +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + public code?: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function apiFetch( + path: string, + options: RequestInit = {}, +): Promise { + const authHeaders = await getAuthHeader(); + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...authHeaders, + ...(options.headers as Record), + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError( + res.status, + body.error || body.message || res.statusText, + body.code, + ); + } + + return res.json(); +} + +// --------------------------------------------------------------------------- +// Typed API methods (mirrors COEADAPT_API.md) +// --------------------------------------------------------------------------- + +export const api = { + // Health & system + health: () => apiFetch<{ status: string; timestamp: string }>("/api/career-box/health"), + + // Token management + verifyToken: () => + apiFetch<{ valid: boolean; userId?: string; deviceName?: string; expiresAt?: string; reason?: string }>( + "/api/career-box/verify-token", + { method: "POST" }, + ), + generateDeviceToken: (deviceName: string) => + apiFetch<{ success: boolean; token: string; deviceName: string; expiresAt: string; expiresIn: number }>( + "/api/career-box/generate-token", + { method: "POST", body: JSON.stringify({ deviceName }) }, + ), + + // User profile + getUser: () => apiFetch("/api/auth/user"), + + // Plans + getPlans: () => apiFetch("/api/plans/me"), + getPlan: (id: string) => apiFetch(`/api/plans/${id}`), + getPlanTasks: (planId: string) => apiFetch(`/api/plans/${planId}/tasks`), + + // Tasks + getTasks: () => apiFetch("/api/tasks/me"), + getTask: (id: string) => apiFetch(`/api/tasks/${id}`), + updateTask: (id: string, updates: Record) => + apiFetch(`/api/tasks/${id}`, { method: "PUT", body: JSON.stringify(updates) }), + + // Evidence + submitEvidence: (taskId: string, evidence: Record) => + apiFetch(`/api/tasks/${taskId}/evidence`, { method: "POST", body: JSON.stringify(evidence) }), + + // Goals + getGoals: () => apiFetch("/api/goals/me"), + createGoal: (goal: Record) => + apiFetch("/api/goals", { method: "POST", body: JSON.stringify(goal) }), + updateGoal: (id: string, updates: Record) => + apiFetch(`/api/goals/${id}`, { method: "PATCH", body: JSON.stringify(updates) }), + + // Habits + getHabits: () => apiFetch("/api/habits"), + getHabitsToday: () => apiFetch("/api/habits/today"), + createHabit: (habit: Record) => + apiFetch("/api/habits", { method: "POST", body: JSON.stringify(habit) }), + completeHabit: (id: string) => + apiFetch(`/api/habits/${id}/complete`, { method: "POST" }), + getHabitStats: () => apiFetch("/api/habits/stats/overview"), + + // Jobs + getJobs: () => apiFetch("/api/jobs"), + discoverJobs: () => apiFetch("/api/jobs/discover"), + bookmarkJob: (jobId: string) => + apiFetch(`/api/jobs/${jobId}/bookmark`, { method: "POST" }), + getBookmarks: () => apiFetch("/api/jobs/bookmarks/me"), + + // Portfolio + getPortfolio: () => apiFetch("/api/portfolio/items"), + + // Skills + getVerifiedSkills: () => apiFetch("/api/skills/verified"), + + // Market / Radar + getMarketFit: () => apiFetch("/api/radar/market-fit"), + getSkillDeltas: () => apiFetch("/api/radar/skill-deltas"), + + // Subscription + getSubscription: () => + apiFetch<{ status: string; plan: string; features: Record }>("/api/subscription/status"), + + // Notifications + getNotifications: () => apiFetch("/api/notifications/me"), + + // Chat with Navi (non-streaming) + sendMessage: (message: string, threadId?: string) => + apiFetch<{ response: string; threadId: string; timestamp: string }>( + "/api/chatbot/agent", + { method: "POST", body: JSON.stringify({ message, threadId: threadId || "default" }) }, + ), +}; diff --git a/coeadapt-launcher/src/lib/constants.ts b/coeadapt-launcher/src/lib/constants.ts new file mode 100644 index 000000000..44c87b356 --- /dev/null +++ b/coeadapt-launcher/src/lib/constants.ts @@ -0,0 +1,31 @@ +// User-facing strings — NO technical jargon +export const STRINGS = { + APP_NAME: "Coeadapt", + WELCOME_TITLE: "Welcome to Coeadapt", + WELCOME_SUBTITLE: "Let's get your career workspace set up.", + SETUP_CHECKING_SYSTEM: "Checking your system...", + SETUP_DISK_OK: "Storage: Ready", + SETUP_DISK_LOW: "Your computer needs more free space", + SETUP_DISK_MINIMUM: "Coeadapt needs at least 15GB of free space.", + SETUP_DOCKER_CHECKING: "Checking for workspace engine...", + SETUP_DOCKER_FOUND: "Workspace engine: Ready", + SETUP_DOCKER_NOT_FOUND: + "Coeadapt needs to install a small helper app to run your workspace.", + SETUP_DOCKER_INSTALL: "Install Docker Desktop", + SETUP_DOCKER_STARTING: "Starting up your workspace engine...", + SETUP_PULLING: "Downloading your workspace...", + SETUP_PULLING_SUBTITLE: "This may take a few minutes on first launch (~5GB).", + SETUP_STARTING: "Starting your workspace...", + SETUP_READY: "Your workspace is ready!", + DASHBOARD_RUNNING: "Workspace Running", + DASHBOARD_STOPPED: "Workspace Stopped", + DASHBOARD_ERROR: "Something went wrong", + BTN_OPEN_WORKSPACE: "Open Workspace", + BTN_START: "Start Workspace", + BTN_STOP: "Stop Workspace", + BTN_GET_STARTED: "Get Started", + BTN_RETRY: "Try Again", + AI_CONNECTED: "AI Copilot: Connected", + AI_DISCONNECTED: "AI Copilot: Disconnected", + MCP_URL: "http://localhost:3100/mcp", +}; diff --git a/coeadapt-launcher/src/lib/mode.ts b/coeadapt-launcher/src/lib/mode.ts new file mode 100644 index 000000000..f34f26628 --- /dev/null +++ b/coeadapt-launcher/src/lib/mode.ts @@ -0,0 +1,10 @@ +/** + * Standalone mode activates automatically when no valid Clerk key is configured. + * The default .env ships with "pk_test_REPLACE_ME", which triggers standalone mode. + * + * Standalone mode: workspace + MCP + Claude — no CoeAdapt account needed. + * CoeAdapt mode: + Navi chat, career tracking, cloud sync. + */ +export const STANDALONE_MODE = + !import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || + import.meta.env.VITE_CLERK_PUBLISHABLE_KEY === "pk_test_REPLACE_ME"; diff --git a/coeadapt-launcher/src/lib/tauri.ts b/coeadapt-launcher/src/lib/tauri.ts new file mode 100644 index 000000000..4071f557f --- /dev/null +++ b/coeadapt-launcher/src/lib/tauri.ts @@ -0,0 +1,56 @@ +import type { + DockerInfo, + DiskStatus, + DockerDiskUsage, + ContainerStatus, + ClaudeStatus, + McpHealthInfo, +} from "./types"; + +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +async function safeInvoke(cmd: string, args?: Record): Promise { + if (!isTauri()) { + throw new Error(`Not in Tauri context (tried to invoke "${cmd}")`); + } + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(cmd, args); +} + +export async function safeListen( + event: string, + handler: (event: { payload: T }) => void, +): Promise<() => void> { + if (!isTauri()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return listen(event, handler); +} + +export const tauri = { + isTauri, + detectRuntime: () => safeInvoke("detect_container_runtime"), + checkWsl2: () => safeInvoke("check_wsl2_status"), + checkDiskSpace: () => safeInvoke("check_disk_space"), + getDockerDiskUsage: () => safeInvoke("get_docker_disk_usage"), + pruneImages: () => safeInvoke("prune_docker_images"), + getWorkspaceStatus: () => safeInvoke("get_workspace_status"), + checkImageExists: () => safeInvoke("check_image_exists"), + pullWorkspaceImage: () => safeInvoke("pull_workspace_image"), + createWorkspace: () => safeInvoke("create_workspace"), + startWorkspace: () => safeInvoke("start_workspace"), + stopWorkspace: () => safeInvoke("stop_workspace"), + resetWorkspace: () => safeInvoke("reset_workspace"), + waitForReady: () => safeInvoke("wait_for_workspace_ready"), + checkMcpStatus: () => safeInvoke("check_mcp_status"), + getMcpHealth: () => safeInvoke("get_mcp_health"), + getClaudeStatus: () => safeInvoke("get_claude_status"), + configureClaude: () => safeInvoke("configure_claude"), + openWorkspaceBrowser: () => safeInvoke("open_workspace_browser"), + checkSslTrust: () => safeInvoke("check_ssl_trust"), + installSslCertificate: () => safeInvoke("install_ssl_certificate"), + uninstallSslCertificate: () => safeInvoke("uninstall_ssl_certificate"), + startMcp: () => safeInvoke("start_mcp"), + stopMcp: () => safeInvoke("stop_mcp"), +}; diff --git a/coeadapt-launcher/src/lib/types.ts b/coeadapt-launcher/src/lib/types.ts new file mode 100644 index 000000000..1d5a987bc --- /dev/null +++ b/coeadapt-launcher/src/lib/types.ts @@ -0,0 +1,143 @@ +export interface DockerInfo { + runtime: "Docker" | "Podman" | "None"; + version: string; + is_daemon_running: boolean; +} + +export interface DiskStatus { + available_gb: number; + total_gb: number; + meets_minimum: boolean; + meets_recommended: boolean; + is_low: boolean; +} + +export interface DockerDiskUsage { + images_size: string; + containers_size: string; + volumes_size: string; + total_size: string; +} + +export type ContainerState = + | "NotFound" + | "Running" + | "Stopped" + | "Starting" + | "Pulling" + | { Error: string }; + +export interface ContainerStatus { + state: ContainerState; + container_id: string | null; + uptime: string | null; + image: string; +} + +export interface ClaudeStatus { + is_installed: boolean; + config_path: string | null; + is_configured: boolean; + needs_restart: boolean; +} + +export interface PullProgress { + status: string; + progress: string | null; + percent: number; +} + +export interface McpHealthInfo { + is_running: boolean; + last_tool_call: number | null; + uptime_secs: number | null; +} + +// --------------------------------------------------------------------------- +// Progress tracking types (mirrors the VM-side progress tracker data model) +// --------------------------------------------------------------------------- + +export interface ProgressActivity { + id: number; + type: string; + title: string; + description: string; + duration_minutes: number; + tags: string[]; + metadata: Record; + created_at: string; +} + +export interface ProgressGoal { + id: number; + title: string; + description: string; + category: string; + status: "active" | "completed" | "paused" | "abandoned"; + target_date: string | null; + sub_goals: string[]; + completed_at?: string; + created_at: string; + updated_at: string; +} + +export interface ProgressSkill { + id: number; + name: string; + category: string; + level: "beginner" | "intermediate" | "advanced" | "expert"; + evidence: string[]; + verified: boolean; + created_at: string; + updated_at: string; +} + +export interface ProgressMilestone { + id: number; + title: string; + description: string; + achieved: boolean; + achieved_at: string | null; + created_at: string; +} + +export interface ProgressAssessment { + id: number; + type: string; + skill: string; + score: number; + max_score: number; + notes: string; + created_at: string; +} + +export interface ProgressData { + version: number; + activities: ProgressActivity[]; + assessments: ProgressAssessment[]; + goals: ProgressGoal[]; + skills: ProgressSkill[]; + milestones: ProgressMilestone[]; + daily_log: { date: string; activity_id: number }[]; + progress_percent: number; + streak_days: number; + last_activity_at: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface ProgressSummary { + progress_percent: number; + streak_days: number; + total_activities: number; + total_goals: number; + completed_goals: number; + total_skills: number; + total_milestones: number; + last_activity_at: string | null; +} + +export interface AgentHealthInfo { + progress_tracker: "ok" | "down"; + computer_use: "ok" | "down"; +} diff --git a/coeadapt-launcher/src/main.tsx b/coeadapt-launcher/src/main.tsx new file mode 100644 index 000000000..199d1c446 --- /dev/null +++ b/coeadapt-launcher/src/main.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { ClerkProvider } from "@clerk/clerk-react"; +import { STANDALONE_MODE } from "./lib/mode"; +import App from "./App"; +import "./index.css"; + +const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!CLERK_PUBLISHABLE_KEY && !STANDALONE_MODE) { + console.warn("Missing VITE_CLERK_PUBLISHABLE_KEY — auth will not work"); +} + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {STANDALONE_MODE ? ( + + ) : ( + + + + )} + , +); diff --git a/coeadapt-launcher/src/pages/Chat.tsx b/coeadapt-launcher/src/pages/Chat.tsx new file mode 100644 index 000000000..b4197d31a --- /dev/null +++ b/coeadapt-launcher/src/pages/Chat.tsx @@ -0,0 +1,127 @@ +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useNaviChat } from "../hooks/useNaviChat"; + + +export default function Chat() { + const navigate = useNavigate(); + const { messages, isStreaming, error, sendMessage, stopStreaming } = useNaviChat(); + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const text = input.trim(); + if (!text || isStreaming) return; + setInput(""); + sendMessage(text); + }; + + return ( +
+ {/* Header */} +
+ +
+ + + +
+
+

Navi

+

AI Career Companion

+
+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ + + +
+

Hi, I'm Navi

+

+ Your AI career companion. Ask me about career paths, skill development, job strategies, or anything career-related. +

+
+ )} + + {messages.map((msg) => ( +
+
+ {msg.content || (isStreaming && msg.role === "assistant" ? ( + + Thinking + ... + + ) : null)} +
+
+ ))} + + {error && ( +
+

{error}

+
+ )} + +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + placeholder="Ask Navi anything..." + className="flex-1 bg-surface-200 border border-surface-300 rounded-xl px-4 py-3 text-sm text-text-primary placeholder:text-text-faint focus:outline-none focus:border-brand-500 transition-colors" + disabled={isStreaming} + /> + {isStreaming ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/coeadapt-launcher/src/pages/ClaudeSetup.tsx b/coeadapt-launcher/src/pages/ClaudeSetup.tsx new file mode 100644 index 000000000..c393ed4f6 --- /dev/null +++ b/coeadapt-launcher/src/pages/ClaudeSetup.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useClaudeConnection } from "../hooks/useClaudeConnection"; +import { STRINGS } from "../lib/constants"; + +export default function ClaudeSetup() { + const navigate = useNavigate(); + const claude = useClaudeConnection(); + const [copied, setCopied] = useState(false); + + const copyUrl = async () => { + await navigator.clipboard.writeText(STRINGS.MCP_URL); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+
+ +
+
+
+
+ + + +
+

Connect your AI copilot

+

Your workspace is running. Let's connect your AI assistant.

+
+ + {claude.status?.is_installed && ( +
+
+ + Claude Desktop detected +
+ +
+ )} + +
+

+ {claude.status?.is_installed ? "Or connect manually:" : "Manual setup:"} +

+
    +
  1. Open Claude, ChatGPT, or your preferred AI
  2. +
  3. Go to Settings → Connectors
  4. +
  5. Add a custom MCP connector
  6. +
  7. Paste this URL:
  8. +
+
+ + {STRINGS.MCP_URL} + + +
+
+ + +
+
+
+ ); +} diff --git a/coeadapt-launcher/src/pages/Dashboard.tsx b/coeadapt-launcher/src/pages/Dashboard.tsx new file mode 100644 index 000000000..03a80b4ba --- /dev/null +++ b/coeadapt-launcher/src/pages/Dashboard.tsx @@ -0,0 +1,149 @@ +import { useNavigate } from "react-router-dom"; +import { useContainer } from "../hooks/useContainer"; +import { useDiskSpace } from "../hooks/useDiskSpace"; +import { useClaudeConnection } from "../hooks/useClaudeConnection"; +import { useProgress } from "../hooks/useProgress"; +import { STANDALONE_MODE } from "../lib/mode"; +import { StatusIndicator } from "../components/StatusIndicator"; +import { WorkspaceControls } from "../components/WorkspaceControls"; +import { DiskUsage } from "../components/DiskUsage"; +import { ProgressCard } from "../components/ProgressCard"; +import { STRINGS } from "../lib/constants"; +import type { ContainerState } from "../lib/types"; + +function stateToIndicator(state: ContainerState): "running" | "starting" | "stopped" | "error" { + if (state === "Running") return "running"; + if (state === "Starting" || state === "Pulling") return "starting"; + if (state === "Stopped" || state === "NotFound") return "stopped"; + return "error"; +} + +function stateLabel(state: ContainerState): string { + if (state === "Running") return STRINGS.DASHBOARD_RUNNING; + if (state === "Stopped" || state === "NotFound") return STRINGS.DASHBOARD_STOPPED; + if (state === "Starting") return "Starting..."; + if (state === "Pulling") return "Downloading..."; + if (typeof state === "object" && "Error" in state) return state.Error; + return "Unknown"; +} + +export default function Dashboard() { + const navigate = useNavigate(); + const container = useContainer(); + const disk = useDiskSpace(); + const claude = useClaudeConnection(); + const progress = useProgress(container.isRunning); + + const handleStart = async () => { + if (container.status?.state === "NotFound") await container.createWorkspace(); + else await container.startWorkspace(); + }; + + return ( +
+
+
+ + {STRINGS.APP_NAME} +
+ +
+ +
+
+ {/* Workspace */} +
+
+
+ +
+
+

Workspace

+ +
+
+ + {container.isRunning && container.sslTrusted === false && ( +
+

+ Your browser will show a security warning when opening the workspace. + Install the workspace certificate to fix this. +

+ +
+ )} + {container.error &&

{container.error}

} +
+ + {/* AI Connection */} +
+
+
+ +
+
+

AI Copilot

+ +
+
+ {claude.isIdle && ( +

+ No AI activity for a while. This is normal when you're not using AI tools. +

+ )} + {claude.status?.is_installed && !claude.status?.is_configured && ( + + )} +
+ + {/* Career Progress */} + {container.isRunning && ( + + )} + + {/* Navi - AI Career Companion (CoeAdapt mode only) */} + {!STANDALONE_MODE && ( +
+
+
+ +
+
+

Navi

+

AI Career Companion

+
+
+

+ Get career guidance, track goals, and build your mastery with Navi. +

+ +
+ )} + + {/* Disk */} + {disk.status && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/coeadapt-launcher/src/pages/Login.tsx b/coeadapt-launcher/src/pages/Login.tsx new file mode 100644 index 000000000..5ccc52bd1 --- /dev/null +++ b/coeadapt-launcher/src/pages/Login.tsx @@ -0,0 +1,52 @@ +import { SignIn, useAuth } from "@clerk/clerk-react"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +export default function Login() { + const { isSignedIn, isLoaded } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (isLoaded && isSignedIn) { + navigate("/setup"); + } + }, [isLoaded, isSignedIn, navigate]); + + return ( +
+
+ + Coeadapt +
+

+ Sign in to connect your Career Box to Navi +

+
+ +
+
+ ); +} diff --git a/coeadapt-launcher/src/pages/Settings.tsx b/coeadapt-launcher/src/pages/Settings.tsx new file mode 100644 index 000000000..40beec7cd --- /dev/null +++ b/coeadapt-launcher/src/pages/Settings.tsx @@ -0,0 +1,325 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useUser, useClerk } from "@clerk/clerk-react"; +import { STANDALONE_MODE } from "../lib/mode"; +import { useDiskSpace } from "../hooks/useDiskSpace"; +import { useClaudeConnection } from "../hooks/useClaudeConnection"; +import { useSettings } from "../hooks/useSettings"; +import { useDeviceToken } from "../hooks/useDeviceToken"; +import { DiskUsage } from "../components/DiskUsage"; +import { StatusIndicator } from "../components/StatusIndicator"; +import { ToggleSwitch } from "../components/ToggleSwitch"; +import { STRINGS } from "../lib/constants"; +import { tauri } from "../lib/tauri"; + +type Tab = "account" | "ai" | "workspace" | "general"; + +export default function Settings() { + const navigate = useNavigate(); + const [tab, setTab] = useState(STANDALONE_MODE ? "ai" : "account"); + const disk = useDiskSpace(); + const claude = useClaudeConnection(); + const appSettings = useSettings(); + const { user } = STANDALONE_MODE ? { user: null } : useUser(); + const { signOut } = STANDALONE_MODE ? { signOut: () => {} } : useClerk(); + const { deviceToken, loading: tokenLoading, regenerate: regenerateToken } = STANDALONE_MODE + ? { deviceToken: null, loading: false, regenerate: () => {} } + : useDeviceToken(); + const [resetting, setResetting] = useState(false); + const [pruning, setPruning] = useState(false); + const [copied, setCopied] = useState(false); + + const tabs: { id: Tab; label: string }[] = [ + ...(!STANDALONE_MODE ? [{ id: "account" as Tab, label: "Account" }] : []), + { id: "ai", label: "AI Connection" }, + { id: "workspace", label: "Workspace" }, + { id: "general", label: "General" }, + ]; + + const handleReset = async () => { + if (!confirm("This will delete all your workspace data. Are you sure?")) return; + setResetting(true); + try { + await tauri.resetWorkspace(); + } catch { + // Ignore + } finally { + setResetting(false); + disk.refresh(); + } + }; + + const handlePrune = async () => { + setPruning(true); + try { + await tauri.pruneImages(); + } catch { + // Ignore + } finally { + setPruning(false); + disk.refresh(); + } + }; + + const copyUrl = async () => { + await navigator.clipboard.writeText(STRINGS.MCP_URL); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + Settings +
+ +
+
+ {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Account Tab */} + {tab === "account" && ( +
+
+
+ {user?.imageUrl ? ( + + ) : ( +
+ + {user?.firstName?.[0] || "?"} + +
+ )} +
+

+ {user?.firstName} {user?.lastName} +

+

+ {user?.primaryEmailAddress?.emailAddress} +

+
+
+ +
+
+ Navi Connection + +
+
+ Device Token + + {deviceToken ? `${deviceToken.slice(0, 12)}...` : "None"} + +
+
+ +
+ + +
+
+
+ )} + + {/* AI Connection Tab */} + {tab === "ai" && ( +
+
+
+
+ +
+
+

AI Copilot

+ +
+
+ +
+
+ Claude Desktop + + {claude.status?.is_installed ? "Detected" : "Not found"} + +
+
+ Configuration + + {claude.status?.is_configured ? "Connected" : "Not configured"} + +
+
+ +
+ + +
+
+ +
+
+ + MCP endpoint: {STRINGS.MCP_URL} +
+
+
+ )} + + {/* Workspace Tab */} + {tab === "workspace" && ( +
+ {disk.status && ( +
+ +
+ )} + +
+
+

Configuration

+

Changes apply the next time you reset your workspace.

+
+ +
+ +
+ {[ + { label: "2 GB", value: 2048 }, + { label: "4 GB", value: 4096 }, + { label: "8 GB", value: 8192 }, + ].map((opt) => ( + + ))} +
+
+ +
+ + appSettings.setVncPassword(e.target.value)} + placeholder="Enter password" + className="w-full px-4 py-2 bg-surface-200 border border-surface-300 rounded-lg text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-accent" + /> +
+
+ +
+

Maintenance

+
+ + +
+
+
+ )} + + {/* General Tab */} + {tab === "general" && ( +
+
+

Startup

+ + + +
+ +
+

Application

+
+
+ Version + 0.1.0 +
+
+
+ +
+

About

+
+ +
+

{STRINGS.APP_NAME}

+

Adapting Together

+
+
+
+
+ )} +
+
+
+ ); +} diff --git a/coeadapt-launcher/src/pages/Setup.tsx b/coeadapt-launcher/src/pages/Setup.tsx new file mode 100644 index 000000000..ad4e05853 --- /dev/null +++ b/coeadapt-launcher/src/pages/Setup.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDocker } from "../hooks/useDocker"; +import { useContainer } from "../hooks/useContainer"; +import { useDiskSpace } from "../hooks/useDiskSpace"; +import { ProgressBar } from "../components/ProgressBar"; +import { Spinner } from "../components/Spinner"; +import { STRINGS } from "../lib/constants"; +import { tauri } from "../lib/tauri"; + +type Step = "welcome" | "system" | "docker" | "pull" | "starting" | "ready"; +const STEPS: Step[] = ["welcome", "system", "docker", "pull", "starting", "ready"]; + +function CheckIcon() { + return ( + + + + ); +} + +export default function Setup() { + const [step, setStep] = useState("welcome"); + const [startError, setStartError] = useState(null); + const navigate = useNavigate(); + const docker = useDocker(); + const container = useContainer(); + const disk = useDiskSpace(); + const stepIndex = STEPS.indexOf(step); + + useEffect(() => { + if (step === "system" && disk.status) { + if (!disk.status.meets_minimum) return; + setStep("docker"); + } + }, [step, disk.status]); + + useEffect(() => { + if (step === "docker" && docker.isAvailable) setStep("pull"); + }, [step, docker.isAvailable]); + + useEffect(() => { + if (step === "docker" && !docker.isAvailable && !docker.loading) { + const interval = setInterval(() => docker.refresh(), 5000); + return () => clearInterval(interval); + } + }, [step, docker]); + + const handlePull = async () => { + try { + const exists = await tauri.checkImageExists(); + if (exists) { setStep("starting"); handleStart(); return; } + await container.pullImage(); + if (container.error) return; // Stay on pull step, error shown there + setStep("starting"); + handleStart(); + } catch (e) { + // container.error will be set by the hook - stay on pull step + console.error("[setup] pull failed:", e); + } + }; + + const handleStart = async () => { + setStartError(null); + try { + const status = await tauri.getWorkspaceStatus(); + if (status.state === "NotFound") { + await container.createWorkspace(); + // Check if create actually failed + if (container.error) { + setStartError(container.error); + return; + } + } else if (status.state === "Stopped") { + await container.startWorkspace(); + if (container.error) { + setStartError(container.error); + return; + } + } + await tauri.waitForReady(); + setStep("ready"); + } catch (e) { + const msg = String(e); + console.error("[setup] start failed:", msg); + if (msg.includes("not ready after")) { + setStartError("Your workspace is taking longer than expected. It may still be starting — try again in a moment."); + } else if (msg.includes("No such image") || msg.includes("not found")) { + setStartError("The workspace image wasn't found. The image may not have been downloaded correctly."); + } else { + setStartError(msg.replace(/^Error:\s*/i, "")); + } + } + }; + + const handleRetryStart = () => { + setStartError(null); + handleStart(); + }; + + useEffect(() => { if (step === "pull") handlePull(); }, [step]); + + return ( +
+ {/* Ambient glow */} +
+
+
+
+ + {/* Step progress */} + {step !== "welcome" && ( +
+
+ {STEPS.slice(1).map((s, i) => ( +
+ ))} +
+
+ )} + +
+
+ + {step === "welcome" && ( +
+ Coeadapt +
+

{STRINGS.WELCOME_TITLE}

+

Your AI-powered career workspace,
ready in minutes.

+
+ +

Adapting Together

+
+ )} + + {step === "system" && ( +
+
+

{STRINGS.SETUP_CHECKING_SYSTEM}

+

Making sure everything is ready

+
+
+ {disk.status ? ( + disk.status.meets_minimum ? ( +
+ +

Storage ready

{disk.status.available_gb} GB available

+
+ ) : ( +
+ +

Insufficient storage

Need 15 GB, only {disk.status.available_gb} GB available

+
+ ) + ) : ( +
Checking storage...
+ )} +
+
+ )} + + {step === "docker" && ( +
+
+

{STRINGS.SETUP_DOCKER_CHECKING}

+

We need Docker Desktop to run your workspace

+
+
+ {docker.loading ? ( +
Detecting...
+ ) : docker.isAvailable ? ( +
+

Docker Desktop found

{docker.info?.version}

+
+ ) : ( +
+

{STRINGS.SETUP_DOCKER_NOT_FOUND}

+ + Download Docker Desktop + + +
Waiting for Docker Desktop...
+
+ )} +
+
+ )} + + {step === "pull" && ( +
+
+

{STRINGS.SETUP_PULLING}

+

{STRINGS.SETUP_PULLING_SUBTITLE}

+
+
+ {container.pullProgress ? ( + + ) : ( + + )} +
+ {container.error && ( +

{container.error}

+ )} +
+ )} + + {step === "starting" && ( +
+

{STRINGS.SETUP_STARTING}

+ {startError ? ( +
+
+
+ +
+

Something went wrong

+

{startError}

+
+
+
+
+ + +
+
+ ) : ( + <> + +

This usually takes about 30 seconds

+ + )} +
+ )} + + {step === "ready" && ( +
+
+ +
+

{STRINGS.SETUP_READY}

+

Your workspace is running and ready to use.

+ +
+ )} +
+
+
+ ); +} diff --git a/coeadapt-launcher/src/vite-env.d.ts b/coeadapt-launcher/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/coeadapt-launcher/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/coeadapt-launcher/tsconfig.json b/coeadapt-launcher/tsconfig.json new file mode 100644 index 000000000..a7fc6fbf2 --- /dev/null +++ b/coeadapt-launcher/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/coeadapt-launcher/tsconfig.node.json b/coeadapt-launcher/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/coeadapt-launcher/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/coeadapt-launcher/vite.config.ts b/coeadapt-launcher/vite.config.ts new file mode 100644 index 000000000..d47fde9e0 --- /dev/null +++ b/coeadapt-launcher/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + plugins: [react(), tailwindcss()], + clearScreen: false, + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/dockerfile-kasm-firefox b/dockerfile-kasm-firefox index 51095c4fc..e250da9bb 100644 --- a/dockerfile-kasm-firefox +++ b/dockerfile-kasm-firefox @@ -3,9 +3,9 @@ ARG BASE_IMAGE="core-ubuntu-jammy" FROM kasmweb/$BASE_IMAGE:$BASE_TAG USER root -ENV HOME /home/kasm-default-profile -ENV STARTUPDIR /dockerstartup -ENV INST_SCRIPTS $STARTUPDIR/install +ENV HOME=/home/kasm-default-profile +ENV STARTUPDIR=/dockerstartup +ENV INST_SCRIPTS=$STARTUPDIR/install WORKDIR $HOME ######### Customize Container Here ########### @@ -46,7 +46,7 @@ RUN bash $INST_SCRIPTS/close_browser_breakout_via_file_manager/replace_thunar_wi RUN chown 1000:0 $HOME RUN $STARTUPDIR/set_user_permission.sh $HOME -ENV HOME /home/kasm-user +ENV HOME=/home/kasm-user WORKDIR $HOME RUN mkdir -p $HOME && chown -R 1000:0 $HOME diff --git a/dockerfile-kasm-zorin b/dockerfile-kasm-zorin new file mode 100644 index 000000000..cf747660f --- /dev/null +++ b/dockerfile-kasm-zorin @@ -0,0 +1,70 @@ +ARG BASE_TAG="develop" +ARG BASE_IMAGE="core-ubuntu-jammy" +FROM kasmweb/$BASE_IMAGE:$BASE_TAG + +USER root + +ENV HOME=/home/kasm-default-profile +ENV STARTUPDIR=/dockerstartup +WORKDIR $HOME + +### Environment config +ENV DEBIAN_FRONTEND=noninteractive +ENV INST_SCRIPTS=$STARTUPDIR/install + +### KasmVNC quality: 60fps, balanced quality for smooth performance +ENV MAX_FRAME_RATE=60 +ENV VNCOPTIONS="-DynamicQualityMin 6 -DynamicQualityMax 9 -TreatLossless 9 -JpegVideoQuality 7 -WebpVideoQuality 7 -VideoScaling 2" + +######### Customize Container Here ########### + +# SSL: Generate CA-signed certs so browsers trust the HTTPS connection +COPY ./src/ubuntu/install/ssl $INST_SCRIPTS/ssl/ +RUN bash $INST_SCRIPTS/ssl/install_ssl.sh && rm -rf $INST_SCRIPTS/ssl/ + +# KasmVNC high-quality settings (1080p, 60fps, max quality) +COPY ./src/ubuntu/install/kasmvnc_settings $INST_SCRIPTS/kasmvnc_settings/ +RUN bash $INST_SCRIPTS/kasmvnc_settings/install_kasmvnc_settings.sh && rm -rf $INST_SCRIPTS/kasmvnc_settings/ + +# Install macOS-style theme (Colloid GTK + Plank dock + XFCE panel) +COPY ./src/ubuntu/install/zorin_theme $INST_SCRIPTS/zorin_theme/ +RUN bash $INST_SCRIPTS/zorin_theme/install_zorin_theme.sh && rm -rf $INST_SCRIPTS/zorin_theme/ + +# Install Wine (Windows app compatibility) +COPY ./src/ubuntu/install/wine $INST_SCRIPTS/wine/ +RUN bash $INST_SCRIPTS/wine/install_wine.sh && rm -rf $INST_SCRIPTS/wine/ + +# Install Utilities +COPY ./src/ubuntu/install/misc $INST_SCRIPTS/misc/ +RUN bash $INST_SCRIPTS/misc/install_tools.sh && rm -rf $INST_SCRIPTS/misc/ + +# Install Firefox +COPY ./src/ubuntu/install/firefox/ $INST_SCRIPTS/firefox/ +COPY ./src/ubuntu/install/firefox/firefox.desktop $HOME/Desktop/ +RUN bash $INST_SCRIPTS/firefox/install_firefox.sh && rm -rf $INST_SCRIPTS/firefox/ + +# Install Google Chrome +COPY ./src/ubuntu/install/chrome $INST_SCRIPTS/chrome/ +RUN bash $INST_SCRIPTS/chrome/install_chrome.sh && rm -rf $INST_SCRIPTS/chrome/ + +### Install CareerClaw AI Assistant (OpenClaw fork) +COPY ./src/ubuntu/install/careerclaw $INST_SCRIPTS/careerclaw/ +RUN bash $INST_SCRIPTS/careerclaw/install_careerclaw.sh && rm -rf $INST_SCRIPTS/careerclaw/ +# CareerClaw custom startup (gateway respawn loop) +COPY ./src/ubuntu/install/careerclaw/custom_startup.sh $STARTUPDIR/custom_startup.sh +RUN chmod +x $STARTUPDIR/custom_startup.sh +RUN chmod 755 $STARTUPDIR/custom_startup.sh + +######### End Customizations ########### + +RUN $STARTUPDIR/set_user_permission.sh $HOME +RUN chown 1000:0 $HOME + +ENV HOME=/home/kasm-user +WORKDIR $HOME +RUN mkdir -p $HOME && chown -R 1000:0 $HOME + +USER 1000 + +EXPOSE 18789 +CMD ["--tail-log"] diff --git a/dockerfile-kasm-zorin-deluxe b/dockerfile-kasm-zorin-deluxe new file mode 100644 index 000000000..676cec977 --- /dev/null +++ b/dockerfile-kasm-zorin-deluxe @@ -0,0 +1,112 @@ +ARG BASE_TAG="develop" +ARG BASE_IMAGE="core-ubuntu-jammy" +FROM kasmweb/$BASE_IMAGE:$BASE_TAG + +USER root + +ENV HOME=/home/kasm-default-profile +ENV STARTUPDIR=/dockerstartup +WORKDIR $HOME + +### Environment config +ENV DEBIAN_FRONTEND=noninteractive +ENV KASM_RX_HOME=$STARTUPDIR/kasmrx +ENV INST_SCRIPTS=$STARTUPDIR/install +ENV DONT_PROMPT_WSL_INSTALL="No_Prompt_please" + +### KasmVNC quality: 60fps, balanced quality for smooth performance +ENV MAX_FRAME_RATE=60 +ENV VNCOPTIONS="-DynamicQualityMin 6 -DynamicQualityMax 9 -TreatLossless 9 -JpegVideoQuality 7 -WebpVideoQuality 7 -VideoScaling 2" + +######### Customize Container Here ########### + +# SSL: Generate CA-signed certs so browsers trust the HTTPS connection +COPY ./src/ubuntu/install/ssl $INST_SCRIPTS/ssl/ +RUN bash $INST_SCRIPTS/ssl/install_ssl.sh && rm -rf $INST_SCRIPTS/ssl/ + +# KasmVNC high-quality settings (1080p, 60fps, max quality) +COPY ./src/ubuntu/install/kasmvnc_settings $INST_SCRIPTS/kasmvnc_settings/ +RUN bash $INST_SCRIPTS/kasmvnc_settings/install_kasmvnc_settings.sh && rm -rf $INST_SCRIPTS/kasmvnc_settings/ + +# Install macOS-style theme (Colloid GTK + Plank dock + XFCE panel) +COPY ./src/ubuntu/install/zorin_theme $INST_SCRIPTS/zorin_theme/ +RUN bash $INST_SCRIPTS/zorin_theme/install_zorin_theme.sh && rm -rf $INST_SCRIPTS/zorin_theme/ + +# Install Wine (Windows app compatibility) +COPY ./src/ubuntu/install/wine $INST_SCRIPTS/wine/ +RUN bash $INST_SCRIPTS/wine/install_wine.sh && rm -rf $INST_SCRIPTS/wine/ + +### Install Tools +COPY ./src/ubuntu/install/tools $INST_SCRIPTS/tools/ +RUN bash $INST_SCRIPTS/tools/install_tools_deluxe.sh && rm -rf $INST_SCRIPTS/tools/ + +# Install Utilities +COPY ./src/ubuntu/install/misc $INST_SCRIPTS/misc/ +RUN bash $INST_SCRIPTS/misc/install_tools.sh && rm -rf $INST_SCRIPTS/misc/ + +# Install Google Chrome +COPY ./src/ubuntu/install/chrome $INST_SCRIPTS/chrome/ +RUN bash $INST_SCRIPTS/chrome/install_chrome.sh && rm -rf $INST_SCRIPTS/chrome/ + +# Install Firefox +COPY ./src/ubuntu/install/firefox/ $INST_SCRIPTS/firefox/ +COPY ./src/ubuntu/install/firefox/firefox.desktop $HOME/Desktop/ +RUN bash $INST_SCRIPTS/firefox/install_firefox.sh && rm -rf $INST_SCRIPTS/firefox/ + +### Install Sublime Text +COPY ./src/ubuntu/install/sublime_text $INST_SCRIPTS/sublime_text/ +RUN bash $INST_SCRIPTS/sublime_text/install_sublime_text.sh && rm -rf $INST_SCRIPTS/sublime_text/ + +### Install Visual Studio Code +COPY ./src/ubuntu/install/vs_code $INST_SCRIPTS/vs_code/ +RUN bash $INST_SCRIPTS/vs_code/install_vs_code.sh && rm -rf $INST_SCRIPTS/vs_code/ + +### Install Only Office +COPY ./src/ubuntu/install/only_office $INST_SCRIPTS/only_office/ +RUN bash $INST_SCRIPTS/only_office/install_only_office.sh && rm -rf $INST_SCRIPTS/only_office/ + +### Install GIMP +COPY ./src/ubuntu/install/gimp $INST_SCRIPTS/gimp/ +RUN bash $INST_SCRIPTS/gimp/install_gimp.sh && rm -rf $INST_SCRIPTS/gimp/ + +### Install Thunderbird +COPY ./src/ubuntu/install/thunderbird $INST_SCRIPTS/thunderbird/ +RUN bash $INST_SCRIPTS/thunderbird/install_thunderbird.sh && rm -rf $INST_SCRIPTS/thunderbird/ + +### Install Remmina +COPY ./src/ubuntu/install/remmina $INST_SCRIPTS/remmina/ +RUN bash $INST_SCRIPTS/remmina/install_remmina.sh && rm -rf $INST_SCRIPTS/remmina/ + +### Install CareerClaw AI Assistant (OpenClaw fork) +COPY ./src/ubuntu/install/careerclaw $INST_SCRIPTS/careerclaw/ +RUN bash $INST_SCRIPTS/careerclaw/install_careerclaw.sh && rm -rf $INST_SCRIPTS/careerclaw/ + +# CareerClaw custom startup (gateway respawn loop) +COPY ./src/ubuntu/install/careerclaw/custom_startup.sh $STARTUPDIR/custom_startup.sh +RUN chmod +x $STARTUPDIR/custom_startup.sh +RUN chmod 755 $STARTUPDIR/custom_startup.sh + +# Add extra dock items for deluxe apps +RUN PLANK_DIR="$HOME/.config/plank/dock1/launchers" && \ + echo "[PlankDockItemPreferences]" > "$PLANK_DIR/chrome.dockitem" && \ + echo "Launcher=file:///usr/share/applications/google-chrome.desktop" >> "$PLANK_DIR/chrome.dockitem" && \ + echo "[PlankDockItemPreferences]" > "$PLANK_DIR/vscode.dockitem" && \ + echo "Launcher=file:///usr/share/applications/code.desktop" >> "$PLANK_DIR/vscode.dockitem" && \ + echo "[PlankDockItemPreferences]" > "$PLANK_DIR/gimp.dockitem" && \ + echo "Launcher=file:///usr/share/applications/gimp.desktop" >> "$PLANK_DIR/gimp.dockitem" && \ + sed -i 's/^DockItems=.*/DockItems=files.dockitem;firefox.dockitem;chrome.dockitem;vscode.dockitem;terminal.dockitem;gimp.dockitem;careerclaw.dockitem/' "$HOME/.config/plank/dock1/settings" + +######### End Customizations ########### + +RUN $STARTUPDIR/set_user_permission.sh $HOME + +RUN chown 1000:0 $HOME + +ENV HOME=/home/kasm-user +WORKDIR $HOME +RUN mkdir -p $HOME && chown -R 1000:0 $HOME + +USER 1000 + +EXPOSE 18789 +CMD ["--tail-log"] diff --git a/docs/screenshots/claude-setup.png b/docs/screenshots/claude-setup.png new file mode 100644 index 000000000..338a6d56c Binary files /dev/null and b/docs/screenshots/claude-setup.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 000000000..2df8fde74 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/setup-welcome.png b/docs/screenshots/setup-welcome.png new file mode 100644 index 000000000..aee762cd5 Binary files /dev/null and b/docs/screenshots/setup-welcome.png differ diff --git a/src/ubuntu/install/careerclaw/custom_startup.sh b/src/ubuntu/install/careerclaw/custom_startup.sh new file mode 100644 index 000000000..c07405cc4 --- /dev/null +++ b/src/ubuntu/install/careerclaw/custom_startup.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +START_COMMAND="/usr/local/bin/openclaw" +PGREP="node" +GATEWAY_LOG="$HOME/.openclaw/gateway.log" + +ARGS=${APP_ARGS:-"gateway"} + +options=$(getopt -o gau: -l go,assign,url: -n "$0" -- "$@") || exit +eval set -- "$options" + +while [[ $1 != -- ]]; do + case $1 in + -g|--go) GO='true'; shift 1;; + -a|--assign) ASSIGN='true'; shift 1;; + -u|--url) OPT_URL=$2; shift 2;; + *) echo "bad option: $1" >&2; exit 1;; + esac +done +shift + +kasm_exec() { + /usr/bin/filter_ready + /usr/bin/desktop_ready + + # Open the gateway UI in the default browser + set +e + xdg-open "http://localhost:18789" & + set -e +} + +kasm_startup() { + if [ -n "$DISABLE_CUSTOM_STARTUP" ]; then + echo "CareerClaw custom startup disabled" + return + fi + + # Respawn loop: keep the gateway running + while true; do + # Check if gateway is already running on port 18789 + if ! ss -tlnp 2>/dev/null | grep -q ":18789"; then + /usr/bin/filter_ready + /usr/bin/desktop_ready + + echo "Starting CareerClaw gateway..." + set +e + $START_COMMAND $ARGS >> "$GATEWAY_LOG" 2>&1 & + set -e + + # Wait for the gateway to be ready + for i in $(seq 1 30); do + if ss -tlnp 2>/dev/null | grep -q ":18789"; then + echo "CareerClaw gateway is ready on port 18789" + break + fi + sleep 1 + done + fi + sleep 5 + done +} + +if [ -n "$GO" ] || [ -n "$ASSIGN" ]; then + kasm_exec +else + kasm_startup +fi diff --git a/src/ubuntu/install/careerclaw/fix_startup.sh b/src/ubuntu/install/careerclaw/fix_startup.sh new file mode 100644 index 000000000..e50d987c2 --- /dev/null +++ b/src/ubuntu/install/careerclaw/fix_startup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -ex + +# Fix for "Grey Screen" issue: manually create xstartup to force XFCE launch +VNC_DIR="/home/kasm-user/.vnc" +mkdir -p "$VNC_DIR" + +cat > "$VNC_DIR/xstartup" <<'EOF' +#!/bin/bash +unset SESSION_MANAGER +unset DBUS_SESSION_BUS_ADDRESS +exec startxfce4 +EOF + +chmod +x "$VNC_DIR/xstartup" +chown 1000:0 "$VNC_DIR/xstartup" diff --git a/src/ubuntu/install/careerclaw/install_careerclaw.sh b/src/ubuntu/install/careerclaw/install_careerclaw.sh new file mode 100644 index 000000000..713cfec40 --- /dev/null +++ b/src/ubuntu/install/careerclaw/install_careerclaw.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -ex + +# Tell pnpm/node we're in a non-interactive CI environment (no TTY) +export CI=true + +# Install dependencies including python3 (for gateway launcher) and Node.js 22 +apt-get update +apt-get install -y python3 python3-pip git curl + +# Download and install Node.js 22 +NODESOURCE_SCRIPT=$(mktemp) +curl -fsSL https://deb.nodesource.com/setup_22.x -o "$NODESOURCE_SCRIPT" || { echo "Failed to download Node.js setup script"; exit 1; } +bash "$NODESOURCE_SCRIPT" +rm -f "$NODESOURCE_SCRIPT" +apt-get install -y nodejs + +# Enable corepack for pnpm +corepack enable +corepack prepare pnpm@latest --activate + +# Clone CareerClaw (OpenClaw fork) +CAREERCLAW_DIR="/opt/careerclaw" +if [ -d "$CAREERCLAW_DIR" ]; then + rm -rf "$CAREERCLAW_DIR" +fi + +echo "Cloning CareerClaw repository..." +git clone --depth 1 https://github.com/alexander-acker/careerclaw.git "$CAREERCLAW_DIR" || { echo "Failed to clone CareerClaw repo"; exit 1; } + +# Build CareerClaw +cd "$CAREERCLAW_DIR" +echo "Installing dependencies..." +pnpm install --frozen-lockfile || { echo "Failed to install dependencies"; exit 1; } + +echo "Building CareerClaw..." +pnpm build || { echo "Failed to build CareerClaw"; exit 1; } +pnpm ui:build || { echo "Failed to build UI"; exit 1; } + +# Slim down: drop dev dependencies and git history to save ~300-500 MB +echo "Pruning development dependencies..." +CI=true pnpm prune --prod +rm -rf .git + +# Create CLI wrapper so 'openclaw' is available system-wide +cat > /usr/local/bin/openclaw <<'WRAPPER' +#!/usr/bin/env bash +exec node /opt/careerclaw/openclaw.mjs "$@" +WRAPPER +chmod +x /usr/local/bin/openclaw + +# Create gateway launcher script (used by autostart) +cat > /usr/local/bin/careerclaw-gateway <<'LAUNCHER' +#!/usr/bin/env bash +GATEWAY_LOG="$HOME/.openclaw/gateway.log" +mkdir -p "$HOME/.openclaw" +chmod 700 "$HOME/.openclaw" + +# Don't start if already running +if ss -tlnp 2>/dev/null | grep -q ":18789"; then + echo "CareerClaw gateway already running" >> "$GATEWAY_LOG" + exit 0 +fi + +# Enforce localhost binding — overwrite config if tampered (defense-in-depth) +CONFIG="$HOME/.openclaw/openclaw.json" +if [ -f "$CONFIG" ]; then + BIND_ADDR=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('gateway',{}).get('bind',''))" 2>/dev/null || echo "") + if [ "$BIND_ADDR" != "loopback" ]; then + echo "[$(date)] SECURITY: bind was '$BIND_ADDR', forcing to loopback" >> "$GATEWAY_LOG" + python3 -c " +import json +c = json.load(open('$CONFIG')) +c.setdefault('gateway',{})['bind'] = 'loopback' +json.dump(c, open('$CONFIG','w'), indent=2) +" 2>/dev/null + fi +fi + +echo "[$(date)] Starting CareerClaw gateway..." >> "$GATEWAY_LOG" +exec /usr/local/bin/openclaw gateway >> "$GATEWAY_LOG" 2>&1 +LAUNCHER +chmod +x /usr/local/bin/careerclaw-gateway + +# Create default config directory for the Kasm default profile +OPENCLAW_STATE="$HOME/.openclaw" +mkdir -p "$OPENCLAW_STATE/workspace" +mkdir -p "$OPENCLAW_STATE/agents/main/sessions" +mkdir -p "$OPENCLAW_STATE/credentials" + +cat > "$OPENCLAW_STATE/openclaw.json" <<'CONF' +{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-5-20250929" + } + } + }, + "gateway": { + "mode": "local", + "port": 18789, + "bind": "loopback" + } +} +CONF +chmod 600 "$OPENCLAW_STATE/openclaw.json" + +# Create desktop icon +mkdir -p /usr/share/icons/hicolor/apps +cp "$CAREERCLAW_DIR/assets/icon.png" /usr/share/icons/hicolor/apps/careerclaw.png 2>/dev/null || \ + wget -q -O /usr/share/icons/hicolor/apps/careerclaw.png \ + "https://raw.githubusercontent.com/alexander-acker/careerclaw/main/assets/icon.png" 2>/dev/null || \ + echo "No icon found, using default" + +# Desktop shortcut to open the gateway web UI +cat > /usr/share/applications/careerclaw.desktop <<'DESKTOP' +[Desktop Entry] +Version=1.0 +Type=Application +Name=CareerClaw AI +Comment=AI Career Assistant - OpenClaw Gateway +Exec=xdg-open http://localhost:18789 +Icon=/usr/share/icons/hicolor/apps/careerclaw.png +Terminal=false +Categories=Utility;Network; +StartupNotify=true +DESKTOP + +cp /usr/share/applications/careerclaw.desktop "$HOME/Desktop/" +chmod +x "$HOME/Desktop/careerclaw.desktop" +chown 1000:1000 "$HOME/Desktop/careerclaw.desktop" + +# XFCE autostart: launch gateway automatically at desktop login +mkdir -p /etc/xdg/autostart +cat > /etc/xdg/autostart/careerclaw-gateway.desktop <<'AUTOSTART' +[Desktop Entry] +Type=Application +Name=CareerClaw Gateway +Comment=Start CareerClaw AI gateway in background +Exec=/usr/local/bin/careerclaw-gateway +Hidden=false +NoDisplay=true +X-GNOME-Autostart-enabled=true +AUTOSTART + +# Set ownership +chown -R 1000:0 "$CAREERCLAW_DIR" +chown -R 1000:0 "$OPENCLAW_STATE" + +# Cleanup for app layer +chown -R 1000:0 $HOME +find /usr/share/ -name "icon-theme.cache" -exec rm -f {} \; + +if [ -z ${SKIP_CLEAN+x} ]; then + apt-get autoclean + rm -rf \ + /var/lib/apt/lists/* \ + /var/tmp/* \ + /tmp/* +fi diff --git a/src/ubuntu/install/chrome/install_chrome.sh b/src/ubuntu/install/chrome/install_chrome.sh index 7197b04f8..94ed51b7f 100644 --- a/src/ubuntu/install/chrome/install_chrome.sh +++ b/src/ubuntu/install/chrome/install_chrome.sh @@ -29,7 +29,7 @@ if [[ "${DISTRO}" == @(centos|oracle8|rockylinux9|rockylinux8|oracle9|rhel9|alma fi rm chrome.rpm elif [ "${DISTRO}" == "opensuse" ]; then - zypper ar http://dl.google.com/linux/chrome/rpm/stable/x86_64 Google-Chrome + zypper ar https://dl.google.com/linux/chrome/rpm/stable/x86_64 Google-Chrome wget https://dl.google.com/linux/linux_signing_key.pub rpm --import linux_signing_key.pub rm linux_signing_key.pub diff --git a/src/ubuntu/install/coeadapt-agent/agent/computer_use.py b/src/ubuntu/install/coeadapt-agent/agent/computer_use.py new file mode 100644 index 000000000..599a7d3fa --- /dev/null +++ b/src/ubuntu/install/coeadapt-agent/agent/computer_use.py @@ -0,0 +1,420 @@ +""" +Coeadapt Computer-Use Service — runs inside the Kasm VM. + +Provides a lightweight HTTP API on 127.0.0.1:7701 for full computer +control: mouse movement, clicks, keyboard input, screenshots, and +window management. Designed to be called by the MCP server on the +host via `docker exec curl ...` or direct HTTP. + +Requires: xdotool, xwd/import (imagemagick), xdpyinfo +""" + +import base64 +import json +import os +import subprocess +import tempfile +import time +from http.server import HTTPServer, BaseHTTPRequestHandler + +DISPLAY = os.environ.get("DISPLAY", ":1") + + +# --------------------------------------------------------------------------- +# Low-level X11 helpers (via xdotool / xprop / xwininfo) +# --------------------------------------------------------------------------- + +def _run(cmd: list[str], timeout: int = 10) -> tuple[str, str, int]: + """Run a subprocess and return (stdout, stderr, returncode).""" + env = {**os.environ, "DISPLAY": DISPLAY} + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, env=env + ) + return proc.stdout.strip(), proc.stderr.strip(), proc.returncode + except subprocess.TimeoutExpired: + return "", "timeout", 1 + except FileNotFoundError: + return "", f"command not found: {cmd[0]}", 127 + + +def screenshot_png() -> bytes | None: + """Capture the entire screen as PNG bytes.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + path = f.name + try: + # Try import (ImageMagick) first + stdout, stderr, rc = _run(["import", "-window", "root", path]) + if rc != 0: + # Fallback: xwd -> convert + xwd_path = path.replace(".png", ".xwd") + _run(["xwd", "-root", "-out", xwd_path]) + _run(["convert", xwd_path, path]) + if os.path.exists(xwd_path): + os.unlink(xwd_path) + if os.path.exists(path) and os.path.getsize(path) > 0: + with open(path, "rb") as f: + return f.read() + return None + finally: + if os.path.exists(path): + os.unlink(path) + + +def screenshot_region_png(x: int, y: int, w: int, h: int) -> bytes | None: + """Capture a specific region of the screen.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + path = f.name + try: + geometry = f"{w}x{h}+{x}+{y}" + _run(["import", "-window", "root", "-crop", geometry, path]) + if os.path.exists(path) and os.path.getsize(path) > 0: + with open(path, "rb") as f: + return f.read() + return None + finally: + if os.path.exists(path): + os.unlink(path) + + +def get_screen_size() -> tuple[int, int]: + """Return (width, height) of the primary display.""" + stdout, _, rc = _run(["xdpyinfo"]) + if rc == 0: + for line in stdout.splitlines(): + if "dimensions:" in line: + # e.g. " dimensions: 1920x1080 pixels (508x285 millimeters)" + dim = line.split("dimensions:")[1].strip().split()[0] + w, h = dim.split("x") + return int(w), int(h) + return 1920, 1080 # Default fallback + + +def mouse_move(x: int, y: int) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "mousemove", str(x), str(y)]) + return stderr if rc else "ok", rc + + +def mouse_click(button: int = 1) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "click", str(button)]) + return stderr if rc else "ok", rc + + +def mouse_double_click(button: int = 1) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "click", "--repeat", "2", "--delay", "100", str(button)]) + return stderr if rc else "ok", rc + + +def mouse_down(button: int = 1) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "mousedown", str(button)]) + return stderr if rc else "ok", rc + + +def mouse_up(button: int = 1) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "mouseup", str(button)]) + return stderr if rc else "ok", rc + + +def mouse_scroll(direction: str = "down", clicks: int = 3) -> tuple[str, int]: + btn = "5" if direction == "down" else "4" + stdout, stderr, rc = _run(["xdotool", "click", "--repeat", str(clicks), btn]) + return stderr if rc else "ok", rc + + +def get_mouse_position() -> tuple[int, int]: + stdout, _, rc = _run(["xdotool", "getmouselocation"]) + if rc == 0: + # "x:123 y:456 screen:0 window:12345678" + parts = dict(p.split(":") for p in stdout.split() if ":" in p) + return int(parts.get("x", 0)), int(parts.get("y", 0)) + return 0, 0 + + +def key_type(text: str) -> tuple[str, int]: + """Type text using xdotool, handling special characters.""" + stdout, stderr, rc = _run(["xdotool", "type", "--clearmodifiers", "--delay", "12", text]) + return stderr if rc else "ok", rc + + +def key_press(keys: str) -> tuple[str, int]: + """Press a key combination like 'ctrl+c', 'Return', 'alt+F4'.""" + stdout, stderr, rc = _run(["xdotool", "key", "--clearmodifiers", keys]) + return stderr if rc else "ok", rc + + +def key_down(key: str) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "keydown", key]) + return stderr if rc else "ok", rc + + +def key_up(key: str) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "keyup", key]) + return stderr if rc else "ok", rc + + +def get_active_window() -> dict: + """Get info about the currently active window.""" + stdout, _, rc = _run(["xdotool", "getactivewindow"]) + if rc != 0: + return {"error": "no active window"} + window_id = stdout.strip() + + name_out, _, _ = _run(["xdotool", "getactivewindow", "getwindowname"]) + geo_out, _, _ = _run(["xdotool", "getactivewindow", "getwindowgeometry"]) + + result = {"id": window_id, "name": name_out} + if geo_out: + for line in geo_out.splitlines(): + if "Position:" in line: + pos = line.split("Position:")[1].strip().split("(")[0].strip() + x, y = pos.split(",") + result["x"] = int(x) + result["y"] = int(y) + if "Geometry:" in line: + geo = line.split("Geometry:")[1].strip() + w, h = geo.split("x") + result["width"] = int(w) + result["height"] = int(h) + return result + + +def list_windows() -> list[dict]: + """List all visible windows.""" + stdout, _, rc = _run(["xdotool", "search", "--onlyvisible", "--name", ""]) + if rc != 0: + return [] + windows = [] + for wid in stdout.splitlines(): + wid = wid.strip() + if not wid: + continue + name_out, _, _ = _run(["xdotool", "getwindowname", wid]) + if name_out: + windows.append({"id": wid, "name": name_out}) + return windows[:50] # Cap to avoid huge responses + + +def focus_window(window_id: str) -> tuple[str, int]: + stdout, stderr, rc = _run(["xdotool", "windowactivate", window_id]) + return stderr if rc else "ok", rc + + +def drag(x1: int, y1: int, x2: int, y2: int) -> tuple[str, int]: + """Click-drag from (x1,y1) to (x2,y2).""" + _run(["xdotool", "mousemove", str(x1), str(y1)]) + _run(["xdotool", "mousedown", "1"]) + _run(["xdotool", "mousemove", "--delay", "10", str(x2), str(y2)]) + stdout, stderr, rc = _run(["xdotool", "mouseup", "1"]) + return stderr if rc else "ok", rc + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- + +class ComputerUseHandler(BaseHTTPRequestHandler): + + def log_message(self, format, *args): + pass + + def _json(self, status: int, body: dict): + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _image(self, data: bytes): + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + return json.loads(self.rfile.read(length)) + + def do_GET(self): + path = self.path.split("?")[0].rstrip("/") + + if path == "/health": + self._json(200, {"status": "ok", "service": "computer-use"}) + return + + if path == "/screen/size": + w, h = get_screen_size() + self._json(200, {"width": w, "height": h}) + return + + if path == "/screen/screenshot": + data = screenshot_png() + if data: + b64 = base64.b64encode(data).decode() + self._json(200, {"image": b64, "format": "png"}) + else: + self._json(500, {"error": "screenshot failed"}) + return + + if path == "/mouse/position": + x, y = get_mouse_position() + self._json(200, {"x": x, "y": y}) + return + + if path == "/window/active": + self._json(200, get_active_window()) + return + + if path == "/window/list": + self._json(200, {"windows": list_windows()}) + return + + self._json(404, {"error": "not found"}) + + def do_POST(self): + path = self.path.rstrip("/") + body = self._body() + + if path == "/screen/screenshot": + if "x" in body and "y" in body and "width" in body and "height" in body: + data = screenshot_region_png( + body["x"], body["y"], body["width"], body["height"] + ) + else: + data = screenshot_png() + if data: + b64 = base64.b64encode(data).decode() + self._json(200, {"image": b64, "format": "png"}) + else: + self._json(500, {"error": "screenshot failed"}) + return + + if path == "/mouse/move": + x, y = body.get("x", 0), body.get("y", 0) + msg, rc = mouse_move(x, y) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/click": + button = body.get("button", 1) + msg, rc = mouse_click(button) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/double_click": + button = body.get("button", 1) + msg, rc = mouse_double_click(button) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/down": + button = body.get("button", 1) + msg, rc = mouse_down(button) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/up": + button = body.get("button", 1) + msg, rc = mouse_up(button) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/scroll": + direction = body.get("direction", "down") + clicks = body.get("clicks", 3) + msg, rc = mouse_scroll(direction, clicks) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/mouse/drag": + msg, rc = drag( + body.get("x1", 0), body.get("y1", 0), + body.get("x2", 0), body.get("y2", 0), + ) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/keyboard/type": + text = body.get("text", "") + msg, rc = key_type(text) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/keyboard/press": + keys = body.get("keys", "Return") + msg, rc = key_press(keys) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/keyboard/down": + key = body.get("key", "") + msg, rc = key_down(key) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/keyboard/up": + key = body.get("key", "") + msg, rc = key_up(key) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + if path == "/window/focus": + wid = body.get("window_id", "") + msg, rc = focus_window(wid) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + # Composite action: move + click in one call + if path == "/action/click_at": + x, y = body.get("x", 0), body.get("y", 0) + button = body.get("button", 1) + mouse_move(x, y) + time.sleep(0.05) + msg, rc = mouse_click(button) + self._json(200 if rc == 0 else 500, {"result": msg, "x": x, "y": y}) + return + + # Composite action: move + double-click + if path == "/action/double_click_at": + x, y = body.get("x", 0), body.get("y", 0) + mouse_move(x, y) + time.sleep(0.05) + msg, rc = mouse_double_click() + self._json(200 if rc == 0 else 500, {"result": msg, "x": x, "y": y}) + return + + # Composite action: move + type text (click at location, then type) + if path == "/action/type_at": + x, y = body.get("x", 0), body.get("y", 0) + text = body.get("text", "") + mouse_move(x, y) + time.sleep(0.05) + mouse_click(1) + time.sleep(0.1) + msg, rc = key_type(text) + self._json(200 if rc == 0 else 500, {"result": msg}) + return + + self._json(404, {"error": "not found"}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + host = "127.0.0.1" + port = 7701 + server = HTTPServer((host, port), ComputerUseHandler) + print(f"Computer-use service listening on http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/ubuntu/install/coeadapt-agent/agent/progress_tracker.py b/src/ubuntu/install/coeadapt-agent/agent/progress_tracker.py new file mode 100644 index 000000000..ccb23ae7c --- /dev/null +++ b/src/ubuntu/install/coeadapt-agent/agent/progress_tracker.py @@ -0,0 +1,415 @@ +""" +Coeadapt Progress Tracker — runs inside the Kasm VM. + +Maintains a local JSON store of the user's career development activities, +assessments, goals, and skill evidence. Exposes a lightweight HTTP API +on 127.0.0.1:7700 that the MCP server (running on the host) reaches +via `docker exec` or port-forward. + +Data is persisted to ~/.coeadapt/progress.json so it survives container +restarts (volume-mounted home directory). +""" + +import json +import os +import time +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from datetime import datetime, timezone + +DATA_DIR = Path.home() / ".coeadapt" +PROGRESS_FILE = DATA_DIR / "progress.json" +LOCK = threading.Lock() + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + +def _default_data() -> dict: + return { + "version": 1, + "activities": [], + "assessments": [], + "goals": [], + "skills": [], + "milestones": [], + "daily_log": [], + "progress_percent": 0, + "streak_days": 0, + "last_activity_at": None, + "created_at": _now(), + "updated_at": _now(), + } + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _load() -> dict: + if PROGRESS_FILE.exists(): + try: + return json.loads(PROGRESS_FILE.read_text()) + except (json.JSONDecodeError, OSError): + pass + return _default_data() + + +def _save(data: dict) -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + data["updated_at"] = _now() + tmp = PROGRESS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2)) + tmp.replace(PROGRESS_FILE) + + +def _next_id(items: list) -> int: + if not items: + return 1 + return max(item.get("id", 0) for item in items) + 1 + + +def _recalc_progress(data: dict) -> None: + """Recalculate overall progress_percent from goals and milestones.""" + goals = data.get("goals", []) + if not goals: + data["progress_percent"] = 0 + return + completed = sum(1 for g in goals if g.get("status") == "completed") + data["progress_percent"] = round((completed / len(goals)) * 100) + + +def _update_streak(data: dict) -> None: + """Update streak_days based on daily_log entries.""" + log = data.get("daily_log", []) + if not log: + data["streak_days"] = 0 + return + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + dates = sorted(set(entry.get("date", "") for entry in log), reverse=True) + if not dates or dates[0] != today: + # Check if yesterday is present (still counts) + from datetime import timedelta + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d") + if not dates or dates[0] != yesterday: + data["streak_days"] = 0 + return + streak = 0 + from datetime import timedelta + check_date = datetime.now(timezone.utc).date() + date_set = set(dates) + while check_date.strftime("%Y-%m-%d") in date_set: + streak += 1 + check_date -= timedelta(days=1) + data["streak_days"] = streak + + +# --------------------------------------------------------------------------- +# HTTP request handler +# --------------------------------------------------------------------------- + +class ProgressHandler(BaseHTTPRequestHandler): + """Minimal JSON API for progress tracking.""" + + def log_message(self, format, *args): + # Quiet logging + pass + + def _json_response(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _read_body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + raw = self.rfile.read(length) + return json.loads(raw) + + # --- Routing --- + + def do_GET(self): + path = self.path.rstrip("/") + + if path == "/health": + self._json_response(200, {"status": "ok", "uptime": time.monotonic()}) + return + + if path == "/progress": + with LOCK: + data = _load() + self._json_response(200, data) + return + + if path == "/progress/summary": + with LOCK: + data = _load() + summary = { + "progress_percent": data.get("progress_percent", 0), + "streak_days": data.get("streak_days", 0), + "total_activities": len(data.get("activities", [])), + "total_goals": len(data.get("goals", [])), + "completed_goals": sum( + 1 for g in data.get("goals", []) if g.get("status") == "completed" + ), + "total_skills": len(data.get("skills", [])), + "total_milestones": len(data.get("milestones", [])), + "last_activity_at": data.get("last_activity_at"), + } + self._json_response(200, summary) + return + + if path == "/progress/activities": + with LOCK: + data = _load() + self._json_response(200, {"activities": data.get("activities", [])}) + return + + if path == "/progress/goals": + with LOCK: + data = _load() + self._json_response(200, {"goals": data.get("goals", [])}) + return + + if path == "/progress/skills": + with LOCK: + data = _load() + self._json_response(200, {"skills": data.get("skills", [])}) + return + + if path == "/progress/milestones": + with LOCK: + data = _load() + self._json_response(200, {"milestones": data.get("milestones", [])}) + return + + self._json_response(404, {"error": "not found"}) + + def do_POST(self): + path = self.path.rstrip("/") + + if path == "/progress/activities": + body = self._read_body() + with LOCK: + data = _load() + activity = { + "id": _next_id(data["activities"]), + "type": body.get("type", "general"), + "title": body.get("title", "Untitled"), + "description": body.get("description", ""), + "duration_minutes": body.get("duration_minutes", 0), + "tags": body.get("tags", []), + "metadata": body.get("metadata", {}), + "created_at": _now(), + } + data["activities"].append(activity) + data["last_activity_at"] = activity["created_at"] + # Log daily entry + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + data.setdefault("daily_log", []) + data["daily_log"].append({"date": today, "activity_id": activity["id"]}) + _update_streak(data) + _save(data) + self._json_response(201, activity) + return + + if path == "/progress/goals": + body = self._read_body() + with LOCK: + data = _load() + goal = { + "id": _next_id(data["goals"]), + "title": body.get("title", "Untitled Goal"), + "description": body.get("description", ""), + "category": body.get("category", "general"), + "status": "active", + "target_date": body.get("target_date"), + "sub_goals": body.get("sub_goals", []), + "created_at": _now(), + "updated_at": _now(), + } + data["goals"].append(goal) + _recalc_progress(data) + _save(data) + self._json_response(201, goal) + return + + if path == "/progress/skills": + body = self._read_body() + with LOCK: + data = _load() + skill = { + "id": _next_id(data["skills"]), + "name": body.get("name", "Unknown Skill"), + "category": body.get("category", "general"), + "level": body.get("level", "beginner"), + "evidence": body.get("evidence", []), + "verified": False, + "created_at": _now(), + "updated_at": _now(), + } + data["skills"].append(skill) + _save(data) + self._json_response(201, skill) + return + + if path == "/progress/milestones": + body = self._read_body() + with LOCK: + data = _load() + milestone = { + "id": _next_id(data["milestones"]), + "title": body.get("title", "Untitled Milestone"), + "description": body.get("description", ""), + "achieved": body.get("achieved", False), + "achieved_at": _now() if body.get("achieved") else None, + "created_at": _now(), + } + data["milestones"].append(milestone) + _save(data) + self._json_response(201, milestone) + return + + if path == "/progress/assessments": + body = self._read_body() + with LOCK: + data = _load() + assessment = { + "id": _next_id(data.get("assessments", [])), + "type": body.get("type", "self"), + "skill": body.get("skill", ""), + "score": body.get("score", 0), + "max_score": body.get("max_score", 100), + "notes": body.get("notes", ""), + "created_at": _now(), + } + data.setdefault("assessments", []).append(assessment) + _save(data) + self._json_response(201, assessment) + return + + self._json_response(404, {"error": "not found"}) + + def do_PUT(self): + path = self.path.rstrip("/") + + # PUT /progress/goals/ + if path.startswith("/progress/goals/"): + try: + goal_id = int(path.split("/")[-1]) + except ValueError: + self._json_response(400, {"error": "invalid goal id"}) + return + body = self._read_body() + with LOCK: + data = _load() + for goal in data.get("goals", []): + if goal.get("id") == goal_id: + for key in ("title", "description", "category", "status", "target_date"): + if key in body: + goal[key] = body[key] + goal["updated_at"] = _now() + if goal.get("status") == "completed" and not goal.get("completed_at"): + goal["completed_at"] = _now() + _recalc_progress(data) + _save(data) + self._json_response(200, goal) + return + self._json_response(404, {"error": "goal not found"}) + return + + # PUT /progress/skills/ + if path.startswith("/progress/skills/"): + try: + skill_id = int(path.split("/")[-1]) + except ValueError: + self._json_response(400, {"error": "invalid skill id"}) + return + body = self._read_body() + with LOCK: + data = _load() + for skill in data.get("skills", []): + if skill.get("id") == skill_id: + for key in ("name", "category", "level", "evidence", "verified"): + if key in body: + skill[key] = body[key] + skill["updated_at"] = _now() + _save(data) + self._json_response(200, skill) + return + self._json_response(404, {"error": "skill not found"}) + return + + self._json_response(404, {"error": "not found"}) + + +# --------------------------------------------------------------------------- +# Auto-sync thread (optional platform sync) +# --------------------------------------------------------------------------- + +class PlatformSyncer(threading.Thread): + """Periodically syncs local progress to the Coeadapt platform API.""" + + daemon = True + + def __init__(self, api_url: str | None = None, token: str | None = None): + super().__init__() + self.api_url = api_url or os.environ.get("COEADAPT_API_URL") + self.token = token or os.environ.get("COEADAPT_TOKEN") + + def run(self): + if not self.api_url or not self.token: + return # No platform configured — local-only mode + import urllib.request + while True: + time.sleep(300) # Sync every 5 minutes + try: + with LOCK: + data = _load() + payload = json.dumps({"progress": data}).encode() + req = urllib.request.Request( + f"{self.api_url}/api/career-box/sync-progress", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + }, + method="POST", + ) + urllib.request.urlopen(req, timeout=10) + except Exception: + pass # Best-effort sync + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # Ensure progress file exists + if not PROGRESS_FILE.exists(): + _save(_default_data()) + + # Start platform syncer + syncer = PlatformSyncer() + syncer.start() + + host = "127.0.0.1" + port = 7700 + server = HTTPServer((host, port), ProgressHandler) + print(f"Progress tracker listening on http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/ubuntu/install/coeadapt-agent/custom_startup.sh b/src/ubuntu/install/coeadapt-agent/custom_startup.sh new file mode 100644 index 000000000..3ff00c6b5 --- /dev/null +++ b/src/ubuntu/install/coeadapt-agent/custom_startup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# Coeadapt Agent startup script — keeps services running via respawn loop. +# Pattern matches other Kasm custom_startup.sh scripts. + +PGREP="python3" +AGENT_LOG="$HOME/.coeadapt/logs/agent.log" + +mkdir -p "$HOME/.coeadapt/logs" + +options=$(getopt -o gau: -l go,assign,url: -n "$0" -- "$@") || exit +eval set -- "$options" + +while [[ $1 != -- ]]; do + case $1 in + -g|--go) GO='true'; shift 1;; + -a|--assign) ASSIGN='true'; shift 1;; + -u|--url) OPT_URL=$2; shift 2;; + *) echo "bad option: $1" >&2; exit 1;; + esac +done +shift + +kasm_exec() { + /usr/bin/filter_ready + /usr/bin/desktop_ready +} + +kasm_startup() { + if [ -n "$DISABLE_CUSTOM_STARTUP" ]; then + echo "Coeadapt agent custom startup disabled" + return + fi + + # Respawn loop: keep agent services running + while true; do + if ! ss -tlnp 2>/dev/null | grep -q ":7700" || ! ss -tlnp 2>/dev/null | grep -q ":7701"; then + /usr/bin/filter_ready + /usr/bin/desktop_ready + + echo "[$(date)] Starting Coeadapt agent services..." >> "$AGENT_LOG" + /usr/local/bin/coeadapt-agent >> "$AGENT_LOG" 2>&1 + fi + sleep 5 + done +} + +if [ -n "$GO" ] || [ -n "$ASSIGN" ]; then + kasm_exec +else + kasm_startup +fi diff --git a/src/ubuntu/install/coeadapt-agent/install_coeadapt_agent.sh b/src/ubuntu/install/coeadapt-agent/install_coeadapt_agent.sh new file mode 100644 index 000000000..efb09549f --- /dev/null +++ b/src/ubuntu/install/coeadapt-agent/install_coeadapt_agent.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -ex + +# --------------------------------------------------------------------------- +# Install the Coeadapt VM Agent Services +# +# Two lightweight Python services that run inside the Kasm workspace container: +# 1. Progress Tracker (port 7700) — career progress persistence & API +# 2. Computer-Use (port 7701) — mouse, keyboard, screen control +# +# Both bind to 127.0.0.1 only (defense-in-depth: not reachable from outside). +# The MCP server on the host reaches them via `docker exec`. +# --------------------------------------------------------------------------- + +AGENT_DIR="/opt/coeadapt-agent" +STATE_DIR="/home/kasm-user/.coeadapt" + +# --- System dependencies --- +apt-get update +apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + xdotool \ + imagemagick \ + x11-utils + +# --- Install agent code --- +mkdir -p "$AGENT_DIR" +cp -r "$(dirname "$0")/agent/"*.py "$AGENT_DIR/" +chmod 644 "$AGENT_DIR"/*.py + +# --- Create state directory --- +mkdir -p "$STATE_DIR" + +# Initialize progress.json if it doesn't exist +if [ ! -f "$STATE_DIR/progress.json" ]; then + cat > "$STATE_DIR/progress.json" <<'JSON' +{ + "version": 1, + "activities": [], + "assessments": [], + "goals": [], + "skills": [], + "milestones": [], + "daily_log": [], + "progress_percent": 0, + "streak_days": 0, + "last_activity_at": null, + "created_at": null, + "updated_at": null +} +JSON +fi + +# --- Systemd-style launcher (runs as kasm-user) --- +cat > /usr/local/bin/coeadapt-agent <<'LAUNCHER' +#!/usr/bin/env bash +# Start both agent services in the background +AGENT_DIR="/opt/coeadapt-agent" +LOG_DIR="$HOME/.coeadapt/logs" +mkdir -p "$LOG_DIR" + +# Don't start if already running +if ss -tlnp 2>/dev/null | grep -q ":7700"; then + echo "Progress tracker already running" +else + echo "[$(date)] Starting progress tracker..." >> "$LOG_DIR/progress.log" + DISPLAY=${DISPLAY:-:1} python3 "$AGENT_DIR/progress_tracker.py" >> "$LOG_DIR/progress.log" 2>&1 & +fi + +if ss -tlnp 2>/dev/null | grep -q ":7701"; then + echo "Computer-use service already running" +else + echo "[$(date)] Starting computer-use service..." >> "$LOG_DIR/computer_use.log" + DISPLAY=${DISPLAY:-:1} python3 "$AGENT_DIR/computer_use.py" >> "$LOG_DIR/computer_use.log" 2>&1 & +fi + +echo "Coeadapt agent services started" +LAUNCHER +chmod +x /usr/local/bin/coeadapt-agent + +# --- Stop script --- +cat > /usr/local/bin/coeadapt-agent-stop <<'STOP' +#!/usr/bin/env bash +# Gracefully stop agent services +pkill -f "progress_tracker.py" 2>/dev/null || true +pkill -f "computer_use.py" 2>/dev/null || true +echo "Coeadapt agent services stopped" +STOP +chmod +x /usr/local/bin/coeadapt-agent-stop + +# --- Health check script --- +cat > /usr/local/bin/coeadapt-agent-health <<'HEALTH' +#!/usr/bin/env bash +# Check health of both services +progress=$(curl -sf http://127.0.0.1:7700/health 2>/dev/null && echo "ok" || echo "down") +computer=$(curl -sf http://127.0.0.1:7701/health 2>/dev/null && echo "ok" || echo "down") +echo "{\"progress_tracker\": \"$progress\", \"computer_use\": \"$computer\"}" +HEALTH +chmod +x /usr/local/bin/coeadapt-agent-health + +# --- XFCE autostart entry --- +mkdir -p /etc/xdg/autostart +cat > /etc/xdg/autostart/coeadapt-agent.desktop <<'AUTOSTART' +[Desktop Entry] +Type=Application +Name=Coeadapt Agent +Comment=Start Coeadapt progress tracker and computer-use services +Exec=/usr/local/bin/coeadapt-agent +Hidden=false +NoDisplay=true +X-GNOME-Autostart-enabled=true +AUTOSTART + +# --- Set ownership --- +chown -R 1000:0 "$AGENT_DIR" +chown -R 1000:0 "$STATE_DIR" + +# --- Cleanup --- +chown -R 1000:0 /home/kasm-user +find /usr/share/ -name "icon-theme.cache" -exec rm -f {} \; + +if [ -z ${SKIP_CLEAN+x} ]; then + apt-get autoclean + rm -rf \ + /var/lib/apt/lists/* \ + /var/tmp/* \ + /tmp/* +fi diff --git a/src/ubuntu/install/kasmvnc_settings/install_kasmvnc_settings.sh b/src/ubuntu/install/kasmvnc_settings/install_kasmvnc_settings.sh new file mode 100644 index 000000000..4473809cb --- /dev/null +++ b/src/ubuntu/install/kasmvnc_settings/install_kasmvnc_settings.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -ex + +# ============================================================================ +# KasmVNC High-Quality Settings +# Patches the existing kasmvnc.yaml to set 60 FPS and max quality +# without replacing the entire file (preserves base image defaults) +# ============================================================================ + +KASMVNC_YAML="/etc/kasmvnc/kasmvnc.yaml" + +if [ ! -f "${KASMVNC_YAML}" ]; then + echo "WARNING: ${KASMVNC_YAML} not found, skipping VNC config" + exit 0 +fi + +# Patch encoding settings for maximum quality +# Use sed to modify specific values in the existing config + +# Frame rate: 24 -> 60 +sed -i 's/max_frame_rate: [0-9]*/max_frame_rate: 60/' "${KASMVNC_YAML}" + +# Rect encoding quality: raise min/max +sed -i '/rect_encoding_mode:/,/video_encoding_mode:/ { + s/min_quality: [0-9]*/min_quality: 8/ + s/max_quality: [0-9]*/max_quality: 9/ + s/consider_lossless_quality: [0-9]*/consider_lossless_quality: 9/ +}' "${KASMVNC_YAML}" + +# Video encoding quality: raise JPEG and WebP +sed -i '/video_encoding_mode:/,/compare_framebuffer:/ { + s/jpeg_quality: -\?[0-9]*/jpeg_quality: 8/ + s/webp_quality: -\?[0-9]*/webp_quality: 8/ +}' "${KASMVNC_YAML}" + +# Resolution: set default to 1920x1080 +sed -i '/desktop:/,/network:/ { + s/width: [0-9]*/width: 1920/ + s/height: [0-9]*/height: 1080/ +}' "${KASMVNC_YAML}" + +echo "KasmVNC patched: 1920x1080 @ 60fps, quality 8-9/9" diff --git a/src/ubuntu/install/owncloud/install_owncloud.sh b/src/ubuntu/install/owncloud/install_owncloud.sh index c469379a9..60254af07 100644 --- a/src/ubuntu/install/owncloud/install_owncloud.sh +++ b/src/ubuntu/install/owncloud/install_owncloud.sh @@ -4,7 +4,7 @@ set -ex wget -nv https://download.opensuse.org/repositories/isv:ownCloud:desktop/Ubuntu_17.04/Release.key -O Release.key apt-key add - < Release.key apt-get update -sh -c "echo 'deb http://download.opensuse.org/repositories/isv:/ownCloud:/desktop/Ubuntu_16.04/ /' > /etc/apt/sources.list.d/isv:ownCloud:desktop.list" +sh -c "echo 'deb https://download.opensuse.org/repositories/isv:/ownCloud:/desktop/Ubuntu_16.04/ /' > /etc/apt/sources.list.d/isv:ownCloud:desktop.list" apt-get update apt-get install -y owncloud-client mkdir -p $HOME/.config/ownCloud diff --git a/src/ubuntu/install/remmina/install_remmina.sh b/src/ubuntu/install/remmina/install_remmina.sh index 13fd69b62..94adb22e5 100644 --- a/src/ubuntu/install/remmina/install_remmina.sh +++ b/src/ubuntu/install/remmina/install_remmina.sh @@ -83,7 +83,7 @@ window_width=640 ssh_tunnel_server= protocol=VNC disableserverinput=0 -ignore-tls-errors=1 +ignore-tls-errors=0 disableclipboard=0 EOF @@ -108,13 +108,13 @@ group= enable-autostart=0 ssh_tunnel_enabled=0 smartcardname= -gwtransp=http +gwtransp=auto domain= serialname= ssh_tunnel_auth=0 ssh_tunnel_server= loadbalanceinfo= -ignore-tls-errors=1 +ignore-tls-errors=0 clientname= base-cred-for-gw=0 sound=off diff --git a/src/ubuntu/install/smb/install_smb.sh b/src/ubuntu/install/smb/install_smb.sh index 4edf0e92b..b550e79dc 100644 --- a/src/ubuntu/install/smb/install_smb.sh +++ b/src/ubuntu/install/smb/install_smb.sh @@ -53,16 +53,9 @@ client max protocol = SMB3 #### Networking #### # The specific set of interfaces / networks to bind to -# This can be either the interface name or an IP address/netmask; -# interface names are normally preferred -; interfaces = 127.0.0.0/8 eth0 - -# Only bind to the named interfaces and/or networks; you must use the -# 'interfaces' option above to use this. -# It is recommended that you enable this feature if your Samba machine is -# not protected by a firewall or is a firewall itself. However, this -# option cannot handle dynamic or non-broadcast interfaces correctly. -; bind interfaces only = yes +# Restricted to localhost for security — only local file sharing + interfaces = 127.0.0.0/8 + bind interfaces only = yes @@ -125,7 +118,7 @@ client max protocol = SMB3 # This option controls how unsuccessful authentication attempts are mapped # to anonymous connections - map to guest = bad user + map to guest = never ########## Domains ########### @@ -191,7 +184,7 @@ client max protocol = SMB3 # Allow users who've been granted usershare privileges to create # public shares, not just authenticated ones - usershare allow guests = yes + usershare allow guests = no #======================= Share Definitions ======================= diff --git a/src/ubuntu/install/ssl/install_ssl.sh b/src/ubuntu/install/ssl/install_ssl.sh new file mode 100644 index 000000000..9832b41c9 --- /dev/null +++ b/src/ubuntu/install/ssl/install_ssl.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -ex + +# ============================================================================ +# Coeadapt Workspace SSL Certificate Generator +# Generates a local CA + server certificate at Docker build time so +# KasmVNC serves HTTPS that browsers trust (after CA installation on host). +# +# Replaces the default self-signed "snakeoil" certs with a proper +# CA-signed certificate for localhost / 127.0.0.1. +# ============================================================================ + +SSL_DIR="/etc/ssl/coeadapt" +CA_DIR="${SSL_DIR}/ca" +CERT_DIR="${SSL_DIR}/server" +EXPORT_DIR="/usr/share/coeadapt" + +mkdir -p "${CA_DIR}" "${CERT_DIR}" "${EXPORT_DIR}" + +# --- Generate Certificate Authority (10-year validity) --- +openssl genrsa -out "${CA_DIR}/ca.key" 2048 + +openssl req -x509 -new -nodes \ + -key "${CA_DIR}/ca.key" \ + -sha256 -days 3650 \ + -out "${CA_DIR}/ca.crt" \ + -subj "/C=US/ST=Local/L=Localhost/O=Coeadapt/OU=Workspace/CN=Coeadapt Workspace CA" + +# --- Generate Server Certificate signed by the CA (5-year validity) --- +openssl genrsa -out "${CERT_DIR}/server.key" 2048 + +openssl req -new \ + -key "${CERT_DIR}/server.key" \ + -out "${CERT_DIR}/server.csr" \ + -subj "/C=US/ST=Local/L=Localhost/O=Coeadapt/OU=Workspace/CN=localhost" + +# SAN extension — browsers require Subject Alternative Names +cat > "${CERT_DIR}/server.ext" << 'EXTEOF' +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +IP.2 = ::1 +EXTEOF + +openssl x509 -req \ + -in "${CERT_DIR}/server.csr" \ + -CA "${CA_DIR}/ca.crt" \ + -CAkey "${CA_DIR}/ca.key" \ + -CAcreateserial \ + -out "${CERT_DIR}/server.crt" \ + -days 1825 \ + -sha256 \ + -extfile "${CERT_DIR}/server.ext" + +# --- Replace KasmVNC default snakeoil certs --- +cp "${CERT_DIR}/server.crt" /etc/ssl/certs/ssl-cert-snakeoil.pem +cp "${CERT_DIR}/server.key" /etc/ssl/private/ssl-cert-snakeoil.key +chmod 644 /etc/ssl/certs/ssl-cert-snakeoil.pem +chmod 640 /etc/ssl/private/ssl-cert-snakeoil.key + +# --- Export CA cert for host trust-store installation --- +cp "${CA_DIR}/ca.crt" "${EXPORT_DIR}/ca.crt" +chmod 644 "${EXPORT_DIR}/ca.crt" + +# --- Cleanup intermediate files --- +rm -f "${CERT_DIR}/server.csr" "${CERT_DIR}/server.ext" "${CA_DIR}/ca.srl" + +echo "=== Coeadapt SSL setup complete ===" +echo " CA cert (export to host): ${EXPORT_DIR}/ca.crt" +echo " Server cert: /etc/ssl/certs/ssl-cert-snakeoil.pem" +echo " Server key: /etc/ssl/private/ssl-cert-snakeoil.key" diff --git a/src/ubuntu/install/wine/install_wine.sh b/src/ubuntu/install/wine/install_wine.sh index 6582369ab..1b6b99507 100644 --- a/src/ubuntu/install/wine/install_wine.sh +++ b/src/ubuntu/install/wine/install_wine.sh @@ -1,9 +1,115 @@ -#!/bin/bash +#!/usr/bin/env bash +set -ex -# This script currently supports Ubuntu focal only +# ============================================================================ +# Wine + Winetricks for Kasm Workspaces +# Supports Ubuntu Jammy (22.04) and Noble (24.04) +# Uses modern DEB822 .sources format (no deprecated apt-key) +# ============================================================================ + +UBUNTU_CODENAME=$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) +echo "Installing Wine on Ubuntu ${UBUNTU_CODENAME}" + +# -------------------------------------------------------------------------- +# 1. Enable 32-bit architecture (required for most Windows apps) +# -------------------------------------------------------------------------- dpkg --add-architecture i386 -apt update -wget -qO- https://dl.winehq.org/wine-builds/winehq.key | apt-key add - -apt install software-properties-common -apt-add-repository "deb http://dl.winehq.org/wine-builds/ubuntu/ $(lsb_release -cs) main" -apt install -y --install-recommends winehq-stable winetricks + +# -------------------------------------------------------------------------- +# 2. Add WineHQ repository using modern signed-by method +# -------------------------------------------------------------------------- +apt-get update +apt-get install -y software-properties-common wget + +mkdir -pm755 /etc/apt/keyrings +wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key + +# Download the official .sources file for this Ubuntu release +wget -NP /etc/apt/sources.list.d/ \ + "https://dl.winehq.org/wine-builds/ubuntu/dists/${UBUNTU_CODENAME}/winehq-${UBUNTU_CODENAME}.sources" + +apt-get update + +# -------------------------------------------------------------------------- +# 3. Install Wine Stable + Winetricks +# -------------------------------------------------------------------------- +apt-get install -y --install-recommends winehq-stable || \ + apt-get install -y --install-recommends winehq-devel + +apt-get install -y winetricks + +# -------------------------------------------------------------------------- +# 4. Initialize Wine prefix and install core fonts +# This avoids a slow first-run experience for users +# -------------------------------------------------------------------------- +export WINEPREFIX="/home/kasm-default-profile/.wine" +export WINEDLLOVERRIDES="mscoree,mshtml=" +export DISPLAY=:1 + +# Initialize the Wine prefix (silent, no GUI) +wineboot --init 2>/dev/null || true +sleep 2 + +# Install core Windows fonts via winetricks (improves app rendering) +winetricks -q corefonts 2>/dev/null || true + +# -------------------------------------------------------------------------- +# 5. Create .desktop file for Wine configuration +# -------------------------------------------------------------------------- +mkdir -p /home/kasm-default-profile/Desktop +cat > /home/kasm-default-profile/Desktop/wine-config.desktop << 'DEOF' +[Desktop Entry] +Version=1.0 +Type=Application +Name=Wine Configuration +Comment=Configure Wine Windows Compatibility +Exec=winecfg +Icon=wine +Terminal=false +Categories=System; +DEOF +chmod +x /home/kasm-default-profile/Desktop/wine-config.desktop + +# -------------------------------------------------------------------------- +# 6. Set up .exe file association so double-clicking opens with Wine +# -------------------------------------------------------------------------- +mkdir -p /home/kasm-default-profile/.local/share/applications +cat > /home/kasm-default-profile/.local/share/applications/wine.desktop << 'AEOF' +[Desktop Entry] +Version=1.0 +Type=Application +Name=Wine Windows Program Loader +MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-executable; +Exec=wine %f +Icon=wine +Terminal=false +NoDisplay=true +AEOF + +mkdir -p /home/kasm-default-profile/.local/share/mime/packages +cat > /home/kasm-default-profile/.local/share/mime/packages/wine.xml << 'MEOF' + + + + Windows Executable + + + +MEOF + +update-mime-database /home/kasm-default-profile/.local/share/mime 2>/dev/null || true + +# -------------------------------------------------------------------------- +# 7. Cleanup +# -------------------------------------------------------------------------- +chown -R 1000:0 /home/kasm-default-profile + +if [ -z "${SKIP_CLEAN+x}" ]; then + apt-get autoclean + rm -rf \ + /var/lib/apt/lists/* \ + /var/tmp/* \ + /tmp/* +fi + +echo "Wine installation complete: $(wine --version 2>/dev/null || echo 'version check failed')" diff --git a/src/ubuntu/install/zorin_theme/install_zorin_theme.sh b/src/ubuntu/install/zorin_theme/install_zorin_theme.sh new file mode 100644 index 000000000..74e462fd7 --- /dev/null +++ b/src/ubuntu/install/zorin_theme/install_zorin_theme.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -ex + +# ============================================================================ +# Career-Box Desktop Theme — macOS-style +# Installs Colloid GTK/icon themes + Plank dock for a polished macOS layout +# on XFCE (Kasm Workspaces core-ubuntu-jammy / core-ubuntu-noble) +# ============================================================================ + +ARCH=$(arch | sed 's/aarch64/arm64/g' | sed 's/x86_64/amd64/g') + +UBUNTU_CODENAME=$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) +echo "Detected Ubuntu: ${UBUNTU_CODENAME} (${ARCH})" + +# -------------------------------------------------------------------------- +# 1. Install dependencies +# -------------------------------------------------------------------------- + +apt-get update +apt-get install -y \ + git \ + sassc \ + gtk2-engines-murrine \ + gtk2-engines-pixbuf \ + gnome-themes-extra \ + plank \ + dconf-cli \ + unzip \ + wget + +# -------------------------------------------------------------------------- +# 2. Install Colloid GTK theme (dark variant) +# -------------------------------------------------------------------------- + +cd /tmp +git clone --depth 1 https://github.com/vinceliuice/Colloid-gtk-theme.git +cd Colloid-gtk-theme + +# Install dark variant system-wide (to /usr/share/themes) +./install.sh -c dark -d /usr/share/themes + +# Also install the xfwm4 window decorations +if [ -d "src/xfwm4" ]; then + for theme_dir in /usr/share/themes/Colloid-Dark*/; do + if [ -d "$theme_dir" ] && [ ! -d "${theme_dir}xfwm4" ]; then + cp -r src/xfwm4/assets "${theme_dir}xfwm4" 2>/dev/null || true + fi + done +fi + +cd /tmp && rm -rf Colloid-gtk-theme + +# Verify installation +COLLOID_THEME="Colloid-Dark" +if [ ! -d "/usr/share/themes/${COLLOID_THEME}" ]; then + # Fallback: try the exact name that was generated + COLLOID_THEME=$(ls -d /usr/share/themes/Colloid*Dark* 2>/dev/null | head -1 | xargs basename 2>/dev/null || echo "Colloid-Dark") + echo "Using theme: ${COLLOID_THEME}" +fi + +# -------------------------------------------------------------------------- +# 3. Install Colloid icon theme +# -------------------------------------------------------------------------- + +cd /tmp +git clone --depth 1 https://github.com/vinceliuice/Colloid-icon-theme.git +cd Colloid-icon-theme + +# Install system-wide +./install.sh -d /usr/share/icons + +cd /tmp && rm -rf Colloid-icon-theme + +COLLOID_ICONS="Colloid-dark" +if [ ! -d "/usr/share/icons/${COLLOID_ICONS}" ]; then + COLLOID_ICONS=$(ls -d /usr/share/icons/Colloid*dark* 2>/dev/null | head -1 | xargs basename 2>/dev/null || echo "Colloid-dark") + echo "Using icons: ${COLLOID_ICONS}" +fi + +# -------------------------------------------------------------------------- +# 4. Set wallpaper +# -------------------------------------------------------------------------- + +# Use a dark gradient wallpaper — Kasm hardcodes bg_default.png +# Try Zorin wallpapers first (if PPA was previously installed), then fall back +WALLPAPER="" +for candidate in \ + /usr/share/backgrounds/Zorin-Dark.jpg \ + /usr/share/backgrounds/Zorin.jpg \ + /usr/share/backgrounds/bg_kasm.png; do + if [ -f "${candidate}" ]; then + WALLPAPER="${candidate}" + break + fi +done + +if [ -n "${WALLPAPER}" ]; then + cp "${WALLPAPER}" /usr/share/backgrounds/bg_default.png + echo "Set wallpaper: ${WALLPAPER}" +else + echo "Keeping default Kasm wallpaper" +fi + +# -------------------------------------------------------------------------- +# 5. Configure XFCE — Colloid Dark theme +# -------------------------------------------------------------------------- + +XFCE_CONF="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml" +mkdir -p "${XFCE_CONF}" + +# GTK theme + icon theme via xsettings +if [ -f "${XFCE_CONF}/xsettings.xml" ]; then + sed -i "s|||g" "${XFCE_CONF}/xsettings.xml" + sed -i "s|||g" "${XFCE_CONF}/xsettings.xml" + sed -i "s|||g" "${XFCE_CONF}/xsettings.xml" + sed -i "s|||g" "${XFCE_CONF}/xsettings.xml" + sed -i 's|||g' "${XFCE_CONF}/xsettings.xml" + sed -i 's|||g' "${XFCE_CONF}/xsettings.xml" +else + cat > "${XFCE_CONF}/xsettings.xml" << XSEOF + + + + + + + + + + + + +XSEOF +fi + +# Window manager theme +if [ -f "${XFCE_CONF}/xfwm4.xml" ]; then + sed -i "s|||g" "${XFCE_CONF}/xfwm4.xml" + sed -i "s|||g" "${XFCE_CONF}/xfwm4.xml" + sed -i 's|||g' "${XFCE_CONF}/xfwm4.xml" +else + cat > "${XFCE_CONF}/xfwm4.xml" << XWEOF + + + + + + + +XWEOF +fi + +# -------------------------------------------------------------------------- +# 6. Configure macOS-style top panel (menu bar) +# -------------------------------------------------------------------------- + +# macOS layout: slim top panel (menu bar) + Plank dock at bottom +# Top panel: [App Menu | ... spacer ... | System Tray | Clock] +cat > "${XFCE_CONF}/xfce4-panel.xml" << 'PANELEOF' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +PANELEOF + +# -------------------------------------------------------------------------- +# 7. Configure Plank dock (macOS-style bottom dock) +# -------------------------------------------------------------------------- + +PLANK_CONF="$HOME/.config/plank/dock1" +mkdir -p "${PLANK_CONF}/launchers" + +# Plank settings — transparent theme, bottom position, decent icon size +cat > "${PLANK_CONF}/settings" << 'PLANKEOF' +[PlankDockPreferences] +#shared settings +HideMode=0 +UnhideDelay=0 +HideDelay=0 +Monitor= +Position=3 +Offset=0 +Alignment=3 +IconSize=48 +ZoomEnabled=true +ZoomPercent=150 +Theme=Transparent +DockItems=files.dockitem;firefox.dockitem;terminal.dockitem;careerclaw.dockitem +PinnedOnly=false +LockItems=false +PressureReveal=false +CurrentWorkspaceOnly=false +PLANKEOF + +# Create dock item launchers +cat > "${PLANK_CONF}/launchers/files.dockitem" << 'EOF' +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/thunar.desktop +EOF + +cat > "${PLANK_CONF}/launchers/firefox.dockitem" << 'EOF' +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/firefox.desktop +EOF + +cat > "${PLANK_CONF}/launchers/terminal.dockitem" << 'EOF' +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/xfce4-terminal.desktop +EOF + +cat > "${PLANK_CONF}/launchers/careerclaw.dockitem" << 'EOF' +[PlankDockItemPreferences] +Launcher=file:///usr/share/applications/careerclaw.desktop +EOF + +# Autostart Plank at login +mkdir -p /etc/xdg/autostart +cat > /etc/xdg/autostart/plank-dock.desktop << 'AUTOSTART' +[Desktop Entry] +Type=Application +Name=Plank Dock +Comment=macOS-style application dock +Exec=plank +Hidden=false +NoDisplay=true +X-GNOME-Autostart-enabled=true +X-GNOME-Autostart-Delay=2 +AUTOSTART + +# -------------------------------------------------------------------------- +# 8. Install Inter font (clean UI font) +# -------------------------------------------------------------------------- + +apt-get install -y fonts-inter 2>/dev/null || { + mkdir -p /usr/share/fonts/truetype/inter + cd /tmp + wget -q "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip" -O inter.zip || true + if [ -f inter.zip ]; then + unzip -o inter.zip -d inter_font + cp inter_font/Inter*.ttf /usr/share/fonts/truetype/inter/ 2>/dev/null || \ + cp inter_font/extras/ttf/*.ttf /usr/share/fonts/truetype/inter/ 2>/dev/null || true + fc-cache -f + rm -rf inter.zip inter_font + fi +} + +# -------------------------------------------------------------------------- +# 9. Cleanup +# -------------------------------------------------------------------------- + +chown -R 1000:0 $HOME + +if [ -z "${SKIP_CLEAN+x}" ]; then + apt-get autoclean + rm -rf \ + /var/lib/apt/lists/* \ + /var/tmp/* \ + /tmp/* +fi + +echo "Career-Box macOS-style theme installation complete." +echo " GTK theme: ${COLLOID_THEME}" +echo " Icon theme: ${COLLOID_ICONS}" +echo " Dock: Plank (Transparent theme)" +echo " Panel: XFCE top menu bar"