diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27d926f --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +GOOGLE_CLIENT_ID= +# Required whenever HOST binds beyond loopback (e.g. 0.0.0.0 for phone/LAN). +ALLOWED_EMAILS= +# Required for phone/LAN (raw IP) access. Google GIS cannot use raw IP origins. +DASHBOARD_PIN= +# Google-compatible public UI origin (Vercel static host). Default below. +PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app +# Optional: override auto-generated secret for cookie + Bearer token signing +# (~/.config/agent-deck/session-secret) +SESSION_SECRET= +# Optional LAN bind for phone access (LaunchAgent / serve:lan also set this) +# HOST=0.0.0.0 +# PORT=3847 +# UI → Mac API base for a Vercel (or other) static build. Use an HTTPS tunnel URL +# when the UI is on HTTPS; leave empty for same-origin Mac serve. +# VITE_API_BASE=https://your-mac-api.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79165c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build diff --git a/README.md b/README.md index 761ad01..948db19 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Agent Deck -![Agent Deck screenshot](./src/assets/Screenshot.png) +![Agent Deck screenshot](./docs/Screenshot.png) Local macOS dashboard for AI agent usage (Cursor, Claude Code, Codex), Mac resource meters, and your GitHub contribution calendar. -Runs entirely on your machine. Nothing is uploaded. +Runs entirely on your machine. Usage-reset lookups send your local Cursor, Codex, and Claude auth tokens from this Mac only to those vendors' usage APIs (api2.cursor.sh, auth.openai.com / chatgpt.com, api.anthropic.com) - not to Agent Deck or any other service. ![Agent Deck](https://img.shields.io/badge/platform-macOS-black) ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen) ![License](https://img.shields.io/badge/license-MIT-blue) -## Why localhost (not a public web host) +## Why the Mac still runs the API Your machine is the source of truth: @@ -17,41 +17,116 @@ Your machine is the source of truth: - **CPU / GPU / Memory** only exist on this Mac - **GitHub** calendar is fetched with your local `gh` auth -A remote host cannot see those safely. Keep the app on `127.0.0.1`. This GitHub repo is only the source code. +Collectors must stay on this Mac. You can host a **static UI** on Vercel (see below) that talks to the Mac API over a tunnel or LAN URL - do not deploy the collectors to a public cloud. -## Download +## Recommended setup (auto-start + dual auth) + +You do **not** need `npm run dev` day to day. That is only for developers hacking on the UI. -### Option A - Clone with Git +1. Install once: ```bash git clone https://github.com/ctt062/agent-dashboard.git cd agent-dashboard npm install -npm run dev +cp .env.example .env +``` + +2. Configure `.env`: + +```bash +GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com +ALLOWED_EMAILS=you@gmail.com +DASHBOARD_PIN=your-phone-pin +PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app +``` + +Authorized JavaScript origins (Google Cloud Console) - Google GIS does **not** accept raw LAN IPs: + +- `http://127.0.0.1:3847` and `http://localhost:3847` (production serve) +- `http://127.0.0.1:5174` and `http://localhost:5174` (`npm run dev`) +- `https://agent-dashboard-ctt.vercel.app` (or your `PUBLIC_ORIGIN`) + +3. Build + install login auto-start: + +```bash +npm run setup ``` -### Option B - ZIP download (no Git) +`npm run setup` defaults to LAN bind (`HOST=0.0.0.0`) and refuses to install unless `GOOGLE_CLIENT_ID`, `ALLOWED_EMAILS`, and `DASHBOARD_PIN` are set. + +After that, Agent Deck starts when you log into your Mac: + +- Desktop / localhost and the Vercel UI origin use **Google** sign-in (verified email + allowlist). +- Phone on a raw LAN IP uses **PIN** sign-in (`DASHBOARD_PIN`). + +`npm run dev` is optional (hot reload for coding). Prefer `npm run setup` for normal use. + +## Optional: static UI on Vercel + +Public UI: [https://agent-dashboard-ctt.vercel.app/](https://agent-dashboard-ctt.vercel.app/) + +1. Deploy the Vite `dist/` (SPA rewrite is in `vercel.json`). +2. Point the UI at your Mac API with either: + - build-time `VITE_API_BASE=https://your-mac-tunnel.example`, or + - runtime `public/runtime-config.js` → `window.__AGENT_DECK_API_BASE__` +3. Prefer an **HTTPS tunnel** to the Mac when the UI is on HTTPS (browsers block mixed content to bare `http://192.168.x.x`). +4. On the Mac, keep `PUBLIC_ORIGIN=https://agent-dashboard-ctt.vercel.app` so CORS allows that origin + localhost only (no wildcard, no raw LAN origins). +5. Sign-in returns a **Bearer token** stored in the browser (`sessionStorage`) and sent as `Authorization: Bearer …`. Do not rely on third-party cookies for Vercel → Mac. + +Collectors and `/api/*` still run on the Mac. + +## Download + +Day-to-day install is [Recommended setup](#recommended-setup-auto-start--dual-auth) (`.env` auth + `npm run setup`). Auth is required for dashboard data. + +### ZIP (no Git) 1. Open https://github.com/ctt062/agent-dashboard 2. Click **Code → Download ZIP** -3. Unzip, then in that folder: +3. Unzip, then continue from `cp .env.example .env` in Recommended setup. + +### One-command production serve + +After `.env` is configured (see Recommended setup), build and serve API + static UI on one port: ```bash -npm install -npm run dev +npm run serve ``` -Open **http://127.0.0.1:5174** +Open **http://127.0.0.1:3847** (Google sign-in). + +`npm start` alone also works after `npm run build` (serves `dist/` when present). Hot-reload coding: `npm run dev` → **http://127.0.0.1:5174**. + +## View on your phone (same Wi-Fi) -### Production-style start (optional) +Agent Deck is a local web app. Your Mac still runs the API; your phone opens it in Safari/Chrome and signs in with the **PIN** (Google cannot authorize raw IP origins). ```bash -npm install -npm run build -npm start +npm run serve:lan +``` + +The terminal prints a LAN URL like `http://192.168.x.x:3847`. Open that on your phone (same Wi-Fi) and enter `DASHBOARD_PIN`. On iOS you can use **Share → Add to Home Screen** for an app-like icon. + +Dev equivalent: + +```bash +npm run dev:lan ``` -`npm start` serves the API on `http://127.0.0.1:3847`. For the UI, use `npm run preview` (or keep using `npm run dev`). +Then open the printed Vite URL (port `5174`) on your phone. + +Only do this on a trusted network - LAN mode exposes local agent + Mac metrics to devices on that Wi-Fi. + +## Start at login (macOS) + +Use `npm run setup` from [Recommended setup](#recommended-setup-auto-start--dual-auth) (requires `.env` auth when LAN-bound). LaunchAgent only: + +```bash +npm run launchagent:install +``` + +Remove with `npm run launchagent:uninstall`. ## Requirements @@ -63,45 +138,64 @@ npm start - Claude Code logs under `~/.claude/projects/` - Codex sessions under `~/.codex/sessions/` -Missing collectors degrade gracefully - panels show empty or partial data instead of crashing. +Missing collectors degrade gracefully - each panel shows a short hint instead of crashing. + +## Features + +- **Date range**: Today / 7 days / 30 days / This month for agent share and charts +- **Usage resets**: Per-provider token/limit reset times (Cursor billing cycle via local dashboard API, Codex ChatGPT wham/usage windows, Claude rolling 5h/weekly with `/usage` guidance when exact times are unavailable) +- **Detailed agent stats**: period total, avg/day, active days, peak day, acceptance rate (Cursor), input/output tokens +- **Dual-series charts** plus a cross-agent comparison chart +- **Dual auth**: Google on localhost / `PUBLIC_ORIGIN`; PIN on LAN IPs; Bearer token for Vercel → Mac API; `GOOGLE_CLIENT_ID` + `ALLOWED_EMAILS` + `DASHBOARD_PIN` required for LAN bind +- **Local web app**: `npm run setup` auto-starts at login; `serve:lan` / LaunchAgent for phone on the same Wi-Fi +- **Cached collectors** (~10s TTL) with parallel collection; usage-reset lookups cache separately (~3 min). Refresh bypasses both caches ## Stack - Vite + React + TypeScript UI -- Express API on port `3847` (localhost only) +- Express API on port `3847` (Local Mac; also serves `dist/` after build) - Collectors read local files / `top` / `ioreg` / `gh api` ## What the percentages mean -Agent % is **relative share** of a local activity score across Cursor, Claude Code, and Codex on this Mac - not a vendor billing percentage. +Agent % is **relative share** of a local activity score across Cursor, Claude Code, and Codex for the selected date range - not a vendor billing percentage. | Agent | Primary signal | |-------|----------------| -| Cursor | Accepted AI lines (`aiCodeTracking.dailyStats`) + chat volume | +| Cursor | Accepted AI lines (`aiCodeTracking.dailyStats`) | | Claude Code | Tokens from `~/.claude/projects/**/*.jsonl`, else message volume | | Codex | Tokens from `~/.codex/sessions/**/*.jsonl`, else event volume | ## API -Bound to `127.0.0.1` only: +Default bind is `127.0.0.1`. Use `HOST=0.0.0.0` (or `npm run serve:lan`) for LAN/phone access. + +`/api/dashboard` and `/api/system` require auth (session cookie and/or `Authorization: Bearer …`). `/api/health` and `/api/auth/*` are reachable without a prior session (sign-in routes issue the token). | Endpoint | Description | |----------|-------------| -| `GET /api/dashboard` | Full payload (agents + system + GitHub) | -| `GET /api/system` | Mac snapshot only | -| `GET /api/health` | Liveness check | +| `GET /api/dashboard?range=1d\|7d\|30d\|month` | Full payload (agents + system + GitHub). Add `refresh=1` to bypass cache. Auth required. | +| `GET /api/system` | Mac snapshot only. Auth required. | +| `GET /api/health` | Liveness check (no auth) | +| `GET /api/auth/config` | Auth mode for this Host/Origin (`google` or `pin`) | -Override the API port with `PORT` if needed: +Override host/port if needed: ```bash -PORT=4000 npm run dev:api +HOST=0.0.0.0 PORT=4000 npm start ``` ## Privacy -- Nothing is uploaded by this app -- Do not expose port `3847` beyond localhost -- Stats come from files and tools already on your Mac +- Dashboard stats come from files and tools already on your Mac +- Google sign-in uses Google Identity Services on localhost and `PUBLIC_ORIGIN`; LAN IPs use PIN instead +- Cross-origin Vercel UI uses Bearer tokens (not third-party cookies); Mac same-origin UI may still use session cookies +- When LAN bind is enabled, `GOOGLE_CLIENT_ID`, `ALLOWED_EMAILS`, and `DASHBOARD_PIN` are required +- Failed PIN attempts are rate-limited in memory per client IP +- Usage-reset times use local Cursor / Codex / Claude credentials on this machine only to call those vendors' usage APIs; tokens are not sent to Agent Deck or any other service +- Default LaunchAgent bind is LAN-capable (`HOST=0.0.0.0`) - use only on trusted Wi-Fi +- CORS allowlists localhost + `PUBLIC_ORIGIN` only (no wildcard) +- Static UI may live on Vercel; collectors and the API stay on your Mac ## License diff --git a/docs/Screenshot.png b/docs/Screenshot.png new file mode 100644 index 0000000..53f5a1f Binary files /dev/null and b/docs/Screenshot.png differ diff --git a/index.html b/index.html index e394ce7..4af8db8 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,13 @@ - + + + + + + Agent Deck @@ -15,6 +20,7 @@
+ diff --git a/package-lock.json b/package-lock.json index dc6ace2..f3d0b5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,25 @@ { "name": "agent-dashboard", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-dashboard", - "version": "0.0.0", + "version": "0.1.0", + "license": "MIT", "dependencies": { "concurrently": "^10.0.4", - "cors": "^2.8.6", + "cookie-session": "^2.1.1", + "dotenv": "^17.4.2", "express": "^5.2.1", + "google-auth-library": "^10.9.1", "react": "^19.2.7", "react-dom": "^19.2.7", "recharts": "^3.10.1" }, "devDependencies": { - "@types/cors": "^2.8.19", + "@types/cookie-session": "^2.0.49", "@types/express": "^5.0.6", "@types/node": "^24.13.3", "@types/react": "^19.2.17", @@ -26,6 +29,9 @@ "tsx": "^4.23.1", "typescript": "~6.0.2", "vite": "^8.1.1" + }, + "engines": { + "node": ">=22" } }, "node_modules/@emnapi/core": { @@ -660,9 +666,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -680,9 +683,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -700,9 +700,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -720,9 +717,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +734,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -760,9 +751,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -780,9 +768,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -800,9 +785,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -999,9 +981,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1019,9 +998,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1039,9 +1015,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1059,9 +1032,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1079,9 +1049,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1099,9 +1066,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1232,14 +1196,15 @@ "@types/node": "*" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "node_modules/@types/cookie-session": { + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", + "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@types/express": "*", + "@types/keygrip": "*" } }, "node_modules/@types/d3-array": { @@ -1337,6 +1302,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1447,6 +1419,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1471,6 +1452,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1508,6 +1518,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1636,6 +1652,30 @@ "node": ">= 0.6" } }, + "node_modules/cookie-session": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz", + "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==", + "license": "MIT", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -1645,21 +1685,17 @@ "node": ">=6.6.0" } }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "license": "MIT", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "depd": "~2.0.0", + "keygrip": "~1.1.0" }, "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/csstype": { @@ -1790,6 +1826,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1832,6 +1877,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1846,6 +1903,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2023,6 +2089,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2041,6 +2113,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2062,6 +2157,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2104,6 +2211,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2162,6 +2297,32 @@ "node": ">= 0.4" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2218,6 +2379,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2274,6 +2448,48 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2417,9 +2633,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2441,9 +2654,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2465,9 +2675,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2489,9 +2696,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2640,13 +2844,42 @@ "node": ">= 0.6" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/object-inspect": { @@ -2673,6 +2906,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3017,6 +3259,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3274,6 +3536,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", @@ -3472,6 +3743,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index fa58d1a..8650941 100644 --- a/package.json +++ b/package.json @@ -30,21 +30,30 @@ "dev": "concurrently -k -n api,web -c gray.bold,white.bold \"npm:dev:api\" \"npm:dev:web\"", "dev:api": "tsx watch server/index.ts", "dev:web": "vite", + "dev:lan": "concurrently -k -n api,web -c gray.bold,white.bold \"HOST=0.0.0.0 npm run dev:api\" \"VITE_HOST=0.0.0.0 npm run dev:web\"", "build": "tsc -b && vite build", "start": "tsx server/index.ts", + "serve": "npm run build && npm start", + "serve:lan": "npm run build && HOST=0.0.0.0 npm start", "preview": "vite preview", - "lint": "oxlint" + "lint": "oxlint", + "test": "node --test server/lib/range.test.ts server/lib/lan-intent.test.ts server/lib/auth-mode.test.ts server/lib/bearer-token.test.ts server/lib/pin-rate-limit.test.ts src/lib/types.test.ts", + "setup": "npm run build && npm run launchagent:install", + "launchagent:install": "bash scripts/install-launchagent.sh", + "launchagent:uninstall": "bash scripts/uninstall-launchagent.sh" }, "dependencies": { "concurrently": "^10.0.4", - "cors": "^2.8.6", + "cookie-session": "^2.1.1", + "dotenv": "^17.4.2", "express": "^5.2.1", + "google-auth-library": "^10.9.1", "react": "^19.2.7", "react-dom": "^19.2.7", "recharts": "^3.10.1" }, "devDependencies": { - "@types/cors": "^2.8.19", + "@types/cookie-session": "^2.0.49", "@types/express": "^5.0.6", "@types/node": "^24.13.3", "@types/react": "^19.2.17", diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..7188593 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Agent Deck", + "short_name": "Agent Deck", + "description": "Local macOS AI agent dashboard", + "start_url": "/", + "display": "standalone", + "background_color": "#050505", + "theme_color": "#050505", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/public/runtime-config.js b/public/runtime-config.js new file mode 100644 index 0000000..204de5a --- /dev/null +++ b/public/runtime-config.js @@ -0,0 +1,3 @@ +// Optional runtime override for static hosts (Vercel). Set before the app bundle loads. +// Example: window.__AGENT_DECK_API_BASE__ = 'https://your-mac-tunnel.example'; +window.__AGENT_DECK_API_BASE__ = window.__AGENT_DECK_API_BASE__ || ''; diff --git a/scripts/install-launchagent.sh b/scripts/install-launchagent.sh new file mode 100755 index 0000000..591e1e2 --- /dev/null +++ b/scripts/install-launchagent.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Install a LaunchAgent that starts Agent Deck at login and opens the UI. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LABEL="com.ctt062.agent-deck" +PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist" +LOG_DIR="$HOME/Library/Logs/agent-deck" +ENV_FILE="$ROOT/.env" +NODE_BIN="$(command -v node)" +NPM_BIN="$(command -v npm)" +OPEN_BIN="$(command -v open)" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "LaunchAgent install is macOS-only." >&2 + exit 1 +fi + +if [[ -z "$NODE_BIN" || -z "$NPM_BIN" ]]; then + echo "node and npm must be on PATH." >&2 + exit 1 +fi + +mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR" + +# Prefer a built UI+API serve; build once if dist is missing. +if [[ ! -d "$ROOT/dist" ]]; then + echo "Building UI (one-time)…" + (cd "$ROOT" && "$NPM_BIN" run build) +fi + +NODE_DIR="$(dirname "$NODE_BIN")" +NPM_DIR="$(dirname "$NPM_BIN")" +LAUNCH_PATH="${NODE_DIR}:${NPM_DIR}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" + +strip_env_quotes() { + local val="$1" + if [[ "${#val}" -ge 2 ]]; then + local first="${val:0:1}" + local last="${val: -1}" + if [[ "$first" == '"' && "$last" == '"' ]]; then + val="${val:1:${#val}-2}" + elif [[ "$first" == "'" && "$last" == "'" ]]; then + val="${val:1:${#val}-2}" + fi + fi + printf '%s' "$val" +} + +# Default: LAN-capable so phone can open the same always-on server. +HOST_VALUE="${HOST:-0.0.0.0}" +PORT_VALUE="${PORT:-3847}" +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a + # Export only simple KEY=VALUE lines for LaunchAgent env + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// }" ]] && continue + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + val="$(strip_env_quotes "${BASH_REMATCH[2]}")" + export "${key}=${val}" + fi + done <"$ENV_FILE" + set +a + HOST_VALUE="${HOST:-$HOST_VALUE}" + PORT_VALUE="${PORT:-$PORT_VALUE}" +fi + +if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then + missing=() + [[ -z "${GOOGLE_CLIENT_ID:-}" ]] && missing+=("GOOGLE_CLIENT_ID") + [[ -z "${ALLOWED_EMAILS:-}" ]] && missing+=("ALLOWED_EMAILS") + [[ -z "${DASHBOARD_PIN:-}" ]] && missing+=("DASHBOARD_PIN") + if [[ "${#missing[@]}" -gt 0 ]]; then + echo "LAN bind (HOST=${HOST_VALUE}) requires ${missing[*]} in ${ENV_FILE}." >&2 + exit 1 + fi +fi + +# Build EnvironmentVariables dict entries from .env (safe subset) +ENV_XML="" +ENV_XML+=" PATH\n ${LAUNCH_PATH}\n" +ENV_XML+=" HOST\n ${HOST_VALUE}\n" +ENV_XML+=" PORT\n ${PORT_VALUE}\n" +if [[ -n "${GOOGLE_CLIENT_ID:-}" ]]; then + ENV_XML+=" GOOGLE_CLIENT_ID\n ${GOOGLE_CLIENT_ID}\n" +fi +if [[ -n "${ALLOWED_EMAILS:-}" ]]; then + ENV_XML+=" ALLOWED_EMAILS\n ${ALLOWED_EMAILS}\n" +fi +if [[ -n "${DASHBOARD_PIN:-}" ]]; then + ENV_XML+=" DASHBOARD_PIN\n ${DASHBOARD_PIN}\n" +fi +if [[ -n "${PUBLIC_ORIGIN:-}" ]]; then + ENV_XML+=" PUBLIC_ORIGIN\n ${PUBLIC_ORIGIN}\n" +fi +if [[ -n "${SESSION_SECRET:-}" ]]; then + ENV_XML+=" SESSION_SECRET\n ${SESSION_SECRET}\n" +fi + +cat >"$PLIST" < + + + + Label + ${LABEL} + WorkingDirectory + ${ROOT} + ProgramArguments + + ${NPM_BIN} + start + + RunAtLoad + + KeepAlive + + StandardOutPath + ${LOG_DIR}/stdout.log + StandardErrorPath + ${LOG_DIR}/stderr.log + EnvironmentVariables + +$(printf '%b' "$ENV_XML") + + + +EOF + +launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$PLIST" +launchctl enable "gui/$(id -u)/${LABEL}" 2>/dev/null || true + +# Give the server a moment, then open the UI. +sleep 2 +"$OPEN_BIN" "http://127.0.0.1:${PORT_VALUE}" >/dev/null 2>&1 || true + +echo "Installed ${PLIST}" +echo "Agent Deck starts automatically at login." +echo "Open http://127.0.0.1:${PORT_VALUE} (sign in with Google)." +if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then + echo "LAN/phone: same Wi-Fi devices use this Mac's IP on port ${PORT_VALUE} with PIN sign-in." +fi +if [[ -z "${GOOGLE_CLIENT_ID:-}" ]]; then + echo "Warning: GOOGLE_CLIENT_ID is not set. Add it to ${ENV_FILE} then re-run npm run setup." +fi +echo "Logs: ${LOG_DIR}" +echo "Uninstall: npm run launchagent:uninstall" diff --git a/scripts/uninstall-launchagent.sh b/scripts/uninstall-launchagent.sh new file mode 100755 index 0000000..688611e --- /dev/null +++ b/scripts/uninstall-launchagent.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Remove the Agent Deck LaunchAgent. +set -euo pipefail + +LABEL="com.ctt062.agent-deck" +PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "LaunchAgent uninstall is macOS-only." >&2 + exit 1 +fi + +launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true +rm -f "$PLIST" +echo "Removed LaunchAgent ${LABEL}" diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..1527ab6 --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,388 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import cookieSession from 'cookie-session' +import type { Express, NextFunction, Request, Response } from 'express' +import { OAuth2Client } from 'google-auth-library' +import { + type AuthMode, + authModeForHostAndOrigin, + corsAllowedOrigins, + isLanBindHost, + publicOriginFromEnv, +} from './lib/auth-mode.js' +import { + bearerFromAuthorization, + issueBearerToken, + verifyBearerToken, +} from './lib/bearer-token.js' +import { + checkPinRateLimit, + clearPinFailures, + createPinRateLimitStore, + recordPinFailure, +} from './lib/pin-rate-limit.js' + +export type AuthUser = { + email: string + name: string | null + picture: string | null + method: 'google' | 'pin' +} + +declare module 'express-serve-static-core' { + interface Request { + user?: AuthUser + } +} + +type SessionData = { + user?: AuthUser +} + +const pinAttemptStore = createPinRateLimitStore() + +function configDir(): string { + const dir = join(homedir(), '.config', 'agent-deck') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }) + return dir +} + +function sessionSecret(): string { + if (process.env.SESSION_SECRET?.trim()) return process.env.SESSION_SECRET.trim() + const file = join(configDir(), 'session-secret') + if (existsSync(file)) return readFileSync(file, 'utf8').trim() + const secret = randomBytes(32).toString('hex') + writeFileSync(file, `${secret}\n`, { encoding: 'utf8', mode: 0o600 }) + return secret +} + +export function googleClientId(): string | null { + const id = process.env.GOOGLE_CLIENT_ID?.trim() + return id || null +} + +export function dashboardPin(): string | null { + const pin = process.env.DASHBOARD_PIN?.trim() + return pin || null +} + +export function allowedEmails(): Set | null { + const raw = process.env.ALLOWED_EMAILS?.trim() + if (!raw) return null + return new Set( + raw + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ) +} + +export function publicOrigin(): string { + return publicOriginFromEnv() +} + +export function authModeForRequest(req: Request): AuthMode { + return authModeForHostAndOrigin({ + hostHeader: req.get('x-forwarded-host') || req.get('host'), + origin: req.get('origin'), + publicOrigin: publicOrigin(), + }) +} + +export function googleAuthReady(): boolean { + return Boolean(googleClientId()) +} + +export function pinAuthReady(): boolean { + return Boolean(dashboardPin()) +} + +/** True when the server can authenticate at least one supported mode. */ +export function authConfigured(): boolean { + return googleAuthReady() || pinAuthReady() +} + +export function assertLanAuthRequirements(host: string): void { + if (!isLanBindHost(host)) return + const missing: string[] = [] + if (!allowedEmails()?.size) missing.push('ALLOWED_EMAILS') + if (!dashboardPin()) missing.push('DASHBOARD_PIN') + if (!googleClientId()) missing.push('GOOGLE_CLIENT_ID') + if (missing.length === 0) return + console.error( + `LAN bind (HOST=${host}) requires ${missing.join(', ')} in .env. Refusing to start.`, + ) + process.exit(1) +} + +function pinMatches(input: string): boolean { + const expected = dashboardPin() + if (!expected) return false + const a = Buffer.from(input) + const b = Buffer.from(expected) + if (a.length !== b.length) { + timingSafeEqual(b, b) + return false + } + return timingSafeEqual(a, b) +} + +function pinRateLimitKey(req: Request): string { + return req.socket.remoteAddress || 'unknown' +} + +export function attachCors(app: Express): void { + const allowed = corsAllowedOrigins() + app.use((req, res, next) => { + const origin = req.get('origin') + if (origin && allowed.has(origin.replace(/\/$/, ''))) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization', + ) + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET,POST,OPTIONS', + ) + res.setHeader('Vary', 'Origin') + } + if (req.method === 'OPTIONS') { + res.status(204).end() + return + } + next() + }) +} + +export function attachSession(app: Express): void { + app.set('trust proxy', 1) + const secret = sessionSecret() + app.use( + cookieSession({ + name: 'agent_deck_session', + keys: [secret], + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'lax', + secure: false, + }), + ) +} + +function readSession(req: Request): SessionData { + return (req.session ?? {}) as SessionData +} + +function modeConfigured(mode: AuthMode): boolean { + return mode === 'google' ? googleAuthReady() : pinAuthReady() +} + +function resolveUser(req: Request): AuthUser | null { + const bearer = bearerFromAuthorization(req.get('authorization')) + if (bearer) { + const user = verifyBearerToken(bearer, sessionSecret()) + if (user) return user + } + return readSession(req).user ?? null +} + +function issueAuthResponse(req: Request, res: Response, user: AuthUser): void { + const session = readSession(req) + session.user = user + req.session = session + const token = issueBearerToken(user, sessionSecret()) + res.json({ user, token }) +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const mode = authModeForRequest(req) + if (!modeConfigured(mode)) { + res.status(503).json({ + error: 'auth_not_configured', + mode, + message: + mode === 'google' + ? 'Set GOOGLE_CLIENT_ID (and ALLOWED_EMAILS when LAN bind is enabled) in .env, then restart Agent Deck.' + : 'Set DASHBOARD_PIN in .env for LAN / non-Google hosts, then restart Agent Deck.', + }) + return + } + const user = resolveUser(req) + if (!user?.email) { + res.status(401).json({ error: 'unauthorized', mode }) + return + } + req.user = user + next() +} + +export function mountAuthRoutes(app: Express): void { + app.get('/api/auth/config', (req, res) => { + const mode = authModeForRequest(req) + const configured = modeConfigured(mode) + res.json({ + mode, + configured, + clientId: mode === 'google' ? googleClientId() : null, + allowedEmailsConfigured: Boolean(allowedEmails()?.size), + publicOrigin: publicOrigin(), + pinConfigured: pinAuthReady(), + }) + }) + + app.get('/api/auth/me', (req, res) => { + const mode = authModeForRequest(req) + if (!modeConfigured(mode)) { + res.status(503).json({ + error: 'auth_not_configured', + configured: false, + mode, + }) + return + } + const user = resolveUser(req) + if (!user?.email) { + res.status(401).json({ error: 'unauthorized', configured: true, mode }) + return + } + res.json({ user, configured: true, mode }) + }) + + app.post('/api/auth/google', async (req, res) => { + const mode = authModeForRequest(req) + if (mode !== 'google') { + res.status(400).json({ + error: 'wrong_auth_mode', + message: 'This host uses PIN sign-in, not Google.', + mode, + }) + return + } + const clientId = googleClientId() + if (!clientId) { + res.status(503).json({ error: 'auth_not_configured', mode }) + return + } + const credential = + typeof req.body?.credential === 'string' ? req.body.credential : null + if (!credential) { + res.status(400).json({ error: 'missing_credential' }) + return + } + + try { + const client = new OAuth2Client(clientId) + const ticket = await client.verifyIdToken({ + idToken: credential, + audience: clientId, + }) + const payload = ticket.getPayload() + if (!payload?.email) { + res.status(401).json({ error: 'invalid_token' }) + return + } + if (!payload.email_verified) { + res.status(403).json({ + error: 'email_not_verified', + message: 'Google account email must be verified.', + }) + return + } + + const email = payload.email.toLowerCase() + const allow = allowedEmails() + const lanBound = isLanBindHost(process.env.HOST ?? '127.0.0.1') + if (lanBound && !allow?.size) { + res.status(503).json({ + error: 'allowlist_required', + message: 'ALLOWED_EMAILS is required when LAN bind is enabled.', + }) + return + } + if (allow && !allow.has(email)) { + res.status(403).json({ + error: 'email_not_allowed', + message: 'This Google account is not allowed to open Agent Deck.', + }) + return + } + + const user: AuthUser = { + email, + name: payload.name ?? null, + picture: payload.picture ?? null, + method: 'google', + } + issueAuthResponse(req, res, user) + } catch (err) { + res.status(401).json({ + error: 'verify_failed', + message: err instanceof Error ? err.message : String(err), + }) + } + }) + + app.post('/api/auth/pin', (req, res) => { + const mode = authModeForRequest(req) + if (mode !== 'pin') { + res.status(400).json({ + error: 'wrong_auth_mode', + message: 'This host uses Google sign-in, not PIN.', + mode, + }) + return + } + if (!dashboardPin()) { + res.status(503).json({ error: 'auth_not_configured', mode }) + return + } + + const rateKey = pinRateLimitKey(req) + const limited = checkPinRateLimit(pinAttemptStore, rateKey) + if (!limited.ok) { + const retryAfterSec = Math.max(1, Math.ceil(limited.retryAfterMs / 1000)) + res.setHeader('Retry-After', String(retryAfterSec)) + res.status(429).json({ + error: 'pin_rate_limited', + message: `Too many incorrect PIN attempts. Try again in ${retryAfterSec}s.`, + retryAfterMs: limited.retryAfterMs, + }) + return + } + + const pin = typeof req.body?.pin === 'string' ? req.body.pin : '' + if (!pinMatches(pin)) { + const result = recordPinFailure(pinAttemptStore, rateKey) + if (result.retryAfterMs > 0) { + const retryAfterSec = Math.max(1, Math.ceil(result.retryAfterMs / 1000)) + res.setHeader('Retry-After', String(retryAfterSec)) + res.status(429).json({ + error: 'pin_rate_limited', + message: `Too many incorrect PIN attempts. Try again in ${retryAfterSec}s.`, + retryAfterMs: result.retryAfterMs, + }) + return + } + res.status(401).json({ error: 'invalid_pin', message: 'Incorrect PIN.' }) + return + } + + clearPinFailures(pinAttemptStore, rateKey) + const user: AuthUser = { + email: 'pin@local', + name: 'PIN access', + picture: null, + method: 'pin', + } + issueAuthResponse(req, res, user) + }) + + app.post('/api/auth/logout', (req, res) => { + req.session = null + res.json({ ok: true }) + }) +} diff --git a/server/collectors/claude.ts b/server/collectors/claude.ts index 94f38ef..05ea217 100644 --- a/server/collectors/claude.ts +++ b/server/collectors/claude.ts @@ -2,10 +2,15 @@ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { createInterface } from 'node:readline' +import { buildStats } from '../lib/agents.js' +import { localDateFromTimestamp } from '../lib/range.js' import type { AgentUsage, DailyPoint } from '../types.js' const PROJECTS = join(homedir(), '.claude', 'projects') +const HINT = + 'Run Claude Code so session JSONL logs appear under ~/.claude/projects/.' + function walkJsonl(dir: string, out: string[] = []): string[] { if (!existsSync(dir)) return out for (const name of readdirSync(dir)) { @@ -20,13 +25,35 @@ function walkJsonl(dir: string, out: string[] = []): string[] { export async function collectClaude(): Promise { const byDay = new Map< string, - { tokens: number; messages: number; input: number; output: number } + { + tokens: number + messages: number + input: number + output: number + cacheTokens: number + } >() let sessions = 0 let messages = 0 let inputTokens = 0 let outputTokens = 0 + let cacheTokens = 0 let note: string | undefined + const rootExists = existsSync(PROJECTS) + + if (!rootExists) { + return { + id: 'claude', + name: 'Claude Code', + score: 0, + available: false, + metrics: {}, + daily: [], + stats: buildStats([], 30), + note: 'Claude Code projects folder not found.', + hint: HINT, + } + } try { const files = walkJsonl(PROJECTS) @@ -44,7 +71,10 @@ export async function collectClaude(): Promise { } catch { continue } - const ts = typeof o.timestamp === 'string' ? o.timestamp.slice(0, 10) : null + const ts = + typeof o.timestamp === 'string' + ? localDateFromTimestamp(o.timestamp) + : null const type = o.type if (type === 'user' || type === 'assistant') { messages += 1 @@ -54,6 +84,7 @@ export async function collectClaude(): Promise { messages: 0, input: 0, output: 0, + cacheTokens: 0, } cur.messages += 1 byDay.set(ts, cur) @@ -74,16 +105,19 @@ export async function collectClaude(): Promise { const total = inn + outn + cache inputTokens += inn outputTokens += outn + cacheTokens += cache if (ts && total > 0) { const cur = byDay.get(ts) ?? { tokens: 0, messages: 0, input: 0, output: 0, + cacheTokens: 0, } cur.tokens += total cur.input += inn cur.output += outn + cur.cacheTokens += cache byDay.set(ts, cur) } } @@ -92,35 +126,54 @@ export async function collectClaude(): Promise { note = err instanceof Error ? err.message : String(err) } + const totalTokens = inputTokens + outputTokens + cacheTokens const daily: DailyPoint[] = [...byDay.entries()] .sort(([a], [b]) => a.localeCompare(b)) - .slice(-60) - .map(([date, v]) => ({ - date, - value: v.tokens > 0 ? v.tokens : v.messages, - label: v.tokens > 0 ? 'tokens' : 'messages', - })) + .slice(-90) + .map(([date, v]) => { + const useTokens = v.tokens > 0 + return { + date, + primary: useTokens ? v.tokens : v.messages, + secondary: useTokens ? v.output : undefined, + primaryLabel: useTokens ? 'tokens' : 'messages', + secondaryLabel: useTokens ? 'output' : undefined, + extras: { + tokens: v.tokens, + messages: v.messages, + input: v.input, + output: v.output, + cacheTokens: v.cacheTokens, + }, + } + }) - const totalTokens = inputTokens + outputTokens - // Prefer tokens; fall back to message volume when token logs are empty + const stats = buildStats(daily, 30) const score = totalTokens > 0 ? totalTokens : messages * 800 + sessions * 2000 + const available = !note && (sessions > 0 || messages > 0) return { id: 'claude', name: 'Claude Code', score, + available, metrics: { sessions, messages, inputTokens, outputTokens, + cacheTokens, totalTokens, }, daily, + stats, note: note ?? - (totalTokens === 0 && messages > 0 - ? 'Local sessions found; token fields mostly empty (billing/API gaps). Score uses message volume.' - : undefined), + (!available + ? 'No Claude Code session logs yet.' + : totalTokens === 0 && messages > 0 + ? 'Sessions found; token fields mostly empty. Score uses message volume.' + : undefined), + hint: !available && !note ? HINT : undefined, } } diff --git a/server/collectors/codex.ts b/server/collectors/codex.ts index a1f8a35..96cb514 100644 --- a/server/collectors/codex.ts +++ b/server/collectors/codex.ts @@ -2,10 +2,15 @@ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { createInterface } from 'node:readline' +import { buildStats } from '../lib/agents.js' +import { localDateFromTimestamp } from '../lib/range.js' import type { AgentUsage, DailyPoint } from '../types.js' const SESSIONS = join(homedir(), '.codex', 'sessions') +const HINT = + 'Run Codex so session JSONL rollouts appear under ~/.codex/sessions/.' + function walkJsonl(dir: string, out: string[] = []): string[] { if (!existsSync(dir)) return out for (const name of readdirSync(dir)) { @@ -34,13 +39,28 @@ function pickUsage(obj: unknown): Record | null { export async function collectCodex(): Promise { const byDay = new Map< string, - { tokens: number; events: number } + { tokens: number; events: number; input: number; output: number } >() let sessions = 0 let events = 0 let inputTokens = 0 let outputTokens = 0 let note: string | undefined + const rootExists = existsSync(SESSIONS) + + if (!rootExists) { + return { + id: 'codex', + name: 'Codex', + score: 0, + available: false, + metrics: {}, + daily: [], + stats: buildStats([], 30), + note: 'Codex sessions folder not found.', + hint: HINT, + } + } try { const files = walkJsonl(SESSIONS) @@ -62,10 +82,15 @@ export async function collectCodex(): Promise { events += 1 const ts = typeof o.timestamp === 'string' - ? o.timestamp.slice(0, 10) + ? localDateFromTimestamp(o.timestamp) ?? dayFromName : dayFromName if (ts) { - const cur = byDay.get(ts) ?? { tokens: 0, events: 0 } + const cur = byDay.get(ts) ?? { + tokens: 0, + events: 0, + input: 0, + output: 0, + } cur.events += 1 byDay.set(ts, cur) } @@ -86,8 +111,15 @@ export async function collectCodex(): Promise { inputTokens += Number(inn) || 0 outputTokens += Number(outn) || 0 if (ts && total > 0) { - const cur = byDay.get(ts) ?? { tokens: 0, events: 0 } + const cur = byDay.get(ts) ?? { + tokens: 0, + events: 0, + input: 0, + output: 0, + } cur.tokens += total + cur.input += Number(inn) || 0 + cur.output += Number(outn) || 0 byDay.set(ts, cur) } } @@ -96,23 +128,37 @@ export async function collectCodex(): Promise { note = err instanceof Error ? err.message : String(err) } + const totalTokens = inputTokens + outputTokens const daily: DailyPoint[] = [...byDay.entries()] .sort(([a], [b]) => a.localeCompare(b)) - .slice(-60) - .map(([date, v]) => ({ - date, - value: v.tokens > 0 ? v.tokens : v.events, - label: v.tokens > 0 ? 'tokens' : 'events', - })) + .slice(-90) + .map(([date, v]) => { + const useTokens = v.tokens > 0 + return { + date, + primary: useTokens ? v.tokens : v.events, + secondary: useTokens ? v.output : undefined, + primaryLabel: useTokens ? 'tokens' : 'events', + secondaryLabel: useTokens ? 'output' : undefined, + extras: { + tokens: v.tokens, + events: v.events, + input: v.input, + output: v.output, + }, + } + }) - const totalTokens = inputTokens + outputTokens + const stats = buildStats(daily, 30) const score = totalTokens > 0 ? totalTokens : events * 120 + sessions * 1500 + const available = !note && (sessions > 0 || events > 0) return { id: 'codex', name: 'Codex', score, + available, metrics: { sessions, events, @@ -121,10 +167,14 @@ export async function collectCodex(): Promise { totalTokens, }, daily, + stats, note: note ?? - (totalTokens === 0 && sessions > 0 - ? 'Session logs found; token usage sparse in local rollouts. Score uses event volume.' - : undefined), + (!available + ? 'No Codex session logs yet.' + : totalTokens === 0 && sessions > 0 + ? 'Sessions found; token usage sparse in local rollouts. Score uses event volume.' + : undefined), + hint: !available && !note ? HINT : undefined, } } diff --git a/server/collectors/cursor.ts b/server/collectors/cursor.ts index 35d820a..df095ff 100644 --- a/server/collectors/cursor.ts +++ b/server/collectors/cursor.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' +import { buildStats } from '../lib/agents.js' import type { AgentUsage, DailyPoint } from '../types.js' const DB = join( @@ -8,14 +10,34 @@ const DB = join( 'Library/Application Support/Cursor/User/globalStorage/state.vscdb', ) +const INSTALL_HINT = + 'Install Cursor and use Agent / Tab / Chat so AI line stats land in the local SQLite DB.' +const EMPTY_HINT = + 'Use Agent / Tab / Chat so AI line stats land in the local SQLite DB.' + export function collectCursor(): AgentUsage { - const daily: DailyPoint[] = [] + const dailyMap = new Map() let acceptedLines = 0 let suggestedLines = 0 let composers = 0 let bubbles = 0 let costCents = 0 let note: string | undefined + const dbExists = existsSync(DB) + + if (!dbExists) { + return { + id: 'cursor', + name: 'Cursor', + score: 0, + available: false, + metrics: {}, + daily: [], + stats: buildStats([], 30), + note: 'Cursor state database not found.', + hint: INSTALL_HINT, + } + } try { const db = new DatabaseSync(DB, { readOnly: true }) @@ -41,11 +63,10 @@ export function collectCursor(): AgentUsage { (d.composerSuggestedLines ?? 0) + (d.tabSuggestedLines ?? 0) acceptedLines += accepted suggestedLines += suggested - daily.push({ - date, - value: accepted, - label: 'accepted lines', - }) + const cur = dailyMap.get(date) ?? { accepted: 0, suggested: 0 } + cur.accepted += accepted + cur.suggested += suggested + dailyMap.set(date, cur) } catch { /* skip bad row */ } @@ -88,21 +109,45 @@ export function collectCursor(): AgentUsage { note = err instanceof Error ? err.message : String(err) } - // Activity score: accepted AI lines (primary) + chat volume weight + const daily: DailyPoint[] = [...dailyMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .slice(-90) + .map(([date, v]) => ({ + date, + primary: v.accepted, + secondary: v.suggested, + primaryLabel: 'accepted', + secondaryLabel: 'suggested', + extras: { accepted: v.accepted, suggested: v.suggested }, + })) + + const stats = buildStats(daily, 30) const score = acceptedLines + composers * 50 + bubbles * 0.05 + const available = !note && (daily.length > 0 || composers > 0 || bubbles > 0) return { id: 'cursor', name: 'Cursor', score, + available, metrics: { acceptedLines, suggestedLines, + acceptanceRate: + suggestedLines > 0 + ? Math.round((acceptedLines / suggestedLines) * 1000) / 10 + : 0, composers, messages: bubbles, - costUsd: costCents / 100, + costUsd: Math.round((costCents / 100) * 100) / 100, }, - daily: daily.slice(-60), - note, + daily, + stats, + note: + note ?? + (daily.length === 0 + ? 'Cursor is installed but AI daily stats are empty yet.' + : undefined), + hint: !available && !note ? EMPTY_HINT : undefined, } } diff --git a/server/collectors/github.ts b/server/collectors/github.ts index 48a9c77..3808a89 100644 --- a/server/collectors/github.ts +++ b/server/collectors/github.ts @@ -1,15 +1,48 @@ import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, join } from 'node:path' import type { GithubSnapshot } from '../types.js' const QUERY = 'query { viewer { login contributionsCollection { contributionCalendar { totalContributions weeks { contributionDays { date contributionCount color } } } } } }' +const INSTALL_HINT = + 'Install GitHub CLI and run `gh auth login`, then refresh. Agent Deck reads your contribution calendar via local gh auth.' + +const AUTH_HINT = + 'Run `gh auth login`, then refresh. Agent Deck reads your contribution calendar via local gh auth.' + +function ghEnv(): NodeJS.ProcessEnv { + const extras = [ + join(homedir(), '.local/bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + ] + const path = [...extras, process.env.PATH ?? ''].filter(Boolean).join(delimiter) + return { ...process.env, PATH: path } +} + +function resolveGh(): string { + if (process.env.GH_PATH && existsSync(process.env.GH_PATH)) { + return process.env.GH_PATH + } + for (const candidate of [ + join(homedir(), '.local/bin/gh'), + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]) { + if (existsSync(candidate)) return candidate + } + return 'gh' +} + export function collectGithub(): GithubSnapshot { try { const raw = execFileSync( - 'gh', + resolveGh(), ['api', 'graphql', '-f', `query=${QUERY}`], - { encoding: 'utf8', timeout: 20000 }, + { encoding: 'utf8', timeout: 20000, env: ghEnv() }, ) const data = JSON.parse(raw) as { data?: { @@ -35,10 +68,11 @@ export function collectGithub(): GithubSnapshot { if (!viewer) { return { ok: false, - error: 'GitHub GraphQL returned no viewer (check gh auth)', + error: 'GitHub GraphQL returned no viewer (check gh auth).', login: null, totalContributions: 0, days: [], + hint: AUTH_HINT, } } const cal = viewer.contributionsCollection.contributionCalendar @@ -57,12 +91,19 @@ export function collectGithub(): GithubSnapshot { days, } } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const missingGh = + /ENOENT|not found|command not found/i.test(message) || + message.includes('spawn gh') return { ok: false, - error: err instanceof Error ? err.message : String(err), + error: missingGh + ? 'GitHub CLI (`gh`) is not installed or not on PATH.' + : message, login: null, totalContributions: 0, days: [], + hint: missingGh ? INSTALL_HINT : AUTH_HINT, } } } diff --git a/server/collectors/usageResets.ts b/server/collectors/usageResets.ts new file mode 100644 index 0000000..2dac57e --- /dev/null +++ b/server/collectors/usageResets.ts @@ -0,0 +1,390 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import type { UsageReset, UsageResetWindow } from '../types.js' + +const CURSOR_DB = join( + homedir(), + 'Library/Application Support/Cursor/User/globalStorage/state.vscdb', +) +const CODEX_AUTH = join(homedir(), '.codex', 'auth.json') + +function msOrSecToIso(value: unknown): string | null { + const n = typeof value === 'string' ? Number(value) : Number(value) + if (!Number.isFinite(n) || n <= 0) return null + // Cursor sends ms; ChatGPT sends seconds + const ms = n > 1e12 ? n : n * 1000 + return new Date(ms).toISOString() +} + +async function fetchJson( + url: string, + init: RequestInit, +): Promise<{ ok: boolean; status: number; json: unknown; text: string }> { + try { + const res = await fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(8_000), + }) + const text = await res.text() + let json: unknown = null + try { + json = JSON.parse(text) as unknown + } catch { + json = null + } + return { ok: res.ok, status: res.status, json, text } + } catch (err) { + return { + ok: false, + status: 0, + json: null, + text: err instanceof Error ? err.message : String(err), + } + } +} + +function cursorAccessToken(): string | null { + if (!existsSync(CURSOR_DB)) return null + try { + const db = new DatabaseSync(CURSOR_DB, { readOnly: true }) + const row = db + .prepare(`SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'`) + .get() as { value: string } | undefined + db.close() + return row?.value ?? null + } catch { + return null + } +} + +export async function collectCursorUsageReset(): Promise { + const token = cursorAccessToken() + if (!token) { + return { + ok: false, + windows: [], + error: 'Cursor auth token not found locally.', + } + } + const res = await fetchJson( + 'https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: '{}', + }, + ) + if (!res.ok || !res.json || typeof res.json !== 'object') { + return { + ok: false, + windows: [], + error: `Cursor usage API HTTP ${res.status}`, + } + } + const data = res.json as { + billingCycleStart?: string | number + billingCycleEnd?: string | number + planUsage?: { totalPercentUsed?: number; includedSpend?: number; limit?: number } + } + const at = msOrSecToIso(data.billingCycleEnd) + const start = msOrSecToIso(data.billingCycleStart) + const used = data.planUsage?.totalPercentUsed + const windows: UsageResetWindow[] = [ + { + label: 'Billing cycle', + at, + usedPercent: + typeof used === 'number' ? Math.round(used * 10) / 10 : undefined, + note: start + ? `Cycle started ${new Date(start).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })}` + : 'Monthly Cursor included usage', + }, + ] + return { ok: Boolean(at), windows } +} + +type CodexAuthFile = { + tokens?: { + access_token?: string + refresh_token?: string + id_token?: string + } + last_refresh?: string +} + +async function refreshCodexAccessToken( + refreshToken: string, +): Promise { + const res = await fetchJson('https://auth.openai.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: 'app_EMoamEEZ73f0CkXaXp7hrann', + grant_type: 'refresh_token', + redirect_uri: 'http://localhost:1455/auth/callback', + refresh_token: refreshToken, + }), + }) + if (!res.ok || !res.json || typeof res.json !== 'object') return null + const data = res.json as { + access_token?: string + refresh_token?: string + id_token?: string + } + if (!data.access_token) return null + + // Fail closed: rotated refresh tokens must land on disk or Codex CLI breaks. + if (!existsSync(CODEX_AUTH)) return null + try { + const raw = JSON.parse(readFileSync(CODEX_AUTH, 'utf8')) as CodexAuthFile + raw.tokens = { + ...raw.tokens, + access_token: data.access_token, + refresh_token: data.refresh_token ?? raw.tokens?.refresh_token, + id_token: data.id_token ?? raw.tokens?.id_token, + } + raw.last_refresh = new Date().toISOString() + const tmp = join(dirname(CODEX_AUTH), `.auth.json.${process.pid}.tmp`) + writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + renameSync(tmp, CODEX_AUTH) + } catch { + return null + } + return data.access_token +} + +function codexAccessToken(): { access: string | null; refresh: string | null } { + if (!existsSync(CODEX_AUTH)) return { access: null, refresh: null } + try { + const raw = JSON.parse(readFileSync(CODEX_AUTH, 'utf8')) as CodexAuthFile + return { + access: raw.tokens?.access_token ?? null, + refresh: raw.tokens?.refresh_token ?? null, + } + } catch { + return { access: null, refresh: null } + } +} + +function windowFromCodex(raw: unknown, fallbackLabel: string): UsageResetWindow | null { + if (!raw || typeof raw !== 'object') return null + const w = raw as { + used_percent?: number + limit_window_seconds?: number + reset_after_seconds?: number + reset_at?: number + } + const at = msOrSecToIso(w.reset_at) + const secs = w.limit_window_seconds + let label = fallbackLabel + if (secs === 18_000) label = '5h window' + else if (secs === 604_800) label = 'Weekly' + else if (secs === 2_592_000) label = 'Monthly' + else if (typeof secs === 'number' && secs > 0) { + const h = Math.round(secs / 3600) + label = h >= 24 ? `${Math.round(h / 24)}d window` : `${h}h window` + } + return { + label, + at, + usedPercent: + typeof w.used_percent === 'number' + ? Math.round(w.used_percent * 10) / 10 + : undefined, + note: + typeof w.reset_after_seconds === 'number' + ? `Resets in ~${Math.max(0, Math.round(w.reset_after_seconds / 3600))}h` + : undefined, + } +} + +export async function collectCodexUsageReset(): Promise { + let { access, refresh } = codexAccessToken() + if (!access && refresh) { + access = await refreshCodexAccessToken(refresh) + } + if (!access) { + return { + ok: false, + windows: [], + error: 'Codex/ChatGPT auth not found. Run `codex login`.', + } + } + + let res = await fetchJson('https://chatgpt.com/backend-api/wham/usage', { + headers: { + Authorization: `Bearer ${access}`, + Accept: 'application/json', + }, + }) + if ((!res.ok || res.status === 401) && refresh) { + access = await refreshCodexAccessToken(refresh) + if (access) { + res = await fetchJson('https://chatgpt.com/backend-api/wham/usage', { + headers: { + Authorization: `Bearer ${access}`, + Accept: 'application/json', + }, + }) + } + } + if (!res.ok || !res.json || typeof res.json !== 'object') { + return { + ok: false, + windows: [], + error: `Codex usage API HTTP ${res.status}`, + } + } + const data = res.json as { + plan_type?: string + rate_limit?: { + primary_window?: unknown + secondary_window?: unknown + } + } + const windows: UsageResetWindow[] = [] + const primary = windowFromCodex(data.rate_limit?.primary_window, 'Primary') + const secondary = windowFromCodex( + data.rate_limit?.secondary_window, + 'Secondary', + ) + if (primary) windows.push(primary) + if (secondary) windows.push(secondary) + if (windows.length === 0) { + return { + ok: false, + windows: [], + error: 'Codex usage payload had no reset windows.', + } + } + if (data.plan_type) { + windows[0] = { + ...windows[0], + note: [windows[0].note, `Plan: ${data.plan_type}`] + .filter(Boolean) + .join(' · '), + } + } + return { ok: true, windows } +} + +function claudeOAuthAccessToken(): string | null { + try { + const out = execFileSync( + 'security', + ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], + { encoding: 'utf8', timeout: 3000 }, + ).trim() + if (!out) return null + const parsed = JSON.parse(out) as { + claudeAiOauth?: { accessToken?: string } + } + return parsed.claudeAiOauth?.accessToken ?? null + } catch { + return null + } +} + +function claudeWindowUsedPercent(block: { + utilization?: number + utilized?: number + usage?: number +}): number | undefined { + if (typeof block.utilization === 'number') { + return Math.round(block.utilization * 10) / 10 + } + if (typeof block.utilized === 'number') { + return Math.round(block.utilized * 10) / 10 + } + if (typeof block.usage === 'number') { + return Math.round(block.usage * 10) / 10 + } + return undefined +} + +export async function collectClaudeUsageReset(): Promise { + // Prefer Claude Code / Anthropic OAuth-style usage when available. + const accessToken = claudeOAuthAccessToken() + if (accessToken) { + const res = await fetchJson('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + 'anthropic-version': '2023-06-01', + 'User-Agent': 'claude-code/2.1.212', + Accept: 'application/json', + }, + }) + if (res.ok && res.json && typeof res.json === 'object') { + const data = res.json as Record + const windows: UsageResetWindow[] = [] + for (const [label, keyName] of [ + ['5h session', 'five_hour'], + ['Weekly', 'seven_day'], + ['Weekly', 'weekly'], + ] as const) { + const block = data[keyName] + if (block && typeof block === 'object') { + const b = block as { + resets_at?: string + reset_at?: string + utilization?: number + utilized?: number + usage?: number + } + windows.push({ + label, + at: b.resets_at ?? b.reset_at ?? null, + usedPercent: claudeWindowUsedPercent(b), + }) + } + } + if (windows.length > 0) return { ok: true, windows } + } + } + + // Claude Code Pro/Max limits are rolling windows; exact times live in `/usage`. + return { + ok: true, + windows: [ + { + label: '5h session', + at: null, + note: 'Rolling window · run `/usage` in Claude Code for exact time', + }, + { + label: 'Weekly', + at: null, + note: 'Rolling 7-day cap · not calendar Monday', + }, + ], + } +} + +export async function collectUsageResets(): Promise<{ + cursor: UsageReset + claude: UsageReset + codex: UsageReset +}> { + const [cursor, claude, codex] = await Promise.all([ + collectCursorUsageReset(), + collectClaudeUsageReset(), + collectCodexUsageReset(), + ]) + return { cursor, claude, codex } +} diff --git a/server/index.ts b/server/index.ts index 44400c2..ad35b9d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,52 +1,170 @@ -import cors from 'cors' +import 'dotenv/config' import express from 'express' +import { existsSync } from 'node:fs' +import { networkInterfaces } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + assertLanAuthRequirements, + attachCors, + attachSession, + authConfigured, + dashboardPin, + googleAuthReady, + mountAuthRoutes, + publicOrigin, + requireAuth, +} from './auth.js' import { collectClaude } from './collectors/claude.js' import { collectCodex } from './collectors/codex.js' import { collectCursor } from './collectors/cursor.js' import { collectGithub } from './collectors/github.js' import { collectSystem } from './collectors/system.js' -import type { AgentShare, DashboardPayload } from './types.js' +import { collectUsageResets } from './collectors/usageResets.js' +import { applyRange, withShares } from './lib/agents.js' +import { parseRange, rangeStartDate } from './lib/range.js' +import type { DashboardPayload, RawCollectors, UsageReset } from './types.js' const PORT = Number(process.env.PORT ?? 3847) +/** Default loopback. Set HOST=0.0.0.0 to reach from phone on the same LAN. */ +const HOST = process.env.HOST ?? '127.0.0.1' +const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS ?? 10_000) +const USAGE_RESETS_TTL_MS = Number(process.env.USAGE_RESETS_TTL_MS ?? 180_000) +const __dirname = dirname(fileURLToPath(import.meta.url)) +const DIST = join(__dirname, '../dist') -function withShares( - agents: Awaited>[], -): AgentShare[] { - const total = agents.reduce((s, a) => s + Math.max(0, a.score), 0) - return agents.map((a) => ({ - ...a, - percent: - total > 0 - ? Math.round((Math.max(0, a.score) / total) * 1000) / 10 - : 0, - })) +assertLanAuthRequirements(HOST) + +type UsageResetsPayload = { + cursor: UsageReset + claude: UsageReset + codex: UsageReset +} + +let cache: { at: number; data: RawCollectors } | null = null +let inflight: Promise | null = null +let usageResetsCache: { at: number; data: UsageResetsPayload } | null = null +let usageResetsInflight: Promise | null = null + +function lanUrls(port: number): string[] { + const out: string[] = [] + const nets = networkInterfaces() + for (const entries of Object.values(nets)) { + for (const net of entries ?? []) { + if (net.family === 'IPv4' && !net.internal) { + out.push(`http://${net.address}:${port}`) + } + } + } + return out +} + +async function getUsageResets(force = false): Promise { + if (force) { + usageResetsCache = null + } + if ( + !force && + usageResetsCache && + Date.now() - usageResetsCache.at < USAGE_RESETS_TTL_MS + ) { + return usageResetsCache.data + } + if (!usageResetsInflight) { + usageResetsInflight = collectUsageResets() + .then((data) => { + usageResetsCache = { at: Date.now(), data } + return data + }) + .finally(() => { + usageResetsInflight = null + }) + } + return usageResetsInflight +} + +async function collectRaw(force = false): Promise<{ data: RawCollectors; cached: boolean }> { + if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) { + return { data: cache.data, cached: true } + } + + const execute = async (): Promise => { + const [cursor, claude, codex, system, github, resets] = await Promise.all([ + Promise.resolve().then(() => collectCursor()), + collectClaude(), + collectCodex(), + Promise.resolve().then(() => collectSystem()), + Promise.resolve().then(() => collectGithub()), + getUsageResets(force), + ]) + cursor.usageReset = resets.cursor + claude.usageReset = resets.claude + codex.usageReset = resets.codex + const data: RawCollectors = { cursor, claude, codex, system, github } + cache = { at: Date.now(), data } + return data + } + + if (force) { + const data = await execute() + return { data, cached: false } + } + + if (!inflight) { + inflight = execute().finally(() => { + inflight = null + }) + } + const data = await inflight + return { data, cached: false } } -async function buildPayload(): Promise { - const [claude, codex] = await Promise.all([ - collectClaude(), - collectCodex(), - ]) - const cursor = collectCursor() - const agents = withShares([cursor, claude, codex]) +async function buildPayload( + rangeParam: unknown, + force = false, +): Promise { + const range = parseRange(rangeParam) + const since = rangeStartDate(range) + const { data, cached } = await collectRaw(force) + const agents = withShares( + [data.cursor, data.claude, data.codex].map((a) => + applyRange(a, range, since), + ), + ) return { generatedAt: new Date().toISOString(), + range, + cached, agents, - system: collectSystem(), - github: collectGithub(), + system: data.system, + github: data.github, } } const app = express() -app.use(cors()) +app.use(express.json({ limit: '32kb' })) +attachCors(app) +attachSession(app) +mountAuthRoutes(app) app.get('/api/health', (_req, res) => { - res.json({ ok: true }) + res.json({ + ok: true, + dist: existsSync(DIST), + host: HOST, + authConfigured: authConfigured(), + googleAuthReady: googleAuthReady(), + pinAuthReady: Boolean(dashboardPin()), + publicOrigin: publicOrigin(), + cacheTtlMs: CACHE_TTL_MS, + usageResetsTtlMs: USAGE_RESETS_TTL_MS, + }) }) -app.get('/api/dashboard', async (_req, res) => { +app.get('/api/dashboard', requireAuth, async (req, res) => { try { - const payload = await buildPayload() + const force = req.query.refresh === '1' || req.query.refresh === 'true' + const payload = await buildPayload(req.query.range, force) res.json(payload) } catch (err) { res.status(500).json({ @@ -55,10 +173,54 @@ app.get('/api/dashboard', async (_req, res) => { } }) -app.get('/api/system', (_req, res) => { +app.get('/api/system', requireAuth, (_req, res) => { res.json(collectSystem()) }) -app.listen(PORT, '127.0.0.1', () => { - console.log(`agent-dashboard API on http://127.0.0.1:${PORT}`) +if (existsSync(DIST)) { + app.use(express.static(DIST)) + app.get('/{*path}', (req, res, next) => { + if (req.path.startsWith('/api')) return next() + res.sendFile(join(DIST, 'index.html')) + }) +} + +app.listen(PORT, HOST, () => { + const servingUi = existsSync(DIST) + const mode = servingUi ? 'API + UI' : 'API only' + console.log(`agent-dashboard ${mode} on http://${HOST === '0.0.0.0' ? '127.0.0.1' : HOST}:${PORT}`) + console.log(`Public UI origin (Google): ${publicOrigin()}`) + if (!googleAuthReady()) { + console.log( + 'Auth: GOOGLE_CLIENT_ID is not set. Copy .env.example to .env and add a Google OAuth Web client ID.', + ) + } else { + console.log('Auth: Google sign-in on localhost and PUBLIC_ORIGIN.') + } + if (dashboardPin()) { + console.log('Auth: PIN sign-in enabled for LAN / non-Google hosts.') + } else { + console.log('Auth: DASHBOARD_PIN is not set (required for phone/LAN IP access).') + } + if (HOST === '0.0.0.0' || HOST === '::') { + const urls = lanUrls(PORT) + if (urls.length > 0) { + if (servingUi) { + console.log('Phone / LAN (same Wi-Fi, PIN sign-in):') + for (const url of urls) console.log(` ${url}`) + } else { + console.log('API only (LAN, same Wi-Fi):') + for (const url of urls) console.log(` ${url}`) + console.log( + 'Phone UI: open the Vite network URL on port 5174 (from `npm run dev:lan`), not these API URLs.', + ) + } + } else { + console.log('LAN bind enabled, but no non-loopback IPv4 address was found.') + } + console.log('Only use on a trusted network. This exposes local agent + Mac metrics.') + } + if (!servingUi) { + console.log('Tip: run `npm run build` (or `npm run serve`) to serve the UI from this port.') + } }) diff --git a/server/lib/agents.ts b/server/lib/agents.ts new file mode 100644 index 0000000..22e3659 --- /dev/null +++ b/server/lib/agents.ts @@ -0,0 +1,191 @@ +import type { AgentShare, AgentStats, AgentUsage, DailyPoint } from '../types.js' +import { daysInRange, type DateRange } from './range.js' + +function emptyStats(): AgentStats { + return { + activeDays: 0, + avgPerDay: 0, + peakDay: null, + peakValue: 0, + periodTotal: 0, + } +} + +export function buildStats(daily: DailyPoint[], rangeDays: number): AgentStats { + if (daily.length === 0) return emptyStats() + let periodTotal = 0 + let activeDays = 0 + let peakDay: string | null = null + let peakValue = 0 + for (const d of daily) { + periodTotal += d.primary + if (d.primary > 0) activeDays += 1 + if (d.primary > peakValue) { + peakValue = d.primary + peakDay = d.date + } + } + return { + activeDays, + avgPerDay: Math.round((periodTotal / Math.max(rangeDays, 1)) * 10) / 10, + peakDay, + peakValue, + periodTotal, + } +} + +function sumExtra(daily: DailyPoint[], key: string): number { + return daily.reduce((s, d) => s + (d.extras?.[key] ?? 0), 0) +} + +/** + * Period activity score for the filtered window. + * Cursor: accepted AI lines only. Claude/Codex: tokens, else message/event volume. + */ +function computePeriodScore(agent: AgentUsage, daily: DailyPoint[]): number { + if (agent.id === 'cursor') { + const sumPrimary = daily.reduce((s, d) => s + d.primary, 0) + return sumExtra(daily, 'accepted') || sumPrimary + } + + const totalTokens = sumExtra(daily, 'tokens') + if (agent.id === 'claude') { + return totalTokens > 0 ? totalTokens : sumExtra(daily, 'messages') * 800 + } + + return totalTokens > 0 ? totalTokens : sumExtra(daily, 'events') * 120 +} + +function hasInRangeActivity(daily: DailyPoint[]): boolean { + return daily.some( + (d) => + d.primary > 0 || + (d.secondary ?? 0) > 0 || + Object.values(d.extras ?? {}).some((v) => v > 0), + ) +} + +/** Align Claude/Codex day primaries to one unit for the filtered window. */ +function normalizePeriodDaily(agent: AgentUsage, daily: DailyPoint[]): DailyPoint[] { + if (agent.id === 'cursor' || daily.length === 0) return daily + + const useTokens = sumExtra(daily, 'tokens') > 0 + const volumeKey = agent.id === 'claude' ? 'messages' : 'events' + + return daily.map((d) => { + if (useTokens) { + return { + ...d, + primary: d.extras?.tokens ?? 0, + secondary: d.extras?.output, + primaryLabel: 'tokens', + secondaryLabel: 'output', + } + } + return { + ...d, + primary: d.extras?.[volumeKey] ?? 0, + secondary: undefined, + primaryLabel: volumeKey, + secondaryLabel: undefined, + } + }) +} + +function periodMetrics(agent: AgentUsage, daily: DailyPoint[]): Record { + const score = computePeriodScore(agent, daily) + const base = { ...agent.metrics } + + if (agent.id === 'cursor') { + const sumPrimary = daily.reduce((s, d) => s + d.primary, 0) + const accepted = sumExtra(daily, 'accepted') || sumPrimary + const suggested = sumExtra(daily, 'suggested') + return { + ...base, + acceptedLines: accepted, + suggestedLines: suggested, + acceptanceRate: + suggested > 0 ? Math.round((accepted / suggested) * 1000) / 10 : 0, + periodScore: score, + } + } + + if (agent.id === 'claude') { + const inputTokens = sumExtra(daily, 'input') + const outputTokens = sumExtra(daily, 'output') + const totalTokens = sumExtra(daily, 'tokens') + const cacheTokens = sumExtra(daily, 'cacheTokens') + const messages = sumExtra(daily, 'messages') + return { + ...base, + inputTokens, + outputTokens, + totalTokens, + cacheTokens, + messages, + periodScore: score, + } + } + + const inputTokens = sumExtra(daily, 'input') + const outputTokens = sumExtra(daily, 'output') + const totalTokens = sumExtra(daily, 'tokens') + const events = sumExtra(daily, 'events') + return { + ...base, + inputTokens, + outputTokens, + totalTokens, + events, + periodScore: score, + } +} + +/** Filter full-history agent data down to the selected date range and recompute score/stats. */ +export function applyRange(agent: AgentUsage, range: DateRange, since: string): AgentUsage { + if (!agent.available) { + return { + ...agent, + score: 0, + metrics: {}, + daily: [], + stats: emptyStats(), + usageReset: agent.usageReset, + note: agent.note ?? 'No local data found for this agent.', + hint: agent.hint, + } + } + + const filtered = agent.daily.filter((d) => d.date >= since) + const daily = normalizePeriodDaily(agent, filtered) + const stats = buildStats(daily, daysInRange(range)) + const metrics = periodMetrics(agent, daily) + + const note = + agent.note ?? + (!hasInRangeActivity(daily) + ? `No activity in the selected ${ + range === '1d' ? 'day' : range === 'month' ? 'month' : 'period' + }.` + : undefined) + + return { + ...agent, + score: metrics.periodScore, + metrics, + daily, + stats, + usageReset: agent.usageReset, + note, + hint: agent.hint, + } +} + +export function withShares(agents: AgentUsage[]): AgentShare[] { + const total = agents.reduce((s, a) => s + Math.max(0, a.score), 0) + return agents.map((a) => ({ + ...a, + percent: + total > 0 ? Math.round((Math.max(0, a.score) / total) * 1000) / 10 : 0, + })) +} diff --git a/server/lib/auth-mode.test.ts b/server/lib/auth-mode.test.ts new file mode 100644 index 0000000..98b679f --- /dev/null +++ b/server/lib/auth-mode.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + DEFAULT_PUBLIC_ORIGIN, + authModeForHostAndOrigin, + corsAllowedOrigins, + isLanBindHost, + isLoopbackHostname, + publicOriginFromEnv, +} from './auth-mode.ts' + +describe('auth-mode', () => { + it('treats loopback as Google-compatible', () => { + assert.equal(isLoopbackHostname('127.0.0.1'), true) + assert.equal(isLoopbackHostname('localhost'), true) + assert.equal(isLoopbackHostname('::1'), true) + assert.equal(isLoopbackHostname('192.168.1.10'), false) + }) + + it('requires LAN extras when binding non-loopback', () => { + assert.equal(isLanBindHost('0.0.0.0'), true) + assert.equal(isLanBindHost('::'), true) + assert.equal(isLanBindHost('127.0.0.1'), false) + assert.equal(isLanBindHost('localhost'), false) + }) + + it('selects google for localhost Host and PUBLIC_ORIGIN Origin', () => { + assert.equal( + authModeForHostAndOrigin({ + hostHeader: '127.0.0.1:3847', + origin: undefined, + }), + 'google', + ) + assert.equal( + authModeForHostAndOrigin({ + hostHeader: '192.168.1.20:3847', + origin: DEFAULT_PUBLIC_ORIGIN, + }), + 'google', + ) + }) + + it('selects pin for raw LAN IP Host without Google-compatible Origin', () => { + assert.equal( + authModeForHostAndOrigin({ + hostHeader: '192.168.1.20:3847', + origin: undefined, + }), + 'pin', + ) + assert.equal( + authModeForHostAndOrigin({ + hostHeader: '192.168.1.20:3847', + origin: 'http://192.168.1.20:3847', + }), + 'pin', + ) + }) + + it('defaults PUBLIC_ORIGIN and CORS allowlist', () => { + assert.equal(publicOriginFromEnv({}), DEFAULT_PUBLIC_ORIGIN) + const origins = corsAllowedOrigins({ PORT: '3847' }) + assert.ok(origins.has(DEFAULT_PUBLIC_ORIGIN)) + assert.ok(origins.has('http://127.0.0.1:3847')) + assert.ok(origins.has('http://127.0.0.1:5174')) + assert.equal(origins.has('http://192.168.1.20:3847'), false) + }) +}) diff --git a/server/lib/auth-mode.ts b/server/lib/auth-mode.ts new file mode 100644 index 0000000..d56fcd4 --- /dev/null +++ b/server/lib/auth-mode.ts @@ -0,0 +1,77 @@ +export const DEFAULT_PUBLIC_ORIGIN = 'https://agent-dashboard-ctt.vercel.app' + +export type AuthMode = 'google' | 'pin' + +export function publicOriginFromEnv( + env: NodeJS.ProcessEnv = process.env, +): string { + const raw = env.PUBLIC_ORIGIN?.trim() || DEFAULT_PUBLIC_ORIGIN + return raw.replace(/\/$/, '') +} + +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '').toLowerCase() + return h === 'localhost' || h === '127.0.0.1' || h === '::1' +} + +export function isLanBindHost(host: string): boolean { + const h = host.trim().toLowerCase() + if (!h || h === '0.0.0.0' || h === '::' || h === '[::]') return true + if (isLoopbackHostname(h)) return false + return true +} + +function hostnameOf(hostOrUrl: string): string | null { + const raw = hostOrUrl.trim() + if (!raw) return null + try { + if (raw.includes('://')) return new URL(raw).hostname.toLowerCase() + return new URL(`http://${raw}`).hostname.toLowerCase() + } catch { + return null + } +} + +/** Google GIS works on loopback and configured PUBLIC_ORIGIN; everything else uses PIN. */ +export function authModeForHostAndOrigin(input: { + hostHeader: string | null | undefined + origin: string | null | undefined + publicOrigin?: string +}): AuthMode { + const pub = (input.publicOrigin ?? DEFAULT_PUBLIC_ORIGIN).replace(/\/$/, '') + const pubHost = hostnameOf(pub) + + const origin = input.origin?.trim() + if (origin) { + const normalized = origin.replace(/\/$/, '') + if (normalized === pub) return 'google' + const originHost = hostnameOf(origin) + if (originHost && isLoopbackHostname(originHost)) return 'google' + return 'pin' + } + + const host = hostnameOf(input.hostHeader ?? '') + if (!host) return 'pin' + if (isLoopbackHostname(host)) return 'google' + if (pubHost && host === pubHost) return 'google' + return 'pin' +} + +export function corsAllowedOrigins( + env: NodeJS.ProcessEnv = process.env, +): Set { + const pub = publicOriginFromEnv(env) + const origins = new Set([ + pub, + 'http://127.0.0.1:3847', + 'http://localhost:3847', + 'http://127.0.0.1:5174', + 'http://localhost:5174', + ]) + const port = env.PORT?.trim() + if (port && port !== '3847') { + origins.add(`http://127.0.0.1:${port}`) + origins.add(`http://localhost:${port}`) + } + return origins +} diff --git a/server/lib/bearer-token.test.ts b/server/lib/bearer-token.test.ts new file mode 100644 index 0000000..2dab1ae --- /dev/null +++ b/server/lib/bearer-token.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + bearerFromAuthorization, + issueBearerToken, + verifyBearerToken, +} from './bearer-token.ts' + +describe('bearer-token', () => { + const secret = 'test-secret-key' + const user = { + email: 'you@example.com', + name: 'You', + picture: null, + method: 'google' as const, + } + + it('issues a verifiable token', () => { + const token = issueBearerToken(user, secret, 60_000, 1_000) + const parsed = verifyBearerToken(token, secret, 1_500) + assert.deepEqual(parsed, user) + }) + + it('rejects expired, tampered, or wrong-secret tokens', () => { + const token = issueBearerToken(user, secret, 100, 1_000) + assert.equal(verifyBearerToken(token, secret, 1_200), null) + assert.equal(verifyBearerToken(token, 'other', 1_050), null) + assert.equal(verifyBearerToken(`${token}x`, secret, 1_050), null) + assert.equal(verifyBearerToken('not-a-token', secret), null) + }) + + it('parses Authorization Bearer headers', () => { + assert.equal(bearerFromAuthorization('Bearer abc.def'), 'abc.def') + assert.equal(bearerFromAuthorization('bearer abc.def'), 'abc.def') + assert.equal(bearerFromAuthorization('Basic abc'), null) + assert.equal(bearerFromAuthorization(undefined), null) + }) +}) diff --git a/server/lib/bearer-token.ts b/server/lib/bearer-token.ts new file mode 100644 index 0000000..093e9d7 --- /dev/null +++ b/server/lib/bearer-token.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +export type BearerUser = { + email: string + name: string | null + picture: string | null + method: 'google' | 'pin' +} + +type TokenPayload = { + u: BearerUser + exp: number +} + +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000 + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url') +} + +export function issueBearerToken( + user: BearerUser, + secret: string, + ttlMs = DEFAULT_TTL_MS, + now = Date.now(), +): string { + const body: TokenPayload = { u: user, exp: now + ttlMs } + const payload = Buffer.from(JSON.stringify(body), 'utf8').toString('base64url') + return `${payload}.${sign(payload, secret)}` +} + +export function verifyBearerToken( + token: string, + secret: string, + now = Date.now(), +): BearerUser | null { + const dot = token.indexOf('.') + if (dot <= 0 || dot === token.length - 1) return null + const payload = token.slice(0, dot) + const sig = token.slice(dot + 1) + const expected = sign(payload, secret) + const a = Buffer.from(sig) + const b = Buffer.from(expected) + if (a.length !== b.length || !timingSafeEqual(a, b)) return null + try { + const raw = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ) as TokenPayload + if (!raw?.u?.email || typeof raw.exp !== 'number') return null + if (raw.exp < now) return null + if (raw.u.method !== 'google' && raw.u.method !== 'pin') return null + return { + email: raw.u.email, + name: raw.u.name ?? null, + picture: raw.u.picture ?? null, + method: raw.u.method, + } + } catch { + return null + } +} + +export function bearerFromAuthorization( + header: string | null | undefined, +): string | null { + if (!header) return null + const match = /^Bearer\s+(\S+)$/i.exec(header.trim()) + return match?.[1] ?? null +} diff --git a/server/lib/lan-intent.test.ts b/server/lib/lan-intent.test.ts new file mode 100644 index 0000000..8843682 --- /dev/null +++ b/server/lib/lan-intent.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..') + +describe('LAN phone webapp intent', () => { + it('exposes serve:lan and dev:lan with HOST=0.0.0.0', () => { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + scripts: Record + } + assert.match(pkg.scripts['serve:lan'], /HOST=0\.0\.0\.0/) + assert.match(pkg.scripts['dev:lan'], /HOST=0\.0\.0\.0/) + assert.match(pkg.scripts['dev:lan'], /VITE_HOST=0\.0\.0\.0/) + }) + + it('ships a standalone web manifest for Add to Home Screen', () => { + const manifest = JSON.parse( + readFileSync(join(root, 'public/manifest.webmanifest'), 'utf8'), + ) as { + name: string + short_name: string + display: string + start_url: string + } + assert.equal(manifest.name, 'Agent Deck') + assert.equal(manifest.short_name, 'Agent Deck') + assert.equal(manifest.display, 'standalone') + assert.equal(manifest.start_url, '/') + }) + + it('wires mobile PWA meta + manifest into index.html', () => { + const html = readFileSync(join(root, 'index.html'), 'utf8') + assert.match(html, /rel="manifest" href="\/manifest\.webmanifest"/) + assert.match(html, /name="apple-mobile-web-app-capable" content="yes"/) + assert.match(html, /viewport-fit=cover/) + }) + + it('documents Mac API + optional Vercel static UI and LAN PIN access', () => { + const readme = readFileSync(join(root, 'README.md'), 'utf8') + assert.match(readme, /Why the Mac still runs the API/) + assert.match(readme, /do not deploy the collectors to a public cloud/i) + assert.match(readme, /agent-dashboard-ctt\.vercel\.app/) + assert.match(readme, /DASHBOARD_PIN/) + assert.match(readme, /npm run serve:lan/) + assert.match(readme, /npm run dev:lan/) + assert.match(readme, /Add to Home Screen/) + assert.match(readme, /Google GIS does \*\*not\*\* accept raw LAN IPs/) + assert.match(readme, /127\.0\.0\.1:5174/) + assert.match(readme, /localhost:5174/) + assert.match(readme, /Bearer token/) + assert.doesNotMatch( + readme, + /Authorized JavaScript origins[\s\S]{0,400}192\.168/, + ) + }) +}) diff --git a/server/lib/pin-rate-limit.test.ts b/server/lib/pin-rate-limit.test.ts new file mode 100644 index 0000000..78c35b5 --- /dev/null +++ b/server/lib/pin-rate-limit.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + checkPinRateLimit, + clearPinFailures, + createPinRateLimitStore, + pinLockMsForFails, + recordPinFailure, +} from './pin-rate-limit.ts' + +describe('pin-rate-limit', () => { + it('locks after five failures with progressive backoff', () => { + assert.equal(pinLockMsForFails(4), 0) + assert.equal(pinLockMsForFails(5), 60_000) + assert.equal(pinLockMsForFails(6), 120_000) + assert.equal(pinLockMsForFails(10), 15 * 60_000) + }) + + it('rate-limits an IP after repeated failures and clears on success path', () => { + const store = createPinRateLimitStore() + const now = 1_000_000 + + for (let i = 0; i < 4; i++) { + const result = recordPinFailure(store, '10.0.0.1', now + i) + assert.equal(result.retryAfterMs, 0) + assert.equal(checkPinRateLimit(store, '10.0.0.1', now + i).ok, true) + } + + const locked = recordPinFailure(store, '10.0.0.1', now + 10) + assert.equal(locked.retryAfterMs, 60_000) + const blocked = checkPinRateLimit(store, '10.0.0.1', now + 11) + assert.equal(blocked.ok, false) + if (!blocked.ok) assert.ok(blocked.retryAfterMs > 0) + + assert.equal( + checkPinRateLimit(store, '10.0.0.1', now + 10 + 60_000).ok, + true, + ) + + clearPinFailures(store, '10.0.0.1') + assert.equal(checkPinRateLimit(store, '10.0.0.1', now + 20).ok, true) + }) +}) diff --git a/server/lib/pin-rate-limit.ts b/server/lib/pin-rate-limit.ts new file mode 100644 index 0000000..5fff154 --- /dev/null +++ b/server/lib/pin-rate-limit.ts @@ -0,0 +1,50 @@ +export type PinRateLimitState = { + fails: number + lockedUntil: number +} + +export type PinRateLimitStore = Map + +const MAX_ATTEMPTS_BEFORE_LOCK = 5 +const BASE_LOCK_MS = 60_000 +const MAX_LOCK_MS = 15 * 60_000 + +export function createPinRateLimitStore(): PinRateLimitStore { + return new Map() +} + +export function pinLockMsForFails(fails: number): number { + if (fails < MAX_ATTEMPTS_BEFORE_LOCK) return 0 + const extra = fails - MAX_ATTEMPTS_BEFORE_LOCK + return Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** extra) +} + +export function checkPinRateLimit( + store: PinRateLimitStore, + ip: string, + now = Date.now(), +): { ok: true } | { ok: false; retryAfterMs: number } { + const entry = store.get(ip) + if (!entry || entry.lockedUntil <= now) return { ok: true } + return { ok: false, retryAfterMs: entry.lockedUntil - now } +} + +export function recordPinFailure( + store: PinRateLimitStore, + ip: string, + now = Date.now(), +): { lockedUntil: number; retryAfterMs: number } { + const prev = store.get(ip) + const fails = (prev?.fails ?? 0) + 1 + const lockMs = pinLockMsForFails(fails) + const lockedUntil = lockMs > 0 ? now + lockMs : 0 + store.set(ip, { fails, lockedUntil }) + return { + lockedUntil, + retryAfterMs: lockMs > 0 ? lockMs : 0, + } +} + +export function clearPinFailures(store: PinRateLimitStore, ip: string): void { + store.delete(ip) +} diff --git a/server/lib/range.test.ts b/server/lib/range.test.ts new file mode 100644 index 0000000..70e96fd --- /dev/null +++ b/server/lib/range.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + DATE_RANGES, + daysInRange, + parseRange, + rangeLabel, + rangeStartDate, +} from './range.ts' + +describe('date range helpers', () => { + it('includes This month alongside Today/7d/30d', () => { + assert.deepEqual(DATE_RANGES, ['1d', '7d', '30d', 'month']) + assert.equal(rangeLabel('1d'), 'Today') + assert.equal(rangeLabel('7d'), '7 days') + assert.equal(rangeLabel('30d'), '30 days') + assert.equal(rangeLabel('month'), 'This month') + }) + + it('parses month and rejects unknown values', () => { + assert.equal(parseRange('month'), 'month') + assert.equal(parseRange('7d'), '7d') + assert.equal(parseRange('nope'), '7d') + assert.equal(parseRange(undefined), '7d') + }) + + it('starts This month on the 1st of the local calendar month', () => { + const now = new Date(2026, 6, 28, 15, 30, 0) // Jul 28, 2026 local + assert.equal(rangeStartDate('month', now), '2026-07-01') + assert.equal(daysInRange('month', now), 28) + assert.equal(rangeStartDate('1d', now), '2026-07-28') + assert.equal(rangeStartDate('7d', now), '2026-07-22') + assert.equal(rangeStartDate('30d', now), '2026-06-29') + }) +}) diff --git a/server/lib/range.ts b/server/lib/range.ts new file mode 100644 index 0000000..506cab0 --- /dev/null +++ b/server/lib/range.ts @@ -0,0 +1,52 @@ +export const DATE_RANGES = ['1d', '7d', '30d', 'month'] as const +export type DateRange = (typeof DATE_RANGES)[number] + +export function parseRange(value: unknown): DateRange { + if (typeof value === 'string' && (DATE_RANGES as readonly string[]).includes(value)) { + return value as DateRange + } + return '7d' +} + +/** Inclusive day count for avg/day math. */ +export function daysInRange(range: DateRange, now = new Date()): number { + if (range === '1d') return 1 + if (range === '7d') return 7 + if (range === '30d') return 30 + // This month: from the 1st through today (inclusive) + return Math.max(1, now.getDate()) +} + +export function formatLocalDate(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Local calendar YYYY-MM-DD for an ISO/RFC3339 (or Date-parseable) timestamp. */ +export function localDateFromTimestamp(value: string): string | null { + const d = new Date(value) + if (Number.isNaN(d.getTime())) return null + return formatLocalDate(d) +} + +/** Inclusive start date (YYYY-MM-DD) for the selected range ending today. */ +export function rangeStartDate(range: DateRange, now = new Date()): string { + const d = new Date(now) + d.setHours(0, 0, 0, 0) + if (range === 'month') { + d.setDate(1) + return formatLocalDate(d) + } + const days = daysInRange(range, now) + d.setDate(d.getDate() - (days - 1)) + return formatLocalDate(d) +} + +export function rangeLabel(range: DateRange): string { + if (range === '1d') return 'Today' + if (range === '7d') return '7 days' + if (range === '30d') return '30 days' + return 'This month' +} diff --git a/server/types.ts b/server/types.ts index 56a3667..feefca4 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,7 +1,36 @@ +import type { DateRange } from './lib/range.js' + export type DailyPoint = { date: string - value: number + primary: number + secondary?: number + primaryLabel: string + secondaryLabel?: string + /** Extra numeric breakdown for tooltips and period rollups */ + extras?: Record +} + +export type AgentStats = { + activeDays: number + avgPerDay: number + peakDay: string | null + peakValue: number + periodTotal: number +} + +/** Provider-specific token/usage limit reset windows. */ +export type UsageResetWindow = { label: string + /** ISO timestamp when this window resets; null if unknown / N/A */ + at: string | null + usedPercent?: number + note?: string +} + +export type UsageReset = { + ok: boolean + windows: UsageResetWindow[] + error?: string } export type AgentUsage = { @@ -9,9 +38,14 @@ export type AgentUsage = { name: string /** Comparable activity score used for percentage share */ score: number + available: boolean metrics: Record daily: DailyPoint[] + stats: AgentStats + usageReset?: UsageReset note?: string + /** How to enable this collector when data is missing */ + hint?: string } export type AgentShare = AgentUsage & { @@ -45,11 +79,22 @@ export type GithubSnapshot = { login: string | null totalContributions: number days: GithubDay[] + hint?: string } export type DashboardPayload = { generatedAt: string + range: DateRange + cached: boolean agents: AgentShare[] system: SystemSnapshot github: GithubSnapshot } + +export type RawCollectors = { + cursor: AgentUsage + claude: AgentUsage + codex: AgentUsage + system: SystemSnapshot + github: GithubSnapshot +} diff --git a/src/App.tsx b/src/App.tsx index c892106..5079387 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,67 +1,173 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { AgentPanel } from './components/AgentPanel' import { GithubPanel } from './components/GithubPanel' +import { Login } from './components/Login' import { SystemPanel } from './components/SystemPanel' -import type { DashboardPayload } from './lib/types' +import { apiFetch, clearAuthToken } from './lib/api' +import type { DashboardPayload, DateRange } from './lib/types' const REFRESH_MS = 15_000 +type AuthUser = { + email: string + name: string | null + picture: string | null + method?: 'google' | 'pin' +} + export default function App() { + const [authChecked, setAuthChecked] = useState(false) + const [user, setUser] = useState(null) + const [range, setRange] = useState('7d') const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const abortRef = useRef(null) + const inFlightRef = useRef(false) - const load = useCallback(async () => { - try { - const res = await fetch('/api/dashboard') - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const json = (await res.json()) as DashboardPayload - setData(json) - setError(null) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } + useEffect(() => { + void (async () => { + try { + const res = await apiFetch('/api/auth/me') + if (res.ok) { + const json = (await res.json()) as { user: AuthUser } + setUser(json.user) + } else { + setUser(null) + } + } catch { + setUser(null) + } finally { + setAuthChecked(true) + } + })() }, []) + const load = useCallback( + async (opts?: { refresh?: boolean; mode?: 'replace' | 'poll' }) => { + const mode = opts?.mode ?? 'replace' + + if (mode === 'poll') { + if (inFlightRef.current) return + } else { + abortRef.current?.abort() + } + + const controller = new AbortController() + abortRef.current = controller + inFlightRef.current = true + try { + const params = new URLSearchParams({ range }) + if (opts?.refresh) params.set('refresh', '1') + const res = await apiFetch(`/api/dashboard?${params}`, { + signal: controller.signal, + }) + if (res.status === 401 || res.status === 503) { + if (res.status === 401) clearAuthToken() + setUser(null) + setData(null) + throw new Error( + res.status === 503 + ? 'Sign-in is not configured on this Mac.' + : 'Signed out', + ) + } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as DashboardPayload + if (controller.signal.aborted) return + setData(json) + setError(null) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (abortRef.current === controller) inFlightRef.current = false + if (!controller.signal.aborted) setLoading(false) + } + }, + [range], + ) + useEffect(() => { - void load() - const id = window.setInterval(() => void load(), REFRESH_MS) - return () => window.clearInterval(id) - }, [load]) + if (!user) return + setLoading(true) + void load({ mode: 'replace' }) + const id = window.setInterval(() => void load({ mode: 'poll' }), REFRESH_MS) + return () => { + window.clearInterval(id) + abortRef.current?.abort() + } + }, [load, user]) + + async function logout() { + try { + await apiFetch('/api/auth/logout', { method: 'POST' }) + } catch { + /* ignore */ + } + clearAuthToken() + setUser(null) + setData(null) + } + + if (!authChecked) { + return ( +
+

Checking sign-in…

+
+ ) + } + + if (!user) { + return + } + + const identity = + user.method === 'pin' ? 'PIN access' : user.email return (
-

Localhost only

+

Local Mac

Agent Deck

+ {identity} {data - ? `Updated ${new Date(data.generatedAt).toLocaleTimeString()}` + ? ` · Updated ${new Date(data.generatedAt).toLocaleTimeString()}${ + data.cached ? ' · cached' : '' + }` : loading - ? 'Loading…' - : 'Offline'} + ? ' · Loading…' + : ' · Offline'}

