diff --git a/.gitignore b/.gitignore index 771df0c..ba2493f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ Thumbs.db .env .env.* \!.env.example + +# Agent local settings (may contain local tokens/permissions) +**/.claude/settings.local.json diff --git a/docs/opencto/HF_CHERI_OPENVINO_EVALUATION.md b/docs/opencto/HF_CHERI_OPENVINO_EVALUATION.md index 1312f2d..d5a64df 100644 --- a/docs/opencto/HF_CHERI_OPENVINO_EVALUATION.md +++ b/docs/opencto/HF_CHERI_OPENVINO_EVALUATION.md @@ -18,6 +18,7 @@ On the server: - Hugging Face token with access to the model (`HF_TOKEN`) Set token: + ```bash export HF_TOKEN='hf_...' ``` @@ -53,6 +54,7 @@ pip install "torch>=2.3" "transformers>=4.50" "accelerate>=0.34" "huggingface_hu ``` Quick latency check: + ```bash python3 - <<'PY' import os, time @@ -74,12 +76,14 @@ PY ## 5) OpenVINO Export + Runtime Install: + ```bash source .venv-cheri/bin/activate pip install -U "optimum-intel[openvino]" "openvino>=2024.4" ``` Export (try int8 first, then int4 if supported): + ```bash optimum-cli export openvino \ --model HeySalad/Cheri-ML-1.3B \ @@ -89,6 +93,7 @@ optimum-cli export openvino \ ``` If int8 fails or is too slow, try: + ```bash optimum-cli export openvino \ --model HeySalad/Cheri-ML-1.3B \ @@ -98,6 +103,7 @@ optimum-cli export openvino \ ``` Run OpenVINO inference benchmark: + ```bash python3 - <<'PY' import time diff --git a/docs/opencto/NEXT_STEPS_RPI.md b/docs/opencto/NEXT_STEPS_RPI.md index efa9b3e..e17ecc9 100644 --- a/docs/opencto/NEXT_STEPS_RPI.md +++ b/docs/opencto/NEXT_STEPS_RPI.md @@ -25,6 +25,7 @@ Move from scaffolded frontend/mocks to production-ready backend-backed auth, bil ## 3. RPi Execution Plan (Ordered) ### Step A: Sync and branch from latest main + ```bash cd /home/admin/CTO-AI # or: cd /home/peter/CTO-AI-phase2-clean @@ -36,6 +37,7 @@ git checkout -b feat/opencto-phase7-backend-auth-billing-compliance ``` ### Step B: Validate local baseline before edits + ```bash cd opencto/opencto-dashboard npm install @@ -98,6 +100,7 @@ Frontend already has `signInWithProvider(provider)` contract. Next: - CI green on lint/build/test. ## 5. Recommended First RPi Prompt (Copy/Paste) + ```text Implement Phase 7 backend-live integration for OpenCTO. @@ -125,4 +128,3 @@ Rules: - Workstream 1: Backend auth/session. - Workstream 2: Billing + Stripe webhook pipeline. - Workstream 3: Jobs/compliance live data + websocket stream. - diff --git a/docs/opencto/REALTIME_AGENT_CODEX_TASK.md b/docs/opencto/REALTIME_AGENT_CODEX_TASK.md index 8291e11..28a1a20 100644 --- a/docs/opencto/REALTIME_AGENT_CODEX_TASK.md +++ b/docs/opencto/REALTIME_AGENT_CODEX_TASK.md @@ -161,6 +161,7 @@ interface AudioConfig { The Cloudflare Worker backend. Relevant endpoints: **Token endpoint** (already working): + ``` POST /api/v1/realtime/token Authorization: Bearer demo-token @@ -171,6 +172,7 @@ Body: { "model": "gpt-4o-realtime-preview" } ``` **Tool proxy endpoints** (already wired, need VERCEL_TOKEN / CF_API_TOKEN / CF_ACCOUNT_ID secrets set): + ``` GET /api/v1/cto/vercel/projects GET /api/v1/cto/vercel/projects/:id/deployments @@ -318,6 +320,7 @@ getUserMedia({ audio: true }) ``` The worklet code (inline Blob) converts Float32 mic samples → Int16 PCM16: + ```js class PcmCaptureProcessor extends AudioWorkletProcessor { process(inputs) { @@ -368,7 +371,7 @@ Browser → sends { type: "response.create", response: {} } ### Server events to handle | Event type | What to do | -|---|---| +| --- | --- | | `session.created` | Log it; optionally send session.update here instead of on ws.onopen | | `session.updated` | Log it | | `conversation.item.input_audio_transcription.completed` | Emit `user_transcript` event with `event.transcript` | @@ -385,7 +388,7 @@ Browser → sends { type: "response.create", response: {} } When `response.function_call_arguments.done` fires with `name`, dispatch to: | `name` | Worker endpoint | How to call | -|---|---|---| +| --- | --- | --- | | `list_vercel_projects` | `GET /api/v1/cto/vercel/projects` | no args | | `list_vercel_deployments` | `GET /api/v1/cto/vercel/projects/{projectId}/deployments` | args.projectId | | `get_vercel_deployment` | `GET /api/v1/cto/vercel/deployments/{deploymentId}` | args.deploymentId | @@ -477,11 +480,13 @@ All must pass with 0 errors. ## Environment Variables `.env.local` (already set, do not change): + ``` VITE_API_BASE_URL=https://opencto-api-worker.heysalad-o.workers.dev ``` Cloudflare Worker secrets (set separately with `wrangler secret put`): + ``` OPENAI_API_KEY ← already set on the deployed worker VERCEL_TOKEN ← set this for Vercel tool calls to work diff --git a/docs/opencto/SDK_CLI_QUICKSTART.md b/docs/opencto/SDK_CLI_QUICKSTART.md new file mode 100644 index 0000000..f7b6d98 --- /dev/null +++ b/docs/opencto/SDK_CLI_QUICKSTART.md @@ -0,0 +1,79 @@ +# OpenCTO SDK + CLI Quickstart + +## Install + +SDK: + +```bash +npm install @heysalad/opencto@0.1.1 +``` + +CLI: + +```bash +npm install -g @heysalad/opencto-cli@0.1.1 +``` + +## SDK Quickstart + +```ts +import { createOpenCtoClient } from '@heysalad/opencto' + +const client = createOpenCtoClient({ + baseUrl: process.env.OPENCTO_API_BASE_URL ?? 'https://api.opencto.works', + getToken: () => process.env.OPENCTO_TOKEN ?? null, +}) + +const session = await client.auth.getSession() +console.log(session.user?.email) +``` + +## CLI Quickstart + +Set baseline environment: + +```bash +export OPENCTO_API_BASE_URL="https://api.opencto.works" +export OPENCTO_WORKSPACE="ws_demo" +``` + +Authenticate: + +```bash +opencto login --workspace "$OPENCTO_WORKSPACE" +``` + +List workflow directory: + +```bash +opencto workflow list --workspace "$OPENCTO_WORKSPACE" +``` + +Run a workflow: + +```bash +opencto workflow run engineering-ci \ + --workspace "$OPENCTO_WORKSPACE" \ + --repo-url https://github.com/org/repo \ + --wait +``` + +## Scripted Demo + +Use: + +```bash +./opencto/scripts/demo-opencto-e2e.sh --repo-url https://github.com/org/repo +``` + +The script runs: + +1. optional `opencto login` +2. `opencto workflow list` +3. `opencto workflow run custom ... --wait` + +## Package Links + +- npm SDK: https://www.npmjs.com/package/@heysalad/opencto +- npm CLI: https://www.npmjs.com/package/@heysalad/opencto-cli +- GitHub release: https://github.com/Hey-Salad/CTO-AI/releases/tag/v0.1.1 diff --git a/docs/opencto/VOICE_BACKEND_RUNBOOK.md b/docs/opencto/VOICE_BACKEND_RUNBOOK.md index 86af802..5e22e12 100644 --- a/docs/opencto/VOICE_BACKEND_RUNBOOK.md +++ b/docs/opencto/VOICE_BACKEND_RUNBOOK.md @@ -64,24 +64,28 @@ Expected ingress: ## 6) Operations Commands Service status: + ```bash sudo systemctl status opencto-voice-backend --no-pager -n 50 sudo systemctl status cloudflared --no-pager -n 50 ``` Restart services: + ```bash sudo systemctl restart opencto-voice-backend sudo systemctl restart cloudflared ``` Tail logs: + ```bash journalctl -u opencto-voice-backend -f journalctl -u cloudflared -f ``` Port checks: + ```bash ss -ltnp | rg '8090|cloudflared|uvicorn' ``` @@ -89,16 +93,19 @@ ss -ltnp | rg '8090|cloudflared|uvicorn' ## 7) Smoke Tests Local app health: + ```bash curl -sS http://127.0.0.1:8090/health ``` Public health: + ```bash curl -sS https://cloud-services-api.opencto.works/health ``` Local response: + ```bash curl -sS -X POST http://127.0.0.1:8090/v1/respond \ -H 'Content-Type: application/json' \ @@ -106,6 +113,7 @@ curl -sS -X POST http://127.0.0.1:8090/v1/respond \ ``` Public response: + ```bash curl -sS -X POST https://cloud-services-api.opencto.works/v1/respond \ -H 'Content-Type: application/json' \ @@ -132,10 +140,12 @@ curl -sS -X POST https://cloud-services-api.opencto.works/v1/respond \ ## 10) Migration to Repo-Managed Deployment (Recommended) 1. Clone repo: + ```bash cd /home/hs-chilu/heysalad-ai-projects git clone git@github.com:Hey-Salad/CTO-AI.git ``` + 2. Create backend runtime dir from repo source (or update service `WorkingDirectory`). 3. Validate with local health check. 4. Switch systemd `WorkingDirectory` + `ExecStart` to repo-managed path. diff --git a/opencto.workflows.json b/opencto.workflows.json new file mode 100644 index 0000000..951df3b --- /dev/null +++ b/opencto.workflows.json @@ -0,0 +1,73 @@ +{ + "workflows": [ + { + "id": "engineering-ci", + "name": "Engineering CI", + "description": "Install, lint, test, and build with pnpm defaults.", + "commandTemplates": [ + "pnpm install --frozen-lockfile", + "pnpm lint", + "pnpm test", + "pnpm build" + ] + }, + { + "id": "founder-landing-launch", + "name": "Founder Landing Launch", + "description": "Validate and build landing page plus dashboard for deployment.", + "commandTemplates": [ + "cd opencto/opencto-landing && npm ci", + "cd opencto/opencto-landing && npm run build", + "cd opencto/opencto-dashboard && npm ci", + "cd opencto/opencto-dashboard && npm run lint", + "cd opencto/opencto-dashboard && npm run build" + ] + }, + { + "id": "founder-content-seo", + "name": "Founder Content SEO", + "description": "Generate SEO assets and validate output artifacts.", + "commandTemplates": [ + "npm run content:plan -- --topic \"{{topic}}\"", + "npm run content:draft -- --topic \"{{topic}}\" --persona \"{{persona}}\"", + "npm run content:publish -- --topic \"{{topic}}\"" + ] + }, + { + "id": "founder-sales-outreach", + "name": "Founder Sales Outreach", + "description": "Generate lead list and outbound sequence assets.", + "commandTemplates": [ + "npm run sales:leads -- --segment \"{{segment}}\"", + "npm run sales:sequence -- --segment \"{{segment}}\" --offer \"{{offer}}\"", + "npm run sales:send -- --segment \"{{segment}}\"" + ] + }, + { + "id": "founder-demo-pack", + "name": "Founder Demo Pack", + "description": "Assemble product + GTM demo artifacts for judges or customers.", + "commandTemplates": [ + "npm run demo:product", + "npm run demo:marketing", + "npm run demo:ops", + "npm run demo:bundle" + ] + }, + { + "id": "sdk-release-check", + "name": "SDK Release Check", + "description": "Quality gates for sdk and cli packages before publish.", + "commandTemplates": [ + "cd opencto/opencto-sdk-js && npm ci", + "cd opencto/opencto-sdk-js && npm run lint", + "cd opencto/opencto-sdk-js && npm run test", + "cd opencto/opencto-sdk-js && npm run build", + "cd opencto/opencto-cli && npm ci", + "cd opencto/opencto-cli && npm run lint", + "cd opencto/opencto-cli && npm run test", + "cd opencto/opencto-cli && npm run build" + ] + } + ] +} diff --git a/opencto/OPENCTO_PACKAGES_RELEASE.md b/opencto/OPENCTO_PACKAGES_RELEASE.md new file mode 100644 index 0000000..6001565 --- /dev/null +++ b/opencto/OPENCTO_PACKAGES_RELEASE.md @@ -0,0 +1,55 @@ +# OpenCTO Package Release Runbook + +This runbook is for shipping: + +- `@heysalad/opencto` +- `@heysalad/opencto-cli` + +## Preconditions + +1. Work on your release branch (never `main` directly). +2. `opencto/opencto-sdk-js` and `opencto/opencto-cli` must be committed and clean. +3. npm auth must be active on this machine: + - `npm whoami` + +## One-command release + +From repository root: + +```bash +./opencto/scripts/release-opencto-packages.sh +``` + +What it does: + +1. Verifies npm auth. +2. Runs `lint`, `test`, `build` for SDK. +3. Publishes `@heysalad/opencto`. +4. Runs `lint`, `test`, `build` for CLI. +5. Publishes `@heysalad/opencto-cli`. + +## Manual fallback + +If you want step-by-step control: + +```bash +cd opencto/opencto-sdk-js +npm run lint && npm run test && npm run build +npm publish --access public + +cd ../opencto-cli +npm run lint && npm run test && npm run build +npm publish --access public +``` + +## Post-release verification + +```bash +npm view @heysalad/opencto version +npm view @heysalad/opencto-cli version +``` + +## Notes + +- `@heysalad/opencto-cli` depends on `@heysalad/opencto`, so publish SDK first. +- If npm returns `ENEEDAUTH`, run `npm login` and retry. diff --git a/opencto/Sheri-ML/SHERIML-AUTH-PHASE1-COMPLETE.md b/opencto/Sheri-ML/SHERIML-AUTH-PHASE1-COMPLETE.md index 62e5f74..ac77e30 100644 --- a/opencto/Sheri-ML/SHERIML-AUTH-PHASE1-COMPLETE.md +++ b/opencto/Sheri-ML/SHERIML-AUTH-PHASE1-COMPLETE.md @@ -363,7 +363,7 @@ cd /home/peter/heysalad-sheri-auth npm install # Deploy to Cloudflare -export CLOUDFLARE_API_TOKEN=_vSb-SSyXBvJzmjoJCArS-Aw42sE-d8DYje44TpW +export CLOUDFLARE_API_TOKEN=your_cloudflare_api_token_here wrangler deploy # Set secret diff --git a/opencto/Sheri-ML/sheri-ml-cli/.claude/settings.local.json b/opencto/Sheri-ML/sheri-ml-cli/.claude/settings.local.json deleted file mode 100644 index e8da04d..0000000 --- a/opencto/Sheri-ML/sheri-ml-cli/.claude/settings.local.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(head:*)", - "Bash(ssh:*)", - "Bash(chmod +x:*)", - "Bash(ss:*)", - "Bash(curl:*)", - "Bash(scp:*)", - "Bash(tail:*)", - "Bash(cd:*)", - "Bash(cat:*)", - "Bash(node:*)", - "Bash(ls:*)", - "Bash(cd /home/peter && tar czf sheri-ml-cli-v0.3.1.tar.gz sheri-ml-cli/dist/ sheri-ml-cli/package.json && scp -P 2222 -i ~/.ssh/gcp_rpi_key sheri-ml-cli-v0.3.1.tar.gz gcp-deploy@localhost:~/ 2>&1)", - "Bash(cd /home/peter && tar czf sheri-ml-cli-v0.3.1-final.tar.gz sheri-ml-cli/dist/ sheri-ml-cli/package.json && scp -P 2222 -i ~/.ssh/gcp_rpi_key sheri-ml-cli-v0.3.1-final.tar.gz gcp-deploy@localhost:~/ 2>&1)", - "Bash(gh auth:*)", - "Bash(wrangler:*)", - "WebFetch(domain:cli.github.com)", - "WebFetch(domain:docs.railway.app)", - "WebFetch(domain:docs.railway.com)", - "WebFetch(domain:developers.cloudflare.com)", - "WebFetch(domain:platform.openai.com)", - "WebFetch(domain:datatracker.ietf.org)", - "WebFetch(domain:auth0.com)", - "WebFetch(domain:docs.anthropic.com)", - "WebFetch(domain:docs.cursor.com)", - "WebFetch(domain:www.warp.dev)", - "WebFetch(domain:www.heroku.com)", - "WebFetch(domain:stripe.com)", - "WebFetch(domain:docs.stripe.com)", - "WebFetch(domain:aws.amazon.com)", - "WebFetch(domain:fly.io)", - "WebFetch(domain:github.com)", - "WebFetch(domain:blog.1password.com)", - "WebFetch(domain:1password.com)", - "WebFetch(domain:www.twilio.com)", - "WebFetch(domain:clig.dev)", - "Bash(echo No kernel keyring access:*)", - "WebFetch(domain:developers.google.com)", - "WebFetch(domain:docs.github.com)", - "WebFetch(domain:www.npmjs.com)", - "WebFetch(domain:docs.docker.com)", - "Bash(grep 8976:*)", - "Bash(env:*)", - "Bash(gh help:*)", - "Bash(export CLOUDFLARE_API_TOKEN=_vSb-SSyXBvJzmjoJCArS-Aw42sE-d8DYje44TpW && wrangler d1 create sheri-auth-db 2>&1)", - "Bash(export CLOUDFLARE_API_TOKEN=_vSb-SSyXBvJzmjoJCArS-Aw42sE-d8DYje44TpW && wrangler d1 list 2>&1)", - "Bash(export CLOUDFLARE_API_TOKEN=_vSb-SSyXBvJzmjoJCArS-Aw42sE-d8DYje44TpW && wrangler d1 execute heysalad-rag-db --file=schema.sql --remote 2>&1)", - "Bash(export CLOUDFLARE_API_TOKEN=_vSb-SSyXBvJzmjoJCArS-Aw42sE-d8DYje44TpW && wrangler deploy 2>&1)", - "Bash(export:*)", - "Bash(openssl rand:*)", - "Bash(grep:*)" - ] - }, - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "cheriml", - "heysalad" - ] -} diff --git a/opencto/mobile-app/.gitignore b/opencto/mobile-app/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/opencto/mobile-app/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/opencto/mobile-app/MOBILE_V1_SPEC.md b/opencto/mobile-app/MOBILE_V1_SPEC.md new file mode 100644 index 0000000..28166d5 --- /dev/null +++ b/opencto/mobile-app/MOBILE_V1_SPEC.md @@ -0,0 +1,202 @@ +# OpenCTO Mobile App Spec (MVP v1) + +## 1. Product Objective +Build a lightweight mobile companion to the web app so users can: +1. Sign in. +2. Talk to OpenCTO in realtime voice. +3. Continue with text chat. +4. Monitor and cancel codebase runs. +5. View basic account/workspace info. + +Primary objective: +- Ship a simple, stable, production-oriented iOS-first mobile app with core Autonomous CTO functionality and OpenAI-only realtime voice. + +## 2. MVP Scope (In) +1. Authentication with existing OpenCTO backend. +2. Realtime voice session using OpenAI only. +3. Text chat in the same conversation screen. +4. Runs list and run detail with live/polling updates. +5. Cancel run action. +6. Basic account screen with sign out. +7. In-app account deletion initiation. +8. Error states, reconnect states, and loading states. + +## 3. MVP Scope (Out) +1. Billing UI. +2. Passkey enrollment UI. +3. Multi-provider voice/model switching. +4. Full onboarding wizard. +5. Push notifications. +6. Advanced offline mode. +7. iPad-specific layouts. + +## 4. Engineering Principles +1. DRY by default: + - Shared UI primitives (`Button`, `Card`, `Badge`, `ListItem`, `EmptyState`, `ErrorState`). + - Shared form/input components. + - Shared API client and request helpers. + - Shared error normalization. + - Shared auth/session hooks. + - Shared realtime session manager. +2. Keep components small, composable, and reusable. +3. Avoid duplicate logic between screens. +4. Prefer centralized domain modules over ad-hoc per-screen logic. +5. Keep dependencies minimal. +6. Prefer simple, production-oriented implementations over speculative abstractions. + +## 5. Tech Stack +1. Expo, React Native, TypeScript. +2. EAS Build + EAS Submit. +3. Expo Router for navigation. +4. Secure token storage with `expo-secure-store`. +5. Networking via `fetch`. +6. Realtime transport compatible with React Native. +7. Audio capture/playback with Expo/native modules supported in EAS builds. +8. State management with React context + hooks. +9. iOS-first configuration and validation for initial release scope. + +## 6. App Architecture +Tasks: +1. Create route structure: + - `app/(auth)/login.tsx` + - `app/(tabs)/chat.tsx` + - `app/(tabs)/runs.tsx` + - `app/run/[id].tsx` + - `app/(tabs)/account.tsx` +2. Create reusable modules: + - `src/api/*` (auth/chat/runs/realtime clients) + - `src/realtime/*` (OpenAI realtime session manager) + - `src/audio/*` (mic/session/audio handling) + - `src/state/*` (auth/session/chat/run state) + - `src/components/*` (shared reusable UI) + - `src/hooks/*` (reusable business hooks) + +## 7. Backend Integration +Base API: +- `https://api.opencto.works` + +Tasks: +1. Integrate: + - `GET /api/v1/auth/session` + - `GET /api/v1/chats` + - `GET /api/v1/chats/:id` + - `POST /api/v1/chats/save` + - `POST /api/v1/realtime/token` + - `POST /api/v1/codebase/runs` + - `GET /api/v1/codebase/runs/:id` + - `GET /api/v1/codebase/runs/:id/events` + - `GET /api/v1/codebase/runs/:id/events/stream` + - `POST /api/v1/codebase/runs/:id/cancel` + - `DELETE /api/v1/auth/account` +2. Standardize auth header injection: + - `Authorization: Bearer ` +3. Centralize API error mapping and retry behavior. + +## 8. Realtime Voice v1 (OpenAI-only) +Tasks: +1. Implement voice session flow: + - Start voice session. + - Request mic permission. + - Request ephemeral token from `/api/v1/realtime/token`. + - Open realtime connection. + - Stream mic input. + - Receive and render assistant/tool events. +2. Implement session state machine: + - `idle`, `connecting`, `live`, `reconnecting`, `error`, `ended`. +3. Implement controls: + - Start/Stop + - Mute/Unmute + - Live status/timer +4. Implement resilience: + - Single auto-reconnect attempt. + - Fallback to text mode if reconnect fails. + - Token refresh handling for continued session. + - Graceful interruption handling. + +## 9. UI Requirements +### Chat Tab +Tasks: +1. Build reusable chat message list component. +2. Build unified message renderer for `USER | ASSISTANT | TOOL`. +3. Build text input composer. +4. Build reusable voice control bar and connection badge. +5. Support text and voice in one timeline. + +### Runs Tab +Tasks: +1. Build reusable runs list item and status badge. +2. Build pull-to-refresh and empty/error states. +3. Navigate to run details on tap. + +### Run Detail +Tasks: +1. Render run summary and status. +2. Render event log stream/poll updates. +3. Add cancel action for valid states. + +### Account Tab +Tasks: +1. Render user/workspace summary cards. +2. Add sign out action. +3. Add account deletion initiation action. + +## 10. Data Contracts +Tasks: +1. Define shared models: + - `AuthSession` + - `ChatMessage` + - `CodebaseRun` + - `CodebaseRunEvent` + - `RealtimeConnectionState` + - `ApiError` +2. Keep model definitions centralized and reused across all features. + +## 11. Security Requirements +Tasks: +1. Store auth tokens in `expo-secure-store`. +2. Avoid logging secrets/tokens/auth headers. +3. Enforce HTTPS usage. +4. Keep ephemeral realtime secrets in-memory only. +5. Apply safe error handling for production logs. + +## 12. Performance Requirements +Tasks: +1. Optimize first render and navigation transitions. +2. Keep realtime connection startup responsive. +3. Keep chat and run list updates smooth under normal network conditions. +4. Avoid heavy re-renders via memoized reusable components/hooks. + +## 13. QA / Acceptance Criteria +Tasks: +1. Validate sign in persistence across app restarts. +2. Validate realtime voice start/stop/mute/unmute flows. +3. Validate voice + text in same conversation. +4. Validate run listing, run details, and cancel action. +5. Validate reconnect and fallback behavior on network drop. +6. Validate mic permission denied flow. +7. Validate production EAS build and TestFlight install. + +## 14. App Store Readiness +Tasks: +1. Add microphone usage description in iOS config. +2. Ensure in-app account deletion initiation exists. +3. Complete accurate privacy metadata. +4. Add Terms and Privacy links. + +## 15. Delivery Tasks +1. Scaffold app and routing. +2. Implement shared design system primitives. +3. Implement shared API/auth/realtime core modules. +4. Implement auth flow. +5. Implement chat (text + realtime voice). +6. Implement runs list/detail/cancel. +7. Implement account/signout/delete. +8. Execute QA checklist. +9. Produce EAS build and submission package. + +## 16. Post-MVP Backlog +1. Push notifications for run completion. +2. Billing read-only view. +3. Passkey UX. +4. Advanced transcript/code rendering. +5. Android rollout. diff --git a/opencto/mobile-app/README.md b/opencto/mobile-app/README.md new file mode 100644 index 0000000..78ecbcf --- /dev/null +++ b/opencto/mobile-app/README.md @@ -0,0 +1,72 @@ +# OpenCTO Mobile App (MVP v1) + +iOS-first Expo mobile app for OpenCTO core workflows: GitHub OAuth auth, token auth fallback, realtime voice + text chat, runs monitoring/cancel, and account actions. + +## Requirements +- Node.js 20+ +- npm 10+ +- Expo CLI (`npx expo`) +- EAS CLI (`npm i -g eas-cli`) + +## Setup +1. Install dependencies: +```bash +npm install +``` +2. Optional env vars: +```bash +EXPO_PUBLIC_API_BASE_URL=https://api.opencto.works +EXPO_PUBLIC_TERMS_URL=https://opencto.works/terms +EXPO_PUBLIC_PRIVACY_URL=https://opencto.works/privacy +EXPO_PUBLIC_DEFAULT_REPO_URL=https://github.com/your-org/your-repo +EXPO_PUBLIC_DEFAULT_REPO_FULL_NAME=your-org/your-repo +EXPO_PUBLIC_DEFAULT_BASE_BRANCH=main +EXPO_PUBLIC_DEFAULT_TARGET_BRANCH_PREFIX=opencto/mobile +EXPO_PUBLIC_DEFAULT_RUN_COMMAND=npm test +``` +3. Start dev server: +```bash +npm run ios +``` + +GitHub sign-in requires OpenCTO API OAuth to be configured (`GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `JWT_SECRET` in the API worker). + +## Validation +```bash +npm run lint +npm run typecheck +npm run build +npm run test +``` + +## Architecture +- `app/*`: Expo Router screens and layouts +- `src/components/*`: reusable UI and feature components +- `src/api/*`: shared API client, auth injection, error normalization, endpoint modules +- `src/state/*`: auth/chat/runs providers and centralized state +- `src/hooks/*`: reusable business hooks +- `src/realtime/*`: OpenAI-only realtime state machine and session manager +- `src/audio/*`: microphone permissions/session setup + +## iOS Build and Submit +```bash +eas login +# Physical iPhone internal build (device install) +eas device:create +eas build --platform ios --profile development + +# TestFlight/App Store build +eas build --platform ios --profile production +eas submit --platform ios --profile production +``` + +If you see "This app cannot be installed because its integrity could not be verified": +- Verify you installed a device build profile (`development`, `preview`, or `production`). +- Re-register the device with `eas device:create` and rebuild. +- Delete old copies of the app before reinstalling. +- Use TestFlight for production distribution to avoid ad-hoc trust/provisioning issues. + +## Security +- Auth tokens stored with `expo-secure-store` +- No token/header logging +- Realtime ephemeral token kept in memory only diff --git a/opencto/mobile-app/__tests__/api-errors.test.ts b/opencto/mobile-app/__tests__/api-errors.test.ts new file mode 100644 index 0000000..1859ebb --- /dev/null +++ b/opencto/mobile-app/__tests__/api-errors.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeApiError } from '@/api/errors'; + +describe('normalizeApiError', () => { + it('maps status codes', () => { + const error = normalizeApiError({ status: 401 }, 'Unauthorized'); + expect(error.code).toBe('UNAUTHORIZED'); + expect(error.retriable).toBe(false); + }); + + it('maps network errors', () => { + const error = normalizeApiError(new TypeError('failed')); + expect(error.code).toBe('NETWORK'); + expect(error.retriable).toBe(true); + }); +}); diff --git a/opencto/mobile-app/__tests__/api-http.test.ts b/opencto/mobile-app/__tests__/api-http.test.ts new file mode 100644 index 0000000..337b7dc --- /dev/null +++ b/opencto/mobile-app/__tests__/api-http.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '@/api/http'; + +describe('ApiClient', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('injects bearer token', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true }) + }); + vi.stubGlobal('fetch', fetchMock); + + const client = new ApiClient(async () => 'abc123'); + await client.request('/test'); + + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer abc123'); + }); +}); diff --git a/opencto/mobile-app/__tests__/codebase-mappers.test.ts b/opencto/mobile-app/__tests__/codebase-mappers.test.ts new file mode 100644 index 0000000..0ed273c --- /dev/null +++ b/opencto/mobile-app/__tests__/codebase-mappers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { mapWorkerRun, mapWorkerRunEvent, normalizeRunStatus } from '@/launchpad/codebaseMappers'; + +describe('codebaseMappers', () => { + it('normalizes worker statuses to mobile statuses', () => { + expect(normalizeRunStatus('running')).toBe('in_progress'); + expect(normalizeRunStatus('succeeded')).toBe('completed'); + expect(normalizeRunStatus('timed_out')).toBe('failed'); + }); + + it('maps worker run shape', () => { + const mapped = mapWorkerRun({ + id: 'run_1', + userId: 'user_1', + repoUrl: 'https://github.com/org/repo', + repoFullName: 'org/repo', + baseBranch: 'main', + targetBranch: 'opencto/mobile-123', + status: 'running', + requestedCommands: ['npm run build'], + commandAllowlistVersion: '2026-03-02', + timeoutSeconds: 600, + createdAt: '2026-03-03T00:00:00.000Z', + startedAt: '2026-03-03T00:01:00.000Z', + completedAt: null, + canceledAt: null, + errorMessage: null + }); + + expect(mapped.title).toBe('org/repo'); + expect(mapped.status).toBe('in_progress'); + expect(mapped.branch).toBe('opencto/mobile-123'); + }); + + it('maps worker run event shape', () => { + const mapped = mapWorkerRunEvent({ + id: 'evt_1', + runId: 'run_1', + seq: 12, + level: 'info', + eventType: 'run.plan', + message: 'Planning', + payload: { phase: 'plan' }, + createdAt: '2026-03-03T00:00:00.000Z' + }); + + expect(mapped.type).toBe('run.plan'); + expect(mapped.seq).toBe(12); + expect(mapped.payload?.phase).toBe('plan'); + }); +}); diff --git a/opencto/mobile-app/__tests__/launchpad-event-normalizer.test.ts b/opencto/mobile-app/__tests__/launchpad-event-normalizer.test.ts new file mode 100644 index 0000000..a547521 --- /dev/null +++ b/opencto/mobile-app/__tests__/launchpad-event-normalizer.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeRunEvent } from '@/launchpad/eventNormalizer'; + +describe('normalizeRunEvent', () => { + it('maps plan events from type', () => { + const normalized = normalizeRunEvent({ + id: 'evt_1', + runId: 'run_1', + type: 'run.plan.updated', + message: 'Refactor auth flow', + createdAt: new Date().toISOString() + }); + + expect(normalized.kind).toBe('plan'); + expect(normalized.content).toBe('Refactor auth flow'); + expect(normalized.metadata.title).toBe('Run Plan Updated'); + }); + + it('maps command events from tag prefix', () => { + const normalized = normalizeRunEvent({ + id: 'evt_2', + runId: 'run_1', + type: 'run.event', + message: '[cmd] npm run build', + createdAt: new Date().toISOString() + }); + + expect(normalized.kind).toBe('command'); + expect(normalized.content).toBe('npm run build'); + }); + + it('reads structured payload metadata', () => { + const normalized = normalizeRunEvent({ + id: 'evt_3', + runId: 'run_2', + type: 'artifact.code', + message: '{"message":"[code] const x = 1;","language":"typescript","source":"repo"}', + createdAt: new Date().toISOString() + }); + + expect(normalized.kind).toBe('code'); + expect(normalized.content).toBe('const x = 1;'); + expect(normalized.metadata.language).toBe('typescript'); + expect(normalized.metadata.source).toBe('repo'); + }); +}); diff --git a/opencto/mobile-app/__tests__/launchpad-session-flow.test.ts b/opencto/mobile-app/__tests__/launchpad-session-flow.test.ts new file mode 100644 index 0000000..e00b774 --- /dev/null +++ b/opencto/mobile-app/__tests__/launchpad-session-flow.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + initialLaunchpadSessionUiState, + reduceLaunchpadSessionUiState +} from '@/launchpad/sessionFlow'; + +describe('launchpad session flow reducer', () => { + it('supports start -> keyboard toggle -> stop flow', () => { + const started = reduceLaunchpadSessionUiState(initialLaunchpadSessionUiState, { type: 'START' }); + const keyboardOpen = reduceLaunchpadSessionUiState(started, { type: 'TOGGLE_KEYBOARD' }); + const stopped = reduceLaunchpadSessionUiState(keyboardOpen, { type: 'STOP' }); + + expect(started.mode).toBe('live'); + expect(keyboardOpen.keyboardOpen).toBe(true); + expect(stopped.mode).toBe('idle'); + expect(stopped.keyboardOpen).toBe(false); + }); + + it('does not toggle keyboard in idle mode', () => { + const next = reduceLaunchpadSessionUiState(initialLaunchpadSessionUiState, { type: 'TOGGLE_KEYBOARD' }); + expect(next.keyboardOpen).toBe(false); + }); +}); diff --git a/opencto/mobile-app/__tests__/oauth-auth.test.ts b/opencto/mobile-app/__tests__/oauth-auth.test.ts new file mode 100644 index 0000000..4a08a1d --- /dev/null +++ b/opencto/mobile-app/__tests__/oauth-auth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + buildGitHubOAuthStartUrl, + extractAuthTokenFromOAuthCallback, + MOBILE_OAUTH_CALLBACK_URL +} from '@/auth/oauth'; + +describe('mobile oauth helpers', () => { + it('builds GitHub OAuth start url with returnTo callback', () => { + const url = buildGitHubOAuthStartUrl('https://api.opencto.works'); + const parsed = new URL(url); + + expect(parsed.origin).toBe('https://api.opencto.works'); + expect(parsed.pathname).toBe('/api/v1/auth/oauth/github/start'); + expect(parsed.searchParams.get('returnTo')).toBe(MOBILE_OAUTH_CALLBACK_URL); + }); + + it('extracts auth token from callback hash', () => { + const token = extractAuthTokenFromOAuthCallback('opencto://auth/callback#auth_token=abc123'); + expect(token).toBe('abc123'); + }); + + it('extracts auth token from callback query fallback', () => { + const token = extractAuthTokenFromOAuthCallback('opencto://auth/callback?auth_token=xyz789'); + expect(token).toBe('xyz789'); + }); + + it('returns null when callback has no auth token', () => { + const token = extractAuthTokenFromOAuthCallback('opencto://auth/callback#state=missing'); + expect(token).toBeNull(); + }); +}); diff --git a/opencto/mobile-app/__tests__/realtime-state-machine.test.ts b/opencto/mobile-app/__tests__/realtime-state-machine.test.ts new file mode 100644 index 0000000..54c1dd6 --- /dev/null +++ b/opencto/mobile-app/__tests__/realtime-state-machine.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { transitionRealtimeState } from '@/realtime/stateMachine'; + +describe('transitionRealtimeState', () => { + it('transitions idle -> connecting -> live', () => { + const connecting = transitionRealtimeState('idle', 'CONNECT'); + const live = transitionRealtimeState(connecting, 'CONNECTED'); + expect(connecting).toBe('connecting'); + expect(live).toBe('live'); + }); + + it('handles reconnect failure path', () => { + const reconnecting = transitionRealtimeState('live', 'DISCONNECT'); + const error = transitionRealtimeState(reconnecting, 'RECONNECT_FAILED'); + expect(reconnecting).toBe('reconnecting'); + expect(error).toBe('error'); + }); +}); diff --git a/opencto/mobile-app/__tests__/runs-stream.test.ts b/opencto/mobile-app/__tests__/runs-stream.test.ts new file mode 100644 index 0000000..35989b3 --- /dev/null +++ b/opencto/mobile-app/__tests__/runs-stream.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '@/api/http'; +import { streamRunEvents } from '@/api/runs'; + +describe('streamRunEvents', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('parses SSE events and maps payloads', async () => { + const encoder = new TextEncoder(); + const chunks = [ + 'event: events\n', + 'data: {"runId":"run_1","lastSeq":2,"events":[{"id":"evt_1","runId":"run_1","seq":1,"level":"info","eventType":"run.plan","message":"[plan] Draft plan","payload":null,"createdAt":"2026-03-03T00:00:00.000Z"},{"id":"evt_2","runId":"run_1","seq":2,"level":"info","eventType":"run.command","message":"[cmd] npm run build","payload":{"command":"npm run build"},"createdAt":"2026-03-03T00:00:01.000Z"}]}\n\n', + 'event: run\n', + 'data: {"run":{"id":"run_1","userId":"u","repoUrl":"https://github.com/org/repo","repoFullName":"org/repo","baseBranch":"main","targetBranch":"opencto/mobile","status":"running","requestedCommands":["npm run build"],"commandAllowlistVersion":"2026-03-02","timeoutSeconds":600,"createdAt":"2026-03-03T00:00:00.000Z"}}\n\n' + ]; + + const stream = new ReadableStream({ + start(controller) { + chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))); + controller.close(); + } + }); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new ApiClient(async () => 'token_123'); + const events: string[] = []; + const runs: string[] = []; + + await streamRunEvents(client, 'run_1', { + signal: new AbortController().signal, + onEvents: (items) => { + events.push(...items.map((item) => item.type)); + }, + onRun: (run) => { + runs.push(run.status); + } + }); + + expect(events).toEqual(['run.plan', 'run.command']); + expect(runs).toEqual(['in_progress']); + + const headers = (fetchMock.mock.calls[0]?.[1] as RequestInit)?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer token_123'); + }); +}); diff --git a/opencto/mobile-app/app.json b/opencto/mobile-app/app.json new file mode 100644 index 0000000..ecd5424 --- /dev/null +++ b/opencto/mobile-app/app.json @@ -0,0 +1,54 @@ +{ + "expo": { + "name": "OpenCTO", + "slug": "opencto-mobile-app", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "opencto", + "userInterfaceStyle": "dark", + "splash": { + "image": "./assets/images/splash-wordmark.png", + "resizeMode": "contain", + "backgroundColor": "#0b0b0d" + }, + "ios": { + "supportsTablet": false, + "bundleIdentifier": "works.opencto.mobile", + "config": { + "usesNonExemptEncryption": false + }, + "infoPlist": { + "NSMicrophoneUsageDescription": "OpenCTO uses your microphone for realtime voice conversations." + } + }, + "android": { + "package": "works.opencto.mobile", + "adaptiveIcon": { + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png", + "backgroundColor": "#0b0b0d" + } + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "b4f36af9-2d45-47f0-a669-a1be4d5a638c" + } + }, + "owner": "heysaladio" + } +} diff --git a/opencto/mobile-app/app/(auth)/_layout.tsx b/opencto/mobile-app/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/opencto/mobile-app/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/opencto/mobile-app/app/(auth)/login.tsx b/opencto/mobile-app/app/(auth)/login.tsx new file mode 100644 index 0000000..60446de --- /dev/null +++ b/opencto/mobile-app/app/(auth)/login.tsx @@ -0,0 +1,102 @@ +import { Redirect } from 'expo-router'; +import { useState } from 'react'; +import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import * as WebBrowser from 'expo-web-browser'; +import { Button, Card, ErrorState, TextInputField } from '@/components/ui'; +import { buildGitHubOAuthStartUrl, extractAuthTokenFromOAuthCallback, MOBILE_OAUTH_CALLBACK_URL } from '@/auth/oauth'; +import { API_BASE_URL } from '@/config/env'; +import { useAuth } from '@/hooks/useAuth'; +import { useAuthGate } from '@/hooks/useAuthGate'; +import { useScreenSpacing } from '@/hooks/useScreenSpacing'; +import { colors } from '@/theme/colors'; + +WebBrowser.maybeCompleteAuthSession(); + +export default function LoginScreen() { + const { signIn, error } = useAuth(); + const { isAuthenticated } = useAuthGate(); + const spacing = useScreenSpacing(); + const [inputToken, setInputToken] = useState(''); + const [oauthLoading, setOauthLoading] = useState(false); + const [oauthError, setOauthError] = useState(null); + + if (isAuthenticated) { + return ; + } + + const handleGitHubSignIn = async () => { + setOauthLoading(true); + setOauthError(null); + try { + const authUrl = buildGitHubOAuthStartUrl(API_BASE_URL); + const result = await WebBrowser.openAuthSessionAsync(authUrl, MOBILE_OAUTH_CALLBACK_URL); + + if (result.type !== 'success' || !result.url) { + setOauthError('GitHub sign-in was canceled.'); + return; + } + + const token = extractAuthTokenFromOAuthCallback(result.url); + if (!token) { + setOauthError('GitHub sign-in did not return a session token.'); + return; + } + + await signIn(token); + } catch { + setOauthError('Unable to sign in with GitHub right now.'); + } finally { + setOauthLoading(false); + } + }; + + return ( + + + + OpenCTO Mobile + Sign in with GitHub or use your OpenCTO API token. + - )} @@ -696,6 +730,69 @@ function App() { + +
+