- +
+ + +
{error ? (

- API unreachable ({error}). Start with{' '} - npm run dev so both Vite and the local collector are up. + API unreachable ({error}). If Agent Deck is not running, use{' '} + npm run setup once so it starts automatically at login.

) : null} - {data ? ( + {data && data.range === range ? (
- +
@@ -71,18 +177,31 @@ export default function App() {
Runs only on this Mac. Agent % is relative share of local activity - scores (Cursor accepted lines / Claude & Codex tokens or session volume). + scores for the selected date range (Cursor accepted lines / Claude & + Codex tokens or session volume).
diff --git a/src/components/GithubPanel.tsx b/src/components/GithubPanel.tsx index a418a99..b514bbd 100644 --- a/src/components/GithubPanel.tsx +++ b/src/components/GithubPanel.tsx @@ -53,10 +53,15 @@ export function GithubPanel({ github }: Props) { ))}
) : ( -

- Run gh auth login so this machine can read your - contribution calendar. -

+
+

{github.error ?? 'Unavailable'}

+ {github.hint ?

{github.hint}

: ( +

+ Run gh auth login so this machine can read your + contribution calendar. +

+ )} +
)} + + ) +} diff --git a/src/index.css b/src/index.css index 18b5553..3394472 100644 --- a/src/index.css +++ b/src/index.css @@ -35,6 +35,8 @@ body, body { min-height: 100vh; + min-height: 100dvh; + -webkit-tap-highlight-color: transparent; } button { @@ -44,6 +46,8 @@ button { border: 1px solid var(--line); cursor: pointer; padding: 0.45rem 0.8rem; + min-height: 2.5rem; + touch-action: manipulation; } button:hover { diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..1ba0914 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,68 @@ +declare global { + interface Window { + __AGENT_DECK_API_BASE__?: string + } +} + +const TOKEN_KEY = 'agent_deck_token' + +function trimSlash(value: string): string { + return value.replace(/\/$/, '') +} + +/** Build-time VITE_API_BASE, overridden by runtime `window.__AGENT_DECK_API_BASE__`. */ +export function apiBase(): string { + const runtime = + typeof window !== 'undefined' ? window.__AGENT_DECK_API_BASE__ : undefined + if (typeof runtime === 'string' && runtime.trim()) { + return trimSlash(runtime.trim()) + } + const baked = import.meta.env.VITE_API_BASE + if (typeof baked === 'string' && baked.trim()) { + return trimSlash(baked.trim()) + } + return '' +} + +export function apiUrl(path: string): string { + const base = apiBase() + const p = path.startsWith('/') ? path : `/${path}` + return `${base}${p}` +} + +export function getAuthToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +export function setAuthToken(token: string | null): void { + try { + if (token) sessionStorage.setItem(TOKEN_KEY, token) + else sessionStorage.removeItem(TOKEN_KEY) + } catch { + /* ignore quota / private mode */ + } +} + +export function clearAuthToken(): void { + setAuthToken(null) +} + +export function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers) + const token = getAuthToken() + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`) + } + return fetch(apiUrl(path), { + ...init, + headers, + credentials: 'include', + }) +} diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts new file mode 100644 index 0000000..693904f --- /dev/null +++ b/src/lib/types.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { formatResetAt, rangeLabel, DATE_RANGES } from '../../src/lib/types.ts' + +describe('frontend range + reset display helpers', () => { + it('keeps This month in the UI date-range set', () => { + assert.deepEqual(DATE_RANGES, ['1d', '7d', '30d', 'month']) + assert.equal(rangeLabel('month'), 'This month') + }) + + it('formats provider reset timestamps for display (not local midnight only)', () => { + const formatted = formatResetAt('2026-08-01T14:30:00.000Z') + assert.match(formatted, /Aug/) + assert.match(formatted, /01|1/) + assert.doesNotMatch(formatted, /^00:00$/) + }) +}) diff --git a/src/lib/types.ts b/src/lib/types.ts index 9484842..0cd189a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,7 +1,34 @@ +export const DATE_RANGES = ['1d', '7d', '30d', 'month'] as const +export type DateRange = (typeof DATE_RANGES)[number] + export type DailyPoint = { date: string - value: number + primary: number + secondary?: number + primaryLabel: string + secondaryLabel?: string + extras?: Record +} + +export type AgentStats = { + activeDays: number + avgPerDay: number + peakDay: string | null + peakValue: number + periodTotal: number +} + +export type UsageResetWindow = { label: string + at: string | null + usedPercent?: number + note?: string +} + +export type UsageReset = { + ok: boolean + windows: UsageResetWindow[] + error?: string } export type AgentShare = { @@ -9,9 +36,13 @@ export type AgentShare = { name: string score: number percent: number + available: boolean metrics: Record daily: DailyPoint[] + stats: AgentStats + usageReset?: UsageReset note?: string + hint?: string } export type SystemSnapshot = { @@ -41,10 +72,13 @@ export type GithubSnapshot = { login: string | null totalContributions: number days: GithubDay[] + hint?: string } export type DashboardPayload = { generatedAt: string + range: DateRange + cached: boolean agents: AgentShare[] system: SystemSnapshot github: GithubSnapshot @@ -69,3 +103,23 @@ export function formatUptime(sec: number): string { if (h > 0) return `${h}h ${m}m` return `${m}m` } + +export function rangeLabel(range: DateRange): string { + if (range === '1d') return 'Today' + if (range === '7d') return '7 days' + if (range === '30d') return '30 days' + return 'This month' +} + +export function formatResetAt(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 6830b6f..2a0e8bc 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,5 +22,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0f32683 --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +} diff --git a/vite.config.ts b/vite.config.ts index 4f6187e..3df4cad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,17 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +const apiTarget = `http://127.0.0.1:${process.env.PORT ?? 3847}` + export default defineConfig({ plugins: [react()], server: { port: 5174, + host: process.env.VITE_HOST || '127.0.0.1', proxy: { '/api': { - target: 'http://127.0.0.1:3847', - changeOrigin: true, + target: apiTarget, + changeOrigin: false, }, }, },