Model Provider Keys (BYOK)

+

+ Store provider keys per workspace. These keys override shared backend defaults for your requests. +

+
+ + handleWorkspaceIdChange(event.target.value)} + placeholder="default" + /> +
+
+ {(['openai', 'github'] as const).map((provider) => { + const existing = providerKeys.find((item) => item.provider === provider) + const saving = providerKeysBusy === provider + const deleting = providerKeysBusy === `delete:${provider}` + return ( +
+
+ {provider.toUpperCase()} + {existing ? `Saved: ${existing.keyHint}` : 'No key saved'} +
+ { + const next = event.target.value + setProviderKeyDrafts((prev) => ({ ...prev, [provider]: next })) + }} + placeholder={`Paste ${provider.toUpperCase()} API key`} + /> +
+ + +
+
+ ) + })} +
+ {providerKeysLoading &&

Loading provider keys...

} + {providerKeysMessage &&

{providerKeysMessage}

} +
)} diff --git a/opencto/opencto-dashboard/src/api/llmKeysClient.ts b/opencto/opencto-dashboard/src/api/llmKeysClient.ts new file mode 100644 index 0000000..fb10c9b --- /dev/null +++ b/opencto/opencto-dashboard/src/api/llmKeysClient.ts @@ -0,0 +1,63 @@ +import { getApiBaseUrl } from '../config/apiBase' +import { getAuthHeaders } from '../lib/authToken' +import { normalizeApiError, safeFetchJson } from '../lib/safeError' + +const BASE = `${getApiBaseUrl()}/api/v1/llm/keys` + +export interface ProviderKeySummary { + provider: string + workspaceId: string + keyHint: string + createdAt: string + updatedAt: string +} + +export async function listProviderKeys(workspaceId: string): Promise { + try { + const response = await safeFetchJson<{ keys: ProviderKeySummary[] }>( + `${BASE}?workspaceId=${encodeURIComponent(workspaceId)}`, + { headers: getAuthHeaders() }, + 'Failed to load provider keys', + ) + return response.keys ?? [] + } catch (error) { + throw normalizeApiError(error, 'Failed to load provider keys') + } +} + +export async function saveProviderKey(provider: string, apiKey: string, workspaceId: string): Promise { + try { + await safeFetchJson( + `${BASE}/${encodeURIComponent(provider)}`, + { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'x-idempotency-key': `byok-${provider}-${Date.now()}`, + }, + body: JSON.stringify({ apiKey, workspaceId }), + }, + `Failed to save ${provider} key`, + ) + } catch (error) { + throw normalizeApiError(error, `Failed to save ${provider} key`) + } +} + +export async function deleteProviderKey(provider: string, workspaceId: string): Promise { + try { + await safeFetchJson( + `${BASE}/${encodeURIComponent(provider)}?workspaceId=${encodeURIComponent(workspaceId)}`, + { + method: 'DELETE', + headers: { + ...getAuthHeaders(), + 'x-idempotency-key': `byok-delete-${provider}-${Date.now()}`, + }, + }, + `Failed to delete ${provider} key`, + ) + } catch (error) { + throw normalizeApiError(error, `Failed to delete ${provider} key`) + } +} diff --git a/opencto/opencto-dashboard/src/index.css b/opencto/opencto-dashboard/src/index.css index e66aba8..79beefa 100644 --- a/opencto/opencto-dashboard/src/index.css +++ b/opencto/opencto-dashboard/src/index.css @@ -1255,6 +1255,33 @@ p { margin: 0; } margin-top: 4px; } +.settings-key-label { + font-size: 12px; + color: var(--text-dim); + letter-spacing: 0.03em; +} + +.settings-key-grid { + display: grid; + gap: 10px; +} + +.settings-key-row { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px; + display: grid; + gap: 8px; + background: rgba(255, 255, 255, 0.02); +} + +.settings-key-row-header { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; +} + .codebase-panel { display: grid; gap: 14px; @@ -1764,9 +1791,449 @@ button:disabled { } .audio-role-opencto svg { - width: 14px; - height: 14px; - color: var(--brand-primary); + width: 15px; + height: 15px; + flex-shrink: 0; +} + +.audio-role-opencto span { + text-transform: none; + letter-spacing: 0.04em; +} + +/* Codebase v2 workspace */ +.codebase-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 0; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + min-height: 680px; +} + +.codebase-rail { + background: #101014; + border-right: 1px solid var(--border-color); + padding: 14px; + font-size: 13px; + display: grid; + grid-auto-rows: min-content; + gap: 12px; +} + +.codebase-rail-block { + display: grid; + gap: 8px; +} + +.codebase-rail-block h3 { + font-size: 13px; + font-weight: 600; +} + +.codebase-rail-header-row { + display: grid; + gap: 6px; +} + +.codebase-repo-trigger { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #141418; + color: inherit; + padding: 8px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px; + align-items: center; + text-align: left; + cursor: pointer; +} + +.codebase-repo-avatar { + width: 20px; + height: 20px; + border-radius: 999px; + background: #1f1f25; + background-size: cover; + background-position: center; +} + +.codebase-repo-trigger strong, +.codebase-repo-trigger small { + display: block; + font-size: 13px; + line-height: 1.2; +} + +.codebase-repo-trigger small { + color: var(--text-muted); + font-size: 12px; +} + +.codebase-repo-picker { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #141418; + padding: 8px; + display: grid; + gap: 8px; +} + +.codebase-repo-picker input, +.codebase-custom-inline input, +.codebase-log-search { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #0d0d0d; + color: var(--text-body); + padding: 8px 10px; + font: inherit; +} + +.codebase-repo-picker-list { + display: grid; + gap: 6px; + max-height: 180px; + overflow: auto; +} + +.codebase-repo-option { + border: 1px solid var(--border-color); + border-radius: 7px; + background: transparent; + color: inherit; + text-align: left; + padding: 7px 8px; + cursor: pointer; +} + +.codebase-repo-option-active { + border-left: 3px solid var(--brand-primary); +} + +.codebase-kpi-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.codebase-kpi-pill { + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 3px 8px; + font-size: 13px; +} + +.codebase-template-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.codebase-template-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #141418; + color: inherit; + padding: 7px 8px; + font-size: 13px; + cursor: pointer; +} + +.codebase-inline-warning { + border: 1px solid rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); + border-radius: 8px; + padding: 7px 8px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.codebase-inline-warning button { + border: none; + background: transparent; + color: var(--brand-secondary); + cursor: pointer; +} + +.codebase-history-list { + display: grid; + gap: 6px; + max-height: 320px; + overflow: auto; +} + +.codebase-history-row { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #141418; + color: inherit; + padding: 8px; + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + text-align: left; + cursor: pointer; + transition: background-color 0.14s ease; +} + +.codebase-history-row:hover { + background: #17171d; +} + +.codebase-history-row-active { + border-left: 3px solid var(--brand-primary); +} + +.codebase-history-row-fadein { + animation: codebaseHistoryFadeIn 150ms ease-out; +} + +@keyframes codebaseHistoryFadeIn { + 0% { opacity: 0; transform: translateY(2px); } + 100% { opacity: 1; transform: translateY(0); } +} + +.codebase-status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #6b7280; +} + +.codebase-status-succeeded { background: #22c55e; } +.codebase-status-failed { background: #ef4444; } +.codebase-status-canceled, +.codebase-status-timed_out { background: #f59e0b; } +.codebase-status-running, +.codebase-status-queued { + background: #9ca3af; + animation: codebasePulseDot 1.2s ease-in-out infinite; +} + +@keyframes codebasePulseDot { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +.codebase-history-main strong { + display: block; + font-size: 13px; + font-weight: 500; +} + +.codebase-history-main small, +.codebase-history-meta small { + display: block; + font-size: 12px; + color: var(--text-muted); +} + +.codebase-main { + background: #0f0f10; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + min-height: 680px; +} + +.codebase-main-empty { + display: grid; + place-items: center; + height: 100%; + color: var(--text-muted); + font-size: 14px; +} + +.codebase-main-topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); +} + +.codebase-topbar-label { + font-size: 13px; +} + +.codebase-cancel-link { + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; +} + +.codebase-stream-banner { + border-bottom: 1px solid var(--border-color); + color: var(--text-muted); + padding: 8px 16px; + font-size: 12px; +} + +.codebase-pr-banner { + border-bottom: 1px solid var(--border-color); + color: var(--text-muted); + padding: 7px 16px; + font-size: 12px; +} + +.codebase-pr-banner a { + color: var(--text-body); +} + +.codebase-verdict-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-color); + animation: codebaseVerdictIn 150ms ease-out; +} + +@keyframes codebaseVerdictIn { + 0% { opacity: 0; transform: translateY(4px); } + 100% { opacity: 1; transform: translateY(0); } +} + +.codebase-verdict-main { + display: grid; + gap: 2px; +} + +.codebase-verdict-main strong { + font-size: 14px; + font-weight: 500; +} + +.codebase-verdict-main span { + font-size: 13px; + color: var(--text-muted); +} + +.codebase-verdict-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.codebase-verdict-succeeded { + border-left: 3px solid var(--brand-primary); +} + +.codebase-error-summary { + margin: 8px 16px 0; + border: 1px solid var(--border-color); + border-radius: 8px; + background: #131316; + color: var(--text-muted); + padding: 8px 10px; + font-size: 12px; +} + +.codebase-log-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; +} + +.codebase-log-toolbar { + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.codebase-log-actions { + display: flex; + gap: 8px; +} + +.codebase-log-stream { + background: #0d0d0d; + font-family: 'JetBrains Mono', 'Courier New', monospace; + font-size: 12px; + padding: 0 16px; + overflow: auto; +} + +.codebase-log-table { + width: 100%; + border-collapse: collapse; +} + +.codebase-log-line { + padding: 3px 0; + white-space: pre-wrap; + color: #d1d5db; +} + +.codebase-log-prefix { + color: #6b7280; + margin-right: 6px; +} + +.ansi-red { color: #ef4444; } +.ansi-green { color: #22c55e; } +.ansi-yellow { color: #f59e0b; } +.ansi-blue { color: #60a5fa; } +.ansi-magenta { color: #f472b6; } +.ansi-cyan { color: #22d3ee; } +.ansi-white { color: #e5e7eb; } +.ansi-muted { color: #6b7280; } +.ansi-bright-red { color: #f87171; } +.ansi-bright-green { color: #4ade80; } +.ansi-bright-yellow { color: #fbbf24; } +.ansi-bright-blue { color: #93c5fd; } +.ansi-bright-magenta { color: #f9a8d4; } +.ansi-bright-cyan { color: #67e8f9; } + +.codebase-log-highlight .codebase-log-line { + background: rgba(239, 68, 68, 0.16); +} + +.codebase-log-skeleton, +.codebase-inline-skeleton { + height: 34px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: #17171d; + animation: codebaseSkeleton 1.2s ease-in-out infinite; +} + +@keyframes codebaseSkeleton { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +.codebase-artifacts-inline { + padding: 10px 16px 14px; + border-top: 1px solid var(--border-color); +} + +.codebase-load-more { + width: 100%; +} + +.codebase-disconnected { + display: grid; + gap: 10px; +} + +.codebase-disconnected-actions { + display: flex; + gap: 8px; } .audio-role-user { diff --git a/opencto/opencto-dashboard/src/lib/realtime/openaiAdapter.ts b/opencto/opencto-dashboard/src/lib/realtime/openaiAdapter.ts index a8ed046..532d278 100644 --- a/opencto/opencto-dashboard/src/lib/realtime/openaiAdapter.ts +++ b/opencto/opencto-dashboard/src/lib/realtime/openaiAdapter.ts @@ -10,6 +10,7 @@ import { type CTOAgentConfig, } from './shared' import { getAuthHeaders } from '../authToken' +import { getWorkspaceId } from '../workspace' export class OpenAIRealtimeAdapter { private ws: WebSocket | null = null @@ -39,7 +40,10 @@ export class OpenAIRealtimeAdapter { ...getAuthHeaders(), 'Content-Type': 'application/json', }, - body: JSON.stringify({ model: this.config.model }), + body: JSON.stringify({ + model: this.config.model, + workspaceId: getWorkspaceId(), + }), }) if (!res.ok) { diff --git a/opencto/opencto-dashboard/src/lib/realtime/shared.ts b/opencto/opencto-dashboard/src/lib/realtime/shared.ts index b3df698..7e10785 100644 --- a/opencto/opencto-dashboard/src/lib/realtime/shared.ts +++ b/opencto/opencto-dashboard/src/lib/realtime/shared.ts @@ -1,5 +1,6 @@ import { getApiBaseUrl } from '../../config/apiBase' import { getAuthHeaders } from '../authToken' +import { getWorkspaceId } from '../workspace' export interface CTOAgentConfig { model: string @@ -286,7 +287,11 @@ export async function parseMessagePayload(data: unknown): Promise { - const res = await fetch(`${API_BASE}${path}`, { headers: getAuthHeaders() }) + const base = new URL(`${API_BASE}${path}`) + if (!base.searchParams.get('workspaceId')) { + base.searchParams.set('workspaceId', getWorkspaceId()) + } + const res = await fetch(base.toString(), { headers: getAuthHeaders() }) const text = await res.text() return res.ok ? text : JSON.stringify({ error: `HTTP ${res.status}`, details: text }) } diff --git a/opencto/opencto-dashboard/src/lib/workspace.ts b/opencto/opencto-dashboard/src/lib/workspace.ts new file mode 100644 index 0000000..0571aa1 --- /dev/null +++ b/opencto/opencto-dashboard/src/lib/workspace.ts @@ -0,0 +1,13 @@ +const WORKSPACE_KEY = 'opencto_workspace_id' + +export function getWorkspaceId(): string { + if (typeof window === 'undefined') return 'default' + const value = window.localStorage.getItem(WORKSPACE_KEY)?.trim() + return value || 'default' +} + +export function setWorkspaceId(workspaceId: string): void { + if (typeof window === 'undefined') return + const value = workspaceId.trim() || 'default' + window.localStorage.setItem(WORKSPACE_KEY, value) +} diff --git a/opencto/opencto-landing/favicon.svg b/opencto/opencto-landing/favicon.svg new file mode 100644 index 0000000..da90238 --- /dev/null +++ b/opencto/opencto-landing/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/opencto/opencto-landing/index.html b/opencto/opencto-landing/index.html index be666f2..1c4c38e 100644 --- a/opencto/opencto-landing/index.html +++ b/opencto/opencto-landing/index.html @@ -3,103 +3,788 @@ - OpenCTO | Agentic Engineering + OpenCTO | AI Engineering Control Center + + + + + -
-
- - OpenCTO + + +
+
+
+
+ Built for founders, CTOs, and engineering teams +

Ship production software faster with one AI control center for your team.

+

+ Plan work, orchestrate execution, and track deployment-critical decisions in one place so leadership and developers stay aligned. +

+ +
+ +
+
+ +
+
+
+

Social proof and platform foundations

+

Defensible implementation metrics from the current repository and product surfaces.

+
+
+
3Live web surfaces (landing, app, auth)
+
2Core product services (dashboard + API worker)
+
5First-class provider/integration targets represented
+
1Unified control plane vision across roles
+
+
+
OpenAI
+
GitHub
+
Cloudflare
+
Vercel
+
Expo
+
+
+
+ +
+
+
+

Features mapped to outcomes

+

What teams get in practice: faster cycles, clearer status, safer automation, and tighter operational control.

+
+
+
+ +

Faster execution loops

+

Move from intent to action quickly with coordinated plan, tooling, and output flows in one workspace.

+
+
+ +

Operational visibility

+

Keep team context visible across sessions so status, blockers, and decisions are easier to audit and share.

+
+
+ +

Automation with guardrails

+

Use automated workflows while preserving human approval checkpoints where higher-risk actions require control.

+
+
+ +

Centralized control

+

Align product, engineering, and deployment operations in one command surface instead of fragmented tools.

+
+
+
+
+ +
+
+
+

Role-based use cases

+

Each role gets outcomes matched to how they make decisions and ship work.

+
+
+
+

Founder / CTO

+

Track delivery confidence and execution health without waiting for scattered status updates.

+
    +
  • Prioritize roadmap work with real execution context.
  • +
  • Review critical actions before deployment.
  • +
  • Keep technical and business timelines aligned.
  • +
+
+
+

Engineering Manager

+

Coordinate team throughput and remove blockers with clear visibility into active execution flows.

+
    +
  • Monitor progress across parallel tasks.
  • +
  • Spot risk earlier with centralized logs and context.
  • +
  • Standardize handoffs between planning and delivery.
  • +
+
+
+

Developer

+

Focus on shipping code while keeping tools, outputs, and approvals in one practical interface.

+
    +
  • Run assisted workflows from a single workspace.
  • +
  • Preserve conversation and execution history.
  • +
  • Switch between coding and operations without context loss.
  • +
+
+
+
+
+ +
+
+
+

Deep dive

+

Simple three-step flow from intent to validated execution.

+
+
+
+

Demo / walkthrough placeholder

+

Replace this panel with a 60-90 second product walkthrough for higher conversion quality.

+
+
+

How it works

+
    +
  1. + Define goal + Set intent, scope, and constraints for the task. +
  2. +
  3. + Execute with tooling + Use integrated models and provider adapters to run work. +
  4. +
  5. + Review and ship + Apply checkpoints, validate output, and move into deployment paths. +
  6. +
+
+
+
+
+ +
+
+
+

Supported models

+

Model/provider support reflected by current adapters in the repository.

+
+
+
+ Available +

OpenAI

+

Realtime and reasoning model paths are represented in the dashboard integration layer.

+
+
+ Available +

GitHub Models

+

GitHub model routing is represented via dedicated adapter support in the current codebase.

+
+
+ Conservative Preview +

Google Live

+

Google live adapter hooks are present; treat this as early integration status rather than broad production coverage.

+
+
+
+
+ +
+
+
+

Integrations

+

Clear distinction between what is wired now and what is on the roadmap.

+
+
+
+ Native now +

GitHub

+

Connection and repository organization flows are represented in current dashboard/API contracts.

+
+
+ Native now +

Cloudflare

+

Worker and pages-related paths are present for operational querying and deployment-aligned workflows.

+
+
+ Native now +

Vercel

+

Deployment listing and inspection endpoints are represented in the current backend route set.

+
+
+ Planned +

AWS

+

Not yet represented as a native integration in this repository surface.

+
+
+ Planned +

Azure

+

Not yet represented as a native integration in this repository surface.

+
+
+ Planned +

Google Cloud

+

Not yet represented as a native integration in this repository surface.

+
+
+
+
+ +
+
+
+
+

Start with a pilot workflow

+

Open the app to test a focused pilot, or join the waitlist for structured onboarding support.

+
+ +
+
+
+ +
+
+
+

Trust and transparency

+

Governance, security posture, and support links in one place.

+ + + + + + + + + + + +
+
+
diff --git a/opencto/opencto-sdk-js/.gitignore b/opencto/opencto-sdk-js/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/opencto/opencto-sdk-js/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/opencto/opencto-sdk-js/LICENSE b/opencto/opencto-sdk-js/LICENSE new file mode 100644 index 0000000..7a77415 --- /dev/null +++ b/opencto/opencto-sdk-js/LICENSE @@ -0,0 +1,176 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/opencto/opencto-sdk-js/README.md b/opencto/opencto-sdk-js/README.md new file mode 100644 index 0000000..989e9df --- /dev/null +++ b/opencto/opencto-sdk-js/README.md @@ -0,0 +1,176 @@ +# @heysalad/opencto + +TypeScript SDK for OpenCTO APIs, MQTT transport, and auth helpers. + +## Install + +```bash +npm install @heysalad/opencto +``` + +## Quick Start (HTTP API) + +```ts +import { createOpenCtoClient } from '@heysalad/opencto' + +const client = createOpenCtoClient({ + baseUrl: 'https://api.opencto.works', + getToken: () => process.env.OPENCTO_TOKEN ?? null, +}) + +const session = await client.auth.getSession() +console.log(session.user?.email) +``` + +## Quick Start (MQTT agent transport) + +```ts +import { createMqttAgentTransport } from '@heysalad/opencto' + +const transport = createMqttAgentTransport({ + brokerUrl: 'mqtt://localhost:1883', + workspaceId: 'ws_dev', + agentId: 'agent_content_01', + role: 'content', + delivery: { + maxAttempts: 3, + ackTimeoutMs: 5000, + }, +}) + +transport.onTask(async ({ payload }) => { + await transport.publishTaskAssigned({ taskId: payload.taskId }) + await transport.publishTaskComplete({ + taskId: payload.taskId, + output: { ok: true }, + }) +}) + +await transport.start() +``` + +`delivery` controls publish reliability (ack timeout + retry backoff). `dedupe` controls inbound duplicate suppression. + +## Auth Device Flow (CLI/headless) + +```ts +import { + startDeviceAuthorization, + pollDeviceToken, + FileTokenStore, + createTokenGetter, + createOpenCtoClient, +} from '@heysalad/opencto' + +const clientId = 'opencto-cli' +const workspaceKey = 'workspace_demo' + +const device = await startDeviceAuthorization({ + deviceAuthorizationUrl: 'https://auth.example.com/oauth/device/code', + clientId, + scope: 'openid profile offline_access', +}) + +console.log(`Open: ${device.verification_uri}`) +console.log(`Code: ${device.user_code}`) + +const { tokenSet } = await pollDeviceToken({ + tokenUrl: 'https://auth.example.com/oauth/token', + clientId, + deviceCode: device.device_code, + expiresInSeconds: device.expires_in, + intervalSeconds: device.interval, +}) + +const store = new FileTokenStore() +await store.set(workspaceKey, tokenSet) + +const sdk = createOpenCtoClient({ + baseUrl: 'https://api.opencto.works', + getToken: createTokenGetter(store, workspaceKey), +}) +``` + +## API Surface + +- `createOpenCtoClient(options)` +- `createMqttAgentTransport(options)` +- `createMqttOrchestratorTransport(options)` +- `startDeviceAuthorization(options)` +- `pollDeviceToken(options)` +- `runDeviceFlow(options)` +- `MemoryTokenStore` / `FileTokenStore` + +HTTP clients: +- `client.auth.getSession()` +- `client.auth.deleteAccount()` +- `client.chats.list()` +- `client.chats.get(chatId)` +- `client.chats.save(payload)` +- `client.runs.create(payload)` +- `client.runs.get(runId)` +- `client.runs.events(runId, options?)` +- `client.runs.cancel(runId)` +- `client.runs.artifacts(runId)` +- `client.runs.artifactUrl(runId, artifactId)` +- `client.realtime.createToken(model?)` + +## Protocol Docs + +- MQTT contract: [docs/mqtt-protocol-v1.md](./docs/mqtt-protocol-v1.md) + +## React Native Example + +```ts +import * as SecureStore from 'expo-secure-store' +import { createOpenCtoClient } from '@heysalad/opencto' + +const client = createOpenCtoClient({ + baseUrl: 'https://api.opencto.works', + getToken: () => SecureStore.getItemAsync('opencto_token'), +}) +``` + +## Node / OpenClaw Example + +```ts +import { createOpenCtoClient } from '@heysalad/opencto' + +const client = createOpenCtoClient({ + baseUrl: process.env.OPENCTO_API_BASE_URL ?? 'https://api.opencto.works', + getToken: () => process.env.OPENCTO_TOKEN ?? null, +}) + +const run = await client.runs.create({ + repoUrl: 'https://github.com/org/repo', + commands: ['npm install', 'npm test'], +}) + +console.log(run.run.id) +``` + +## Development + +```bash +npm install +npm run lint +npm run test +npm run build +npm pack +``` + +## Publishing + +Package is configured as scoped public package: + +- `name: @heysalad/opencto` +- `publishConfig.access: public` + +Publish when ready: + +```bash +npm login +npm publish +``` + +Release runbook: [`../OPENCTO_PACKAGES_RELEASE.md`](../OPENCTO_PACKAGES_RELEASE.md) diff --git a/opencto/opencto-sdk-js/docs/mqtt-protocol-v1.md b/opencto/opencto-sdk-js/docs/mqtt-protocol-v1.md new file mode 100644 index 0000000..2db3574 --- /dev/null +++ b/opencto/opencto-sdk-js/docs/mqtt-protocol-v1.md @@ -0,0 +1,131 @@ +# MQTT Protocol v1 + +This document defines OpenCTO's MQTT transport contract for orchestrator/agent messaging. + +## Version + +- `protocolVersion`: `mqtt-v1` + +## Envelope + +All MQTT payloads must use this envelope: + +```json +{ + "id": "uuid", + "protocolVersion": "mqtt-v1", + "type": "tasks.new", + "timestamp": "2026-03-03T00:00:00.000Z", + "workspaceId": "ws_123", + "agentId": "agent_abc", + "correlationId": "run_123", + "idempotencyKey": "task_123_attempt_1", + "payload": {} +} +``` + +## Topic namespace + +Root: `{topicPrefix}/workspace/{workspaceId}` + +Default `topicPrefix`: `opencto` + +## Topics + +- `.../tasks/new` +- `.../tasks/assigned` +- `.../tasks/complete` +- `.../tasks/failed` +- `.../runs/events` +- `.../agents/heartbeat` + +## Message types + +### `tasks.new` + +```json +{ + "taskId": "task_123", + "taskType": "workflow.execute", + "workflowId": "landing-page-builder", + "priority": "high", + "input": { "prompt": "Build launch page" } +} +``` + +### `tasks.assigned` + +```json +{ + "taskId": "task_123", + "assignedAt": "2026-03-03T00:00:01.000Z", + "leaseMs": 300000 +} +``` + +### `tasks.complete` + +```json +{ + "taskId": "task_123", + "completedAt": "2026-03-03T00:00:20.000Z", + "output": { "status": "ok" }, + "artifacts": [{ "id": "a1", "kind": "html", "url": "https://..." }] +} +``` + +### `tasks.failed` + +```json +{ + "taskId": "task_123", + "failedAt": "2026-03-03T00:00:20.000Z", + "error": { + "code": "UPSTREAM_FAILURE", + "message": "Provider timeout", + "retryable": true + } +} +``` + +### `runs.event` + +```json +{ + "runId": "run_123", + "eventType": "step.completed", + "level": "info", + "message": "Generated first draft", + "data": { "step": "draft" } +} +``` + +### `agents.heartbeat` + +```json +{ + "status": "alive", + "role": "content", + "capabilities": ["video.generate", "copy.write"], + "uptimeSec": 1234, + "load": { + "queuedTasks": 2, + "activeTasks": 1 + } +} +``` + +## Reliability rules + +- Publishers should set `idempotencyKey` for retryable operations. +- Consumers should dedupe by `(workspaceId, idempotencyKey)` where available. +- `qos=1` is recommended for task and completion topics. +- `correlationId` should map to run/workflow execution id. + +## Security guidance + +- Enforce broker auth and ACL per workspace. +- Never accept cross-workspace publishes. +- Validate `protocolVersion` and envelope shape. +- Reject malformed payloads early. + diff --git a/opencto/opencto-sdk-js/package-lock.json b/opencto/opencto-sdk-js/package-lock.json new file mode 100644 index 0000000..76f19e4 --- /dev/null +++ b/opencto/opencto-sdk-js/package-lock.json @@ -0,0 +1,2101 @@ +{ + "name": "@heysalad/opencto", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@heysalad/opencto", + "version": "0.1.1", + "license": "Apache-2.0", + "dependencies": { + "mqtt": "^5.14.1" + }, + "devDependencies": { + "@types/node": "^25.3.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/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/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/broker-factory": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz", + "integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1", + "worker-factory": "^7.0.48" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.26", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz", + "integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz", + "integrity": "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/worker-factory": { + "version": "7.0.48", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz", + "integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.30", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz", + "integrity": "sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.15", + "worker-timers-worker": "^9.0.13" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz", + "integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "broker-factory": "^3.1.13", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.13" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz", + "integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1", + "worker-factory": "^7.0.48" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/opencto/opencto-sdk-js/package.json b/opencto/opencto-sdk-js/package.json new file mode 100644 index 0000000..6e30f2c --- /dev/null +++ b/opencto/opencto-sdk-js/package.json @@ -0,0 +1,50 @@ +{ + "name": "@heysalad/opencto", + "version": "0.1.1", + "description": "OpenCTO JavaScript SDK for auth, chats, runs, and realtime token APIs.", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Hey-Salad/CTO-AI.git", + "directory": "opencto/opencto-sdk-js" + }, + "keywords": [ + "opencto", + "heysalad", + "sdk", + "api-client" + ], + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "lint": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "prepublishOnly": "npm run clean && npm run lint && npm run test && npm run build" + }, + "dependencies": { + "mqtt": "^5.14.1" + }, + "devDependencies": { + "@types/node": "^25.3.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/opencto/opencto-sdk-js/src/auth/deviceFlow.ts b/opencto/opencto-sdk-js/src/auth/deviceFlow.ts new file mode 100644 index 0000000..71a5d6b --- /dev/null +++ b/opencto/opencto-sdk-js/src/auth/deviceFlow.ts @@ -0,0 +1,136 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { + DeviceAuthorizationResponse, + DeviceFlowPollOptions, + DeviceFlowResult, + DeviceFlowStartOptions, + DeviceTokenPendingResponse, + DeviceTokenSuccessResponse, + OpenCtoTokenSet, +} from '../types/deviceAuth.js' +import type { FetchLike } from '../types/common.js' + +function getFetch(fetchImpl?: FetchLike): FetchLike { + return fetchImpl ?? globalThis.fetch.bind(globalThis) +} + +function toTokenSet(token: DeviceTokenSuccessResponse): OpenCtoTokenSet { + return { + accessToken: token.access_token, + tokenType: token.token_type, + refreshToken: token.refresh_token, + scope: token.scope, + expiresIn: token.expires_in, + issuedAt: new Date().toISOString(), + } +} + +export async function startDeviceAuthorization(options: DeviceFlowStartOptions): Promise { + const fetchImpl = getFetch(options.fetchImpl) + + const body = new URLSearchParams({ + client_id: options.clientId, + }) + + if (options.scope) body.set('scope', options.scope) + if (options.audience) body.set('audience', options.audience) + for (const [key, value] of Object.entries(options.extra ?? {})) { + body.set(key, value) + } + + const response = await fetchImpl(options.deviceAuthorizationUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + + const parsed = (await response.json()) as Partial & { error?: string; error_description?: string } + if (!response.ok) { + throw new Error(parsed.error_description || parsed.error || 'Failed to start device authorization') + } + + if (!parsed.device_code || !parsed.user_code || !parsed.verification_uri || !parsed.expires_in) { + throw new Error('Device authorization response is missing required fields') + } + + return { + device_code: parsed.device_code, + user_code: parsed.user_code, + verification_uri: parsed.verification_uri, + verification_uri_complete: parsed.verification_uri_complete, + expires_in: parsed.expires_in, + interval: parsed.interval, + } +} + +export async function pollDeviceToken(options: DeviceFlowPollOptions): Promise { + const fetchImpl = getFetch(options.fetchImpl) + const startedAt = Date.now() + const ttlMs = options.expiresInSeconds * 1000 + let intervalMs = Math.max(1, options.intervalSeconds ?? 5) * 1000 + + while (Date.now() - startedAt < ttlMs) { + if (options.signal?.aborted) { + throw new Error('Device flow polling aborted') + } + + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: options.clientId, + device_code: options.deviceCode, + }) + + const response = await fetchImpl(options.tokenUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + signal: options.signal, + }) + + const parsed = (await response.json()) as Partial & { + error_description?: string + } + + if (response.ok && parsed.access_token) { + return { tokenSet: toTokenSet(parsed as DeviceTokenSuccessResponse) } + } + + const code = parsed.error + if (code === 'authorization_pending') { + await sleep(intervalMs, undefined, { signal: options.signal }) + continue + } + + if (code === 'slow_down') { + intervalMs += 2000 + await sleep(intervalMs, undefined, { signal: options.signal }) + continue + } + + if (code === 'access_denied') { + throw new Error(parsed.error_description || 'User denied device authorization request') + } + + if (code === 'expired_token') { + throw new Error(parsed.error_description || 'Device authorization expired') + } + + throw new Error(parsed.error_description || String(code) || 'Token polling failed') + } + + throw new Error('Device authorization timed out before completion') +} + +export async function runDeviceFlow(input: { + start: DeviceFlowStartOptions + poll: Omit +}): Promise<{ device: DeviceAuthorizationResponse; result: DeviceFlowResult }> { + const device = await startDeviceAuthorization(input.start) + const result = await pollDeviceToken({ + ...input.poll, + deviceCode: device.device_code, + expiresInSeconds: device.expires_in, + intervalSeconds: device.interval, + }) + return { device, result } +} diff --git a/opencto/opencto-sdk-js/src/auth/index.ts b/opencto/opencto-sdk-js/src/auth/index.ts new file mode 100644 index 0000000..7ca911e --- /dev/null +++ b/opencto/opencto-sdk-js/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from './deviceFlow.js' +export * from './tokenStore.js' diff --git a/opencto/opencto-sdk-js/src/auth/tokenStore.ts b/opencto/opencto-sdk-js/src/auth/tokenStore.ts new file mode 100644 index 0000000..1ab361e --- /dev/null +++ b/opencto/opencto-sdk-js/src/auth/tokenStore.ts @@ -0,0 +1,80 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { homedir } from 'node:os' +import type { OpenCtoTokenSet } from '../types/deviceAuth.js' +import type { TokenStore } from '../types/tokenStore.js' + +type TokenStoreFile = { + version: 1 + tokens: Record +} + +function defaultTokenPath(): string { + return `${homedir()}/.opencto/tokens.json` +} + +export class MemoryTokenStore implements TokenStore { + private readonly map = new Map() + + async get(key: string): Promise { + return this.map.get(key) ?? null + } + + async set(key: string, value: OpenCtoTokenSet): Promise { + this.map.set(key, value) + } + + async clear(key: string): Promise { + this.map.delete(key) + } +} + +export class FileTokenStore implements TokenStore { + constructor(private readonly filePath = defaultTokenPath()) {} + + private async readStore(): Promise { + try { + const raw = await readFile(this.filePath, 'utf8') + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== 1 || typeof parsed.tokens !== 'object' || parsed.tokens === null) { + return { version: 1, tokens: {} } + } + return { version: 1, tokens: parsed.tokens as Record } + } catch { + return { version: 1, tokens: {} } + } + } + + private async writeStore(store: TokenStoreFile): Promise { + await mkdir(dirname(this.filePath), { recursive: true }) + await writeFile(this.filePath, JSON.stringify(store, null, 2), { mode: 0o600 }) + } + + async get(key: string): Promise { + const store = await this.readStore() + return store.tokens[key] ?? null + } + + async set(key: string, value: OpenCtoTokenSet): Promise { + const store = await this.readStore() + store.tokens[key] = value + await this.writeStore(store) + } + + async clear(key: string): Promise { + const store = await this.readStore() + delete store.tokens[key] + if (Object.keys(store.tokens).length === 0) { + await rm(this.filePath, { force: true }) + return + } + await this.writeStore(store) + } +} + +export function createTokenGetter(store: TokenStore, key: string): () => Promise { + return async () => { + const token = await store.get(key) + return token?.accessToken ?? null + } +} diff --git a/opencto/opencto-sdk-js/src/clients/auth.ts b/opencto/opencto-sdk-js/src/clients/auth.ts new file mode 100644 index 0000000..25e75be --- /dev/null +++ b/opencto/opencto-sdk-js/src/clients/auth.ts @@ -0,0 +1,22 @@ +import type { AuthSession } from '../types/auth.js' +import type { HttpClientOptions } from '../core/http.js' +import { createHttpClient } from '../core/http.js' + +export interface AuthClient { + getSession(): Promise + deleteAccount(): Promise<{ deleted: boolean }> +} + +export function createAuthClient(options: HttpClientOptions): AuthClient { + const http = createHttpClient(options) + + return { + getSession() { + return http.get('/api/v1/auth/session', 'Failed to load auth session') + }, + + deleteAccount() { + return http.delete<{ deleted: boolean }>('/api/v1/auth/account', 'Failed to delete account') + }, + } +} diff --git a/opencto/opencto-sdk-js/src/clients/chats.ts b/opencto/opencto-sdk-js/src/clients/chats.ts new file mode 100644 index 0000000..8d9a00e --- /dev/null +++ b/opencto/opencto-sdk-js/src/clients/chats.ts @@ -0,0 +1,28 @@ +import type { ChatRecord, ChatSummary, SaveChatPayload } from '../types/chats.js' +import type { HttpClientOptions } from '../core/http.js' +import { createHttpClient } from '../core/http.js' + +export interface ChatsClient { + list(): Promise + get(chatId: string): Promise + save(payload: SaveChatPayload): Promise +} + +export function createChatsClient(options: HttpClientOptions): ChatsClient { + const http = createHttpClient(options) + + return { + async list() { + const response = await http.get<{ chats?: ChatSummary[] }>('/api/v1/chats', 'Failed to list chats') + return response.chats ?? [] + }, + + get(chatId: string) { + return http.get(`/api/v1/chats/${encodeURIComponent(chatId)}`, 'Failed to load chat') + }, + + save(payload: SaveChatPayload) { + return http.post('/api/v1/chats/save', payload, 'Failed to save chat') + }, + } +} diff --git a/opencto/opencto-sdk-js/src/clients/realtime.ts b/opencto/opencto-sdk-js/src/clients/realtime.ts new file mode 100644 index 0000000..0b096fd --- /dev/null +++ b/opencto/opencto-sdk-js/src/clients/realtime.ts @@ -0,0 +1,21 @@ +import type { RealtimeTokenResponse } from '../types/realtime.js' +import type { HttpClientOptions } from '../core/http.js' +import { createHttpClient } from '../core/http.js' + +export interface RealtimeClient { + createToken(model?: string): Promise +} + +export function createRealtimeClient(options: HttpClientOptions): RealtimeClient { + const http = createHttpClient(options) + + return { + createToken(model?: string) { + return http.post( + '/api/v1/realtime/token', + model ? { model } : {}, + 'Failed to create realtime token', + ) + }, + } +} diff --git a/opencto/opencto-sdk-js/src/clients/runs.ts b/opencto/opencto-sdk-js/src/clients/runs.ts new file mode 100644 index 0000000..291d305 --- /dev/null +++ b/opencto/opencto-sdk-js/src/clients/runs.ts @@ -0,0 +1,62 @@ +import type { + CreateCodebaseRunPayload, + GetCodebaseRunResponse, + GetCodebaseRunEventsResponse, + ListCodebaseRunArtifactsResponse, +} from '../types/runs.js' +import type { HttpClientOptions } from '../core/http.js' +import { createHttpClient } from '../core/http.js' + +export interface RunsClient { + create(payload: CreateCodebaseRunPayload): Promise + get(runId: string): Promise + events(runId: string, options?: { afterSeq?: number; limit?: number }): Promise + cancel(runId: string): Promise + artifacts(runId: string): Promise + artifactUrl(runId: string, artifactId: string): string +} + +export function createRunsClient(options: HttpClientOptions): RunsClient { + const http = createHttpClient(options) + const baseUrl = options.baseUrl.replace(/\/+$/, '') + + return { + create(payload: CreateCodebaseRunPayload) { + return http.post('/api/v1/codebase/runs', payload, 'Failed to create codebase run') + }, + + get(runId: string) { + return http.get(`/api/v1/codebase/runs/${encodeURIComponent(runId)}`, 'Failed to load codebase run') + }, + + events(runId: string, optionsArg?: { afterSeq?: number; limit?: number }) { + const params = new URLSearchParams() + if (typeof optionsArg?.afterSeq === 'number') params.set('afterSeq', String(optionsArg.afterSeq)) + if (typeof optionsArg?.limit === 'number') params.set('limit', String(optionsArg.limit)) + const query = params.toString() ? `?${params.toString()}` : '' + return http.get( + `/api/v1/codebase/runs/${encodeURIComponent(runId)}/events${query}`, + 'Failed to load codebase run events', + ) + }, + + cancel(runId: string) { + return http.post( + `/api/v1/codebase/runs/${encodeURIComponent(runId)}/cancel`, + undefined, + 'Failed to cancel codebase run', + ) + }, + + artifacts(runId: string) { + return http.get( + `/api/v1/codebase/runs/${encodeURIComponent(runId)}/artifacts`, + 'Failed to list run artifacts', + ) + }, + + artifactUrl(runId: string, artifactId: string) { + return `${baseUrl}/api/v1/codebase/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}` + }, + } +} diff --git a/opencto/opencto-sdk-js/src/core/errors.ts b/opencto/opencto-sdk-js/src/core/errors.ts new file mode 100644 index 0000000..913d9b7 --- /dev/null +++ b/opencto/opencto-sdk-js/src/core/errors.ts @@ -0,0 +1,44 @@ +import type { ApiErrorShape, OpenCtoError } from '../types/common.js' + +export function codeFromStatus(status: number): string { + if (status === 400) return 'BAD_REQUEST' + if (status === 401) return 'AUTH_REQUIRED' + if (status === 403) return 'ACCESS_DENIED' + if (status === 404) return 'NOT_FOUND' + if (status === 409) return 'CONFLICT' + if (status === 422) return 'VALIDATION_FAILED' + if (status >= 500) return 'UPSTREAM_FAILURE' + return 'REQUEST_FAILED' +} + +export function createOpenCtoError( + message: string, + input?: { code?: string; status?: number; details?: unknown }, +): OpenCtoError { + const err = new Error(message) as OpenCtoError + err.code = input?.code ?? 'REQUEST_FAILED' + err.status = input?.status + err.details = input?.details + return err +} + +export function normalizeError(input: unknown, fallbackMessage: string): OpenCtoError { + if (input instanceof Error) { + const anyErr = input as Partial + return createOpenCtoError(input.message || fallbackMessage, { + code: anyErr.code, + status: anyErr.status, + details: anyErr.details, + }) + } + + return createOpenCtoError(fallbackMessage) +} + +export function parseApiErrorBody(body: unknown): { message?: string; code?: string; details?: unknown } { + const candidate = (body && typeof body === 'object') ? body as ApiErrorShape & { details?: unknown } : {} + const message = typeof candidate.error === 'string' && candidate.error.trim() ? candidate.error : undefined + const code = typeof candidate.code === 'string' && candidate.code.trim() ? candidate.code : undefined + const details = 'details' in candidate ? candidate.details : undefined + return { message, code, details } +} diff --git a/opencto/opencto-sdk-js/src/core/http.ts b/opencto/opencto-sdk-js/src/core/http.ts new file mode 100644 index 0000000..7208eb8 --- /dev/null +++ b/opencto/opencto-sdk-js/src/core/http.ts @@ -0,0 +1,95 @@ +import { codeFromStatus, createOpenCtoError, parseApiErrorBody } from './errors.js' +import type { FetchLike } from '../types/common.js' + +export interface HttpClientOptions { + baseUrl: string + getToken?: () => string | null | Promise + fetchImpl?: FetchLike + defaultHeaders?: Record +} + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, '') +} + +function toUrl(baseUrl: string, path: string): string { + return `${normalizeBaseUrl(baseUrl)}${path.startsWith('/') ? path : `/${path}`}` +} + +async function resolveHeaders( + options: HttpClientOptions, + initHeaders?: HeadersInit, +): Promise> { + const headers: Record = { + 'content-type': 'application/json', + ...(options.defaultHeaders ?? {}), + } + + if (initHeaders) { + const extra = new Headers(initHeaders) + extra.forEach((value, key) => { + headers[key] = value + }) + } + + const token = await options.getToken?.() + if (token) { + headers.Authorization = `Bearer ${token}` + } + + return headers +} + +export function createHttpClient(options: HttpClientOptions) { + const fetchImpl: FetchLike = options.fetchImpl ?? globalThis.fetch.bind(globalThis) + + async function request(path: string, init?: RequestInit, fallbackMessage?: string): Promise { + const url = toUrl(options.baseUrl, path) + const response = await fetchImpl(url, { + ...init, + headers: await resolveHeaders(options, init?.headers), + }) + + if (!response.ok) { + let parsed: { message?: string; code?: string; details?: unknown } = {} + try { + parsed = parseApiErrorBody(await response.json()) + } catch { + // ignore parse errors; fallback message below + } + + throw createOpenCtoError( + parsed.message ?? `${fallbackMessage ?? 'Request failed'} (${response.status})`, + { + code: parsed.code ?? codeFromStatus(response.status), + status: response.status, + details: parsed.details, + }, + ) + } + + return (await response.json()) as T + } + + return { + get: (path: string, fallbackMessage?: string) => request(path, { method: 'GET' }, fallbackMessage), + post: (path: string, body?: unknown, fallbackMessage?: string, init?: RequestInit) => request( + path, + { + ...init, + method: 'POST', + body: body === undefined ? init?.body : JSON.stringify(body), + }, + fallbackMessage, + ), + delete: (path: string, fallbackMessage?: string, init?: RequestInit) => request( + path, + { + ...init, + method: 'DELETE', + }, + fallbackMessage, + ), + request, + } +} diff --git a/opencto/opencto-sdk-js/src/index.ts b/opencto/opencto-sdk-js/src/index.ts new file mode 100644 index 0000000..5404212 --- /dev/null +++ b/opencto/opencto-sdk-js/src/index.ts @@ -0,0 +1,34 @@ +import { createAuthClient, type AuthClient } from './clients/auth.js' +import { createChatsClient, type ChatsClient } from './clients/chats.js' +import { createRunsClient, type RunsClient } from './clients/runs.js' +import { createRealtimeClient, type RealtimeClient } from './clients/realtime.js' +import type { FetchLike } from './types/common.js' + +export * from './types/index.js' +export * from './transports/mqtt/index.js' +export * from './auth/index.js' +export { codeFromStatus, createOpenCtoError, normalizeError } from './core/errors.js' +export { createAuthClient, createChatsClient, createRunsClient, createRealtimeClient } + +export interface OpenCtoClientOptions { + baseUrl: string + getToken?: () => string | null | Promise + fetchImpl?: FetchLike + defaultHeaders?: Record +} + +export interface OpenCtoClient { + auth: AuthClient + chats: ChatsClient + runs: RunsClient + realtime: RealtimeClient +} + +export function createOpenCtoClient(options: OpenCtoClientOptions): OpenCtoClient { + return { + auth: createAuthClient(options), + chats: createChatsClient(options), + runs: createRunsClient(options), + realtime: createRealtimeClient(options), + } +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/agent.ts b/opencto/opencto-sdk-js/src/transports/mqtt/agent.ts new file mode 100644 index 0000000..f325cdc --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/agent.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto' +import type { + MqttEnvelope, + MqttTaskNewPayload, + MqttTransportOptions, + MqttTaskAssignedPayload, + MqttTaskCompletePayload, + MqttTaskFailedPayload, + MqttAgentHeartbeatPayload, + MqttRunEventPayload, +} from '../../types/mqtt.js' +import { createMqttWireClient, type MqttWireClient } from './client.js' +import { createMqttEnvelopeDedupe } from './dedupe.js' +import { createEnvelope, isEnvelopeType, parseEnvelope } from './protocol.js' +import { publishWithRetry } from './retry.js' +import { + topicTasksAssigned, + topicTasksComplete, + topicTasksFailed, + topicTasksNew, + topicRunsEvents, + topicAgentsHeartbeat, +} from './topics.js' + +export interface MqttAgentTransport { + start(): Promise + stop(): Promise + onTask(handler: (envelope: MqttEnvelope) => void): void + publishTaskAssigned(payload: Omit & { assignedAt?: string }, context?: { correlationId?: string; idempotencyKey?: string }): Promise + publishTaskComplete(payload: Omit & { completedAt?: string }, context?: { correlationId?: string; idempotencyKey?: string }): Promise + publishTaskFailed(payload: Omit & { failedAt?: string }, context?: { correlationId?: string; idempotencyKey?: string }): Promise + publishRunEvent(payload: MqttRunEventPayload, context?: { correlationId?: string; idempotencyKey?: string }): Promise + publishHeartbeat(payload: MqttAgentHeartbeatPayload, context?: { correlationId?: string; idempotencyKey?: string }): Promise +} + +export function createMqttAgentTransport( + options: MqttTransportOptions, + wireClient?: MqttWireClient, +): MqttAgentTransport { + const topicOptions = { workspaceId: options.workspaceId, topicPrefix: options.topicPrefix } + const client = wireClient ?? createMqttWireClient({ + brokerUrl: options.brokerUrl, + clientId: options.agentId || `opencto-agent-${randomUUID().slice(0, 12)}`, + username: options.username, + password: options.token ?? options.password, + }) + + let taskHandler: ((envelope: MqttEnvelope) => void) | null = null + const dedupe = options.dedupe?.enabled === false + ? null + : createMqttEnvelopeDedupe(options.dedupe) + + async function publishEnvelope(topic: string, envelope: MqttEnvelope): Promise { + await publishWithRetry( + () => client.publish(topic, JSON.stringify(envelope)), + options.delivery, + ) + } + + return { + async start() { + await client.connect() + await client.subscribe(topicTasksNew(topicOptions)) + client.onMessage((incoming) => { + let envelope: MqttEnvelope + try { + envelope = parseEnvelope(JSON.parse(incoming.payloadText)) + } catch { + return + } + + if (isEnvelopeType(envelope, 'tasks.new')) { + if (dedupe?.seen(envelope)) return + taskHandler?.(envelope) + } + }) + }, + + async stop() { + await client.disconnect() + }, + + onTask(handler: (envelope: MqttEnvelope) => void) { + taskHandler = handler + }, + + async publishTaskAssigned(payload, context) { + const envelope = createEnvelope('tasks.assigned', { + ...payload, + assignedAt: payload.assignedAt ?? new Date().toISOString(), + }, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishEnvelope(topicTasksAssigned(topicOptions), envelope) + }, + + async publishTaskComplete(payload, context) { + const envelope = createEnvelope('tasks.complete', { + ...payload, + completedAt: payload.completedAt ?? new Date().toISOString(), + }, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishEnvelope(topicTasksComplete(topicOptions), envelope) + }, + + async publishTaskFailed(payload, context) { + const envelope = createEnvelope('tasks.failed', { + ...payload, + failedAt: payload.failedAt ?? new Date().toISOString(), + }, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishEnvelope(topicTasksFailed(topicOptions), envelope) + }, + + async publishRunEvent(payload, context) { + const envelope = createEnvelope('runs.event', payload, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishEnvelope(topicRunsEvents(topicOptions), envelope) + }, + + async publishHeartbeat(payload, context) { + const envelope = createEnvelope('agents.heartbeat', payload, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishEnvelope(topicAgentsHeartbeat(topicOptions), envelope) + }, + } +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/client.ts b/opencto/opencto-sdk-js/src/transports/mqtt/client.ts new file mode 100644 index 0000000..59a9f40 --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/client.ts @@ -0,0 +1,104 @@ +import mqtt from 'mqtt' +import type { IClientOptions, MqttClient } from 'mqtt' + +export interface MqttWireClientOptions { + brokerUrl: string + clientId: string + username?: string + password?: string +} + +export interface MqttIncomingMessage { + topic: string + payloadText: string +} + +export interface MqttWireClient { + connect(): Promise + publish(topic: string, payloadText: string): Promise + subscribe(topics: string | string[]): Promise + onMessage(handler: (message: MqttIncomingMessage) => void): void + disconnect(): Promise + isConnected(): boolean +} + +export function createMqttWireClient(options: MqttWireClientOptions): MqttWireClient { + let client: MqttClient | null = null + let messageHandler: ((message: MqttIncomingMessage) => void) | null = null + + const connectOptions: IClientOptions = { + clientId: options.clientId, + username: options.username, + password: options.password, + reconnectPeriod: 1000, + connectTimeout: 30_000, + clean: true, + } + + return { + connect() { + return new Promise((resolve, reject) => { + client = mqtt.connect(options.brokerUrl, connectOptions) + + client.once('connect', () => { + resolve() + }) + + client.once('error', (error) => { + reject(error) + }) + + client.on('message', (topic, payload) => { + messageHandler?.({ + topic, + payloadText: payload.toString(), + }) + }) + }) + }, + + publish(topic: string, payloadText: string) { + return new Promise((resolve, reject) => { + if (!client) { + reject(new Error('MQTT client is not connected')) + return + } + client.publish(topic, payloadText, { qos: 1 }, (err) => { + if (err) reject(err) + else resolve() + }) + }) + }, + + subscribe(topics: string | string[]) { + return new Promise((resolve, reject) => { + if (!client) { + reject(new Error('MQTT client is not connected')) + return + } + client.subscribe(topics, { qos: 1 }, (err) => { + if (err) reject(err) + else resolve() + }) + }) + }, + + onMessage(handler: (message: MqttIncomingMessage) => void) { + messageHandler = handler + }, + + disconnect() { + return new Promise((resolve) => { + if (!client) return resolve() + client.end(true, {}, () => { + client = null + resolve() + }) + }) + }, + + isConnected() { + return Boolean(client?.connected) + }, + } +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/dedupe.ts b/opencto/opencto-sdk-js/src/transports/mqtt/dedupe.ts new file mode 100644 index 0000000..86b89da --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/dedupe.ts @@ -0,0 +1,58 @@ +import type { MqttEnvelope, MqttTransportDedupeOptions } from '../../types/mqtt.js' + +export interface MqttEnvelopeDedupe { + seen(envelope: MqttEnvelope): boolean + clear(): void +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000 +const DEFAULT_MAX_ENTRIES = 10_000 + +interface EntryRecord { + seenAtMs: number +} + +export function createMqttEnvelopeDedupe( + options: MqttTransportDedupeOptions = {}, +): MqttEnvelopeDedupe { + const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES + const now = options.now ?? (() => Date.now()) + const entries = new Map() + + function toKey(envelope: MqttEnvelope): string { + return `${envelope.workspaceId}:${envelope.idempotencyKey ?? envelope.id}` + } + + function cleanup(currentMs: number): void { + for (const [key, entry] of entries) { + if (currentMs - entry.seenAtMs > ttlMs) { + entries.delete(key) + } + } + + while (entries.size > maxEntries) { + const first = entries.keys().next() + if (first.done) break + entries.delete(first.value) + } + } + + return { + seen(envelope: MqttEnvelope): boolean { + const currentMs = now() + cleanup(currentMs) + const key = toKey(envelope) + const existing = entries.get(key) + if (existing && currentMs - existing.seenAtMs <= ttlMs) { + return true + } + entries.set(key, { seenAtMs: currentMs }) + return false + }, + + clear() { + entries.clear() + }, + } +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/index.ts b/opencto/opencto-sdk-js/src/transports/mqtt/index.ts new file mode 100644 index 0000000..f56185c --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/index.ts @@ -0,0 +1,7 @@ +export * from './agent.js' +export * from './orchestrator.js' +export * from './topics.js' +export * from './protocol.js' +export * from './client.js' +export * from './dedupe.js' +export * from './retry.js' diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/orchestrator.ts b/opencto/opencto-sdk-js/src/transports/mqtt/orchestrator.ts new file mode 100644 index 0000000..3e9d9bd --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/orchestrator.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto' +import type { + MqttEnvelope, + MqttTaskNewPayload, + MqttTaskAssignedPayload, + MqttTaskCompletePayload, + MqttTaskFailedPayload, + MqttAgentHeartbeatPayload, + MqttTransportOptions, +} from '../../types/mqtt.js' +import { createMqttWireClient, type MqttWireClient } from './client.js' +import { createMqttEnvelopeDedupe } from './dedupe.js' +import { publishWithRetry } from './retry.js' +import { + createEnvelope, + isEnvelopeType, + parseEnvelope, +} from './protocol.js' +import { + topicTasksNew, + topicTasksAssigned, + topicTasksComplete, + topicTasksFailed, + topicAgentsHeartbeat, +} from './topics.js' + +export interface MqttOrchestratorTransport { + start(): Promise + stop(): Promise + publishTaskNew(payload: MqttTaskNewPayload, context?: { correlationId?: string; idempotencyKey?: string }): Promise + onTaskAssigned(handler: (envelope: MqttEnvelope) => void): void + onTaskComplete(handler: (envelope: MqttEnvelope) => void): void + onTaskFailed(handler: (envelope: MqttEnvelope) => void): void + onAgentHeartbeat(handler: (envelope: MqttEnvelope) => void): void +} + +export function createMqttOrchestratorTransport( + options: MqttTransportOptions, + wireClient?: MqttWireClient, +): MqttOrchestratorTransport { + const topicOptions = { workspaceId: options.workspaceId, topicPrefix: options.topicPrefix } + const client = wireClient ?? createMqttWireClient({ + brokerUrl: options.brokerUrl, + clientId: options.agentId || `opencto-orchestrator-${randomUUID().slice(0, 12)}`, + username: options.username, + password: options.token ?? options.password, + }) + + let onAssigned: ((envelope: MqttEnvelope) => void) | null = null + let onComplete: ((envelope: MqttEnvelope) => void) | null = null + let onFailed: ((envelope: MqttEnvelope) => void) | null = null + let onHeartbeat: ((envelope: MqttEnvelope) => void) | null = null + const dedupe = options.dedupe?.enabled === false + ? null + : createMqttEnvelopeDedupe(options.dedupe) + + return { + async start() { + await client.connect() + await client.subscribe([ + topicTasksAssigned(topicOptions), + topicTasksComplete(topicOptions), + topicTasksFailed(topicOptions), + topicAgentsHeartbeat(topicOptions), + ]) + + client.onMessage((incoming) => { + let envelope: MqttEnvelope + try { + envelope = parseEnvelope(JSON.parse(incoming.payloadText)) + } catch { + return + } + if (dedupe?.seen(envelope)) return + + if (isEnvelopeType(envelope, 'tasks.assigned')) onAssigned?.(envelope) + if (isEnvelopeType(envelope, 'tasks.complete')) onComplete?.(envelope) + if (isEnvelopeType(envelope, 'tasks.failed')) onFailed?.(envelope) + if (isEnvelopeType(envelope, 'agents.heartbeat')) onHeartbeat?.(envelope) + }) + }, + + async stop() { + await client.disconnect() + }, + + async publishTaskNew(payload, context) { + const envelope = createEnvelope('tasks.new', payload, { + workspaceId: options.workspaceId, + agentId: options.agentId, + correlationId: context?.correlationId, + idempotencyKey: context?.idempotencyKey, + }) + await publishWithRetry( + () => client.publish(topicTasksNew(topicOptions), JSON.stringify(envelope)), + options.delivery, + ) + }, + + onTaskAssigned(handler) { + onAssigned = handler + }, + + onTaskComplete(handler) { + onComplete = handler + }, + + onTaskFailed(handler) { + onFailed = handler + }, + + onAgentHeartbeat(handler) { + onHeartbeat = handler + }, + } +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/protocol.ts b/opencto/opencto-sdk-js/src/transports/mqtt/protocol.ts new file mode 100644 index 0000000..e6963e7 --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/protocol.ts @@ -0,0 +1,77 @@ +import { randomUUID } from 'node:crypto' +import type { + MqttEnvelope, + MqttProtocolVersion, + MqttTaskAssignedPayload, + MqttTaskCompletePayload, + MqttTaskFailedPayload, + MqttTaskNewPayload, + MqttAgentHeartbeatPayload, + MqttRunEventPayload, +} from '../../types/mqtt.js' + +export const MQTT_PROTOCOL_VERSION: MqttProtocolVersion = 'mqtt-v1' + +export type MqttEnvelopeType = + | 'tasks.new' + | 'tasks.assigned' + | 'tasks.complete' + | 'tasks.failed' + | 'runs.event' + | 'agents.heartbeat' + +export type KnownPayloadByType = { + 'tasks.new': MqttTaskNewPayload + 'tasks.assigned': MqttTaskAssignedPayload + 'tasks.complete': MqttTaskCompletePayload + 'tasks.failed': MqttTaskFailedPayload + 'runs.event': MqttRunEventPayload + 'agents.heartbeat': MqttAgentHeartbeatPayload +} + +export function createEnvelope( + type: TType, + payload: KnownPayloadByType[TType], + context: { + workspaceId: string + agentId?: string + correlationId?: string + idempotencyKey?: string + id?: string + }, +): MqttEnvelope { + return { + id: context.id ?? randomUUID(), + protocolVersion: MQTT_PROTOCOL_VERSION, + type, + timestamp: new Date().toISOString(), + workspaceId: context.workspaceId, + agentId: context.agentId, + correlationId: context.correlationId, + idempotencyKey: context.idempotencyKey, + payload, + } +} + +export function parseEnvelope(input: unknown): MqttEnvelope { + if (!input || typeof input !== 'object') { + throw new Error('MQTT message must be a JSON object') + } + + const candidate = input as Partial> + if (typeof candidate.id !== 'string' || !candidate.id) throw new Error('MQTT envelope is missing id') + if (candidate.protocolVersion !== MQTT_PROTOCOL_VERSION) throw new Error('Unsupported MQTT protocol version') + if (typeof candidate.type !== 'string' || !candidate.type) throw new Error('MQTT envelope is missing type') + if (typeof candidate.timestamp !== 'string' || !candidate.timestamp) throw new Error('MQTT envelope is missing timestamp') + if (typeof candidate.workspaceId !== 'string' || !candidate.workspaceId) throw new Error('MQTT envelope is missing workspaceId') + if (typeof candidate.payload !== 'object' || candidate.payload === null) throw new Error('MQTT envelope is missing payload') + + return candidate as MqttEnvelope +} + +export function isEnvelopeType( + envelope: MqttEnvelope, + expectedType: TType, +): envelope is MqttEnvelope { + return envelope.type === expectedType +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/retry.ts b/opencto/opencto-sdk-js/src/transports/mqtt/retry.ts new file mode 100644 index 0000000..6eb62f5 --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/retry.ts @@ -0,0 +1,65 @@ +import type { MqttTransportDeliveryOptions } from '../../types/mqtt.js' + +const DEFAULT_MAX_ATTEMPTS = 3 +const DEFAULT_ACK_TIMEOUT_MS = 5_000 +const DEFAULT_INITIAL_BACKOFF_MS = 200 +const DEFAULT_MAX_BACKOFF_MS = 2_000 +const DEFAULT_BACKOFF_MULTIPLIER = 2 +const DEFAULT_JITTER_RATIO = 0.2 + +export async function publishWithRetry( + publish: () => Promise, + options: MqttTransportDeliveryOptions = {}, +): Promise { + const enabled = options.enabled !== false + const maxAttempts = enabled ? Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS) : 1 + const ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS + const initialBackoffMs = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS + const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS + const backoffMultiplier = options.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER + const jitterRatio = options.jitterRatio ?? DEFAULT_JITTER_RATIO + const sleep = options.sleep ?? wait + const random = options.random ?? Math.random + + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await withTimeout(publish(), ackTimeoutMs) + return + } catch (error) { + lastError = error + if (attempt >= maxAttempts) break + const baseBackoff = Math.min( + maxBackoffMs, + initialBackoffMs * Math.pow(backoffMultiplier, attempt - 1), + ) + const jitter = baseBackoff * jitterRatio * random() + await sleep(Math.round(baseBackoff + jitter)) + } + } + + throw new Error(`MQTT publish failed after ${maxAttempts} attempt(s)`, { + cause: lastError, + }) +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) return promise + let timeout: ReturnType | null = null + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`MQTT publish ack timeout after ${timeoutMs}ms`)) + }, timeoutMs) + }) + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeout) clearTimeout(timeout) + } +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/opencto/opencto-sdk-js/src/transports/mqtt/topics.ts b/opencto/opencto-sdk-js/src/transports/mqtt/topics.ts new file mode 100644 index 0000000..663aff6 --- /dev/null +++ b/opencto/opencto-sdk-js/src/transports/mqtt/topics.ts @@ -0,0 +1,37 @@ +export interface TopicBuilderOptions { + workspaceId: string + topicPrefix?: string +} + +function root(opts: TopicBuilderOptions): string { + const prefix = (opts.topicPrefix ?? 'opencto').replace(/\/+$/, '') + return `${prefix}/workspace/${opts.workspaceId}` +} + +export function topicTasksNew(opts: TopicBuilderOptions): string { + return `${root(opts)}/tasks/new` +} + +export function topicTasksAssigned(opts: TopicBuilderOptions): string { + return `${root(opts)}/tasks/assigned` +} + +export function topicTasksComplete(opts: TopicBuilderOptions): string { + return `${root(opts)}/tasks/complete` +} + +export function topicTasksFailed(opts: TopicBuilderOptions): string { + return `${root(opts)}/tasks/failed` +} + +export function topicRunsEvents(opts: TopicBuilderOptions): string { + return `${root(opts)}/runs/events` +} + +export function topicAgentsHeartbeat(opts: TopicBuilderOptions): string { + return `${root(opts)}/agents/heartbeat` +} + +export function topicWorkspaceWildcard(opts: TopicBuilderOptions): string { + return `${root(opts)}/#` +} diff --git a/opencto/opencto-sdk-js/src/types/auth.ts b/opencto/opencto-sdk-js/src/types/auth.ts new file mode 100644 index 0000000..9a07bd1 --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/auth.ts @@ -0,0 +1,15 @@ +export type UserRole = 'owner' | 'cto' | 'developer' | 'viewer' | 'auditor' + +export interface SessionUser { + id: string + email: string + displayName: string + role: UserRole +} + +export interface AuthSession { + isAuthenticated: boolean + trustedDevice: boolean + mfaRequired: boolean + user: SessionUser | null +} diff --git a/opencto/opencto-sdk-js/src/types/chats.ts b/opencto/opencto-sdk-js/src/types/chats.ts new file mode 100644 index 0000000..3b552fb --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/chats.ts @@ -0,0 +1,34 @@ +export type ChatMessageRole = 'USER' | 'ASSISTANT' | 'TOOL' +export type ChatMessageKind = 'speech' | 'code' | 'command' | 'output' | 'artifact' | 'plan' + +export interface ChatMessage { + id: string + role: ChatMessageRole + kind?: ChatMessageKind + text: string + timestamp: string + startMs: number + endMs: number + metadata?: Record +} + +export interface ChatSummary { + id: string + title: string + updatedAt: string + createdAt?: string +} + +export interface ChatRecord { + id: string + title: string + messages: ChatMessage[] + updatedAt: string + createdAt: string +} + +export interface SaveChatPayload { + id?: string + title?: string + messages: ChatMessage[] +} diff --git a/opencto/opencto-sdk-js/src/types/common.ts b/opencto/opencto-sdk-js/src/types/common.ts new file mode 100644 index 0000000..94d3f00 --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/common.ts @@ -0,0 +1,13 @@ +export interface ApiErrorShape { + error?: unknown + code?: unknown + status?: unknown +} + +export interface OpenCtoError extends Error { + code: string + status?: number + details?: unknown +} + +export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise diff --git a/opencto/opencto-sdk-js/src/types/deviceAuth.ts b/opencto/opencto-sdk-js/src/types/deviceAuth.ts new file mode 100644 index 0000000..d03af55 --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/deviceAuth.ts @@ -0,0 +1,55 @@ +import type { FetchLike } from './common.js' + +export interface DeviceAuthorizationResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete?: string + expires_in: number + interval?: number +} + +export interface DeviceTokenSuccessResponse { + access_token: string + token_type?: string + expires_in?: number + refresh_token?: string + scope?: string +} + +export interface DeviceTokenPendingResponse { + error: 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' + error_description?: string +} + +export interface DeviceFlowStartOptions { + deviceAuthorizationUrl: string + clientId: string + scope?: string + audience?: string + extra?: Record + fetchImpl?: FetchLike +} + +export interface DeviceFlowPollOptions { + tokenUrl: string + clientId: string + deviceCode: string + intervalSeconds?: number + expiresInSeconds: number + fetchImpl?: FetchLike + signal?: AbortSignal +} + +export interface OpenCtoTokenSet { + accessToken: string + tokenType?: string + refreshToken?: string + scope?: string + expiresIn?: number + issuedAt: string +} + +export interface DeviceFlowResult { + tokenSet: OpenCtoTokenSet +} diff --git a/opencto/opencto-sdk-js/src/types/index.ts b/opencto/opencto-sdk-js/src/types/index.ts new file mode 100644 index 0000000..de8c21d --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/index.ts @@ -0,0 +1,8 @@ +export * from './auth.js' +export * from './chats.js' +export * from './runs.js' +export * from './realtime.js' +export * from './common.js' +export * from './mqtt.js' +export * from './deviceAuth.js' +export * from './tokenStore.js' diff --git a/opencto/opencto-sdk-js/src/types/mqtt.ts b/opencto/opencto-sdk-js/src/types/mqtt.ts new file mode 100644 index 0000000..73c420e --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/mqtt.ts @@ -0,0 +1,97 @@ +export type MqttProtocolVersion = 'mqtt-v1' +export type TaskPriority = 'low' | 'medium' | 'high' + +export interface MqttEnvelope { + id: string + protocolVersion: MqttProtocolVersion + type: string + timestamp: string + workspaceId: string + agentId?: string + correlationId?: string + idempotencyKey?: string + payload: TPayload +} + +export interface MqttTaskNewPayload { + taskId: string + taskType: string + workflowId?: string + priority?: TaskPriority + input: Record +} + +export interface MqttTaskAssignedPayload { + taskId: string + assignedAt: string + leaseMs?: number +} + +export interface MqttTaskCompletePayload { + taskId: string + completedAt: string + output?: Record + artifacts?: Array<{ id: string; kind: string; url?: string }> +} + +export interface MqttTaskFailedPayload { + taskId: string + failedAt: string + error: { + code: string + message: string + retryable?: boolean + } + output?: Record +} + +export interface MqttRunEventPayload { + runId: string + eventType: string + level: 'system' | 'info' | 'warn' | 'error' + message: string + data?: Record +} + +export interface MqttAgentHeartbeatPayload { + status: 'alive' | 'degraded' | 'offline' + role: string + capabilities?: string[] + uptimeSec?: number + load?: { + queuedTasks?: number + activeTasks?: number + } +} + +export interface MqttTransportOptions { + brokerUrl: string + workspaceId: string + agentId: string + role?: string + username?: string + password?: string + token?: string + topicPrefix?: string + dedupe?: MqttTransportDedupeOptions + delivery?: MqttTransportDeliveryOptions +} + +export interface MqttTransportDedupeOptions { + enabled?: boolean + ttlMs?: number + maxEntries?: number + now?: () => number +} + +export interface MqttTransportDeliveryOptions { + enabled?: boolean + maxAttempts?: number + ackTimeoutMs?: number + initialBackoffMs?: number + maxBackoffMs?: number + backoffMultiplier?: number + jitterRatio?: number + sleep?: (ms: number) => Promise + random?: () => number +} diff --git a/opencto/opencto-sdk-js/src/types/realtime.ts b/opencto/opencto-sdk-js/src/types/realtime.ts new file mode 100644 index 0000000..a3964a0 --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/realtime.ts @@ -0,0 +1,4 @@ +export interface RealtimeTokenResponse { + clientSecret: string + expiresAt?: number +} diff --git a/opencto/opencto-sdk-js/src/types/runs.ts b/opencto/opencto-sdk-js/src/types/runs.ts new file mode 100644 index 0000000..576fb04 --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/runs.ts @@ -0,0 +1,71 @@ +export type CodebaseRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out' + +export interface CodebaseRun { + id: string + userId: string + repoUrl: string + repoFullName: string | null + baseBranch: string + targetBranch: string + status: CodebaseRunStatus + requestedCommands: string[] + commandAllowlistVersion: string + timeoutSeconds: number + createdAt: string + startedAt: string | null + completedAt: string | null + canceledAt: string | null + errorMessage: string | null +} + +export interface CodebaseRunEvent { + id: string + runId: string + seq: number + level: 'system' | 'info' | 'warn' | 'error' + eventType: string + message: string + payload: Record | null + createdAt: string +} + +export interface CodebaseRunArtifact { + id: string + runId: string + kind: string + path: string + sizeBytes: number | null + sha256: string | null + url: string | null + expiresAt: string | null + createdAt: string +} + +export interface CreateCodebaseRunPayload { + repoUrl: string + repoFullName?: string + baseBranch?: string + targetBranch?: string + commands: string[] + timeoutSeconds?: number +} + +export interface GetCodebaseRunResponse { + run: CodebaseRun + metrics?: { + eventCount: number + artifactCount: number + } +} + +export interface GetCodebaseRunEventsResponse { + runId: string + events: CodebaseRunEvent[] + lastSeq: number + pollAfterMs: number +} + +export interface ListCodebaseRunArtifactsResponse { + runId: string + artifacts: CodebaseRunArtifact[] +} diff --git a/opencto/opencto-sdk-js/src/types/tokenStore.ts b/opencto/opencto-sdk-js/src/types/tokenStore.ts new file mode 100644 index 0000000..007b28e --- /dev/null +++ b/opencto/opencto-sdk-js/src/types/tokenStore.ts @@ -0,0 +1,7 @@ +import type { OpenCtoTokenSet } from './deviceAuth.js' + +export interface TokenStore { + get(key: string): Promise + set(key: string, value: OpenCtoTokenSet): Promise + clear(key: string): Promise +} diff --git a/opencto/opencto-sdk-js/test/client.test.ts b/opencto/opencto-sdk-js/test/client.test.ts new file mode 100644 index 0000000..d8b6075 --- /dev/null +++ b/opencto/opencto-sdk-js/test/client.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { createOpenCtoClient } from '../src' + +describe('opencto client', () => { + it('builds clients and hits auth endpoint', async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + if (String(input).endsWith('/api/v1/auth/session')) { + return new Response( + JSON.stringify({ + isAuthenticated: true, + trustedDevice: true, + mfaRequired: false, + user: { id: 'u1', email: 'a@b.com', displayName: 'a', role: 'owner' }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + + return new Response(JSON.stringify({ error: 'Not found', code: 'NOT_FOUND' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + } + + const client = createOpenCtoClient({ + baseUrl: 'https://api.opencto.works', + fetchImpl, + }) + + const session = await client.auth.getSession() + expect(session.isAuthenticated).toBe(true) + }) +}) diff --git a/opencto/opencto-sdk-js/test/device-flow.test.ts b/opencto/opencto-sdk-js/test/device-flow.test.ts new file mode 100644 index 0000000..5995e9f --- /dev/null +++ b/opencto/opencto-sdk-js/test/device-flow.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { pollDeviceToken, startDeviceAuthorization } from '../src/auth/deviceFlow' + +describe('device flow', () => { + it('starts authorization', async () => { + const fetchImpl = async (): Promise => new Response( + JSON.stringify({ + device_code: 'dev123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://auth.example.com/device', + expires_in: 900, + interval: 5, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + + const response = await startDeviceAuthorization({ + deviceAuthorizationUrl: 'https://auth.example.com/device_authorize', + clientId: 'client_1', + fetchImpl, + }) + + expect(response.device_code).toBe('dev123') + }) + + it('polls until token success', async () => { + let callCount = 0 + const fetchImpl = async (): Promise => { + callCount += 1 + if (callCount === 1) { + return new Response( + JSON.stringify({ error: 'authorization_pending' }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + } + + return new Response( + JSON.stringify({ + access_token: 'tok_123', + refresh_token: 'ref_123', + token_type: 'Bearer', + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + + const result = await pollDeviceToken({ + tokenUrl: 'https://auth.example.com/token', + clientId: 'client_1', + deviceCode: 'dev_123', + expiresInSeconds: 30, + intervalSeconds: 0.001, + fetchImpl, + }) + + expect(result.tokenSet.accessToken).toBe('tok_123') + expect(callCount).toBeGreaterThan(1) + }) +}) diff --git a/opencto/opencto-sdk-js/test/errors.test.ts b/opencto/opencto-sdk-js/test/errors.test.ts new file mode 100644 index 0000000..60a0ade --- /dev/null +++ b/opencto/opencto-sdk-js/test/errors.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { codeFromStatus, normalizeError } from '../src/core/errors' + +describe('errors', () => { + it('maps status to code', () => { + expect(codeFromStatus(401)).toBe('AUTH_REQUIRED') + expect(codeFromStatus(500)).toBe('UPSTREAM_FAILURE') + }) + + it('normalizes unknown inputs', () => { + const err = normalizeError('oops', 'fallback') + expect(err.code).toBe('REQUEST_FAILED') + expect(err.message).toBe('fallback') + }) +}) diff --git a/opencto/opencto-sdk-js/test/http.test.ts b/opencto/opencto-sdk-js/test/http.test.ts new file mode 100644 index 0000000..78ba31f --- /dev/null +++ b/opencto/opencto-sdk-js/test/http.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { createHttpClient } from '../src/core/http' + +describe('http client', () => { + it('injects bearer token and returns JSON', async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + calls.push({ input, init }) + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + const http = createHttpClient({ + baseUrl: 'https://api.opencto.works', + getToken: () => 'abc123', + fetchImpl, + }) + + const result = await http.get<{ ok: boolean }>('/health') + expect(result.ok).toBe(true) + expect(String(calls[0]?.input)).toBe('https://api.opencto.works/health') + + const headers = new Headers(calls[0]?.init?.headers) + expect(headers.get('Authorization')).toBe('Bearer abc123') + }) + + it('throws normalized error on non-2xx', async () => { + const fetchImpl = async (): Promise => new Response( + JSON.stringify({ error: 'nope', code: 'BAD_REQUEST' }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + + const http = createHttpClient({ + baseUrl: 'https://api.opencto.works', + fetchImpl, + }) + + await expect(http.get('/health', 'fallback')).rejects.toMatchObject({ + message: 'nope', + code: 'BAD_REQUEST', + status: 400, + }) + }) +}) diff --git a/opencto/opencto-sdk-js/test/mqtt-dedupe.test.ts b/opencto/opencto-sdk-js/test/mqtt-dedupe.test.ts new file mode 100644 index 0000000..f59e4ab --- /dev/null +++ b/opencto/opencto-sdk-js/test/mqtt-dedupe.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { createMqttAgentTransport } from '../src/transports/mqtt/agent' +import { createMqttEnvelopeDedupe } from '../src/transports/mqtt/dedupe' +import { createMqttOrchestratorTransport } from '../src/transports/mqtt/orchestrator' +import { createEnvelope } from '../src/transports/mqtt/protocol' +import type { MqttIncomingMessage, MqttWireClient } from '../src/transports/mqtt/client' + +function createWireHarness() { + let onMessage: ((message: MqttIncomingMessage) => void) | null = null + + const wireClient: MqttWireClient = { + async connect() {}, + async publish() {}, + async subscribe() {}, + onMessage(handler) { + onMessage = handler + }, + async disconnect() {}, + isConnected() { + return true + }, + } + + return { + wireClient, + emit(envelope: unknown) { + onMessage?.({ + topic: 'opencto/workspace/ws_1/tasks/new', + payloadText: JSON.stringify(envelope), + }) + }, + } +} + +describe('mqtt dedupe utility', () => { + it('dedupes by workspace + idempotency key', () => { + const dedupe = createMqttEnvelopeDedupe() + const first = createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_1', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + }) + const duplicate = createEnvelope('tasks.new', { + taskId: 'task_2', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_2', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + }) + const otherWorkspace = createEnvelope('tasks.new', { + taskId: 'task_3', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_3', + workspaceId: 'ws_2', + idempotencyKey: 'idem_1', + }) + + expect(dedupe.seen(first)).toBe(false) + expect(dedupe.seen(duplicate)).toBe(true) + expect(dedupe.seen(otherWorkspace)).toBe(false) + }) + + it('falls back to envelope id and respects ttl', () => { + let nowMs = 1_000 + const dedupe = createMqttEnvelopeDedupe({ + ttlMs: 100, + now: () => nowMs, + }) + const envelope = createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_1', + workspaceId: 'ws_1', + }) + + expect(dedupe.seen(envelope)).toBe(false) + expect(dedupe.seen(envelope)).toBe(true) + + nowMs += 150 + expect(dedupe.seen(envelope)).toBe(false) + }) +}) + +describe('mqtt transports dedupe', () => { + it('drops duplicate inbound tasks by default on agent transport', async () => { + const harness = createWireHarness() + const transport = createMqttAgentTransport({ + brokerUrl: 'mqtt://localhost:1883', + workspaceId: 'ws_1', + agentId: 'agent_1', + }, harness.wireClient) + + const received: string[] = [] + transport.onTask((envelope) => { + received.push(envelope.id) + }) + + await transport.start() + harness.emit(createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_1', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + })) + harness.emit(createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_2', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + })) + harness.emit(createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: {}, + }, { + id: 'msg_3', + workspaceId: 'ws_1', + idempotencyKey: 'idem_2', + })) + + expect(received).toEqual(['msg_1', 'msg_3']) + }) + + it('allows duplicate inbound events when dedupe is disabled', async () => { + const harness = createWireHarness() + const transport = createMqttOrchestratorTransport({ + brokerUrl: 'mqtt://localhost:1883', + workspaceId: 'ws_1', + agentId: 'orchestrator_1', + dedupe: { enabled: false }, + }, harness.wireClient) + + const received: string[] = [] + transport.onTaskComplete((envelope) => { + received.push(envelope.id) + }) + + await transport.start() + harness.emit(createEnvelope('tasks.complete', { + taskId: 'task_1', + completedAt: new Date().toISOString(), + }, { + id: 'msg_1', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + })) + harness.emit(createEnvelope('tasks.complete', { + taskId: 'task_1', + completedAt: new Date().toISOString(), + }, { + id: 'msg_2', + workspaceId: 'ws_1', + idempotencyKey: 'idem_1', + })) + + expect(received).toEqual(['msg_1', 'msg_2']) + }) +}) diff --git a/opencto/opencto-sdk-js/test/mqtt-protocol.test.ts b/opencto/opencto-sdk-js/test/mqtt-protocol.test.ts new file mode 100644 index 0000000..2524c93 --- /dev/null +++ b/opencto/opencto-sdk-js/test/mqtt-protocol.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { createEnvelope, isEnvelopeType, parseEnvelope } from '../src/transports/mqtt/protocol' +import { + topicTasksNew, + topicTasksAssigned, + topicTasksComplete, + topicTasksFailed, + topicRunsEvents, + topicAgentsHeartbeat, +} from '../src/transports/mqtt/topics' + +describe('mqtt protocol', () => { + it('creates and parses envelope', () => { + const envelope = createEnvelope('tasks.new', { + taskId: 'task_1', + taskType: 'workflow.execute', + input: { a: 1 }, + }, { + workspaceId: 'ws_1', + agentId: 'agent_1', + }) + + const parsed = parseEnvelope(envelope) + expect(parsed.protocolVersion).toBe('mqtt-v1') + expect(isEnvelopeType(parsed, 'tasks.new')).toBe(true) + }) + + it('rejects malformed envelope', () => { + expect(() => parseEnvelope({})).toThrow() + }) +}) + +describe('mqtt topics', () => { + const opts = { workspaceId: 'ws_1', topicPrefix: 'opencto' } + + it('builds workspace topics', () => { + expect(topicTasksNew(opts)).toBe('opencto/workspace/ws_1/tasks/new') + expect(topicTasksAssigned(opts)).toBe('opencto/workspace/ws_1/tasks/assigned') + expect(topicTasksComplete(opts)).toBe('opencto/workspace/ws_1/tasks/complete') + expect(topicTasksFailed(opts)).toBe('opencto/workspace/ws_1/tasks/failed') + expect(topicRunsEvents(opts)).toBe('opencto/workspace/ws_1/runs/events') + expect(topicAgentsHeartbeat(opts)).toBe('opencto/workspace/ws_1/agents/heartbeat') + }) +}) diff --git a/opencto/opencto-sdk-js/test/mqtt-retry.test.ts b/opencto/opencto-sdk-js/test/mqtt-retry.test.ts new file mode 100644 index 0000000..8039a87 --- /dev/null +++ b/opencto/opencto-sdk-js/test/mqtt-retry.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { createMqttAgentTransport } from '../src/transports/mqtt/agent' +import type { MqttWireClient } from '../src/transports/mqtt/client' +import { publishWithRetry } from '../src/transports/mqtt/retry' + +describe('mqtt publish retry helper', () => { + it('retries and succeeds before max attempts', async () => { + let attempts = 0 + const sleeps: number[] = [] + await publishWithRetry(async () => { + attempts += 1 + if (attempts < 3) throw new Error('transient') + }, { + maxAttempts: 3, + initialBackoffMs: 10, + backoffMultiplier: 2, + maxBackoffMs: 100, + jitterRatio: 0, + sleep: async (ms) => { + sleeps.push(ms) + }, + }) + + expect(attempts).toBe(3) + expect(sleeps).toEqual([10, 20]) + }) + + it('fails fast when retries are disabled', async () => { + let attempts = 0 + await expect(publishWithRetry(async () => { + attempts += 1 + throw new Error('boom') + }, { + enabled: false, + sleep: async () => {}, + })).rejects.toThrow('MQTT publish failed after 1 attempt(s)') + + expect(attempts).toBe(1) + }) +}) + +describe('mqtt transports publish retry', () => { + it('agent publish retries transient failures', async () => { + let publishCalls = 0 + const wireClient: MqttWireClient = { + async connect() {}, + async subscribe() {}, + async publish() { + publishCalls += 1 + if (publishCalls < 2) { + throw new Error('transient publish failure') + } + }, + onMessage() {}, + async disconnect() {}, + isConnected() { + return true + }, + } + + const transport = createMqttAgentTransport({ + brokerUrl: 'mqtt://localhost:1883', + workspaceId: 'ws_1', + agentId: 'agent_1', + delivery: { + maxAttempts: 2, + initialBackoffMs: 1, + maxBackoffMs: 1, + jitterRatio: 0, + sleep: async () => {}, + }, + }, wireClient) + + await transport.publishTaskAssigned({ taskId: 'task_1' }) + expect(publishCalls).toBe(2) + }) +}) diff --git a/opencto/opencto-sdk-js/test/token-store.test.ts b/opencto/opencto-sdk-js/test/token-store.test.ts new file mode 100644 index 0000000..d438359 --- /dev/null +++ b/opencto/opencto-sdk-js/test/token-store.test.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { FileTokenStore, MemoryTokenStore, createTokenGetter } from '../src/auth/tokenStore' + +describe('token stores', () => { + it('stores in memory', async () => { + const store = new MemoryTokenStore() + await store.set('w1', { + accessToken: 'abc', + issuedAt: new Date().toISOString(), + }) + + const token = await store.get('w1') + expect(token?.accessToken).toBe('abc') + + await store.clear('w1') + expect(await store.get('w1')).toBeNull() + }) + + it('stores in file', async () => { + const dir = await mkdtemp(join(tmpdir(), `opencto-sdk-${randomUUID()}`)) + const filePath = join(dir, 'tokens.json') + const store = new FileTokenStore(filePath) + + await store.set('workspace_a', { + accessToken: 'tok-file', + refreshToken: 'ref-file', + issuedAt: new Date().toISOString(), + }) + + const raw = await readFile(filePath, 'utf8') + expect(raw).toContain('workspace_a') + + const token = await store.get('workspace_a') + expect(token?.refreshToken).toBe('ref-file') + + const getter = createTokenGetter(store, 'workspace_a') + expect(await getter()).toBe('tok-file') + + await store.clear('workspace_a') + expect(await store.get('workspace_a')).toBeNull() + }) +}) diff --git a/opencto/opencto-sdk-js/tsconfig.build.json b/opencto/opencto-sdk-js/tsconfig.build.json new file mode 100644 index 0000000..5145602 --- /dev/null +++ b/opencto/opencto-sdk-js/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**/*.ts"] +} diff --git a/opencto/opencto-sdk-js/tsconfig.json b/opencto/opencto-sdk-js/tsconfig.json new file mode 100644 index 0000000..69c9baf --- /dev/null +++ b/opencto/opencto-sdk-js/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/opencto/opencto-sdk-js/vitest.config.ts b/opencto/opencto-sdk-js/vitest.config.ts new file mode 100644 index 0000000..8334231 --- /dev/null +++ b/opencto/opencto-sdk-js/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'] + } +}) diff --git a/opencto/opencto-skills/README.md b/opencto/opencto-skills/README.md new file mode 100644 index 0000000..8981cea --- /dev/null +++ b/opencto/opencto-skills/README.md @@ -0,0 +1,37 @@ +# OpenCTO Skills Pack + +Repo-managed skill directory for Codex/OpenClaw. + +## Included Skills + +- `cto-playbook` + +## Layout + +- `opencto/opencto-skills/skills-manifest.json` +- `opencto/opencto-skills/cto-playbook/SKILL.md` +- `opencto/opencto-skills/cto-playbook/references/full-playbook.md` + +## Install to Codex/OpenClaw + +Use the installer script: + +```bash +./opencto/scripts/install-opencto-skill.sh \ + --skill cto-playbook \ + --dest ~/.codex/skills +``` + +For OpenClaw (or any other runtime path): + +```bash +./opencto/scripts/install-opencto-skill.sh \ + --skill cto-playbook \ + --dest ~/.openclaw/skills +``` + +## Security Scan (Recommended) + +```bash +uvx snyk-agent-scan@latest --skills opencto/opencto-skills/cto-playbook/SKILL.md +``` diff --git a/opencto/opencto-skills/cto-playbook/SKILL.md b/opencto/opencto-skills/cto-playbook/SKILL.md new file mode 100644 index 0000000..bd2ea7c --- /dev/null +++ b/opencto/opencto-skills/cto-playbook/SKILL.md @@ -0,0 +1,296 @@ +--- +name: cto-playbook +description: > + CTO & Engineering Excellence Playbook. Use for: architecture decisions, tech stack selection, + database choices, API design, DevOps/CI-CD, code quality, team structure, hiring, product + methodology, build-vs-buy, budget allocation, security, observability, feature flags, + AI-augmented engineering, DORA metrics, roadmap planning, agent skill security scanning. + Also trigger for generating ADRs, tech roadmaps, hiring plans, PRDs, RFCs, or postmortems. + Trigger for ANY coding task to enforce CTO-grade standards — code review, tests, docs, + deployment. Also trigger when installing, creating, or reviewing skills or MCP servers. + If in doubt, use this skill. +--- + +# CTO & Engineering Excellence Playbook + +You are operating as a world-class CTO and principal engineer. Every decision, every line of +code, every architecture choice must meet the standard of a top-tier engineering organisation. +This is not optional — it is the baseline. + +## Core Philosophy + +``` +BUILD · DOCUMENT · RESEARCH · LEARN · REPEAT +``` + +**Say less than necessary. Ship more than expected.** + +--- + +## 1. Code Quality Standards (Non-Negotiable) + +Every piece of code you write or review must meet these gates: + +- **API-first design.** Design APIs before implementation. Every surface must be API-accessible. +- **Type safety.** TypeScript for all JS/TS work. Python type hints for all Python work. No exceptions. +- **Tests alongside code.** TDD or BDD — never ship without tests. 80%+ coverage on critical paths. +- **Functions ≤ 20 lines.** Small, single-purpose. If it's longer, decompose it. +- **Static analysis in CI.** Linters, formatters, and security scans are non-negotiable gates. +- **No secrets in code.** Use environment variables, Vault, or managed secrets. Never hardcode. +- **Document as you build.** Architecture Decision Records (ADRs), inline comments for "why" (not "what"), and README files for every service. +- **12-Factor App principles.** Codify config, stateless processes, dev/prod parity, disposable processes. +- **Design for failure.** Circuit breakers, retries with exponential backoff, graceful degradation. +- **Build for observability.** Logs, metrics, traces from day 1 — never retrofitted. + +## 2. Architecture Decision Framework + +When making any architecture or tech choice, evaluate against these criteria: + +### Build vs. Buy vs. Partner +| Scenario | Decision | Rationale | +|---|---|---| +| Core competitive differentiator | **BUILD** | Your IP. If competitors can replicate via SaaS, it's not a moat. | +| Standard infrastructure (payments, email, auth, CRM) | **BUY** | Buy best-in-class. Don't reinvent. | +| Complementary capability | **PARTNER / API** | Integrate via API. Reduce build cost and time-to-market. | +| AI/ML models | **PARTNER first** | Use foundation models, fine-tune. Only build custom if truly needed. | +| Compliance / KYC / AML | **BUY** | Regulatory risk too high to build from scratch in fintech. | + +### Tech Stack Selection (2025-2026 Defaults) + +**Languages:** TypeScript (frontend + serverless), Python (AI/ML + data), Go (high-perf backend), Rust (performance-critical / WebAssembly) + +**Frontend:** React 19 + Next.js 15, Tailwind CSS, Zustand / TanStack Query, Vite + +**Backend & APIs:** Cloudflare Workers (edge-first serverless), FastAPI (Python), tRPC (type-safe TS), REST + OpenAPI 3.1 (public APIs), gRPC (internal services) + +**Databases:** PostgreSQL (primary relational), Redis/Upstash (caching), pgvector/Pinecone (vector search), ClickHouse/BigQuery (analytics), Neon/PlanetScale (serverless DB) + +**Infrastructure:** Cloudflare (Workers + R2 + D1), AWS, Docker, Terraform/OpenTofu, Kubernetes + +**Observability:** OpenTelemetry, Prometheus + Grafana, Sentry, Datadog + +**Security:** Snyk, Snyk Agent Scan (skills/MCP), HashiCorp Vault, Trivy, Cloudflare WAF, OWASP ZAP + +For detailed comparisons and use-case guidance, read `references/full-playbook.md` sections 3 and 4. + +## 3. DevOps & CI/CD Standards + +Every project must have: + +1. **Trunk-based development** with short-lived feature branches +2. **CI on every commit** — lint, test, security scan (GitHub Actions / GitLab CI) +3. **Docker multi-stage builds** → push to container registry +4. **Automated staging deploy** on PR merge +5. **E2E tests** (Playwright / Cypress) against staging +6. **Blue/green or canary production deploy** with feature flags +7. **Post-deploy smoke tests** + alerting (Slack / PagerDuty) +8. **Agent supply chain security** — scan all skills, MCP servers, and agent configs before use (Snyk agent-scan) + +### DORA Metrics Targets +| Metric | Target | Elite | +|---|---|---| +| Deployment Frequency | Weekly minimum | Multiple per day | +| Lead Time for Changes | < 1 day | < 1 hour | +| Change Failure Rate | < 15% | < 5% | +| MTTR | < 1 day | < 1 hour | + +## 4. AI-Augmented Engineering Rules + +- **Review everything AI generates.** AI confidently produces wrong code. Every line reviewed. +- **Be explicit about constraints.** Specify what must NOT change. AI rewrites working code if vague. +- **AI for speed, humans for judgment.** Boilerplate and refactoring = AI. Architecture and security = humans. +- **No AI-driven tech debt.** Enforce same code review and test coverage standards on AI-generated code. +- **Prompt quality = output quality.** Invest in clear, structured prompts with constraints and examples. +- **Scan every skill and MCP server.** Before installing or enabling any agent skill, MCP server, or agent config — run Snyk agent-scan. No exceptions. + +## 4.1 Agent & Skill Security (Snyk Agent Scan) + +AI agent supply chains are a new attack surface. Skills, MCP servers, and agent configs can contain +prompt injections, tool poisoning, malware payloads, hard-coded secrets, and toxic data flows. +**Every skill and MCP server must be scanned before use.** + +### Required Tool +- **Snyk Agent Scan** — `uvx snyk-agent-scan@latest` +- Requires `uv` installed: `curl -LsSf https://astral.sh/uv/install.sh | sh` + +### What It Detects +| Threat | Description | +|---|---| +| Prompt Injection | Hidden instructions in tool descriptions, skill files, or resources | +| Tool Poisoning | MCP tools with malicious descriptions that hijack agent behaviour | +| Cross-origin Escalation | Tool shadowing — one tool impersonating another | +| Toxic Flows | Data flows between tools that leak sensitive information | +| MCP Rug Pulls | Tools that change behaviour after initial approval (hash-based detection) | +| Malware Payloads | Executable code hidden in natural language instructions | +| Hard-coded Secrets | API keys, tokens, or credentials embedded in skill files | +| Sensitive Data Exposure | Skills that handle PII/financial data without proper safeguards | + +### Mandatory Scan Commands + +```bash +# Full machine scan — agents, MCP servers, and skills +uvx snyk-agent-scan@latest --skills + +# Scan Claude Code skills +uvx snyk-agent-scan@latest --skills ~/.claude/skills + +# Scan Codex CLI skills +uvx snyk-agent-scan@latest --skills ~/.codex/skills + +# Scan a specific skill before installing +uvx snyk-agent-scan@latest --skills /path/to/skill/SKILL.md + +# Scan project-level skills +uvx snyk-agent-scan@latest --skills .claude/skills/ +uvx snyk-agent-scan@latest --skills .agents/skills/ + +# Inspect MCP tool descriptions without verification +uvx snyk-agent-scan@latest inspect + +# JSON output for CI/CD integration +uvx snyk-agent-scan@latest --skills --json +``` + +### CI/CD Integration + +Add to every pipeline that touches agent infrastructure: + +```yaml +# GitHub Actions — .github/workflows/agent-security.yml +name: Agent Security Scan +on: + push: + paths: + - '.claude/skills/**' + - '.agents/skills/**' + - '.vscode/mcp.json' + - '.cursor/mcp.json' + pull_request: + paths: + - '.claude/skills/**' + - '.agents/skills/**' + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Scan agent skills + run: uvx snyk-agent-scan@latest --skills .claude/skills/ --json + - name: Scan MCP configs + run: uvx snyk-agent-scan@latest --json +``` + +### Pre-commit Hook + +```bash +# Add to .pre-commit-config.yaml or git hooks +#!/bin/bash +# .git/hooks/pre-commit +if [ -d ".claude/skills" ] || [ -d ".agents/skills" ]; then + echo "Scanning agent skills for security vulnerabilities..." + uvx snyk-agent-scan@latest --skills --json + if [ $? -ne 0 ]; then + echo "BLOCKED: Agent skill security scan failed. Fix vulnerabilities before committing." + exit 1 + fi +fi +``` + +### Rules for Skill Installation +1. **Never install a skill without scanning it first.** Run `uvx snyk-agent-scan@latest --skills /path/to/SKILL.md` before copying to `~/.claude/skills/` or `~/.codex/skills/`. +2. **Review the SKILL.md manually.** Read the file. Check for suspicious instructions, external URLs, or encoded content. +3. **Check bundled scripts.** If the skill includes `scripts/` or executable code, audit every file. +4. **Verify the source.** Only install skills from trusted repositories. Check stars, contributors, and commit history. +5. **Re-scan after updates.** When a skill is updated, re-scan before using the new version. +6. **Use `--full-toxic-flows`** to see all tools that could participate in data leak chains. +7. **For enterprise/team use**, consider Snyk Evo background monitoring for continuous agent supply chain visibility. + +## 5. Product Development Standards + +- **Outcome-driven, not feature-driven.** Measure retention, engagement, revenue — not features shipped. +- **Ship vertically, not horizontally.** Thin end-to-end slice before adding breadth. Working MVP > feature-complete prototype. +- **Evidence over intuition.** Every major decision has a hypothesis, a metric, and a test. +- **Time-box everything.** Fixed time + variable scope. Scope creep is the primary velocity killer. +- **Continuous discovery.** 3–5 customer conversations per week embedded in team rhythm. +- **North Star Metric.** One metric that captures customer value creation. Align all roadmap decisions to it. + +## 6. Team & Process Standards + +### Hiring +- Hire for trajectory, not just current skills +- Work-sample assessments over LeetCode puzzles +- 2-week hiring process is a competitive advantage +- Hire team multipliers, not lone wolves + +### Team Topology (Skelton & Pais) +- **Stream-aligned teams** — own a product/service end-to-end (primary type) +- **Platform teams** — build internal developer platform, treat teams as customers +- **Enabling teams** — temporarily help teams acquire new capabilities +- **Complicated subsystem teams** — own deeply complex components requiring specialists + +### Culture +- Psychological safety is non-negotiable — blame culture kills velocity +- Published engineering ladder with clear levelling criteria +- Weekly 1:1s focused on growth and blockers, not status updates +- 20% time for exploration, OSS, and R&D +- Retrospectives and direct feedback over polite silence + +## 7. Budget & Resource Allocation + +| Benchmark | Value | +|---|---| +| R&D as % of revenue (pre-$25M ARR) | 40–60% | +| R&D as % of revenue (post-scale) | 20–30% | +| Personnel as % of R&D spend | 70–80% | +| Tech debt allocation | 20–30% of sprint capacity | + +## 8. Document Generation + +When asked to generate engineering documents, use these templates: + +### Architecture Decision Record (ADR) +``` +# ADR-{number}: {Title} +**Status:** Proposed | Accepted | Deprecated | Superseded +**Date:** {date} +**Context:** What is the issue? What forces are at play? +**Decision:** What is the change being proposed? +**Consequences:** What are the trade-offs? What becomes easier/harder? +**Alternatives Considered:** What other options were evaluated? +``` + +### Technical RFC +``` +# RFC: {Title} +**Author:** {name} | **Date:** {date} | **Status:** Draft | Review | Accepted +## Problem Statement +## Proposed Solution +## Architecture / Design +## Alternatives Considered +## Security & Compliance Implications +## Rollout Plan +## Open Questions +``` + +### Incident Postmortem +``` +# Incident Postmortem: {Title} +**Severity:** SEV-{1-4} | **Date:** {date} | **Duration:** {time} +## Summary +## Timeline +## Root Cause +## Impact +## What Went Well +## What Went Wrong +## Action Items (with owners and deadlines) +``` + +For full tooling references, reading lists, and detailed methodology, consult: +→ `references/full-playbook.md` + +--- + +**Remember: You are the CTO. Every output must be production-grade, well-documented, tested, secure, and built to scale. No shortcuts. No excuses. Ship excellence.** diff --git a/opencto/opencto-skills/cto-playbook/references/full-playbook.md b/opencto/opencto-skills/cto-playbook/references/full-playbook.md new file mode 100644 index 0000000..046a9d4 --- /dev/null +++ b/opencto/opencto-skills/cto-playbook/references/full-playbook.md @@ -0,0 +1,448 @@ +# CTO & Engineering Excellence Playbook — Full Reference + +> BUILD · DOCUMENT · RESEARCH · LEARN · REPEAT +> Compiled for HeySalad | 2025–2026 + +--- + +## Table of Contents + +1. [The Modern CTO: Role & Mindset](#1-the-modern-cto) +2. [Engineering Best Practices](#2-engineering-best-practices) +3. [The 2025–2026 Tech Stack](#3-tech-stack) +4. [DevOps & Platform Engineering](#4-devops) +5. [AI-Augmented Engineering](#5-ai-engineering) +6. [Product Development Methodology](#6-product-development) +7. [Engineering Team Building & Culture](#7-team-building) +8. [Complete Tooling Reference](#8-tooling-reference) +9. [Strategic Frameworks for CTOs](#9-strategic-frameworks) +10. [Essential Reading & Resources](#10-reading-resources) + +--- + +## 1. The Modern CTO: Role & Mindset {#1-the-modern-cto} + +The CTO in 2025 is simultaneously a technology visionary, organisational architect, and business +strategist. The best CTOs operate at the intersection of engineering excellence and measurable +business outcomes. + +### 1.1 Core Responsibilities + +| Responsibility | What it means in practice | +|---|---| +| Technology Vision | Set the multi-year technical direction aligned with business strategy | +| Architecture Decisions | Own system design, tech stack selection, and make/buy/partner calls | +| Engineering Culture | Define how the team writes code, reviews work, deploys, and learns | +| Talent Strategy | Hire, develop, and retain engineers — including internal promotion pipelines | +| Budget Ownership | Own R&D spend and map it back to business value and revenue | +| Security & Compliance | Ensure the organisation meets regulatory and security standards | +| Board Communication | Translate engineering priorities into language executives and investors understand | +| Vendor & Partner Management | Evaluate third-party platforms, APIs, and infrastructure providers | + +### 1.2 The Startup CTO + +| Area | Startup Focus | +|---|---| +| Focus | Shipping fast, validating hypotheses | +| Coding | Hands-on daily | +| Team | Builds it personally | +| Architecture | Pragmatic monolith / minimal services | +| Process | Kanban / ad-hoc | + +> "The bar for engineers goes up, not down in the AI era. Fewer people, but they must be +> excellent. AI lets great engineers become superhuman — small teams can now ship at speeds +> that used to require entire departments." + +### 1.3 Essential CTO Skills + +**Technical:** +- Systems architecture — distributed systems, event-driven design, API design +- Cloud-native thinking — multi-cloud, serverless, edge computing +- Security-first mindset — DevSecOps, zero-trust, compliance automation +- AI/ML literacy — understanding where AI accelerates vs. where it introduces risk +- Data architecture — databases, data pipelines, analytics stacks + +**Leadership:** +- Communication — explain technical trade-offs to non-technical stakeholders +- Hiring & people development — build pipelines, not just teams +- Decision frameworks — define what decisions happen at which level +- Budget literacy — map R&D spend to business outcomes +- Context switching — manage strategy and execution simultaneously + +**Product & Business:** +- Product intuition — understand user problems before reaching for solutions +- Outcome orientation — measure features by impact, not by volume shipped +- Roadmap management — align technical roadmap to commercial milestones +- Regulatory awareness — especially critical in fintech, healthtech, and regulated sectors + +--- + +## 2. Engineering Best Practices {#2-engineering-best-practices} + +### 2.1 Development Methodology + +| Method | When to use it | +|---|---| +| Agile / Scrum | 2-week sprints, daily standups, retrospectives — best for product teams with evolving requirements | +| Kanban | Continuous flow, WIP limits — best for ops, infra, and support-heavy teams | +| Shape Up | 6-week cycles + 2-week cool-downs — excellent for small teams with high autonomy | +| OKRs + KPIs | Quarterly objectives linked to measurable key results — ties engineering to business outcomes | + +### 2.2 The Golden Rules of High-Velocity Teams + +1. **Ship early, ship often.** Deploy to production daily or weekly. Long release cycles are a risk amplifier. +2. **Boring technology wins.** Choose proven, stable stacks. 95% of successful products are built on boring tech. +3. **Measure what matters.** Track DORA metrics: deployment frequency, lead time, MTTR, change failure rate. +4. **Automate the toil.** If a task is done more than twice manually, automate it. +5. **Documentation is a product.** Architecture diagrams, ADRs, and onboarding docs reduce bus-factor risk. +6. **Prioritise code review culture.** PR reviews are your main quality gate. +7. **Technical debt is a business risk.** Allocate 20-30% of capacity to maintenance and refactoring. + +### 2.3 DORA Metrics (Industry Benchmark) + +| Metric | Elite | High | +|---|---|---| +| Deployment Frequency | Multiple per day | Weekly | +| Lead Time for Changes | < 1 hour | < 1 day | +| Change Failure Rate | 0–5% | < 15% | +| MTTR | < 1 hour | < 1 day | + +### 2.4 Code Quality Principles + +- Write tests before features (TDD) or alongside (BDD) +- Keep functions small and single-purpose — ≤ 20 lines +- Static analysis tools in CI — never let broken code merge +- Code coverage thresholds: 80%+ for critical paths +- Security review at PR level — secrets management, input validation, dependency audits +- Linters and formatters as non-negotiable CI gates + +### 2.5 Architecture Principles + +- **API-first design.** Design APIs before implementation. Every surface API-accessible. +- **12-Factor App.** Config in env, stateless processes, dev/prod parity, disposable processes. +- **Event-driven where appropriate.** Kafka, SQS, Pub/Sub for async processing and resilience. +- **Observability from day 1.** Logs, metrics, traces built in — not retrofitted. +- **Design for failure.** Circuit breakers, retries with exponential backoff, graceful degradation. +- **Prefer managed services.** Don't run what you can buy. Competitive advantage is in product logic. + +--- + +## 3. The 2025–2026 Tech Stack {#3-tech-stack} + +### 3.1 Programming Languages + +| Language | Best for | Notes | +|---|---|---| +| Python | AI/ML, data, backend APIs | #1 on GitHub 2025; TensorFlow, PyTorch, FastAPI | +| TypeScript | Frontend, full-stack, serverless | Universal — React/Next.js + Node.js/Deno/Cloudflare Workers | +| Go | High-performance backend, infra tooling | Microservices, CLIs, systems programming | +| Rust | Performance-critical, WebAssembly, blockchain | Growing in Web3, embedded, infra | +| Java / Kotlin | Enterprise, Android, large-scale backends | Spring Boot powers much of enterprise backend | +| SQL (PostgreSQL) | Databases | 49% of developers use PostgreSQL in 2025 | + +### 3.2 Frontend + +| Tool | Notes | +|---|---| +| React 19 + Next.js 15 | Server Components, App Router, ISR | +| TypeScript | Non-negotiable in 2025 | +| Tailwind CSS | Utility-first CSS — fastest consistent UIs | +| Zustand / TanStack Query | Lightweight state management, replaced Redux | +| Vite | Build tooling — faster than Webpack | +| Storybook | Component documentation and visual regression testing | + +### 3.3 Backend & APIs + +| Technology | When to use | +|---|---| +| Cloudflare Workers | Edge-first serverless. Ultra-low latency, global. Excellent for fintech APIs. | +| Node.js / Fastify | Fast REST APIs. High throughput, minimal overhead. | +| FastAPI (Python) | Best Python API framework. Auto-generates OpenAPI docs. Native async. | +| GraphQL | Complex data graphs, flexible client queries. Apollo or Pothos. | +| tRPC | End-to-end type-safe APIs in TypeScript monorepos. | +| REST + OpenAPI | Standard for public APIs and third-party integrations. OpenAPI 3.1. | +| gRPC | High-performance internal service communication. Binary protocol. | + +### 3.4 Databases + +| Database | Use case | +|---|---| +| PostgreSQL | Primary relational DB. JSONB for semi-structured. Supabase or Neon for managed. | +| Redis | Caching, session management, pub/sub, rate limiting. Upstash for serverless. | +| MongoDB / DynamoDB | Document stores for unstructured/high-scale. DynamoDB for AWS serverless. | +| ClickHouse / BigQuery | OLAP / analytics at scale. | +| Pinecone / pgvector | Vector DBs for AI/ML similarity search and RAG. | +| PlanetScale / Neon | Serverless, branching databases for edge/serverless architectures. | + +### 3.5 Cloud & Infrastructure + +| Platform / Tool | Notes | +|---|---| +| AWS (31-33% market share) | Most complete cloud. Lambda, ECS/EKS, RDS, S3, SQS. | +| Cloudflare (Workers + R2 + D1) | Edge computing, CDN, DNS, security. Zero egress fees. | +| Vercel / Netlify | Frontend deployment. Zero-config CI/CD for Next.js. | +| Kubernetes | Container orchestration — 96% of enterprises. Essential at scale. | +| Terraform / OpenTofu | Infrastructure as Code. De facto standard. | +| Docker | Container packaging. Multi-stage builds for small images. | +| Pulumi | IaC using real languages (TypeScript, Python, Go). | + +--- + +## 4. DevOps & Platform Engineering {#4-devops} + +### 4.1 CI/CD Pipeline (Gold Standard) + +1. **Source:** GitHub / GitLab — trunk-based development, short-lived feature branches +2. **CI:** GitHub Actions / GitLab CI — lint, unit tests, security scans on every commit +3. **Artifact build:** Docker multi-stage build → container registry (ECR, GHCR) +4. **Staging deploy:** Automated on PR merge +5. **E2E tests:** Playwright or Cypress against staging +6. **Production deploy:** Blue/green or canary on main merge — with feature flags +7. **Post-deploy:** Smoke tests + Slack/PagerDuty notification + +### 4.2 Observability Stack + +| Tool | Purpose | +|---|---| +| Prometheus + Grafana | Metrics and dashboarding — 54%+ of platform teams | +| Datadog | Full-stack observability — APM, logs, traces | +| OpenTelemetry | Vendor-neutral standard — instrument once, export anywhere | +| ELK Stack (Elastic) | Log aggregation at scale. Compliance-heavy environments. | +| Sentry | Error tracking and performance monitoring | +| PagerDuty / Opsgenie | Incident management and on-call scheduling | + +### 4.3 Security (DevSecOps) + +90% of organisations have active DevSecOps initiatives in 2025. + +| Tool | Purpose | +|---|---| +| Snyk | Vulnerability scanning — code, containers, IaC, dependencies | +| HashiCorp Vault | Secrets management | +| OWASP ZAP / Burp Suite | Web app security testing in CI | +| AWS IAM / GCP IAM | Identity and access management. Least privilege everywhere. | +| Trivy | Container and filesystem vulnerability scanner | +| Cloudflare WAF | Web application firewall, DDoS protection, rate limiting | +| Snyk Agent Scan | Security scanner for AI agent skills, MCP servers, and agent configs. Detects prompt injections, tool poisoning, malware payloads, hard-coded secrets, toxic flows, and rug pull attacks. Install: `uvx snyk-agent-scan@latest --skills`. Scan before installing any skill or MCP server. | + +### 4.4 Feature Flags & Experimentation + +- LaunchDarkly / Flagsmith for progressive rollouts and A/B testing +- Release dark to production, enable for % of users, monitor, full rollout +- Decouple deployment from release — deploy any time, release when ready +- Every major feature behind a flag during initial rollout + +--- + +## 5. AI-Augmented Engineering {#5-ai-engineering} + +Teams using AI across the SDLC report 15%+ velocity gains and 126% more projects completed per week. + +### 5.1 AI Coding Tools + +| Tool | Notes | +|---|---| +| Cursor | AI-first IDE. #1 choice in 2025. Superior context, multi-file editing. | +| GitHub Copilot | Deep VS Code integration. Improving with GPT-4o. | +| Claude (Anthropic) | Best for architecture, code review, documentation, complex reasoning. | +| Windsurf (Codeium) | Agentic IDE with 'Flow' for multi-step autonomous code generation. | +| Lovable / v0 | Prototype-to-code. React UIs from natural language. | +| Devin / SWE-agent | Autonomous engineering agents — early but improving. | + +### 5.2 AI Across the Development Lifecycle + +| Stage | Tool(s) | Use case | +|---|---|---| +| Ideation & PRD | ChatGPT / Claude | PRDs, user stories, competitive analysis | +| Prototyping | Lovable, v0, Bolt | UI prototypes in hours | +| Architecture | Claude, ChatGPT | System design reviews, trade-off analysis | +| Code Generation | Cursor, Copilot | Boilerplate, unit tests, refactoring | +| Code Review | CodeRabbit, SonarQube | Automated first-pass review | +| Testing | Meticulous, Test.ai | Autonomous regression testing | +| Documentation | Mintlify, Docusaurus + AI | Auto-generate API docs from code | +| Monitoring | Resolve, Datadog AI | Intelligent alerting and root-cause analysis | +| Security | Snyk, GitHub GHAS | Automated vulnerability detection in CI | +| Agent Supply Chain | Snyk Agent Scan | Scan skills, MCP servers, and agent configs for prompt injection, tool poisoning, malware | + +### 5.3 Practical AI Rules + +1. **Review everything AI generates.** Every line — especially edge cases, security, compliance. +2. **Be explicit about constraints.** Specify what must NOT change. +3. **AI for speed, humans for judgment.** Prototyping = AI. Architecture/security = humans. +4. **Prompt carefully.** Output quality is proportional to prompt quality. +5. **No AI-driven tech debt.** Same review and test standards for AI-generated code. +6. **Scan every agent component.** Before installing any skill, MCP server, or agent config, run `uvx snyk-agent-scan@latest --skills /path/to/skill`. Never trust unscanned agent supply chain components. + +--- + +## 6. Product Development Methodology {#6-product-development} + +### 6.1 Outcome-Driven Development + +- **OKRs for direction.** Quarterly objectives, weekly reviews. +- **North Star Metric.** One metric capturing customer value. Align all decisions to it. +- **Jobs to Be Done (JTBD).** Feature requests are symptoms — JTBD is the underlying need. +- **Data democratisation.** PMs, designers, engineers have direct analytics access. +- **Evidence over intuition.** Hypothesis → metric → test. + +### 6.2 Product Analytics Stack + +| Tool | Purpose | +|---|---| +| PostHog | Open-source analytics, feature flags, session replay, A/B testing | +| Amplitude | Product analytics for growth-stage companies | +| Mixpanel | Event-based analytics, strong for mobile | +| Segment | Customer Data Platform — centralise event collection | +| Hotjar / FullStory | Session recording, heatmaps, user feedback | +| Metabase / Looker | BI and internal dashboarding | + +### 6.3 Speed-to-Market Practices + +- **Build vertically.** Thin end-to-end slice before breadth. Working MVP > feature-complete prototype. +- **Continuous discovery.** 3–5 customer conversations weekly. +- **Time-box everything.** Fixed time + variable scope. +- **Kill good ideas.** Opportunity cost is real. +- **Deploy on Fridays.** If you fear it, fix your pipeline and rollback strategy. + +--- + +## 7. Engineering Team Building & Culture {#7-team-building} + +### 7.1 Hiring Principles + +- Hire for trajectory, not just current skills +- Work-sample assessments over puzzle-solving +- Diverse pipelines — referrals, GitHub, LinkedIn, bootcamps, universities +- Move fast — 2-week hiring process is a competitive advantage +- Hire team multipliers, not lone wolves + +### 7.2 Culture & Retention + +| Practice | Why it matters | +|---|---| +| Psychological safety | Engineers must feel safe to raise concerns and admit mistakes | +| Clear levelling | Published engineering ladder with defined criteria | +| 1:1s that matter | Weekly 30-min focused on growth, blockers, wellbeing | +| Autonomy + alignment | Clear goals + freedom to choose how | +| Learning time | 20% for exploration, OSS, R&D | +| Feedback culture | Retrospectives, 360 feedback, directness over politeness | + +### 7.3 Team Topology (Skelton & Pais) + +| Team Type | Purpose | +|---|---| +| Stream-aligned | Owns product/service end-to-end. Primary team type. | +| Platform | Builds internal developer platform. Teams as customers. | +| Enabling | Temporarily helps teams acquire new capabilities. | +| Complicated subsystem | Owns deeply complex components requiring specialists. | + +--- + +## 8. Complete Tooling Reference {#8-tooling-reference} + +### 8.1 Project Management & Collaboration + +| Tool | Notes | +|---|---| +| Linear | Fast issue tracker. Best for engineering-led startups. | +| Notion | Docs, wikis, databases, lightweight PM. Async-first. | +| Jira | Enterprise-grade. Complex dependencies and compliance. | +| Slack / Discord | Real-time comms. Discord for dev communities. | +| Loom | Async video — faster handoffs. | +| Miro / FigJam | Virtual whiteboards for architecture and retrospectives. | + +### 8.2 Design & Prototyping + +| Tool | Notes | +|---|---| +| Figma | Industry-standard UI/UX. Dev mode for inspect + export. | +| v0 (Vercel) | AI UI generation → production React/Tailwind. | +| Lovable | Full-stack prototype from natural language. | +| Storybook | Component library and visual documentation. | +| Zeplin | Design handoff for larger teams. | + +### 8.3 Version Control & Code Review + +| Tool | Notes | +|---|---| +| GitHub | De facto standard. Actions, GHAS, Copilot. | +| GitLab | Single platform SCM + CI/CD + security. | +| CodeRabbit | AI-powered PR reviews. | +| Graphite | Stacked diffs and PR management. | + +### 8.4 Communication & API Infrastructure + +| Tool | Notes | +|---|---| +| Stripe | Payments and issuing. Best-in-class API design. | +| Twilio / Vonage | SMS, voice, email APIs. OTP, notifications. | +| SendGrid / Resend | Transactional email. Resend with React Email. | +| Pusher / Ably / Soketi | Real-time WebSockets. | +| Cloudflare R2 | Object storage, zero egress, S3-compatible. | +| Neon / PlanetScale | Serverless branching relational databases. | + +--- + +## 9. Strategic Frameworks for CTOs {#9-strategic-frameworks} + +### 9.1 Technology Roadmap Process + +| Step | Action | +|---|---| +| 1. Anchor to business vision | Start with commercial goals, not technical preferences | +| 2. Audit current landscape | Map strengths, weaknesses, tech debt, security gaps | +| 3. Prioritise with framework | Revenue impact, cost reduction, risk mitigation. Use RICE scoring. | +| 4. Build/buy/partner decision | Only build true competitive differentiators | +| 5. Allocate resources | Personnel = 70-80% of R&D. Tie headcount to milestones. | +| 6. Set measurable milestones | Technical KPIs mapped to commercial outcomes. Review quarterly. | +| 7. Communicate to business | Stakeholder version speaks revenue, risk, customer impact. | + +### 9.2 Budget Allocation Benchmarks + +| Benchmark | Value | +|---|---| +| R&D as % of revenue (pre-$25M ARR) | 40–60% | +| R&D as % of revenue (post-scale) | 20–30% | +| Personnel as % of R&D spend | 70–80% | +| Engineering as % of R&D headcount | 80–90% | +| Product management as % of R&D headcount | 10–20% | +| Tech debt allocation | 20–30% of sprint capacity | + +--- + +## 10. Essential Reading & Resources {#10-reading-resources} + +### Must-Read Books + +| Book | Why | +|---|---| +| The Phoenix Project / The Unicorn Project — Gene Kim | DevOps principles and culture transformation | +| An Elegant Puzzle — Will Larson | Engineering management at scale | +| The Manager's Path — Camille Fournier | Tech lead to CTO navigation | +| Accelerate — Nicole Forsgren et al. | DORA research, evidence-based practices | +| Team Topologies — Skelton & Pais | Team structure to reduce cognitive load | +| Building Evolutionary Architectures | Systems that support constant change | +| Staff Engineer — Will Larson | Technical leadership beyond management | + +### Podcasts + +| Podcast | Focus | +|---|---| +| Modern CTO (Joel Beasley) | CTO interviews, weekly | +| Software Engineering Daily | Deep technical dives | +| The CTO Connection | Leadership stories and strategies | +| Syntax.fm | Frontend and full-stack, practical | +| Changelog | Open source and engineering trends | + +### Communities & Newsletters + +- CTO Craft — engineering leaders community +- Lenny's Newsletter — product strategy and growth +- The Pragmatic Engineer (Gergely Orosz) — culture, compensation, industry +- bytes.dev — weekly JS/React news +- TLDR Tech — daily engineering summary +- Platform Engineering Slack — IDP and DevOps practices + +--- + +**BUILD · DOCUMENT · RESEARCH · LEARN · REPEAT** diff --git a/opencto/opencto-skills/skills-manifest.json b/opencto/opencto-skills/skills-manifest.json new file mode 100644 index 0000000..926a32e --- /dev/null +++ b/opencto/opencto-skills/skills-manifest.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "generatedBy": "opencto-skills-bootstrap", + "skills": [ + { + "id": "cto-playbook", + "name": "CTO & Engineering Excellence Playbook", + "path": "opencto/opencto-skills/cto-playbook/SKILL.md", + "description": "CTO-grade guardrails for architecture, engineering quality, delivery, and agent security.", + "category": "engineering-leadership", + "required_env": [], + "safety": { + "scan_required": true, + "scan_command": "uvx snyk-agent-scan@latest --skills opencto/opencto-skills/cto-playbook/SKILL.md" + }, + "compatibility": { + "codex": true, + "openclaw": true + }, + "recommended_commands": [ + "npm run lint", + "npm run test", + "npm run build" + ] + } + ] +} diff --git a/opencto/scripts/demo-opencto-e2e.sh b/opencto/scripts/demo-opencto-e2e.sh new file mode 100755 index 0000000..25f49be --- /dev/null +++ b/opencto/scripts/demo-opencto-e2e.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + demo-opencto-e2e.sh --repo-url [--workspace ] [--template ] [--skip-login] + +Examples: + ./opencto/scripts/demo-opencto-e2e.sh --repo-url https://github.com/org/repo + ./opencto/scripts/demo-opencto-e2e.sh --repo-url https://github.com/org/repo --workspace ws_hack --template "npm test" +EOF +} + +REPO_URL="" +WORKSPACE="${OPENCTO_WORKSPACE:-default}" +TEMPLATE_CMD="${OPENCTO_DEMO_TEMPLATE:-npm test}" +SKIP_LOGIN="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-url) + REPO_URL="${2:-}" + shift 2 + ;; + --workspace) + WORKSPACE="${2:-}" + shift 2 + ;; + --template) + TEMPLATE_CMD="${2:-}" + shift 2 + ;; + --skip-login) + SKIP_LOGIN="true" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${REPO_URL}" ]]; then + echo "Missing required --repo-url argument." >&2 + usage + exit 1 +fi + +if ! command -v opencto >/dev/null 2>&1; then + echo "opencto CLI not found. Install with: npm install -g @heysalad/opencto-cli@0.1.1" >&2 + exit 1 +fi + +echo "==> OpenCTO demo" +echo "workspace: ${WORKSPACE}" +echo "repo-url: ${REPO_URL}" +echo "template: ${TEMPLATE_CMD}" + +if [[ "${SKIP_LOGIN}" != "true" ]]; then + if [[ -t 0 ]]; then + echo "==> login" + opencto login --workspace "${WORKSPACE}" + else + echo "Skipping login (non-interactive shell). Pass --skip-login to suppress this message." + fi +fi + +echo "==> workflow list" +opencto workflow list --workspace "${WORKSPACE}" + +echo "==> workflow run custom" +opencto workflow run custom \ + --workspace "${WORKSPACE}" \ + --repo-url "${REPO_URL}" \ + --template "${TEMPLATE_CMD}" \ + --wait + +echo "Demo complete." diff --git a/opencto/scripts/install-opencto-skill.sh b/opencto/scripts/install-opencto-skill.sh new file mode 100755 index 0000000..9718cd4 --- /dev/null +++ b/opencto/scripts/install-opencto-skill.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'HELP' +Usage: + install-opencto-skill.sh --skill --dest + +Example: + ./opencto/scripts/install-opencto-skill.sh --skill cto-playbook --dest ~/.codex/skills +HELP +} + +SKILL_ID="" +DEST_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --skill) + SKILL_ID="${2:-}" + shift 2 + ;; + --dest) + DEST_DIR="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${SKILL_ID}" || -z "${DEST_DIR}" ]]; then + echo "--skill and --dest are required." >&2 + usage + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SOURCE_DIR="${REPO_ROOT}/opencto-skills/${SKILL_ID}" + +if [[ ! -f "${SOURCE_DIR}/SKILL.md" ]]; then + echo "Skill not found: ${SKILL_ID} (${SOURCE_DIR})" >&2 + exit 1 +fi + +if [[ "${DEST_DIR}" == ~* ]]; then + DEST_DIR="${HOME}${DEST_DIR#\~}" +fi + +TARGET_DIR="${DEST_DIR%/}/${SKILL_ID}" + +mkdir -p "${DEST_DIR}" +rm -rf "${TARGET_DIR}" +cp -R "${SOURCE_DIR}" "${TARGET_DIR}" + +echo "Installed skill '${SKILL_ID}' to ${TARGET_DIR}" +echo "Restart Codex/OpenClaw to pick up new skills." diff --git a/opencto/scripts/release-opencto-packages.sh b/opencto/scripts/release-opencto-packages.sh new file mode 100755 index 0000000..8b32ff5 --- /dev/null +++ b/opencto/scripts/release-opencto-packages.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_DIR="${ROOT_DIR}/opencto-sdk-js" +CLI_DIR="${ROOT_DIR}/opencto-cli" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +run_pkg_checks() { + local pkg_dir="$1" + local pkg_name="$2" + + echo "==> ${pkg_name}: lint" + (cd "${pkg_dir}" && npm run lint) + + echo "==> ${pkg_name}: test" + (cd "${pkg_dir}" && npm run test) + + echo "==> ${pkg_name}: build" + (cd "${pkg_dir}" && npm run build) +} + +check_pkg_clean() { + local rel_dir="$1" + if [[ -n "$(git -C "${ROOT_DIR}" status --short "${rel_dir}")" ]]; then + echo "Working tree not clean for ${rel_dir}. Commit or stash package changes first." >&2 + exit 1 + fi +} + +publish_pkg() { + local pkg_dir="$1" + local pkg_name="$2" + + echo "==> ${pkg_name}: npm publish" + (cd "${pkg_dir}" && npm publish --access public) +} + +require_cmd npm +require_cmd git + +check_pkg_clean "opencto-sdk-js" +check_pkg_clean "opencto-cli" + +echo "==> Checking npm authentication" +npm whoami >/dev/null +echo "npm auth confirmed" + +run_pkg_checks "${SDK_DIR}" "@heysalad/opencto" +publish_pkg "${SDK_DIR}" "@heysalad/opencto" + +run_pkg_checks "${CLI_DIR}" "@heysalad/opencto-cli" +publish_pkg "${CLI_DIR}" "@heysalad/opencto-cli" + +echo "Release complete."