diff --git a/.env.development.local.example b/.env.development.local.example index a246a52528..22b7796ad0 100644 --- a/.env.development.local.example +++ b/.env.development.local.example @@ -1,13 +1,38 @@ -# MAILGUN_API_KEY=key-... -# MAILGUN_DOMAIN=app.kilocode.ai -# NEVERBOUNCE_API_KEY=... +# @url cloud-agent-next +CLOUD_AGENT_API_URL=http://localhost:8794 -# Stripe integration -STRIPE_WEBHOOK_SECRET="...extract this from: stripe listen --forward-to http://localhost:3000/api/stripe/webhook" +# @url cloud-agent-next +CLOUD_AGENT_NEXT_API_URL=http://localhost:8794 -# DEV_SAVE_PROXY_STREAMS=true +# @url cloudflare-code-review-infra +CODE_REVIEW_WORKER_URL=http://localhost:8789 -# Cloud Agent API (local dev) -CLOUD_AGENT_API_URL=http://localhost:8788 -# Cloudflare webhook agent ingest (local dev) -WEBHOOK_AGENT_URL=http://0.0.0.0:8793 \ No newline at end of file +# @url cloudflare-auto-fix-infra +AUTO_FIX_URL=http://localhost:8792 + +# @url cloudflare-auto-triage-infra +AUTO_TRIAGE_URL=http://localhost:8791 + +# @url cloudflare-app-builder +APP_BUILDER_URL=http://localhost:8790 + +# @url kiloclaw +KILOCLAW_API_URL=http://localhost:8795 + +# @url cloudflare-session-ingest +SESSION_INGEST_WORKER_URL=http://localhost:8800 + +# @url cloudflare-deploy-builder +USER_DEPLOYMENTS_API_BASE_URL=http://localhost:8804 + +# @url cloudflare-deploy-dispatcher +USER_DEPLOYMENTS_DISPATCHER_URL=http://localhost:8799 + +# @url cloudflare-webhook-agent-ingest +WEBHOOK_AGENT_URL=http://localhost:8793 + +# @url cloud-agent-next +NEXT_PUBLIC_CLOUD_AGENT_WS_URL=ws://localhost:8794 + +# @url cloud-agent-next +NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL=ws://localhost:8794 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index df9e9f5100..c50d67dba5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -161,20 +161,23 @@ All tests should pass against the local PostgreSQL database. ## Common Development Commands -| Command | Description | -| ----------------------- | ------------------------------------------------------------------------------ | -| `pnpm dev` | Start the Next.js dev server (Turbopack) | -| `pnpm test` | Run the Jest test suite | -| `pnpm typecheck` | Run the TypeScript type checker | -| `pnpm lint` | Lint all source files | -| `pnpm lint:changed` | Lint only files changed since `main` | -| `pnpm format` | Format all supported files with oxfmt | -| `pnpm format:changed` | Format only files changed since `main` | -| `pnpm validate` | Run typecheck, lint changed, format changed, tests, and dependency cycle check | -| `pnpm drizzle migrate` | Apply pending database migrations | -| `pnpm drizzle generate` | Generate a new migration after schema changes | -| `pnpm stripe` | Start Stripe webhook forwarding to localhost | -| `pnpm test:e2e` | Run Playwright end-to-end tests | +| Command | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------- | +| `pnpm dev` | Start the Next.js dev server (Turbopack) | +| `pnpm dev:start` | Start all local services in a tmux dashboard | +| `pnpm dev:stop` | Stop the tmux session and all services | +| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) | +| `pnpm test` | Run the Jest test suite | +| `pnpm typecheck` | Run the TypeScript type checker | +| `pnpm lint` | Lint all source files | +| `pnpm lint:changed` | Lint only files changed since `main` | +| `pnpm format` | Format all supported files with oxfmt | +| `pnpm format:changed` | Format only files changed since `main` | +| `pnpm validate` | Run typecheck, lint changed, format changed, tests, and dependency cycle check | +| `pnpm drizzle migrate` | Apply pending database migrations | +| `pnpm drizzle generate` | Generate a new migration after schema changes | +| `pnpm stripe` | Start Stripe webhook forwarding to localhost | +| `pnpm test:e2e` | Run Playwright end-to-end tests | ## Git Workflow @@ -300,31 +303,67 @@ AI inference works locally without any extra services. The Next.js app includes ### Running workers locally -Each worker in the workspace can be started individually with `wrangler dev` (or `pnpm dev`) from its directory. Workers communicate with Next.js over HTTP using env vars like `CLOUD_AGENT_API_URL`, `CODE_REVIEW_WORKER_URL`, etc. +Each worker in the workspace can be started individually with `wrangler dev` (or `pnpm dev`) from its directory. Workers communicate with Next.js over HTTP using env vars like `CLOUD_AGENT_API_URL`, `CODE_REVIEW_WORKER_URL`, etc. Dev ports are defined in each worker's `wrangler.jsonc`. -| Worker | Dev Port | Env Var | What it does | -| --------------------------------- | -------- | --------------------------- | ------------------------------------------------------ | -| `cloud-agent` | 8788 | `CLOUD_AGENT_API_URL` | CLI agent orchestration (Durable Objects + Containers) | -| `cloud-agent-next` | 8794 | `CLOUD_AGENT_NEXT_API_URL` | Next-gen CLI agent orchestration | -| `cloudflare-session-ingest` | 8787 | `SESSION_INGEST_WORKER_URL` | Session data ingestion | -| `cloudflare-code-review-infra` | 8789 | `CODE_REVIEW_WORKER_URL` | Automated code reviews | -| `cloudflare-app-builder` | 8790 | `APP_BUILDER_URL` | App Builder sandbox | -| `cloudflare-auto-triage-infra` | 8791 | `AUTO_TRIAGE_URL` | Auto-triage for security findings | -| `cloudflare-auto-fix-infra` | 8792 | `AUTO_FIX_URL` | Auto-fix for security findings | -| `cloudflare-webhook-agent-ingest` | 8793 | `WEBHOOK_AGENT_URL` | Incoming webhook processing | -| `kiloclaw` | 8795 | `KILOCLAW_API_URL` | OpenClaw AI assistant (proxies to Fly.io) | +The easiest way to run workers is with `pnpm dev:start` (see [Common Development Commands](#common-development-commands)), which starts groups of related services in a tmux dashboard. + +### Worker `.dev.vars` setup + +Most workers require a `.dev.vars` file with secrets like `NEXTAUTH_SECRET` and `INTERNAL_API_SECRET`. A script automates this: + +```bash +pnpm dev:env +``` + +The script (`dev/local/env-sync/`) scans every `.dev.vars.example` in the repo, resolves each variable's value, and writes (or patches) the corresponding `.dev.vars` file. Before applying, it shows a diff of what will change and asks for confirmation. + +Values are resolved using annotations in `.dev.vars.example` comment lines: + +| Annotation | What it does | Example | +| ------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------- | +| _(none)_ | Copies the value from `.env.local` if the key matches, otherwise keeps the default | `INTERNAL_API_SECRET=your-secret-here` | +| `# @url ` | Builds `http://localhost:` from the service's dev port in `wrangler.jsonc` | `# @url nextjs` β†’ `http://localhost:3000` | +| `# @from ` | Copies the value of a _different_ key from `.env.local` | `# @from CODE_REVIEW_WORKER_AUTH_TOKEN` | +| `# @pkcs8` | Copies from `.env.local` and converts PKCS#1 PEM keys to PKCS#8 format | `# @pkcs8` above a private key var | + +For example, in a `.dev.vars.example`: + +```bash +# @url nextjs +API_URL=http://localhost:3000 + +# @from CODE_REVIEW_WORKER_AUTH_TOKEN +BACKEND_AUTH_TOKEN=your-backend-auth-token +``` + +The `@url` annotation accepts multiple comma-separated services (e.g., `# @url svc-a,svc-b`) and appends path suffixes (e.g., `# @url nextjs/api/events`). + +Run `pnpm dev:env` again after pulling changes that add new env vars to any `.dev.vars.example`. ### Limitations in local dev - **Service bindings** between workers don't function in local `wrangler dev`. This affects chains like session-ingest β†’ o11y, webhook-agent β†’ cloud-agent, and app-builder β†’ db-proxy/git-token-service. - **Cloudflare Containers** (used by cloud-agent, cloud-agent-next, app-builder) always run on Cloudflare's remote infrastructure, even in dev mode. Purely local execution is not possible. - **Cloudflare-specific features** like Analytics Engine, Pipelines, and dispatch namespaces don't work locally. -- Most workers require a `.dev.vars` file (created from `.dev.vars.example` in each worker directory) with secrets like `NEXTAUTH_SECRET` and `INTERNAL_API_SECRET`. ### What works without running any workers The core Next.js app handles profiles, organizations, usage tracking, billing, and the OpenRouter inference proxy without any workers. Features that require a specific worker (e.g., Cloud Agent sessions, code reviews, app builder) will fail gracefully or show connection errors if that worker isn't running. +### Multi-worktree support + +If you use `git worktree` to run multiple checkouts simultaneously, set the `KILO_PORT_OFFSET` environment variable to avoid port collisions between worktrees: + +```bash +# Automatic offset derived from the worktree directory name (0 for the primary worktree): +export KILO_PORT_OFFSET=auto + +# Or a fixed numeric offset (added to every service port): +export KILO_PORT_OFFSET=100 +``` + +With `auto`, the primary worktree gets offset 0 (default ports), and secondary worktrees get a deterministic offset based on the directory name. The offset is added to the Next.js port (3000), all worker dev ports, and the URLs generated by `pnpm dev:env`. + ## Troubleshooting ### Node version mismatch diff --git a/cloud-agent-next/.dev.vars.example b/cloud-agent-next/.dev.vars.example index c1c3ec1dde..a0266394a5 100644 --- a/cloud-agent-next/.dev.vars.example +++ b/cloud-agent-next/.dev.vars.example @@ -10,7 +10,7 @@ INTERNAL_API_SECRET=your-internal-api-secret-here # Affects: # - Session environment variables (KILOCODE_TOKEN and KILOCODE_ORG_ID) # -# Note: With the introduction of server backed sessionsthese should probably not be used any more, +# Note: With the introduction of server-backed sessions, these should probably not be used any more, # you should set KILOCODE_BACKEND_BASE_URL instead and use local dev only. #KILOCODE_TOKEN_OVERRIDE=your-override-token-here #KILOCODE_ORG_ID_OVERRIDE=your-override-org-id-here @@ -19,15 +19,18 @@ INTERNAL_API_SECRET=your-internal-api-secret-here # For local development, point to your local kilocode-backend # Note: you wanna use your actual privatenet address here and not "localhost" # pnpm run dev of kilocode-backend usually gives you both addrs on boot. -KILOCODE_BACKEND_BASE_URL=http://192.168.200.70:3000 -KILO_OPENROUTER_BASE=http://192.168.200.70:3000/api +# @url nextjs +KILOCODE_BACKEND_BASE_URL=http://192.168.x.x:3000 +# @url nextjs/api +KILO_OPENROUTER_BASE=http://192.168.x.x:3000/api # Worker base URL used by the wrapper to connect to /ingest. # Use a host-reachable IP (not localhost) so sandbox containers can connect back. -WORKER_URL=http://192.168.200.72:8794 +# @url cloud-agent-next +WORKER_URL=http://192.168.x.x:8794 # Timeout overrides (optional) -CLI_TIMEOUT_SECONDS=700 +CLI_TIMEOUT_SECONDS=900 REAPER_INTERVAL_MS=300000 STALE_THRESHOLD_MS=600000 PENDING_START_TIMEOUT_MS=300000 @@ -46,11 +49,13 @@ GITHUB_APP_BOT_USER_ID=242397087 # GITHUB_APP_PRIVATE_KEY: The raw PKCS#8 private key (use \n for newlines in env var) # Note that the nextjs app uses PKCS#1 but the worker uses WebCrypto and requires a PKCS#8 GITHUB_APP_ID=2245043 +# @pkcs8 GITHUB_APP_PRIVATE_KEY= # GitHub Lite App credentials (for OSS organizations with read-only permissions) # Same format as standard app credentials above GITHUB_LITE_APP_ID= +# @pkcs8 GITHUB_LITE_APP_PRIVATE_KEY= # Agent Environment Profile Secrets Decryption @@ -68,4 +73,5 @@ R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID="" R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY="" # Local dev worker origins allowed to connect to /stream +# @url nextjs,cloud-agent-next WS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8794 diff --git a/cloud-agent/.dev.vars.example b/cloud-agent/.dev.vars.example index e89e8cb296..d6ea9f47d0 100644 --- a/cloud-agent/.dev.vars.example +++ b/cloud-agent/.dev.vars.example @@ -10,7 +10,7 @@ INTERNAL_API_SECRET=your-internal-api-secret-here # Affects: # - Session environment variables (KILOCODE_TOKEN and KILOCODE_ORG_ID) # -# Note: With the introduction of server backed sessionsthese should probably not be used any more, +# Note: With the introduction of server-backed sessions, these should probably not be used any more, # you should set KILOCODE_BACKEND_BASE_URL instead and use local dev only. #KILOCODE_TOKEN_OVERRIDE=your-override-token-here #KILOCODE_ORG_ID_OVERRIDE=your-override-org-id-here @@ -21,10 +21,12 @@ INTERNAL_API_SECRET=your-internal-api-secret-here # For local development, point to your local kilocode-backend # Note: you wanna use your actual privatenet address here and not "localhost" # pnpm run dev of kilocode-backend usually gives you both addrs on boot. +# @url nextjs KILOCODE_BACKEND_BASE_URL=http://192.168.200.70:3000 # Worker base URL used by the wrapper to connect to /ingest. # Use a host-reachable IP (not localhost) so sandbox containers can connect back. +# @url cloud-agent WORKER_URL=http://192.168.200.72:8788 # Timeout overrides (optional) @@ -48,11 +50,13 @@ GITHUB_APP_BOT_USER_ID=242397087 # GITHUB_APP_PRIVATE_KEY: The raw PKCS#8 private key (use \n for newlines in env var) # Note that the nextjs app uses PKCS#1 but the worker uses WebCrypto and requires a PKCS#8 GITHUB_APP_ID=2245043 +# @pkcs8 GITHUB_APP_PRIVATE_KEY= # GitHub Lite App credentials (for OSS organizations with read-only permissions) # Same format as standard app credentials above GITHUB_LITE_APP_ID= +# @pkcs8 GITHUB_LITE_APP_PRIVATE_KEY= # Agent Environment Profile Secrets Decryption @@ -70,4 +74,5 @@ R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID="" R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY="" # Local dev worker origins allowed to connect to /stream +# @url nextjs,cloud-agent WS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8788 diff --git a/cloudflare-ai-attribution/wrangler.jsonc b/cloudflare-ai-attribution/wrangler.jsonc index 58ab7e7ecf..558fdf741a 100644 --- a/cloudflare-ai-attribution/wrangler.jsonc +++ b/cloudflare-ai-attribution/wrangler.jsonc @@ -6,6 +6,7 @@ "main": "src/ai-attribution.worker.ts", "compatibility_date": "2024-12-01", "compatibility_flags": ["nodejs_compat"], + "dev": { "port": 8787 }, "logpush": true, "observability": { "enabled": true, diff --git a/cloudflare-app-builder/.dev.vars.example b/cloudflare-app-builder/.dev.vars.example index bb311a1674..47c6ae7a42 100644 --- a/cloudflare-app-builder/.dev.vars.example +++ b/cloudflare-app-builder/.dev.vars.example @@ -2,17 +2,24 @@ # Copy this file to .dev.vars and update with your actual values # Authentication token for REST API endpoints (init, preview status, build triggers, logs) -AUTH_TOKEN=dev-token-change-this-in-production +# @from APP_BUILDER_AUTH_TOKEN +AUTH_TOKEN= + +# Allowed CORS origins (comma-separated) +# @url nextjs +ALLOWED_ORIGINS=http://localhost:3000 # Base hostname for preview deployments (e.g., builder.yourdomain.com or your-subdomain.ngrok-free.dev) # Preview URLs will be: https://{appId}.{BUILDER_HOSTNAME} # Can use [your-lan-ip].nip.io to support subdomains on local ip # For example 192-168-0-1.nip.io will resolve to 192.168.0.1 # This needs to be a lan address because Cloud Agent will need to push changes to git repo -BUILDER_HOSTNAME=192-168-0-1.nip.io +# @url cloudflare-app-builder +BUILDER_HOSTNAME=localhost:8790 # Backend push notification (optional - enables auto-build on push) -BACKEND_PUSH_NOTIFICATION_URL=http://192.168.0.1:3000/api/app-builder/push-notification +# @url nextjs/api/app-builder/push-notification +BACKEND_PUSH_NOTIFICATION_URL=http://localhost:3000/api/app-builder/push-notification # JWT secret for generating git authentication tokens (must be at least 32 bytes) # Generate with: openssl rand -base64 32 @@ -23,5 +30,6 @@ GIT_JWT_SECRET=your-secret-key-at-least-32-bytes-long # In dev mode previews are accessed using regular app builder url DEV_MODE=true -# DB Proxy Worker -DB_PROXY_URL=http://192.168.1.1:8792 \ No newline at end of file +# URL of the DB proxy worker for database provisioning +# @url cloudflare-db-proxy +DB_PROXY_URL=http://localhost:8798 \ No newline at end of file diff --git a/cloudflare-app-builder/start-dev.sh b/cloudflare-app-builder/start-dev.sh index 599ce76fa5..51b1988ee1 100755 --- a/cloudflare-app-builder/start-dev.sh +++ b/cloudflare-app-builder/start-dev.sh @@ -4,13 +4,13 @@ # Uses tmux to create a split terminal with all services running # # Services started: -# - cloudflare-db-proxy (port 8792) -# - cloudflare-session-ingest (port 8787) +# - cloudflare-db-proxy (port 8798) +# - cloudflare-session-ingest (port 8800) # - cloud-agent (port 8788) # - cloud-agent-next (port 8794) -# - cloudflare-git-token-service (port 8795) +# - cloudflare-git-token-service (port 8802) # - cloudflare-app-builder (port 8790) -# - cloudflare-images-mcp (port 8796) +# - cloudflare-images-mcp (port 8805) # - cloudflare-webhook-agent-ingest (port 8793) # - ngrok (forwarding to port 8790) # @@ -102,29 +102,29 @@ tmux set-option -t "$SESSION_NAME" pane-border-format " #{pane_index}: #{pane_ti tmux set-option -t "$SESSION_NAME" allow-set-title off # Pane 0 (top-left): cloudflare-db-proxy -tmux select-pane -t "$SESSION_NAME:services.0" -T "db-proxy (8792)" +tmux select-pane -t "$SESSION_NAME:services.0" -T "db-proxy (8798)" # Using different inspector ports to avoid conflicts (default is 9229) -tmux send-keys -t "$SESSION_NAME:services.0" "cd $PROJECT_ROOT/cloudflare-db-proxy && echo 'πŸ—„οΈ Starting cloudflare-db-proxy (port 8792)...' && pnpm exec wrangler dev --inspector-port 9230" C-m +tmux send-keys -t "$SESSION_NAME:services.0" "cd $PROJECT_ROOT/cloudflare-db-proxy && echo 'πŸ—„οΈ Starting cloudflare-db-proxy (port 8798)...' && pnpm exec wrangler dev --inspector-port 9230" C-m # Pane 1: cloudflare-session-ingest -tmux select-pane -t "$SESSION_NAME:services.1" -T "session-ingest (8787)" -tmux send-keys -t "$SESSION_NAME:services.1" "cd $PROJECT_ROOT/cloudflare-session-ingest && echo 'πŸ“₯ Starting cloudflare-session-ingest (port 8787)...' && pnpm exec wrangler dev --inspector-port 9233" C-m +tmux select-pane -t "$SESSION_NAME:services.1" -T "session-ingest (8800)" +tmux send-keys -t "$SESSION_NAME:services.1" "cd $PROJECT_ROOT/cloudflare-session-ingest && echo 'πŸ“₯ Starting cloudflare-session-ingest (port 8800)...' && pnpm exec wrangler dev --inspector-port 9233" C-m # Pane 2: cloud-agent tmux select-pane -t "$SESSION_NAME:services.2" -T "cloud-agent (8788)" tmux send-keys -t "$SESSION_NAME:services.2" "cd $PROJECT_ROOT/cloud-agent && echo 'πŸ€– Starting cloud-agent (port 8788)...' && pnpm exec wrangler dev --inspector-port 9231" C-m # Pane 3: cloudflare-images-mcp -tmux select-pane -t "$SESSION_NAME:services.3" -T "images-mcp (8796)" -tmux send-keys -t "$SESSION_NAME:services.3" "cd $PROJECT_ROOT/cloudflare-images-mcp && echo 'πŸ–ΌοΈ Starting cloudflare-images-mcp (port 8796)...' && pnpm exec wrangler dev --env dev --inspector-port 9236" C-m +tmux select-pane -t "$SESSION_NAME:services.3" -T "images-mcp (8805)" +tmux send-keys -t "$SESSION_NAME:services.3" "cd $PROJECT_ROOT/cloudflare-images-mcp && echo 'πŸ–ΌοΈ Starting cloudflare-images-mcp (port 8805)...' && pnpm exec wrangler dev --env dev --inspector-port 9236" C-m # Pane 4: cloudflare-webhook-agent-ingest tmux select-pane -t "$SESSION_NAME:services.4" -T "webhook-ingest (8793)" tmux send-keys -t "$SESSION_NAME:services.4" "cd $PROJECT_ROOT/cloudflare-webhook-agent-ingest && echo 'πŸͺ Starting cloudflare-webhook-agent-ingest (port 8793)...' && pnpm exec wrangler dev --env dev --inspector-port 9237" C-m # Pane 5: cloudflare-git-token-service -tmux select-pane -t "$SESSION_NAME:services.5" -T "git-token-service (8795)" -tmux send-keys -t "$SESSION_NAME:services.5" "cd $PROJECT_ROOT/cloudflare-git-token-service && echo 'πŸ”‘ Starting cloudflare-git-token-service (port 8795)...' && pnpm exec wrangler dev --inspector-port 9235" C-m +tmux select-pane -t "$SESSION_NAME:services.5" -T "git-token-service (8802)" +tmux send-keys -t "$SESSION_NAME:services.5" "cd $PROJECT_ROOT/cloudflare-git-token-service && echo 'πŸ”‘ Starting cloudflare-git-token-service (port 8802)...' && pnpm exec wrangler dev --inspector-port 9235" C-m # Pane 6: cloudflare-app-builder tmux select-pane -t "$SESSION_NAME:services.6" -T "app-builder (8790)" @@ -146,13 +146,13 @@ echo "╔═══════════════════════ echo "β•‘ App Builder Dev Environment Started! πŸš€ β•‘" echo "╠══════════════════════════════════════════════════════════════════╣" echo "β•‘ Services: β•‘" -echo "β•‘ β€’ cloudflare-db-proxy β†’ http://localhost:8792 β•‘" -echo "β•‘ β€’ cloudflare-session-ingest β†’ http://localhost:8787 β•‘" +echo "β•‘ β€’ cloudflare-db-proxy β†’ http://localhost:8798 β•‘" +echo "β•‘ β€’ cloudflare-session-ingest β†’ http://localhost:8800 β•‘" echo "β•‘ β€’ cloud-agent β†’ http://localhost:8788 β•‘" echo "β•‘ β€’ cloud-agent-next β†’ http://localhost:8794 β•‘" -echo "β•‘ β€’ git-token-service β†’ http://localhost:8795 β•‘" +echo "β•‘ β€’ git-token-service β†’ http://localhost:8802 β•‘" echo "β•‘ β€’ cloudflare-app-builder β†’ http://localhost:8790 β•‘" -echo "β•‘ β€’ cloudflare-images-mcp β†’ http://localhost:8796 β•‘" +echo "β•‘ β€’ cloudflare-images-mcp β†’ http://localhost:8805 β•‘" echo "β•‘ β€’ webhook-agent-ingest β†’ http://localhost:8793 β•‘" echo "β•‘ β€’ ngrok β†’ forwarding to :8790 β•‘" echo "╠══════════════════════════════════════════════════════════════════╣" diff --git a/cloudflare-auto-fix-infra/.dev.vars.example b/cloudflare-auto-fix-infra/.dev.vars.example index dc09569bd9..39c6a16692 100644 --- a/cloudflare-auto-fix-infra/.dev.vars.example +++ b/cloudflare-auto-fix-infra/.dev.vars.example @@ -1,13 +1,16 @@ # Backend API URL for status callbacks (configured in worker env, not passed in payload) -API_URL=http://127.0.0.1:3000 +# @url nextjs +API_URL=http://localhost:3000 # Internal API secret for authentication (shared with backend) INTERNAL_API_SECRET=your-secret-here # Backend auth token for authenticating incoming requests from backend +# @from CODE_REVIEW_WORKER_AUTH_TOKEN BACKEND_AUTH_TOKEN=your-backend-auth-token # Cloud agent URL for auto-fix execution # For local development: use http://127.0.0.1:8794 # For production: use https://cloud-agent-next.kilosessions.ai -CLOUD_AGENT_URL=http://127.0.0.1:8794 +# @url cloud-agent-next +CLOUD_AGENT_URL=http://localhost:8794 diff --git a/cloudflare-auto-triage-infra/.dev.vars.example b/cloudflare-auto-triage-infra/.dev.vars.example index c2a92ed6fe..c501486118 100644 --- a/cloudflare-auto-triage-infra/.dev.vars.example +++ b/cloudflare-auto-triage-infra/.dev.vars.example @@ -1,13 +1,14 @@ # Backend API URL for status callbacks (configured in worker env, not passed in payload) -API_URL=http://127.0.0.1:3000 +# @url nextjs +API_URL=http://localhost:3000 # Internal API secret for authentication (shared with backend) INTERNAL_API_SECRET=your-secret-here # Backend auth token for authenticating incoming requests from backend +# @from CODE_REVIEW_WORKER_AUTH_TOKEN BACKEND_AUTH_TOKEN=your-backend-auth-token # Cloud agent URL for triage execution -# For local development: use http://127.0.0.1:8788 -# For production: use https://cloud-agent.kilosessions.ai -CLOUD_AGENT_URL=http://127.0.0.1:8788 \ No newline at end of file +# @url cloud-agent-next +CLOUD_AGENT_URL=http://localhost:8794 \ No newline at end of file diff --git a/cloudflare-code-review-infra/.dev.vars.example b/cloudflare-code-review-infra/.dev.vars.example index 16f955986a..820f521df1 100644 --- a/cloudflare-code-review-infra/.dev.vars.example +++ b/cloudflare-code-review-infra/.dev.vars.example @@ -1,11 +1,18 @@ # Backend API URL for status callbacks (configured in worker env, not passed in payload) +# @url nextjs API_URL=http://localhost:3000 # Internal API secret for authentication (shared with backend) INTERNAL_API_SECRET=your-secret-here # Backend auth token for authenticating incoming requests from backend +# @from CODE_REVIEW_WORKER_AUTH_TOKEN BACKEND_AUTH_TOKEN=your-backend-auth-token # Cloud agent URL for code review execution -CLOUD_AGENT_URL=https://agent.kilocode.com +# @url cloud-agent-next +CLOUD_AGENT_URL=http://localhost:8794 + +# Cloud agent (next) URL for v2 code review execution +# @url cloud-agent-next +CLOUD_AGENT_NEXT_URL=http://localhost:8794 diff --git a/cloudflare-db-proxy/.dev.vars.example b/cloudflare-db-proxy/.dev.vars.example index 44c65ffe0b..929dc9682d 100644 --- a/cloudflare-db-proxy/.dev.vars.example +++ b/cloudflare-db-proxy/.dev.vars.example @@ -1 +1,3 @@ +# Admin authentication token for the DB proxy API +# @from APP_BUILDER_DB_PROXY_AUTH_TOKEN DB_PROXY_ADMIN_TOKEN=example-token \ No newline at end of file diff --git a/cloudflare-db-proxy/wrangler.jsonc b/cloudflare-db-proxy/wrangler.jsonc index 6cea4d3107..8816d07d19 100644 --- a/cloudflare-db-proxy/wrangler.jsonc +++ b/cloudflare-db-proxy/wrangler.jsonc @@ -5,7 +5,7 @@ "compatibility_date": "2025-09-01", "compatibility_flags": ["nodejs_compat"], "dev": { - "port": 8792, + "port": 8798, "local_protocol": "http", "ip": "0.0.0.0", }, diff --git a/cloudflare-deploy-infra/builder/.dev.vars.example b/cloudflare-deploy-infra/builder/.dev.vars.example index 874a936c0d..a313641f01 100644 --- a/cloudflare-deploy-infra/builder/.dev.vars.example +++ b/cloudflare-deploy-infra/builder/.dev.vars.example @@ -3,11 +3,13 @@ CLOUDFLARE_API_TOKEN="321" # Auth token that Backend uses to make API calls to the builder # Same as USER_DEPLOYMENTS_API_AUTH_KEY env var in the backend -BACKEND_AUTH_TOKEN="555" +# @from USER_DEPLOYMENTS_API_AUTH_KEY +BACKEND_AUTH_TOKEN= # Backend webhook delivery configuration # URL endpoint where build events will be sent (REQUIRED) -BACKEND_EVENTS_URL="http://192.168.100.100:3000/api/user-deployments/webhook" +# @url nextjs/api/user-deployments/webhook +BACKEND_EVENTS_URL=http://localhost:3000/api/user-deployments/webhook # Maximum number of events to batch before sending BACKEND_WEBHOOK_BATCH_MAX_EVENTS="100" diff --git a/cloudflare-deploy-infra/builder/wrangler.jsonc b/cloudflare-deploy-infra/builder/wrangler.jsonc index e40633324b..4110b4669b 100644 --- a/cloudflare-deploy-infra/builder/wrangler.jsonc +++ b/cloudflare-deploy-infra/builder/wrangler.jsonc @@ -10,7 +10,7 @@ "compatibility_date": "2025-10-11", "compatibility_flags": ["nodejs_compat"], "dev": { - "port": 8787, + "port": 8804, "local_protocol": "http", "ip": "0.0.0.0", }, diff --git a/cloudflare-deploy-infra/dispatcher/.dev.vars.example b/cloudflare-deploy-infra/dispatcher/.dev.vars.example index 6774dc4f52..a76c8b34a7 100644 --- a/cloudflare-deploy-infra/dispatcher/.dev.vars.example +++ b/cloudflare-deploy-infra/dispatcher/.dev.vars.example @@ -5,18 +5,10 @@ # Generate with: openssl rand -base64 32 JWT_SECRET=your-secret-key-at-least-32-bytes-long -# Session duration in seconds for JWT tokens and auth cookies (optional) -# Default: 604800 (7 days) +# Session duration in seconds for JWT tokens (optional, configured in wrangler.jsonc) SESSION_DURATION_SECONDS=604800 -# Rate limit window in seconds (optional) -# Default: 900 (15 minutes) -RATE_LIMIT_WINDOW_SECONDS=900 - -# Maximum failed login attempts before blocking (optional) -# Default: 5 -RATE_LIMIT_MAX_ATTEMPTS=5 - # Backend authentication token for Management API endpoints (required) # Used by kilocode-backend to authenticate requests to /api/password/:worker -BACKEND_AUTH_TOKEN=dev-token-change-this-in-production +# @from USER_DEPLOYMENTS_API_AUTH_KEY +BACKEND_AUTH_TOKEN= diff --git a/cloudflare-deploy-infra/dispatcher/wrangler.jsonc b/cloudflare-deploy-infra/dispatcher/wrangler.jsonc index 94ec1c5de4..c69d222d86 100644 --- a/cloudflare-deploy-infra/dispatcher/wrangler.jsonc +++ b/cloudflare-deploy-infra/dispatcher/wrangler.jsonc @@ -9,6 +9,7 @@ "main": "src/index.ts", "compatibility_date": "2025-10-11", "compatibility_flags": ["nodejs_compat"], + "dev": { "port": 8799 }, /** * Single KV namespace for all deployment data. * Keys are prefixed: "password:" for auth, "slug2worker:" and "worker2slug:" for slug mappings. diff --git a/cloudflare-gastown/wrangler.jsonc b/cloudflare-gastown/wrangler.jsonc index e2525d2059..440cf83676 100644 --- a/cloudflare-gastown/wrangler.jsonc +++ b/cloudflare-gastown/wrangler.jsonc @@ -4,6 +4,7 @@ "main": "src/gastown.worker.ts", "compatibility_date": "2026-02-24", "compatibility_flags": ["nodejs_compat"], + "dev": { "port": 8803 }, "placement": { "mode": "smart" }, "observability": { "enabled": true, diff --git a/cloudflare-git-token-service/.dev.vars.example b/cloudflare-git-token-service/.dev.vars.example index 156c5af128..0539feb43f 100644 --- a/cloudflare-git-token-service/.dev.vars.example +++ b/cloudflare-git-token-service/.dev.vars.example @@ -2,9 +2,15 @@ # GITHUB_APP_PRIVATE_KEY: The PEM-encoded private key (base64 encoded for env var storage) GITHUB_APP_ID= +# @pkcs8 GITHUB_APP_PRIVATE_KEY= # GitHub Lite App credentials (for OSS organizations with read-only permissions) # Same format as standard app credentials above GITHUB_LITE_APP_ID= -GITHUB_LITE_APP_PRIVATE_KEY= \ No newline at end of file +# @pkcs8 +GITHUB_LITE_APP_PRIVATE_KEY= + +# GitLab App credentials (optional, used as fallback when metadata lacks client credentials) +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= \ No newline at end of file diff --git a/cloudflare-git-token-service/wrangler.jsonc b/cloudflare-git-token-service/wrangler.jsonc index b13504d998..6c53ce1c5e 100644 --- a/cloudflare-git-token-service/wrangler.jsonc +++ b/cloudflare-git-token-service/wrangler.jsonc @@ -25,7 +25,7 @@ }, ], "dev": { - "port": 8795, + "port": 8802, }, "env": { "dev": { diff --git a/cloudflare-gmail-push/wrangler.jsonc b/cloudflare-gmail-push/wrangler.jsonc index 47cbbd8e74..132eb382fd 100644 --- a/cloudflare-gmail-push/wrangler.jsonc +++ b/cloudflare-gmail-push/wrangler.jsonc @@ -4,6 +4,7 @@ "main": "src/index.ts", "compatibility_date": "2025-03-10", "compatibility_flags": ["nodejs_compat"], + "dev": { "port": 8806 }, "observability": { "enabled": true }, "placement": { "mode": "smart" }, "routes": [ diff --git a/cloudflare-images-mcp/wrangler.jsonc b/cloudflare-images-mcp/wrangler.jsonc index 61e92bce2d..b49d20647e 100644 --- a/cloudflare-images-mcp/wrangler.jsonc +++ b/cloudflare-images-mcp/wrangler.jsonc @@ -6,7 +6,7 @@ "compatibility_flags": ["nodejs_compat"], "workers_dev": true, "dev": { - "port": 8796, + "port": 8805, "local_protocol": "http", "ip": "0.0.0.0", }, diff --git a/cloudflare-o11y/wrangler.jsonc b/cloudflare-o11y/wrangler.jsonc index e64263b735..2b53e19699 100644 --- a/cloudflare-o11y/wrangler.jsonc +++ b/cloudflare-o11y/wrangler.jsonc @@ -13,6 +13,7 @@ }, "logpush": true, "compatibility_flags": ["nodejs_compat"], + "dev": { "port": 8801 }, "rules": [{ "type": "Text", "globs": ["**/*.sql"], "fallthrough": true }], /** * Smart Placement diff --git a/cloudflare-session-ingest/wrangler.jsonc b/cloudflare-session-ingest/wrangler.jsonc index 231cec20c2..365e35c91f 100644 --- a/cloudflare-session-ingest/wrangler.jsonc +++ b/cloudflare-session-ingest/wrangler.jsonc @@ -6,7 +6,7 @@ "compatibility_date": "2026-01-27", "compatibility_flags": ["nodejs_compat"], "dev": { - "port": 8787, + "port": 8800, "local_protocol": "http", "ip": "0.0.0.0", }, diff --git a/cloudflare-webhook-agent-ingest/.dev.vars.example b/cloudflare-webhook-agent-ingest/.dev.vars.example index c7abc8f2d3..7c92605b70 100644 --- a/cloudflare-webhook-agent-ingest/.dev.vars.example +++ b/cloudflare-webhook-agent-ingest/.dev.vars.example @@ -1,2 +1,10 @@ +# Shared secrets (required for local dev β€” secrets store not available in wrangler dev) +NEXTAUTH_SECRET= +INTERNAL_API_SECRET= + +# Worker environment +ENVIRONMENT=development + # Set your host/IP for local development callback URL -WEBHOOK_AGENT_URL=http://192.168.200.174:8793 +# @url cloudflare-webhook-agent-ingest +WEBHOOK_AGENT_URL=http://localhost:8793 diff --git a/dev/auto-fix/dev-auto-fix.sh b/dev/auto-fix/dev-auto-fix.sh index 0f58b6d6cd..77eedd6c98 100755 --- a/dev/auto-fix/dev-auto-fix.sh +++ b/dev/auto-fix/dev-auto-fix.sh @@ -7,7 +7,7 @@ set -uo pipefail # # Services: # 1. Root (Next.js) β€” port 3000 -# 2. Session Worker β€” port 8787 (inspector 9230) +# 2. Session Worker β€” port 8800 (inspector 9230) # 3. Auto Fix Worker β€” port 8792 (inspector 9231) # 4. Agent Next Worker β€” port 8794 (inspector 9232) # diff --git a/dev/local/cli.ts b/dev/local/cli.ts new file mode 100644 index 0000000000..58abcaade2 --- /dev/null +++ b/dev/local/cli.ts @@ -0,0 +1,341 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + resolveTargets, + getService, + getGroups, + getAlwaysOnGroupIds, + getGroupServiceNames, + resolveGroups, + topologicalSort, + portOffset, +} from './services'; +import { syncEnvVars } from './env-sync'; +import { + getSessionName, + sessionExists, + findOtherKiloDevSessions, + createSession, + killSession, + attachSession, + sendKeys, + selectWindow, + listWindows, + splitWindowHorizontal, + setMainLeftLayout, + joinPane, + selectPane, + setPaneTitle, + enablePaneBorders, + isTmuxAvailable, +} from './tmux'; +import { + findRepoRoot, + startServiceInTmux, + startInfra, + readEnvValue, + waitForEnvValueChange, +} from './runner'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function determineEnabledGroups(serviceNames: string[]): string[] { + const nameSet = new Set(serviceNames); + const enabled: string[] = []; + for (const group of getGroups()) { + const members = getGroupServiceNames(group.id); + if (members.length > 0 && members.every(m => nameSet.has(m))) { + enabled.push(group.id); + } + } + return enabled; +} + +// --------------------------------------------------------------------------- +// ANSI helpers +// --------------------------------------------------------------------------- + +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const GREEN = '\x1b[32m'; + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function cmdUp(targets: string[], repoRoot: string): Promise { + // --- Preflight checks --- + if (!isTmuxAvailable()) { + console.error('tmux is not installed. Install it with: brew install tmux'); + process.exit(1); + } + + try { + execSync('docker info', { stdio: 'ignore' }); + } catch { + console.error('Docker is not running. Start Docker Desktop and try again.'); + process.exit(1); + } + + if (!fs.existsSync(path.join(repoRoot, 'node_modules'))) { + console.error('node_modules not found. Run: pnpm install'); + process.exit(1); + } + + const envLocalExists = fs.existsSync(path.join(repoRoot, '.env.local')); + if (!envLocalExists) { + console.warn('⚠ .env.local not found β€” worker secrets will use defaults.'); + console.warn(' To sync from Vercel: vercel env pull .env.local'); + } + + // --- Export port offset for child processes (e.g. scripts/dev.sh) --- + process.env.KILO_PORT_OFFSET = String(portOffset); + + const otherSessions = findOtherKiloDevSessions(); + if (otherSessions.length > 0) { + console.warn(`⚠ Other kilo-dev sessions are running: ${otherSessions.join(', ')}`); + if (portOffset > 0) { + console.warn(` This worktree uses port offset ${portOffset}`); + } else { + console.warn( + ' Port conflicts are likely. Set KILO_PORT_OFFSET=auto or stop other sessions.' + ); + } + } + + if (portOffset > 0) { + console.log(`${DIM}Port offset: ${portOffset} (KILO_PORT_OFFSET)${RESET}`); + } + + // --- Check for existing session --- + const sessionName = getSessionName(); + if (sessionExists(sessionName)) { + console.log(`Session ${sessionName} already running β€” attaching.`); + attachSession(sessionName); + return; + } + + // --- Resolve targets --- + // Always start core (always-on) groups; additional targets are merged in + const coreServices = resolveGroups(getAlwaysOnGroupIds()); + const extraServices = targets.length === 0 ? [] : resolveTargets(targets); + const serviceNames = topologicalSort([...new Set([...coreServices, ...extraServices])]); + + // --- Start Docker infra --- + const hasInfra = serviceNames.some(name => getService(name).type === 'infra'); + if (hasInfra) { + console.log(`${BOLD}Starting infrastructure…${RESET}`); + await startInfra(repoRoot, serviceNames); + console.log(); + } + + // --- Create tmux session --- + createSession(sessionName); + + // --- Start each service in its own tmux window --- + const SIDEBAR_WIDTH = 40; + + // --- Start capture services first (tunnel, stripe) and wait for output --- + const captureServiceSet = new Set(['kiloclaw-tunnel', 'kiloclaw-stripe']); + const captureServices = serviceNames.filter(n => captureServiceSet.has(n)); + const otherServices = serviceNames.filter(n => !captureServiceSet.has(n)); + + if (captureServices.length > 0) { + const oldValues = new Map(); + if (captureServices.includes('kiloclaw-tunnel')) { + oldValues.set( + 'tunnel', + readEnvValue(path.join(repoRoot, 'kiloclaw/.dev.vars'), 'KILOCODE_API_BASE_URL') + ); + } + if (captureServices.includes('kiloclaw-stripe')) { + oldValues.set( + 'stripe', + readEnvValue(path.join(repoRoot, '.env.development.local'), 'STRIPE_WEBHOOK_SECRET') + ); + } + + for (const name of captureServices) { + startServiceInTmux(sessionName, name); + await sleep(300); + } + + console.log(`${BOLD}Waiting for capture services...${RESET}`); + const waits: Promise[] = []; + + if (captureServices.includes('kiloclaw-tunnel')) { + waits.push( + waitForEnvValueChange( + path.join(repoRoot, 'kiloclaw/.dev.vars'), + 'KILOCODE_API_BASE_URL', + oldValues.get('tunnel'), + 30_000 + ).then(ready => { + if (ready) { + console.log(' Tunnel URL captured'); + } else { + console.warn(' Tunnel URL not captured after 30s - check kiloclaw-tunnel window'); + } + }) + ); + } + + if (captureServices.includes('kiloclaw-stripe')) { + waits.push( + waitForEnvValueChange( + path.join(repoRoot, '.env.development.local'), + 'STRIPE_WEBHOOK_SECRET', + oldValues.get('stripe'), + 30_000 + ).then(ready => { + if (ready) { + console.log(' Stripe webhook secret captured'); + } else { + console.warn(' Stripe secret not captured after 30s - check kiloclaw-stripe window'); + } + }) + ); + } + + await Promise.all(waits); + console.log(); + } + + for (const name of otherServices) { + startServiceInTmux(sessionName, name); + await sleep(300); + } + + // --- Set up split layout in window 0: left=sidebar, right=service terminal --- + // Join the preferred service's pane into window 0 as pane 1 (right column). + // join-pane moves the pane process β€” no ghost shells. + let initialViewedService = ''; + if (serviceNames.length > 0) { + const preferred = serviceNames.includes('nextjs') ? 'nextjs' : serviceNames[0]; + const windows = listWindows(sessionName); + const preferredWin = windows.find(w => w.name === preferred); + if (preferredWin) { + joinPane(sessionName, preferredWin.index, 0, 0, 0, 'h'); + initialViewedService = preferred; + } + } else { + // No services β€” create an empty right pane so window 0 has a split + splitWindowHorizontal(sessionName, 0); + } + + // Use main-vertical layout so the sidebar stays at SIDEBAR_WIDTH even after terminal resizes. + setMainLeftLayout(sessionName, 0, SIDEBAR_WIDTH); + + // Show service names in pane border titles + enablePaneBorders(sessionName, 0); + if (initialViewedService) { + setPaneTitle(sessionName, 0, 1, initialViewedService); + } + + // --- Start sidebar TUI in left pane (0.0) --- + const enabledGroupIds = determineEnabledGroups(serviceNames); + const dashboardArgs = [ + JSON.stringify(serviceNames), + initialViewedService, + JSON.stringify(enabledGroupIds), + ]; + const dashboardCmd = `tsx dev/local/dashboard.tsx ${dashboardArgs.map(a => JSON.stringify(a)).join(' ')}`; + sendKeys(sessionName, 0, dashboardCmd, 0); + + // --- Focus sidebar pane and attach --- + selectPane(sessionName, 0, 0); + selectWindow(sessionName, 0); + console.log(`${GREEN}Started ${serviceNames.length} services in session ${sessionName}${RESET}`); + attachSession(sessionName); +} + +async function cmdStop(repoRoot: string): Promise { + const sessionName = getSessionName(); + + if (sessionExists(sessionName)) { + killSession(sessionName); + console.log(`Killed tmux session ${sessionName}`); + } + + console.log('Stopping Docker infrastructure…'); + try { + execSync('docker compose -f dev/docker-compose.yml down', { cwd: repoRoot, stdio: 'inherit' }); + } catch { + // docker compose down may fail if nothing is running + } + + console.log(`${GREEN}All services stopped.${RESET}`); +} + +async function cmdEnv(args: string[], repoRoot: string): Promise { + const check = args.includes('--check') || args.includes('check'); + const yes = args.includes('--yes') || args.includes('-y'); + const targets = args.filter(a => !a.startsWith('-') && a !== 'check'); + + const result = await syncEnvVars({ + repoRoot, + check, + yes, + targets: targets.length > 0 ? targets : undefined, + }); + + if (!result.ok) { + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Usage +// --------------------------------------------------------------------------- + +function printUsage(): void { + console.log(` +Usage: + dev:start [targets...] Start services (default: core) + dev:stop Stop all services + dev:env [targets...] Sync env vars (.dev.vars + .env.development.local) + dev:env --check Validate env vars (CI mode) + dev:env -y Sync without confirmation + +Targets: app, app-builder, agents, all, or any service/group name +Multiple targets can be specified: dev:start kiloclaw agents`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + const repoRoot = findRepoRoot(); + + switch (command) { + case 'up': + await cmdUp(args.slice(1), repoRoot); + break; + case 'stop': + await cmdStop(repoRoot); + break; + case 'env': + await cmdEnv(args.slice(1), repoRoot); + break; + default: + if (command) { + console.error(`Unknown command: ${command}`); + } + printUsage(); + process.exit(1); + } +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/dev/local/dashboard.tsx b/dev/local/dashboard.tsx new file mode 100644 index 0000000000..f0e190f11b --- /dev/null +++ b/dev/local/dashboard.tsx @@ -0,0 +1,772 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; +import { execSync } from 'node:child_process'; +import { + getService, + getGroups, + getGroupServiceNames, + getAlwaysOnGroupIds, + resolveGroups, + resolveGroupTransitiveDeps, +} from './services'; +import type { ServiceGroup } from './services'; +import { getSessionName, killSession } from './tmux'; +import { + probePort, + startServiceInTmux, + stopServiceInTmux, + restartServiceInTmux, + showServiceInTmux, + showGroupInTmux, +} from './runner'; + +// --------------------------------------------------------------------------- +// Types & constants +// --------------------------------------------------------------------------- + +type ServiceStatus = 'up' | 'down' | 'starting'; +type GroupStatus = 'on' | 'off' | 'partial'; + +type SidebarItem = + | { kind: 'group'; groupId: string } + | { kind: 'service'; name: string; groupId: string } + | { kind: 'spacer' }; + +/** + * Tracks what is currently shown in the right panel of window 0. + * - { kind: "service", name } β€” single service pane + * - { kind: "group", groupId, serviceNames } β€” multi-pane group view + * - null β€” nothing shown yet + */ +type ViewedTarget = + | { kind: 'service'; name: string } + | { kind: 'group'; groupId: string; serviceNames: string[] } + | null; + +const REFRESH_MS = 2000; +const STARTING_GRACE_MS = 30_000; +const START_DELAY_MS = 300; +const SIDEBAR_WIDTH = 40; + +// Resolved once at module level +const sessionName = getSessionName(); +const alwaysOnGroupIds = new Set(getAlwaysOnGroupIds()); +const groupsById = new Map(getGroups().map(g => [g.id, g])); + +// --------------------------------------------------------------------------- +// Sidebar item list builder +// --------------------------------------------------------------------------- + +function buildSidebarItems(enabledGroups: Set): SidebarItem[] { + const items: SidebarItem[] = []; + for (const group of getGroups()) { + if (group.sectionBreakBefore) { + items.push({ kind: 'spacer' }); + } + items.push({ kind: 'group', groupId: group.id }); + if (enabledGroups.has(group.id)) { + for (const name of getGroupServiceNames(group.id)) { + items.push({ kind: 'service', name, groupId: group.id }); + } + } + } + return items; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function computeGroupStatus(groupId: string, statuses: Map): GroupStatus { + const members = getGroupServiceNames(groupId); + if (members.length === 0) return 'off'; + let upCount = 0; + for (const name of members) { + if (statuses.get(name) === 'up') upCount++; + } + if (upCount === members.length) return 'on'; + if (upCount === 0) return 'off'; + return 'partial'; +} + +function currentViewedEncoded(viewed: ViewedTarget): string { + if (!viewed) return ''; + if (viewed.kind === 'service') return viewed.name; + return viewed.serviceNames.join(','); +} + +function isGroupView( + viewed: ViewedTarget +): viewed is { kind: 'group'; groupId: string; serviceNames: string[] } { + return viewed !== null && viewed.kind === 'group'; +} + +function doShowService(serviceName: string, viewedRef: React.MutableRefObject): void { + const current = viewedRef.current; + const currentIsGroup = isGroupView(current); + const newViewed = showServiceInTmux( + sessionName, + serviceName, + currentViewedEncoded(current), + currentIsGroup + ); + viewedRef.current = newViewed !== '' ? { kind: 'service', name: newViewed } : null; +} + +function doShowGroup( + groupId: string, + runningServiceNames: string[], + viewedRef: React.MutableRefObject +): void { + if (runningServiceNames.length === 0) return; + const current = viewedRef.current; + const currentIsGroup = isGroupView(current); + const result = showGroupInTmux( + sessionName, + runningServiceNames, + currentViewedEncoded(current), + currentIsGroup + ); + if (result !== currentViewedEncoded(current)) { + viewedRef.current = { kind: 'group', groupId, serviceNames: runningServiceNames }; + } +} + +function doCleanup(): void { + try { + execSync('docker compose -f dev/docker-compose.yml down', { stdio: 'ignore' }); + } catch { + // ignore + } + try { + killSession(sessionName); + } catch { + // ignore + } +} + +/** Find the first running service from a list to swap into view */ +function findViewableService( + serviceNames: string[], + runningServices: Set, + exclude?: string +): string | undefined { + return serviceNames.find(name => runningServices.has(name) && name !== exclude); +} + +/** True if the given service name is currently shown in the right panel */ +function isServiceViewed(name: string, viewed: ViewedTarget): boolean { + if (!viewed) return false; + if (viewed.kind === 'service') return viewed.name === name; + return viewed.serviceNames.includes(name); +} + +/** True if the given group is currently shown as a group view */ +function isGroupViewed(groupId: string, viewed: ViewedTarget): boolean { + return viewed !== null && viewed.kind === 'group' && viewed.groupId === groupId; +} + +/** Get all services needed by currently enabled groups (direct + transitive deps) */ +function getServicesNeededByEnabledGroups(enabledGroups: Set): Set { + const groupIds = [...enabledGroups]; + if (groupIds.length === 0) return new Set(); + return new Set(resolveGroups(groupIds)); +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +const GroupHeader = React.memo(function GroupHeader({ + group, + status, + selected, + viewed, + width, +}: { + group: ServiceGroup; + status: GroupStatus; + selected: boolean; + viewed: boolean; + width: number; +}) { + const statusIcon = + status === 'on' ? '\u25cf on' : status === 'partial' ? '\u25d4 ...' : '\u25cb off'; + const statusColor = status === 'on' ? 'green' : status === 'partial' ? 'yellow' : 'gray'; + const label = ` ${group.label.toUpperCase()}`; + const viewedMark = viewed ? ' \u25c4' : ' '; + const rightPart = ` ${statusIcon}${viewedMark}`; + const padding = Math.max(1, width - label.length - rightPart.length); + + if (selected) { + const content = `${label}${' '.repeat(padding)}${rightPart}`; + const linePad = ' '.repeat(Math.max(0, width - content.length)); + return ( + + {content} + {linePad} + + ); + } + + return ( + + {label} + {' '.repeat(padding)} + {` ${statusIcon}`} + {viewedMark} + + ); +}); + +const ServiceRow = React.memo(function ServiceRow({ + name, + port, + status, + selected, + viewed, + width, +}: { + name: string; + port: number; + status: ServiceStatus; + selected: boolean; + viewed: boolean; + width: number; +}) { + const prefix = selected ? ' \u25b8 ' : ' '; + const viewedChar = viewed ? ' \u25c0' : ' '; + const portStr = port > 0 ? `:${port}` : ''; + const statusChar = status === 'up' ? ' \u2713' : status === 'down' ? ' \u2717' : ' \u2026'; + // +1 trailing space, +1 minimum gap between name and port + const fixedLen = prefix.length + portStr.length + statusChar.length + viewedChar.length + 2; + const maxName = Math.max(4, width - fixedLen); + const displayName = name.length > maxName ? name.slice(0, maxName - 1) + '\u2026' : name; + const namePad = ' '.repeat(Math.max(1, maxName - displayName.length)); + const content = `${prefix}${displayName}${namePad}${portStr}${statusChar}${viewedChar} `; + + if (selected) { + const linePad = ' '.repeat(Math.max(0, width - content.length)); + return ( + + {content} + {linePad} + + ); + } + + const statusColor = status === 'up' ? 'green' : status === 'down' ? 'red' : 'yellow'; + return ( + + {prefix} + {displayName} + {namePad} + {portStr} + {statusChar} + {viewed ? {viewedChar} : {viewedChar}}{' '} + + ); +}); + +function Dashboard({ + serviceNames: initialServiceNames, + initialViewed, + initialEnabledGroupIds, +}: { + serviceNames: string[]; + initialViewed: string; + initialEnabledGroupIds: string[]; +}) { + const { exit } = useApp(); + const { stdout } = useStdout(); + const rows = stdout?.rows ?? 24; + + // --- State --- + const [enabledGroups, setEnabledGroups] = useState>( + () => new Set(initialEnabledGroupIds) + ); + const [runningServices, setRunningServices] = useState>( + () => new Set(initialServiceNames) + ); + const [statuses, setStatuses] = useState>( + () => new Map(initialServiceNames.map(n => [n, 'starting' as const])) + ); + const [selectedIdx, setSelectedIdx] = useState(0); + + const viewedRef = useRef( + initialViewed ? { kind: 'service' as const, name: initialViewed } : null + ); + const scrollRef = useRef(0); + const startTimeRef = useRef(Date.now()); + const togglingRef = useRef(false); + + // --- Mouse refs (read current values in the stable stdin listener) --- + const mouseStateRef = useRef({ + sidebarItems: [] as SidebarItem[], + enabledGroups: new Set(), + runningServices: new Set(), + toggleGroupOn: (_groupId: string) => {}, + toggleGroupOff: (_groupId: string) => {}, + showGroup: (_groupId: string) => {}, + }); + + // --- Restore sidebar width after terminal resize / tab switch --- + // The dashboard runs in pane 0, so it receives SIGWINCH whenever tmux + // proportionally resizes it (e.g. on client resize or terminal tab switch). + // If the width drifts from SIDEBAR_WIDTH, force it back via resize-pane. + useEffect(() => { + const restoreSidebarWidth = () => { + if (process.stdout.columns !== SIDEBAR_WIDTH) { + try { + execSync(`tmux resize-pane -t ${sessionName}:0.0 -x ${SIDEBAR_WIDTH}`, { + stdio: 'ignore', + }); + } catch { + // best-effort + } + } + }; + process.stdout.on('resize', restoreSidebarWidth); + return () => { + process.stdout.off('resize', restoreSidebarWidth); + }; + }, []); + + // --- Derived --- + const sidebarItems = useMemo(() => buildSidebarItems(enabledGroups), [enabledGroups]); + + // --- Status polling --- + useEffect(() => { + const refresh = async () => { + const servicesToProbe = [...runningServices]; + if (servicesToProbe.length === 0) return; + + const inGrace = Date.now() - startTimeRef.current < STARTING_GRACE_MS; + const entries = await Promise.all( + servicesToProbe.map(async (name): Promise<[string, ServiceStatus]> => { + const svc = getService(name); + if (svc.port === 0) return [name, 'up']; + const up = await probePort(svc.port); + return [name, up ? 'up' : inGrace ? 'starting' : 'down']; + }) + ); + setStatuses(prev => { + const changed = entries.some(([name, status]) => prev.get(name) !== status); + if (!changed) return prev; + return new Map(entries); + }); + }; + refresh(); + const timer = setInterval(refresh, REFRESH_MS); + return () => clearInterval(timer); + }, [runningServices]); + + // --- Toggle group ON --- + const toggleGroupOn = useCallback( + (groupId: string) => { + if (togglingRef.current) return; + togglingRef.current = true; + + // Resolve transitive group-level deps (e.g. app-builder β†’ cloud-agent) + const allGroupIds = resolveGroupTransitiveDeps([groupId]); + const allNeeded = resolveGroups(allGroupIds); + const toStart = allNeeded.filter(name => !runningServices.has(name)); + + // Start services with staggered delays + let delay = 0; + for (const name of toStart) { + setTimeout(() => { + try { + startServiceInTmux(sessionName, name); + } catch { + // tmux command failed + } + }, delay); + delay += START_DELAY_MS; + } + + // Update state after last service started + setTimeout(() => { + setRunningServices(prev => { + const next = new Set(prev); + for (const name of toStart) next.add(name); + return next; + }); + setStatuses(prev => { + const next = new Map(prev); + for (const name of toStart) { + if (!next.has(name)) next.set(name, 'starting'); + } + return next; + }); + setEnabledGroups(prev => { + const next = new Set(prev); + for (const id of allGroupIds) next.add(id); + return next; + }); + // Reset starting grace for newly started services + startTimeRef.current = Date.now(); + togglingRef.current = false; + + // Show the group in multi-pane view + const groupServices = getGroupServiceNames(groupId); + const running = groupServices.filter(n => runningServices.has(n) || toStart.includes(n)); + doShowGroup(groupId, running, viewedRef); + }, delay + 100); + }, + [runningServices] + ); + + // --- Toggle group OFF --- + const toggleGroupOff = useCallback( + (groupId: string) => { + if (togglingRef.current) return; + togglingRef.current = true; + + const directMembers = getGroupServiceNames(groupId); + + // Calculate which services are needed by OTHER enabled groups + const otherEnabledGroups = new Set(enabledGroups); + otherEnabledGroups.delete(groupId); + const neededByOthers = getServicesNeededByEnabledGroups(otherEnabledGroups); + + // Only stop direct members that aren't needed by other groups + const toStop = directMembers.filter(name => !neededByOthers.has(name)); + + // If the current view is affected by the stopped services, switch to another + const viewed = viewedRef.current; + const viewedAffected = + viewed !== null && + (viewed.kind === 'group' ? viewed.groupId === groupId : toStop.includes(viewed.name)); + if (viewedAffected) { + const allRunning = new Set(runningServices); + for (const name of toStop) allRunning.delete(name); + const replacement = findViewableService([...allRunning], allRunning); + if (replacement) { + doShowService(replacement, viewedRef); + } + } + + // Stop services + for (const name of toStop) { + try { + stopServiceInTmux(sessionName, name); + } catch { + // tmux command failed + } + } + + setRunningServices(prev => { + const next = new Set(prev); + for (const name of toStop) next.delete(name); + return next; + }); + setStatuses(prev => { + const next = new Map(prev); + for (const name of toStop) next.delete(name); + return next; + }); + setEnabledGroups(prev => { + const next = new Set(prev); + next.delete(groupId); + return next; + }); + togglingRef.current = false; + }, + [enabledGroups, runningServices] + ); + + const showGroup = useCallback( + (groupId: string) => { + const running = getGroupServiceNames(groupId).filter(n => runningServices.has(n)); + doShowGroup(groupId, running, viewedRef); + }, + [runningServices] + ); + + mouseStateRef.current = { + sidebarItems, + enabledGroups, + runningServices, + toggleGroupOn, + toggleGroupOff, + showGroup, + }; + + // --- Keyboard --- + useInput((input, key) => { + if (key.upArrow) { + setSelectedIdx(prev => { + let next = prev - 1; + while (next >= 0 && sidebarItems[next]?.kind === 'spacer') next--; + return Math.max(0, next); + }); + return; + } + if (key.downArrow) { + setSelectedIdx(prev => { + let next = prev + 1; + while (next < sidebarItems.length && sidebarItems[next]?.kind === 'spacer') next++; + return Math.min(sidebarItems.length - 1, next); + }); + return; + } + if (key.return) { + const item = sidebarItems[selectedIdx]; + if (!item) return; + + if (item.kind === 'group') { + if (enabledGroups.has(item.groupId)) { + // Group is running β€” show all its services in multi-pane view + const running = getGroupServiceNames(item.groupId).filter(n => runningServices.has(n)); + doShowGroup(item.groupId, running, viewedRef); + } else { + // Group is off β€” start it (which will also show it) + if (!alwaysOnGroupIds.has(item.groupId)) { + toggleGroupOn(item.groupId); + } + } + } else { + // Service: show single pane + if (runningServices.has(item.name)) { + doShowService(item.name, viewedRef); + } + } + return; + } + if (input === ' ') { + const item = sidebarItems[selectedIdx]; + if (!item) return; + + if (item.kind === 'group') { + // Space stops the group (if it can be stopped) + if (alwaysOnGroupIds.has(item.groupId)) return; + if (enabledGroups.has(item.groupId)) { + toggleGroupOff(item.groupId); + } else { + toggleGroupOn(item.groupId); + } + } else { + // Service: show single pane (same as Enter for services) + if (runningServices.has(item.name)) { + doShowService(item.name, viewedRef); + } + } + return; + } + if (input === 'r') { + const item = sidebarItems[selectedIdx]; + if (!item || item.kind !== 'service') return; + if (!runningServices.has(item.name)) return; + restartServiceInTmux(sessionName, item.name); + return; + } + if (input === 'q') { + doCleanup(); + exit(); + } + }); + + // --- Mouse tracking --- + useEffect(() => { + // Enable SGR extended mouse tracking so tmux forwards clicks to us + process.stdout.write('\x1b[?1000h\x1b[?1006h'); + + const handleStdin = (data: Buffer) => { + const str = data.toString('utf-8'); + const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g; + let m; + while ((m = re.exec(str)) !== null) { + const button = parseInt(m[1], 10); + const row = parseInt(m[3], 10); // 1-based terminal row + const isPress = m[4] === 'M'; + if (!isPress) continue; + + // Scroll wheel: 64 = up, 65 = down + if (button === 64) { + const { sidebarItems: items } = mouseStateRef.current; + setSelectedIdx(prev => { + let next = prev - 1; + while (next >= 0 && items[next]?.kind === 'spacer') next--; + return Math.max(0, next); + }); + continue; + } + if (button === 65) { + const { sidebarItems: items } = mouseStateRef.current; + setSelectedIdx(prev => { + let next = prev + 1; + while (next < items.length && items[next]?.kind === 'spacer') next++; + return Math.min(items.length - 1, next); + }); + continue; + } + + // Left click only + if (button !== 0) continue; + + const { + sidebarItems: items, + enabledGroups: groups, + runningServices: running, + toggleGroupOn: onGroupOn, + showGroup: onShowGroup, + } = mouseStateRef.current; + + // 2 header rows, then visible items starting from scrollRef offset + const itemRow = row - 1 - 2 + scrollRef.current; + if (itemRow < 0 || itemRow >= items.length) continue; + + setSelectedIdx(itemRow); + + const item = items[itemRow]; + if (!item || item.kind === 'spacer') continue; + + if (item.kind === 'group') { + if (groups.has(item.groupId)) { + // Click on running group header = show group view + onShowGroup(item.groupId); + } else { + // Click on stopped group header = start it + if (!alwaysOnGroupIds.has(item.groupId)) { + onGroupOn(item.groupId); + } + } + } else { + if (running.has(item.name)) { + doShowService(item.name, viewedRef); + } + } + } + }; + + process.stdin.on('data', handleStdin); + + return () => { + process.stdout.write('\x1b[?1000l\x1b[?1006l'); + process.stdin.off('data', handleStdin); + }; + // Stable listener β€” reads current values from mouseStateRef + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // --- Scrolling --- + const headerCount = 2; + const footerCount = 1; + const visibleCount = Math.max(1, rows - headerCount - footerCount); + if (selectedIdx >= scrollRef.current + visibleCount) { + scrollRef.current = selectedIdx - visibleCount + 1; + } + if (selectedIdx < scrollRef.current) { + scrollRef.current = selectedIdx; + } + const visibleItems = sidebarItems.slice(scrollRef.current, scrollRef.current + visibleCount); + + return ( + + SERVICES + + + + {visibleItems.map((item, i) => { + const globalIdx = scrollRef.current + i; + const isSelected = globalIdx === selectedIdx; + + if (item.kind === 'spacer') { + return ; + } + + if (item.kind === 'group') { + const group = groupsById.get(item.groupId); + if (!group) return null; + return ( + + ); + } + + const svc = getService(item.name); + return ( + + ); + })} + + + + {' '} + {'\u2191\u2193'} navigate {'\u23ce'} view/start space stop r restart q quit + + + ); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const serviceNames: string[] = JSON.parse(process.argv[2] ?? '[]'); +const initialViewed = process.argv[3] ?? ''; +const initialEnabledGroupIds: string[] = JSON.parse(process.argv[4] ?? '[]'); + +if (serviceNames.length === 0) { + console.error( + "Usage: dashboard.tsx '' [initial-service] ''" + ); + process.exit(1); +} + +// Batch stdout writes so ink's per-line updates arrive as a single +// atomic write, preventing tmux from rendering intermediate frames. +{ + const origWrite = process.stdout.write.bind(process.stdout); + let batch: Buffer[] = []; + let scheduled = false; + process.stdout.write = function stdoutBatchedWrite( + chunk: Uint8Array | string, + encodingOrCb?: BufferEncoding | ((err?: Error | null) => void), + cb?: (err?: Error | null) => void + ): boolean { + batch.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); + if (!scheduled) { + scheduled = true; + queueMicrotask(() => { + const combined = Buffer.concat(batch); + batch = []; + scheduled = false; + origWrite(combined); + }); + } + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; + if (callback) callback(); + return true; + }; +} + +// Clear screen so ink starts with a clean canvas +process.stdout.write('\x1b[2J\x1b[H'); + +const { waitUntilExit } = render( + +); + +waitUntilExit().then(() => { + process.exit(0); +}); diff --git a/dev/local/env-sync/index.ts b/dev/local/env-sync/index.ts new file mode 100644 index 0000000000..f6529a24e1 --- /dev/null +++ b/dev/local/env-sync/index.ts @@ -0,0 +1,167 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as readline from 'node:readline'; +import { resolveTargets } from '../services'; +import { computePlan, findDevVarsExamples } from './plan'; +import { planHasChanges, displayPlan, applyPlan } from './output'; +import type { SyncResult, CheckResult } from './types'; + +// --------------------------------------------------------------------------- +// ANSI color constants (used in CLI output below) +// --------------------------------------------------------------------------- + +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +// --------------------------------------------------------------------------- +// Confirmation prompt +// --------------------------------------------------------------------------- + +function confirm(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(question, answer => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +function resolveServiceFilter(targets?: string[]): Set | undefined { + if (!targets || targets.length === 0) return undefined; + return new Set(resolveTargets(targets)); +} + +async function syncEnvVars(options: { + repoRoot: string; + check?: boolean; + yes?: boolean; + targets?: string[]; +}): Promise { + const { repoRoot, check = false, yes = false, targets } = options; + const serviceFilter = resolveServiceFilter(targets); + const plan = computePlan(repoRoot, serviceFilter); + + if (plan.missingEnvLocal) { + displayPlan(plan); + return { ok: false, changed: 0, missing: 0 }; + } + + const hasChanges = planHasChanges(plan); + const totalMissing = plan.devVarsChanges.reduce((sum, c) => sum + c.missingValues.length, 0); + + displayPlan(plan); + + if (check) { + return { ok: !hasChanges, changed: plan.devVarsChanges.length, missing: totalMissing }; + } + + if (hasChanges) { + const shouldApply = yes || (await confirm(`\nApply changes? [y/N] `)); + if (shouldApply) { + applyPlan(plan, repoRoot); + console.log(`\n${GREEN}βœ“ Applied${RESET}`); + } else { + console.log('Skipped.'); + } + } + + return { ok: true, changed: plan.devVarsChanges.length, missing: totalMissing }; +} + +async function checkEnvVars(repoRoot: string, targets?: string[]): Promise { + const envLocalPath = path.join(repoRoot, '.env.local'); + if (!fs.existsSync(envLocalPath)) { + return { ok: false, envLocalExists: false, missing: 0, workerCount: 0 }; + } + + const serviceFilter = resolveServiceFilter(targets); + const plan = computePlan(repoRoot, serviceFilter); + const totalMissing = plan.devVarsChanges.reduce((sum, c) => sum + c.missingValues.length, 0); + const workerCount = findDevVarsExamples(repoRoot).length; + + return { + ok: + !plan.devVarsChanges.some(c => c.isNew || c.keyChanges.length > 0) && + plan.envDevLocalChanges.length === 0, + envLocalExists: true, + missing: totalMissing, + workerCount, + }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +function findRepoRoot(): string { + let dir = import.meta.dirname; + for (let i = 0; i < 20; i++) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg.name === 'kilocode-backend') return dir; + } catch { + // Not valid JSON, keep walking + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("Could not find repo root (package.json with name 'kilocode-backend')"); +} + +async function main() { + const args = process.argv.slice(2); + const checkMode = args.includes('--check'); + const yesMode = args.includes('--yes') || args.includes('-y'); + const targets = args.filter(a => !a.startsWith('-')); + + const repoRoot = findRepoRoot(); + + if (checkMode) { + const result = await checkEnvVars(repoRoot, targets.length > 0 ? targets : undefined); + if (!result.envLocalExists) { + console.error('βœ— .env.local not found. Run: vercel env pull .env.local'); + process.exit(1); + } + if (!result.ok) { + console.error(`\nβœ— Env vars out of date. Run: pnpm dev:env`); + process.exit(1); + } + console.log(`βœ“ All env vars up to date across ${result.workerCount} workers`); + return; + } + + const result = await syncEnvVars({ + repoRoot, + yes: yesMode, + targets: targets.length > 0 ? targets : undefined, + }); + if (!result.ok) { + process.exit(1); + } +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(import.meta.dirname, 'index.ts'); +if (isMain) { + main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export { syncEnvVars, checkEnvVars }; +export type { EnvSyncPlan, SyncResult, CheckResult } from './types'; diff --git a/dev/local/env-sync/output.ts b/dev/local/env-sync/output.ts new file mode 100644 index 0000000000..f2f10c7e3e --- /dev/null +++ b/dev/local/env-sync/output.ts @@ -0,0 +1,184 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { EnvSyncPlan } from './types'; +import { formatValue } from './parse'; + +// --------------------------------------------------------------------------- +// ANSI color constants +// --------------------------------------------------------------------------- + +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const CYAN = '\x1b[36m'; + +// --------------------------------------------------------------------------- +// Plan display +// --------------------------------------------------------------------------- + +function truncateValue(value: string, maxLen = 50): string { + if (value.length <= maxLen) return value; + return value.slice(0, maxLen - 1) + '…'; +} + +function planHasChanges(plan: EnvSyncPlan): boolean { + const hasDevVarsDrift = plan.devVarsChanges.some(c => c.isNew || c.keyChanges.length > 0); + return hasDevVarsDrift || plan.envDevLocalChanges.length > 0; +} + +function displayPlan(plan: EnvSyncPlan): void { + if (plan.missingEnvLocal) { + console.error('⚠ .env.local not found β€” run: vercel env pull .env.local'); + return; + } + + if (plan.lanIp) { + console.log(`${DIM}LAN IP: ${plan.lanIp}${RESET}`); + console.log(); + } + + let hasOutput = false; + + // .dev.vars changes + if (plan.devVarsChanges.length > 0) { + for (const change of plan.devVarsChanges) { + if (change.isNew) { + console.log(`${GREEN}+ ${change.workerDir}/.dev.vars${RESET} ${DIM}(new)${RESET}`); + } else { + console.log(`${CYAN}✎ ${change.workerDir}/.dev.vars${RESET}`); + for (const kc of change.keyChanges) { + if (kc.oldValue === undefined) { + console.log(` ${GREEN}+ ${kc.key}${RESET} = ${truncateValue(kc.newValue)}`); + } else { + console.log( + ` ${YELLOW}~ ${kc.key}${RESET}: ${truncateValue(kc.oldValue)} β†’ ${truncateValue(kc.newValue)}` + ); + } + } + } + for (const missing of change.missingValues) { + console.log(` ${RED}⚠ ${missing}${RESET} β€” no value found`); + } + } + hasOutput = true; + } + + // .env.development.local changes + if (plan.envDevLocalChanges.length > 0) { + if (hasOutput) console.log(); + console.log(`${CYAN}✎ .env.development.local${RESET}`); + for (const change of plan.envDevLocalChanges) { + if (change.oldValue === undefined) { + console.log(` ${GREEN}+ ${change.key}${RESET} = ${change.newValue}`); + } else { + console.log( + ` ${YELLOW}~ ${change.key}${RESET}: ${truncateValue(change.oldValue)} β†’ ${change.newValue}` + ); + } + } + hasOutput = true; + } + + // Secrets store warnings + if (plan.secretStoreWarnings.length > 0) { + if (hasOutput) console.log(); + for (const warning of plan.secretStoreWarnings) { + console.log( + `${YELLOW}⚠ ${warning.workerDir}${RESET} uses secrets_store β€” missing local secrets:` + ); + for (const binding of warning.bindings) { + console.log( + ` ${binding.binding}: wrangler secrets-store secret create ${binding.store_id} --name ${binding.secret_name} --scopes workers` + ); + } + } + hasOutput = true; + } + + // Consistency warnings + if (plan.consistencyWarnings.length > 0) { + if (hasOutput) console.log(); + for (const warning of plan.consistencyWarnings) { + console.log(`${RED}βœ— Shared secret mismatch: ${warning.sourceKey}${RESET}`); + for (const entry of warning.entries) { + const keyLabel = + entry.workerKey !== warning.sourceKey + ? `${entry.workerDir} (${entry.workerKey})` + : entry.workerDir; + console.log(` ${keyLabel}: ${truncateValue(entry.value)}`); + } + } + hasOutput = true; + } + + if (!hasOutput) { + console.log(`${GREEN}βœ“ All env vars are up to date${RESET}`); + } +} + +// --------------------------------------------------------------------------- +// Plan application +// --------------------------------------------------------------------------- + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function applyPlan(plan: EnvSyncPlan, repoRoot: string): void { + for (const change of plan.devVarsChanges) { + const devVarsPath = path.join(repoRoot, change.workerDir, '.dev.vars'); + + if (change.newFileContent !== undefined) { + fs.writeFileSync(devVarsPath, change.newFileContent, 'utf-8'); + } else { + let content = fs.readFileSync(devVarsPath, 'utf-8'); + const appendLines: string[] = []; + + for (const kc of change.keyChanges) { + const regex = new RegExp(`^${escapeRegex(kc.key)}=.*$`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, `${kc.key}=${formatValue(kc.newValue)}`); + } else { + appendLines.push(`${kc.key}=${formatValue(kc.newValue)}`); + } + } + + if (appendLines.length > 0) { + content = content.trimEnd() + '\n' + appendLines.join('\n') + '\n'; + } + + fs.writeFileSync(devVarsPath, content, 'utf-8'); + } + } + + if (plan.envDevLocalChanges.length > 0) { + const envDevLocalPath = path.join(repoRoot, '.env.development.local'); + + let existingContent = ''; + try { + existingContent = fs.readFileSync(envDevLocalPath, 'utf-8'); + } catch { + // File doesn't exist yet + } + + if (existingContent) { + let content = existingContent; + for (const change of plan.envDevLocalChanges) { + const regex = new RegExp(`^${escapeRegex(change.key)}=.*$`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, `${change.key}=${change.newValue}`); + } else { + content = content.trimEnd() + `\n${change.key}=${change.newValue}\n`; + } + } + fs.writeFileSync(envDevLocalPath, content, 'utf-8'); + } else { + const lines = plan.envDevLocalChanges.map(c => `${c.key}=${c.newValue}`); + fs.writeFileSync(envDevLocalPath, lines.join('\n') + '\n', 'utf-8'); + } + } +} + +export { planHasChanges, displayPlan, applyPlan }; diff --git a/dev/local/env-sync/parse.ts b/dev/local/env-sync/parse.ts new file mode 100644 index 0000000000..7f9e054d7a --- /dev/null +++ b/dev/local/env-sync/parse.ts @@ -0,0 +1,306 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { services } from '../services'; +import type { Annotation, ExampleEntry } from './types'; + +// --------------------------------------------------------------------------- +// JSONC parsing (handles // comments, /* */ comments, trailing commas) +// --------------------------------------------------------------------------- + +function stripJsoncComments(text: string): string { + let result = ''; + let i = 0; + let inString = false; + + while (i < text.length) { + if (inString) { + if (text[i] === '\\' && i + 1 < text.length) { + result += text[i] + text[i + 1]; + i += 2; + } else if (text[i] === '"') { + result += '"'; + inString = false; + i++; + } else { + result += text[i]; + i++; + } + } else { + if (text[i] === '"') { + result += '"'; + inString = true; + i++; + } else if (text[i] === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + } else if (text[i] === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; + } else { + result += text[i]; + i++; + } + } + } + + return result.replace(/,(\s*[}\]])/g, '$1'); +} + +function parseJsonc(content: string): unknown { + return JSON.parse(stripJsoncComments(content)); +} + +// --------------------------------------------------------------------------- +// Env file parsing +// --------------------------------------------------------------------------- + +function stripEnvQuotes(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + return value + .slice(1, -1) + .replace(/\\(["\\n])/g, (_match, ch: string) => (ch === 'n' ? '\n' : ch)); + } + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} + +function parseEnvFile(content: string): Map { + const vars = new Map(); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (line === '' || line.startsWith('#')) continue; + + const eqIdx = line.indexOf('='); + if (eqIdx === -1) continue; + + const key = line.slice(0, eqIdx).trim(); + const value = stripEnvQuotes(line.slice(eqIdx + 1).trim()); + vars.set(key, value); + } + + return vars; +} + +function readEnvFile(filePath: string): Map { + try { + return parseEnvFile(fs.readFileSync(filePath, 'utf-8')); + } catch { + return new Map(); + } +} + +// --------------------------------------------------------------------------- +// Annotation parser +// --------------------------------------------------------------------------- + +const KNOWN_DIRECTIVES = new Set(['url', 'from', 'pkcs8']); + +function parseAnnotation(directive: string, args: string): Annotation | undefined { + switch (directive) { + case 'url': { + const refs = args.split(',').map(ref => { + const trimmed = ref.trim(); + const slashIdx = trimmed.indexOf('/'); + if (slashIdx === -1) return { name: trimmed }; + return { name: trimmed.slice(0, slashIdx), path: trimmed.slice(slashIdx) }; + }); + return { type: 'url', services: refs }; + } + case 'from': + return { type: 'from', envLocalKey: args.trim() }; + case 'pkcs8': + return { type: 'pkcs8' }; + default: + return undefined; + } +} + +function parseExampleFile(content: string): ExampleEntry[] { + const entries: ExampleEntry[] = []; + let pendingAnnotation: Annotation | undefined; + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + + // Blank lines clear pending annotations + if (line === '') { + pendingAnnotation = undefined; + continue; + } + + // Comment lines + if (line.startsWith('#')) { + // Commented-out keys like #KILOCODE_TOKEN_OVERRIDE=... are just comments + const directiveMatch = line.match(/^#\s*@(\w+)\s*(.*)$/); + if (directiveMatch) { + const [, directive, args] = directiveMatch; + if (KNOWN_DIRECTIVES.has(directive)) { + pendingAnnotation = parseAnnotation(directive, args); + } + // Unknown @directives are treated as regular comments (no effect) + } + // Regular comments without @directive don't affect pending annotations + continue; + } + + // KEY=VALUE line + const eqIdx = line.indexOf('='); + if (eqIdx === -1) { + pendingAnnotation = undefined; + continue; + } + + const key = line.slice(0, eqIdx).trim(); + const defaultValue = stripEnvQuotes(line.slice(eqIdx + 1).trim()); + + entries.push({ + key, + defaultValue, + annotation: pendingAnnotation ?? { type: 'passthrough' }, + }); + + pendingAnnotation = undefined; + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Service port resolution (single source of truth: services.ts) +// --------------------------------------------------------------------------- + +function servicePort(name: string): number | undefined { + return services.get(name)?.port; +} + +// --------------------------------------------------------------------------- +// PKCS#1 β†’ PKCS#8 conversion +// --------------------------------------------------------------------------- + +function toPkcs8IfNeeded(pem: string): string { + if (!pem || !pem.includes('-----BEGIN RSA PRIVATE KEY-----')) return pem; + try { + const privateKey = crypto.createPrivateKey({ key: pem, format: 'pem' }); + return privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + } catch { + return pem; + } +} + +// --------------------------------------------------------------------------- +// Annotation-based value resolution +// --------------------------------------------------------------------------- + +function resolveAnnotatedValue( + key: string, + entry: ExampleEntry, + envLocal: Map, + lanIp: string | undefined, + serviceUsesLanIp: boolean +): { value: string; resolved: boolean } { + switch (entry.annotation.type) { + case 'from': { + const val = envLocal.get(entry.annotation.envLocalKey); + if (val !== undefined) return { value: val, resolved: true }; + if (entry.defaultValue) return { value: entry.defaultValue, resolved: true }; + return { value: '', resolved: false }; + } + + case 'url': { + const isOrigins = key.includes('ORIGINS'); + const isHostname = key.includes('HOSTNAME') && !key.includes('URL'); + const isWs = key.includes('_WS_'); + // LAN IP for container services, but never for ORIGINS keys + const host = serviceUsesLanIp && !isOrigins && lanIp ? lanIp : 'localhost'; + const protocol = isWs ? 'ws' : 'http'; + + const resolvedParts: string[] = []; + for (const svcRef of entry.annotation.services) { + const port = servicePort(svcRef.name); + if (port === undefined) { + console.warn(`⚠ Unknown service "${svcRef.name}" in @url annotation for ${key}`); + continue; + } + if (isHostname) { + resolvedParts.push(`${host}:${port}`); + } else if (isOrigins) { + resolvedParts.push(`http://localhost:${port}`); + } else { + const base = `${protocol}://${host}:${port}`; + resolvedParts.push(svcRef.path ? base + svcRef.path : base); + } + } + + if (resolvedParts.length > 0) { + return { value: resolvedParts.join(','), resolved: true }; + } + // All services unknown β€” fall back to default + if (entry.defaultValue) return { value: entry.defaultValue, resolved: true }; + return { value: '', resolved: false }; + } + + case 'pkcs8': { + const val = envLocal.get(key); + if (val !== undefined) return { value: toPkcs8IfNeeded(val), resolved: true }; + if (entry.defaultValue) return { value: entry.defaultValue, resolved: true }; + return { value: '', resolved: false }; + } + + case 'passthrough': { + const val = envLocal.get(key); + if (val !== undefined) return { value: val, resolved: true }; + if (entry.defaultValue) return { value: entry.defaultValue, resolved: true }; + return { value: '', resolved: false }; + } + } +} + +// --------------------------------------------------------------------------- +// File generation utilities +// --------------------------------------------------------------------------- + +function needsQuoting(value: string): boolean { + return ( + value.includes('\n') || + value.includes('"') || + value.includes("'") || + value.includes(' ') || + value.includes('#') + ); +} + +function formatValue(value: string): string { + if (!needsQuoting(value)) return value; + const escaped = value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +function generateDevVars(keys: Map): string { + const header = [ + '# Auto-generated by dev/local/env-sync.ts β€” do not edit manually', + '# Source: .env.local + .dev.vars.example annotations', + `# Generated: ${new Date().toISOString()}`, + '', + ].join('\n'); + + const lines: string[] = []; + for (const [key, value] of keys) { + lines.push(`${key}=${formatValue(value)}`); + } + + return header + lines.join('\n') + '\n'; +} + +export { + parseJsonc, + parseEnvFile, + readEnvFile, + parseExampleFile, + servicePort, + resolveAnnotatedValue, + formatValue, + generateDevVars, +}; diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts new file mode 100644 index 0000000000..7e9c46bd3a --- /dev/null +++ b/dev/local/env-sync/plan.ts @@ -0,0 +1,367 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { services } from '../services'; +import type { + Annotation, + DevVarsFileChange, + EnvDevLocalChange, + EnvSyncPlan, + ExampleEntry, + KeyChange, + SecretStoreBinding, + SecretStoreWarning, + ConsistencyWarning, +} from './types'; +import { + parseEnvFile, + readEnvFile, + parseExampleFile, + resolveAnnotatedValue, + parseJsonc, + generateDevVars, +} from './parse'; + +// --------------------------------------------------------------------------- +// LAN IP detection +// --------------------------------------------------------------------------- + +function detectLanIp(): string | undefined { + const interfaces = os.networkInterfaces(); + for (const addrs of Object.values(interfaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Source key derivation (for cross-worker consistency checks) +// --------------------------------------------------------------------------- + +function getEnvLocalSourceKey(key: string, annotation: Annotation): string | undefined { + switch (annotation.type) { + case 'from': + return annotation.envLocalKey; + case 'url': + return undefined; + case 'pkcs8': + return key; + case 'passthrough': + return key; + } +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +const SKIP_DIRS = new Set([ + 'node_modules', + '.git', + '.kilo', + 'dev', + '.next', + '.turbo', + 'cloud-agent', +]); + +function findDevVarsExamples(repoRoot: string): string[] { + const results: string[] = []; + + function walk(dir: string, relPath: string) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + walk(path.join(dir, entry.name), relPath ? `${relPath}/${entry.name}` : entry.name); + } else if (entry.name === '.dev.vars.example') { + results.push(relPath); + } + } + } + + walk(repoRoot, ''); + return results.sort(); +} + +// --------------------------------------------------------------------------- +// Wrangler env detection from package.json dev script +// --------------------------------------------------------------------------- + +function detectWranglerEnv(repoRoot: string, workerDir: string): string | undefined { + const pkgPath = path.join(repoRoot, workerDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return undefined; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const devScript = pkg?.scripts?.dev; + if (typeof devScript !== 'string') return undefined; + const match = devScript.match(/--env\s+['"]?(\w+)/); + return match?.[1]; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Wrangler config: extract secrets_store_secrets bindings +// --------------------------------------------------------------------------- + +function extractSecretsStoreBindings(repoRoot: string, workerDir: string): SecretStoreBinding[] { + const wranglerPath = path.join(repoRoot, workerDir, 'wrangler.jsonc'); + if (!fs.existsSync(wranglerPath)) return []; + + try { + const config = parseJsonc(fs.readFileSync(wranglerPath, 'utf-8')) as Record; + + const envName = detectWranglerEnv(repoRoot, workerDir); + + // Check the env-specific config first, fall back to top-level + let secretsSection: unknown; + if (envName && config.env) { + const envConfig = (config.env as Record)[envName]; + if (envConfig && typeof envConfig === 'object') { + secretsSection = (envConfig as Record).secrets_store_secrets; + } + } + if (!secretsSection) { + secretsSection = config.secrets_store_secrets; + } + + if (!Array.isArray(secretsSection)) return []; + return secretsSection.map((s: { binding: string; store_id: string; secret_name: string }) => ({ + binding: s.binding, + store_id: s.store_id, + secret_name: s.secret_name, + })); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Local secrets store check (via wrangler CLI) +// --------------------------------------------------------------------------- + +function listLocalStoreSecrets(repoRoot: string, storeId: string): string { + const result = spawnSync('pnpm', ['wrangler', 'secrets-store', 'secret', 'list', storeId], { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return result.status === 0 ? result.stdout : ''; +} + +// --------------------------------------------------------------------------- +// Plan computation +// --------------------------------------------------------------------------- + +function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan { + const envLocalPath = path.join(repoRoot, '.env.local'); + if (!fs.existsSync(envLocalPath)) { + return { + lanIp: undefined, + devVarsChanges: [], + envDevLocalChanges: [], + secretStoreWarnings: [], + consistencyWarnings: [], + missingEnvLocal: true, + }; + } + + const lanIp = detectLanIp(); + const envLocal = parseEnvFile(fs.readFileSync(envLocalPath, 'utf-8')); + const allWorkerDirs = findDevVarsExamples(repoRoot); + + // When filtering by service, only process dirs belonging to targeted services + let workerDirs: string[]; + if (serviceFilter) { + const allowedDirs = new Set(); + for (const name of serviceFilter) { + const svc = services.get(name); + if (svc) allowedDirs.add(svc.dir); + } + workerDirs = allWorkerDirs.filter(d => allowedDirs.has(d)); + } else { + workerDirs = allWorkerDirs; + } + + // Build dirβ†’useLanIp lookup + const dirUsesLanIp = new Map(); + for (const [, svc] of services) { + if (svc.useLanIp) { + dirUsesLanIp.set(svc.dir, true); + } + } + + // --- .dev.vars changes --- + const devVarsChanges: DevVarsFileChange[] = []; + const allResolvedEntries = new Map< + string, + { vars: Map; entries: ExampleEntry[] } + >(); + + for (const workerDir of workerDirs) { + const examplePath = path.join(repoRoot, workerDir, '.dev.vars.example'); + const exampleContent = fs.readFileSync(examplePath, 'utf-8'); + const entries = parseExampleFile(exampleContent); + const serviceUsesLanIp = dirUsesLanIp.get(workerDir) ?? false; + + const resolvedVars = new Map(); + const missingValues: string[] = []; + + for (const entry of entries) { + const { value, resolved } = resolveAnnotatedValue( + entry.key, + entry, + envLocal, + lanIp, + serviceUsesLanIp + ); + resolvedVars.set(entry.key, value); + if (!resolved) missingValues.push(entry.key); + } + + allResolvedEntries.set(workerDir, { vars: resolvedVars, entries }); + + const devVarsPath = path.join(repoRoot, workerDir, '.dev.vars'); + + let existingContent: string | null = null; + try { + existingContent = fs.readFileSync(devVarsPath, 'utf-8'); + } catch { + // File doesn't exist yet + } + + const isNew = existingContent === null; + const keyChanges: KeyChange[] = []; + + if (existingContent !== null) { + const oldVars = parseEnvFile(existingContent); + for (const [key, newVal] of resolvedVars) { + const oldVal = oldVars.get(key); + if (oldVal !== newVal) { + keyChanges.push({ key, oldValue: oldVal, newValue: newVal }); + } + } + } + + if (isNew || keyChanges.length > 0 || missingValues.length > 0) { + devVarsChanges.push({ + workerDir, + isNew, + keyChanges, + missingValues, + newFileContent: isNew ? generateDevVars(resolvedVars) : undefined, + }); + } + } + + // --- .env.development.local changes --- + const envDevLocalChanges: EnvDevLocalChange[] = []; + const processEnvDevLocal = !serviceFilter || serviceFilter.has('nextjs'); + + const envDevLocalExamplePath = path.join(repoRoot, '.env.development.local.example'); + if (processEnvDevLocal && fs.existsSync(envDevLocalExamplePath)) { + const envDevLocalPath = path.join(repoRoot, '.env.development.local'); + const envDevLocal = readEnvFile(envDevLocalPath); + const exampleContent = fs.readFileSync(envDevLocalExamplePath, 'utf-8'); + const entries = parseExampleFile(exampleContent); + + for (const entry of entries) { + const { value: expectedValue, resolved } = resolveAnnotatedValue( + entry.key, + entry, + envLocal, + lanIp, + false // Next.js doesn't use LAN IP + ); + + if (!resolved) continue; + + // Effective value: .env.development.local overrides .env.local + const effectiveValue = envDevLocal.get(entry.key) ?? envLocal.get(entry.key); + + if (effectiveValue !== expectedValue) { + envDevLocalChanges.push({ + key: entry.key, + oldValue: effectiveValue, + newValue: expectedValue, + }); + } + } + } + + // --- Secrets store warnings --- + const secretStoreWarnings: SecretStoreWarning[] = []; + const storeOutputCache = new Map(); + + for (const [name, svc] of services) { + if (svc.type !== 'worker') continue; + if (serviceFilter && !serviceFilter.has(name)) continue; + const bindings = extractSecretsStoreBindings(repoRoot, svc.dir); + if (bindings.length === 0) continue; + + const missingBindings = bindings.filter(b => { + let output = storeOutputCache.get(b.store_id); + if (output === undefined) { + output = listLocalStoreSecrets(repoRoot, b.store_id); + storeOutputCache.set(b.store_id, output); + } + return !output.includes(b.secret_name); + }); + + if (missingBindings.length > 0) { + secretStoreWarnings.push({ workerDir: svc.dir, bindings: missingBindings }); + } + } + + // --- Cross-worker shared secret consistency --- + const sharedSecretMap = new Map< + string, + { workerDir: string; workerKey: string; value: string }[] + >(); + + for (const [workerDir, { vars, entries }] of allResolvedEntries) { + for (const entry of entries) { + const value = vars.get(entry.key); + if (!value) continue; + const sourceKey = getEnvLocalSourceKey(entry.key, entry.annotation); + if (!sourceKey) continue; + const existing = sharedSecretMap.get(sourceKey) ?? []; + existing.push({ workerDir, workerKey: entry.key, value }); + sharedSecretMap.set(sourceKey, existing); + } + } + + const consistencyWarnings: ConsistencyWarning[] = []; + for (const [sourceKey, entries] of sharedSecretMap) { + if (entries.length <= 1) continue; + const distinctValues = new Set(entries.map(e => e.value)); + if (distinctValues.size > 1) { + consistencyWarnings.push({ sourceKey, entries }); + } + } + + return { + lanIp, + devVarsChanges, + envDevLocalChanges, + secretStoreWarnings, + consistencyWarnings, + missingEnvLocal: false, + }; +} + +export { computePlan, findDevVarsExamples }; diff --git a/dev/local/env-sync/types.ts b/dev/local/env-sync/types.ts new file mode 100644 index 0000000000..a2091e7d0e --- /dev/null +++ b/dev/local/env-sync/types.ts @@ -0,0 +1,96 @@ +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type KeyChange = { + key: string; + oldValue: string | undefined; + newValue: string; +}; + +type DevVarsFileChange = { + workerDir: string; + isNew: boolean; + keyChanges: KeyChange[]; + missingValues: string[]; + // Full content only used for new files; existing files are patched in-place + newFileContent: string | undefined; +}; + +type EnvDevLocalChange = { + key: string; + oldValue: string | undefined; + newValue: string; +}; + +type SecretStoreBinding = { + binding: string; + store_id: string; + secret_name: string; +}; + +type SecretStoreWarning = { + workerDir: string; + bindings: SecretStoreBinding[]; +}; + +type ConsistencyWarning = { + sourceKey: string; + entries: { workerDir: string; workerKey: string; value: string }[]; +}; + +type EnvSyncPlan = { + lanIp: string | undefined; + devVarsChanges: DevVarsFileChange[]; + envDevLocalChanges: EnvDevLocalChange[]; + secretStoreWarnings: SecretStoreWarning[]; + consistencyWarnings: ConsistencyWarning[]; + missingEnvLocal: boolean; +}; + +// --------------------------------------------------------------------------- +// Annotation types +// --------------------------------------------------------------------------- + +type Annotation = + | { type: 'passthrough' } + | { type: 'from'; envLocalKey: string } + | { type: 'url'; services: { name: string; path?: string }[] } + | { type: 'pkcs8' }; + +type ExampleEntry = { + key: string; + defaultValue: string; + annotation: Annotation; +}; + +// --------------------------------------------------------------------------- +// Public API result types +// --------------------------------------------------------------------------- + +type SyncResult = { + ok: boolean; + changed: number; + missing: number; +}; + +type CheckResult = { + ok: boolean; + envLocalExists: boolean; + missing: number; + workerCount: number; +}; + +export type { + KeyChange, + DevVarsFileChange, + EnvDevLocalChange, + SecretStoreBinding, + SecretStoreWarning, + ConsistencyWarning, + EnvSyncPlan, + Annotation, + ExampleEntry, + SyncResult, + CheckResult, +}; diff --git a/dev/local/package.json b/dev/local/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/dev/local/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/dev/local/runner.ts b/dev/local/runner.ts new file mode 100644 index 0000000000..41241400a8 --- /dev/null +++ b/dev/local/runner.ts @@ -0,0 +1,301 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import { getService } from './services'; +import { + createWindow, + sendKeys, + sendInterrupt, + listWindows, + killWindow, + killPane, + joinPane, + breakPane, + countPanes, + findServicePane, + selectPane, + setPaneTitle, + setMainLeftLayout, +} from './tmux'; + +// --------------------------------------------------------------------------- +// Repo root +// --------------------------------------------------------------------------- + +export function findRepoRoot(): string { + let dir = import.meta.dirname; + for (let i = 0; i < 20; i++) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg.name === 'kilocode-backend') return dir; + } catch { + // Not valid JSON, keep walking + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("Could not find repo root (package.json with name 'kilocode-backend')"); +} + +// --------------------------------------------------------------------------- +// Port probing +// --------------------------------------------------------------------------- + +export function probePort(port: number, timeoutMs = 500): Promise { + return new Promise(resolve => { + const socket = new net.Socket(); + socket.setTimeout(timeoutMs); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(port, '127.0.0.1'); + }); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function waitForPort(port: number, name: string, maxWaitMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + if (await probePort(port)) return; + await sleep(500); + } + console.warn(`⚠ ${name} on port ${port} did not become ready within ${maxWaitMs / 1000}s`); +} + +// --------------------------------------------------------------------------- +// Command building +// --------------------------------------------------------------------------- + +export function buildStartCommand(serviceName: string): string { + const svc = getService(serviceName); + const parts: string[] = []; + + if (svc.dir !== '.') parts.push(`cd ${svc.dir}`); + parts.push(svc.command.join(' ')); + + return parts.join(' && '); +} + +// --------------------------------------------------------------------------- +// Tmux service lifecycle +// --------------------------------------------------------------------------- + +export function startServiceInTmux(sessionName: string, serviceName: string): void { + const svc = getService(serviceName); + createWindow(sessionName, serviceName); + if (svc.type === 'infra') { + sendKeys( + sessionName, + serviceName, + `docker compose -f dev/docker-compose.yml logs -f ${serviceName}` + ); + } else { + sendKeys(sessionName, serviceName, buildStartCommand(serviceName)); + } +} + +export function stopServiceInTmux(sessionName: string, serviceName: string): void { + const svc = getService(serviceName); + const windows = listWindows(sessionName); + const win = windows.find(w => w.name === serviceName); + if (!win) return; + if (svc.type !== 'infra') { + sendInterrupt(sessionName, win.index); + } + killWindow(sessionName, win.index); +} + +const SIDEBAR_WIDTH = 40; + +/** + * Replace all right-side panes in window 0 with the given service panes. + * + * Uses join-pane to move each service's pane from its own window into window 0. + * The process travels with its pane β€” no ghost shells. + * Before joining, any currently joined panes are broken back out to named windows. + * + * currentPaneNames: comma-separated service names currently shown in panes 1+ + * (empty string if pane 1 is the initial empty split from startup, which gets killed). + */ +function setRightPanes( + sessionName: string, + serviceNames: string[], + currentPaneNames: string +): void { + // Break all current right-side panes (>= 1) back to their own named windows + const total = countPanes(sessionName, 0); + const current = currentPaneNames ? currentPaneNames.split(',') : []; + // Break in reverse order to avoid index shifts + for (let i = total - 1; i >= 1; i--) { + const name = current[i - 1]; + if (name) { + // Service pane β€” break back to its named window + try { + breakPane(sessionName, 0, i, name); + } catch { + // If break fails, just kill the pane + try { + killPane(sessionName, 0, i); + } catch { + /* ignore */ + } + } + } else { + // Empty shell from initial startup split β€” just kill it + try { + killPane(sessionName, 0, i); + } catch { + /* ignore */ + } + } + } + + if (serviceNames.length === 0) return; + + // Join first service horizontally (right of sidebar pane 0) + const firstWin = listWindows(sessionName).find(w => w.name === serviceNames[0]); + if (!firstWin) return; + joinPane(sessionName, firstWin.index, 0, 0, 0, 'h'); + + // Join subsequent services vertically below pane 1 (stacked in the right column) + for (let i = 1; i < serviceNames.length; i++) { + const win = listWindows(sessionName).find(w => w.name === serviceNames[i]); + if (!win) continue; + try { + joinPane(sessionName, win.index, 0, 0, i, 'v'); + } catch { + // skip service if join fails + } + } + + // main-vertical keeps sidebar as the fixed left column with fixed width; + // distributes right-column panes equally in height + try { + setMainLeftLayout(sessionName, 0, SIDEBAR_WIDTH); + } catch { + // best-effort + } + + // Label each right-side pane so pane border titles show the service name + for (let i = 0; i < serviceNames.length; i++) { + try { + setPaneTitle(sessionName, 0, i + 1, serviceNames[i]); + } catch { + /* best-effort */ + } + } + + selectPane(sessionName, 0, 0); +} + +/** + * Show a single service in the right pane of window 0. + * The service's pane is moved (join-pane) from its own window into window 0. + * Returns the service name now shown (unchanged on failure). + */ +export function showServiceInTmux( + sessionName: string, + serviceName: string, + currentPaneNames: string, + currentViewedIsGroup: boolean +): string { + if (serviceName === currentPaneNames && !currentViewedIsGroup) return currentPaneNames; + try { + setRightPanes(sessionName, [serviceName], currentPaneNames); + return serviceName; + } catch { + return currentPaneNames; + } +} + +/** + * Show all running services in a group as stacked panes in the right column of window 0. + * Returns a comma-joined string of service names shown. + */ +export function showGroupInTmux( + sessionName: string, + serviceNames: string[], + currentPaneNames: string, + currentViewedIsGroup: boolean +): string { + if (serviceNames.length === 0) return currentPaneNames; + try { + setRightPanes(sessionName, serviceNames, currentPaneNames); + return serviceNames.join(','); + } catch { + return currentPaneNames; + } +} + +export function restartServiceInTmux(sessionName: string, serviceName: string): void { + const svc = getService(serviceName); + if (svc.type === 'infra') return; + const cmd = buildStartCommand(serviceName); + // Find the service wherever it lives (own window or joined into window 0) + const pane = findServicePane(sessionName, serviceName); + if (!pane) return; + sendInterrupt(sessionName, pane.windowIndex, pane.paneIndex); + setTimeout(() => sendKeys(sessionName, pane.windowIndex, cmd, pane.paneIndex), 1000); +} + +// --------------------------------------------------------------------------- +// Infrastructure +// --------------------------------------------------------------------------- + +export async function startInfra(repoRoot: string, serviceNames: string[]): Promise { + const infraServices = serviceNames.filter(name => getService(name).type === 'infra'); + if (infraServices.length === 0) return; + execSync('docker compose -f dev/docker-compose.yml up -d', { cwd: repoRoot, stdio: 'inherit' }); + for (const name of infraServices) { + const svc = getService(name); + const maxWait = name === 'postgres' ? 30_000 : 15_000; + await waitForPort(svc.port, name, maxWait); + } +} + +// --------------------------------------------------------------------------- +// Env value helpers (used for capture-service coordination) +// --------------------------------------------------------------------------- + +export function readEnvValue(filePath: string, key: string): string | undefined { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } + const match = new RegExp(`^${key}=(.*)$`, 'm').exec(content); + return match ? match[1] : undefined; +} + +export async function waitForEnvValueChange( + filePath: string, + key: string, + previousValue: string | undefined, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const current = readEnvValue(filePath, key); + if (current !== undefined && current !== previousValue) return true; + await sleep(500); + } + return false; +} diff --git a/dev/local/scripts/start-stripe.ts b/dev/local/scripts/start-stripe.ts new file mode 100644 index 0000000000..a14e43ec80 --- /dev/null +++ b/dev/local/scripts/start-stripe.ts @@ -0,0 +1,67 @@ +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); +const envFilePath = path.join(repoRoot, '.env.development.local'); + +function updateEnvValue(filePath: string, key: string, value: string): void { + let content = ''; + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, 'utf-8'); + } + + const pattern = new RegExp(`^${key}=.*`, 'm'); + + if (pattern.test(content)) { + content = content.replace(pattern, `${key}=${value}`); + } else { + content = content.endsWith('\n') || content === '' ? content : content + '\n'; + content += `${key}=${value}\n`; + } + + fs.writeFileSync(filePath, content); +} + +if (spawnSync('stripe', ['--version'], { stdio: 'ignore' }).error) { + console.error( + 'stripe CLI not found on PATH. Install it:\n https://docs.stripe.com/stripe-cli#install\n brew install stripe/stripe-cli/stripe' + ); + process.exit(1); +} + +console.log('Starting Stripe webhook listener...'); + +let secretPattern: RegExp | null = /whsec_[a-zA-Z0-9]+/; + +const child = spawn('pnpm', ['run', 'stripe'], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: repoRoot, +}); + +function handleOutput(data: Buffer) { + process.stdout.write(data); + + if (!secretPattern) return; + const match = data.toString().match(secretPattern); + if (!match) return; + + const secret = match[0]; + updateEnvValue(envFilePath, 'STRIPE_WEBHOOK_SECRET', `"${secret}"`); + + console.log('\nSet STRIPE_WEBHOOK_SECRET in .env.development.local'); + + // Only capture once + secretPattern = null; +} + +child.stdout.on('data', handleOutput); +child.stderr.on('data', handleOutput); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => child.kill(signal)); +} + +child.on('close', code => { + process.exit(code ?? 1); +}); diff --git a/dev/local/scripts/start-tunnel.ts b/dev/local/scripts/start-tunnel.ts new file mode 100644 index 0000000000..4bd70f4693 --- /dev/null +++ b/dev/local/scripts/start-tunnel.ts @@ -0,0 +1,125 @@ +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); +const devVarsPath = path.join(repoRoot, 'kiloclaw/.dev.vars'); + +type TunnelConfig = { + tunnelName: string; + tunnelHostname: string; +}; + +function parseConfFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + const result: Record = {}; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; + const eqIndex = trimmed.indexOf('='); + const key = trimmed.slice(0, eqIndex).trim(); + const raw = trimmed.slice(eqIndex + 1).trim(); + result[key] = raw.replace(/^["']|["']$/g, ''); + } + return result; +} + +function loadTunnelConfig(): TunnelConfig { + const globalPath = path.join(os.homedir(), '.config/kiloclaw/dev-start.conf'); + const localPath = path.join(repoRoot, 'kiloclaw/scripts/.dev-start.conf'); + + const merged = { + ...parseConfFile(globalPath), + ...parseConfFile(localPath), + }; + + return { + tunnelName: merged['TUNNEL_NAME'] ?? '', + tunnelHostname: merged['TUNNEL_HOSTNAME'] ?? '', + }; +} + +function updateEnvValue(filePath: string, key: string, value: string): void { + let content = ''; + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, 'utf-8'); + } + + const activePattern = new RegExp(`^${key}=.*`, 'm'); + const commentedPattern = new RegExp(`^# ${key}=.*`, 'm'); + + if (activePattern.test(content)) { + content = content.replace(activePattern, `${key}=${value}`); + } else if (commentedPattern.test(content)) { + content = content.replace(commentedPattern, `${key}=${value}`); + } else { + content = content.endsWith('\n') || content === '' ? content : content + '\n'; + content += `${key}=${value}\n`; + } + + fs.writeFileSync(filePath, content); +} + +if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { + console.error( + 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' + ); + process.exit(1); +} + +const port = process.argv[2] ?? '3000'; +const config = loadTunnelConfig(); + +let command: string; +let args: string[]; +let urlPattern: RegExp | null = null; + +if (config.tunnelName) { + command = 'cloudflared'; + args = ['tunnel', 'run', config.tunnelName]; + console.log(`Named tunnel: ${config.tunnelName} -> ${config.tunnelHostname}`); + + if (config.tunnelHostname) { + const apiUrl = `https://${config.tunnelHostname}/api/gateway/`; + updateEnvValue(devVarsPath, 'KILOCODE_API_BASE_URL', apiUrl); + } +} else { + command = 'cloudflared'; + args = ['tunnel', '--url', `http://localhost:${port}`]; + urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/; + console.log(`Starting quick tunnel -> http://localhost:${port}...`); +} + +const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], +}); + +function handleOutput(data: Buffer) { + process.stderr.write(data); + + if (!urlPattern) return; + const match = data.toString().match(urlPattern); + if (!match) return; + + const url = match[0]; + const apiUrl = `${url}/api/gateway/`; + updateEnvValue(devVarsPath, 'KILOCODE_API_BASE_URL', apiUrl); + + console.log(`\nTunnel URL: ${url}`); + console.log(`Set KILOCODE_API_BASE_URL=${apiUrl}`); + + // Only capture once + urlPattern = null; +} + +child.stdout.on('data', handleOutput); +child.stderr.on('data', handleOutput); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => child.kill(signal)); +} + +child.on('close', code => { + process.exit(code ?? 1); +}); diff --git a/dev/local/services.ts b/dev/local/services.ts new file mode 100644 index 0000000000..cfd868e8cc --- /dev/null +++ b/dev/local/services.ts @@ -0,0 +1,448 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type ServiceType = 'infra' | 'nextjs' | 'worker' | 'process'; + +type ServiceGroup = { + id: string; + label: string; + alwaysOn: boolean; + groupDependsOn?: string[]; + /** When true, an empty spacer row is rendered above this group in the sidebar. */ + sectionBreakBefore?: boolean; +}; + +const groups: ServiceGroup[] = [ + { id: 'core', label: 'Core', alwaysOn: true }, + { id: 'kiloclaw', label: 'KiloClaw', alwaysOn: false, sectionBreakBefore: true }, + { id: 'cloud-agent', label: 'Cloud Agent', alwaysOn: false }, + { id: 'code-review', label: 'Code Review', alwaysOn: false, groupDependsOn: ['cloud-agent'] }, + { id: 'app-builder', label: 'App Builder', alwaysOn: false, groupDependsOn: ['cloud-agent'] }, + { id: 'gastown', label: 'Gastown', alwaysOn: false }, + { + id: 'auto-triage', + label: 'Auto Triage', + alwaysOn: false, + groupDependsOn: ['cloud-agent'], + sectionBreakBefore: true, + }, + { id: 'auto-fix', label: 'Auto Fix', alwaysOn: false, groupDependsOn: ['cloud-agent'] }, + { id: 'deploy', label: 'Deploy', alwaysOn: false }, + { id: 'observability', label: 'Observability', alwaysOn: false }, +]; + +type ServiceDef = { + name: string; + type: ServiceType; + dir: string; + port: number; + dependsOn: string[]; + command: string[]; + group: string; + useLanIp?: boolean; +}; + +type ServiceMeta = { + group: string; + dependsOn: string[]; + dir?: string; + useLanIp?: boolean; +}; + +const serviceMeta: Record = { + // core + nextjs: { group: 'core', dependsOn: ['postgres', 'redis'] }, + postgres: { group: 'core', dependsOn: [] }, + redis: { group: 'core', dependsOn: [] }, + // cloud-agent + 'cloud-agent-next': { + group: 'cloud-agent', + dependsOn: ['postgres', 'nextjs', 'cloudflare-session-ingest'], + useLanIp: true, + }, + 'cloudflare-webhook-agent-ingest': { + group: 'cloud-agent', + dependsOn: ['cloud-agent-next', 'nextjs', 'postgres'], + }, + 'cloudflare-session-ingest': { group: 'cloud-agent', dependsOn: ['postgres'] }, + // app-builder + 'cloudflare-app-builder': { + group: 'app-builder', + dependsOn: ['cloudflare-db-proxy', 'cloudflare-git-token-service'], + }, + 'cloudflare-db-proxy': { group: 'app-builder', dependsOn: ['postgres'] }, + 'cloudflare-git-token-service': { group: 'app-builder', dependsOn: ['postgres'] }, + // code-review + 'cloudflare-code-review-infra': { + group: 'code-review', + dependsOn: ['cloud-agent-next', 'nextjs'], + }, + // auto-triage + 'cloudflare-auto-triage-infra': { + group: 'auto-triage', + dependsOn: ['cloud-agent-next', 'nextjs'], + }, + // auto-fix + 'cloudflare-auto-fix-infra': { group: 'auto-fix', dependsOn: ['cloud-agent-next', 'nextjs'] }, + // deploy + 'cloudflare-deploy-builder': { + group: 'deploy', + dependsOn: ['nextjs'], + dir: 'cloudflare-deploy-infra/builder', + }, + 'cloudflare-deploy-dispatcher': { + group: 'deploy', + dependsOn: [], + dir: 'cloudflare-deploy-infra/dispatcher', + }, + // kiloclaw + 'kiloclaw-tunnel': { group: 'kiloclaw', dependsOn: [] }, + 'kiloclaw-stripe': { group: 'kiloclaw', dependsOn: [] }, + kiloclaw: { group: 'kiloclaw', dependsOn: ['postgres', 'kiloclaw-tunnel'] }, + // observability + 'cloudflare-o11y': { group: 'observability', dependsOn: ['nextjs'] }, + 'cloudflare-ai-attribution': { group: 'observability', dependsOn: [] }, + // gastown + 'cloudflare-gastown': { + group: 'gastown', + dependsOn: ['postgres', 'cloudflare-git-token-service', 'nextjs'], + }, +}; + +function dockerComposeUp(service: string): string[] { + return ['docker', 'compose', '-f', 'dev/docker-compose.yml', 'up', '-d', service]; +} + +function isPrimaryWorktree(): boolean { + const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf-8' }).trim(); + const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim(); + return path.resolve(gitDir) === path.resolve(gitCommonDir); +} + +function computeAutoOffset(): number { + if (isPrimaryWorktree()) return 0; + + const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + const slug = path.basename(root); + + let hash = 0; + for (let i = 0; i < slug.length; i++) { + hash = ((hash << 5) - hash + slug.charCodeAt(i)) | 0; + } + return (((hash % 50) + 50) % 50) * 100; +} + +function getPortOffset(): number { + const explicit = process.env.KILO_PORT_OFFSET; + if (explicit === undefined) return 0; // disabled by default + if (explicit === 'auto') return computeAutoOffset(); + return Number(explicit); +} + +export const portOffset = getPortOffset(); + +// --------------------------------------------------------------------------- +// Wrangler config discovery +// --------------------------------------------------------------------------- + +function stripJsonComments(text: string): string { + let result = ''; + let i = 0; + while (i < text.length) { + // Strings: copy verbatim (preserves "//" inside strings) + if (text[i] === '"') { + const start = i; + i++; // opening quote + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; // skip escaped char + i++; + } + i++; // closing quote + result += text.slice(start, i); + continue; + } + // Line comment + if (text[i] === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + continue; + } + // Block comment + if (text[i] === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; // skip */ + continue; + } + result += text[i]; + i++; + } + // Remove trailing commas before } or ] + return result.replace(/,(\s*[}\]])/g, '$1'); +} + +function readWranglerPort(dir: string): number { + const configPath = path.join(dir, 'wrangler.jsonc'); + if (!fs.existsSync(configPath)) { + throw new Error(`No wrangler.jsonc found in ${dir}`); + } + const text = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(stripJsonComments(text)); + const port = config?.dev?.port; + if (typeof port !== 'number') { + throw new Error(`No dev.port in ${configPath}`); + } + return port; +} + +// --------------------------------------------------------------------------- +// Build service definitions from serviceMeta + wrangler.jsonc +// --------------------------------------------------------------------------- + +const INFRA_PORTS: Record = { postgres: 5432, redis: 6379 }; + +function buildServiceDefs(): ServiceDef[] { + const repoRoot = path.resolve(import.meta.dirname, '../..'); + const defs: ServiceDef[] = []; + + for (const [name, meta] of Object.entries(serviceMeta)) { + const dir = meta.dir ?? name; + + if (name === 'nextjs') { + defs.push({ + name, + type: 'nextjs', + dir: '.', + port: 3000 + portOffset, + dependsOn: meta.dependsOn, + command: ['pnpm', 'next', 'dev'], + group: meta.group, + }); + continue; + } + + if (name in INFRA_PORTS) { + defs.push({ + name, + type: 'infra', + dir: 'dev', + port: INFRA_PORTS[name], + dependsOn: meta.dependsOn, + command: dockerComposeUp(name), + group: meta.group, + }); + continue; + } + + if (name === 'kiloclaw-tunnel') { + const nextjsPort = 3000 + portOffset; + defs.push({ + name, + type: 'process', + dir: '.', + port: 0, + dependsOn: meta.dependsOn, + command: ['tsx', 'dev/local/scripts/start-tunnel.ts', String(nextjsPort)], + group: meta.group, + }); + continue; + } + + if (name === 'kiloclaw-stripe') { + defs.push({ + name, + type: 'process', + dir: '.', + port: 0, + dependsOn: meta.dependsOn, + command: ['tsx', 'dev/local/scripts/start-stripe.ts'], + group: meta.group, + }); + continue; + } + + // Worker β€” read port from wrangler.jsonc + const basePort = readWranglerPort(path.join(repoRoot, dir)); + const port = basePort + portOffset; + const inspectorPort = port + 10000; + + defs.push({ + name, + type: 'worker', + dir, + port, + dependsOn: meta.dependsOn, + command: [ + 'pnpm', + 'run', + 'dev', + '--', + '--port', + String(port), + '--inspector-port', + String(inspectorPort), + '--ip', + '0.0.0.0', + ], + group: meta.group, + ...(meta.useLanIp ? { useLanIp: true } : {}), + }); + } + + return defs; +} + +const serviceDefs = buildServiceDefs(); + +export const services = new Map(serviceDefs.map(s => [s.name, s])); + +export const shortcuts: Record = { + app: ['nextjs'], + 'app-builder': [ + 'nextjs', + 'cloud-agent-next', + 'cloudflare-session-ingest', + 'cloudflare-db-proxy', + 'cloudflare-git-token-service', + 'cloudflare-app-builder', + ], + agents: ['cloud-agent-next', 'nextjs', 'cloudflare-session-ingest'], + all: serviceDefs.map(s => s.name), +}; + +export function resolveTransitiveDeps(targets: string[]): string[] { + const result = new Set(); + const stack = [...targets]; + + while (stack.length > 0) { + const name = stack.pop()!; + if (result.has(name)) continue; + const svc = services.get(name); + if (!svc) throw new Error(`Unknown service: ${name}`); + result.add(name); + for (const dep of svc.dependsOn) { + if (!result.has(dep)) { + stack.push(dep); + } + } + } + + return [...result]; +} + +// Kahn's algorithm β€” throws on cycles +export function topologicalSort(serviceNames: string[]): string[] { + const nameSet = new Set(serviceNames); + const inDegree = new Map(); + const adjacency = new Map(); + + for (const name of nameSet) { + inDegree.set(name, 0); + adjacency.set(name, []); + } + + for (const name of nameSet) { + const svc = services.get(name); + if (!svc) throw new Error(`Unknown service: ${name}`); + for (const dep of svc.dependsOn) { + if (!nameSet.has(dep)) continue; + adjacency.get(dep)!.push(name); + inDegree.set(name, (inDegree.get(name) ?? 0) + 1); + } + } + + const queue: string[] = []; + for (const [name, degree] of inDegree) { + if (degree === 0) queue.push(name); + } + + const sorted: string[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + sorted.push(current); + for (const neighbor of adjacency.get(current) ?? []) { + const newDegree = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDegree); + if (newDegree === 0) queue.push(neighbor); + } + } + + if (sorted.length !== nameSet.size) { + throw new Error('Cycle detected in service dependency graph'); + } + + return sorted; +} + +const groupIds = new Set(groups.map(g => g.id)); + +export function resolveTargets(targets: string[]): string[] { + const groupIdsToExpand: string[] = []; + for (const target of targets) { + if (target in shortcuts) { + groupIdsToExpand.push(...shortcuts[target].map(name => services.get(name)!.group)); + } else if (groupIds.has(target)) { + groupIdsToExpand.push(target); + } else if (services.has(target)) { + groupIdsToExpand.push(services.get(target)!.group); + } else { + const validTargets = [...services.keys(), ...groupIds, ...Object.keys(shortcuts)].join(', '); + throw new Error(`Unknown target: ${target}. Valid targets: ${validTargets}`); + } + } + const uniqueGroupIds = [...new Set(groupIdsToExpand)]; + const allNames = resolveGroups(resolveGroupTransitiveDeps(uniqueGroupIds)); + return topologicalSort(resolveTransitiveDeps(allNames)); +} + +export function getService(name: string): ServiceDef { + const svc = services.get(name); + if (!svc) throw new Error(`Unknown service: ${name}`); + return svc; +} + +export function getPortMap(): Map { + return new Map([...services.entries()].map(([name, svc]) => [name, svc.port])); +} + +export function getGroups(): ServiceGroup[] { + return groups; +} + +export function getGroup(groupId: string): ServiceGroup { + const g = groups.find(group => group.id === groupId); + if (!g) throw new Error(`Unknown group: ${groupId}`); + return g; +} + +export function getGroupServiceNames(groupId: string): string[] { + return serviceDefs.filter(s => s.group === groupId).map(s => s.name); +} + +export function getAlwaysOnGroupIds(): string[] { + return groups.filter(g => g.alwaysOn).map(g => g.id); +} + +export function resolveGroups(groupIds: string[]): string[] { + const directNames = groupIds.flatMap(id => getGroupServiceNames(id)); + return topologicalSort(resolveTransitiveDeps(directNames)); +} + +/** Resolves transitive group-level dependencies (groupDependsOn), returning all group IDs needed. */ +export function resolveGroupTransitiveDeps(groupIds: string[]): string[] { + const result = new Set(); + const stack = [...groupIds]; + while (stack.length > 0) { + const id = stack.pop()!; + if (result.has(id)) continue; + const group = groups.find(g => g.id === id); + if (!group) throw new Error(`Unknown group: ${id}`); + result.add(id); + for (const dep of group.groupDependsOn ?? []) { + if (!result.has(dep)) stack.push(dep); + } + } + return [...result]; +} + +export type { ServiceDef, ServiceType, ServiceGroup }; diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts new file mode 100644 index 0000000000..b3d0fb3357 --- /dev/null +++ b/dev/local/tmux.ts @@ -0,0 +1,410 @@ +import { execSync, execFileSync } from 'node:child_process'; +import * as path from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type WindowInfo = { + index: number; + name: string; +}; + +// --------------------------------------------------------------------------- +// Worktree root (cached) +// --------------------------------------------------------------------------- + +let cachedWorktreeRoot: string | undefined; + +function getWorktreeRoot(): string { + if (cachedWorktreeRoot === undefined) { + cachedWorktreeRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + } + return cachedWorktreeRoot; +} + +// --------------------------------------------------------------------------- +// Session management +// --------------------------------------------------------------------------- + +function getSessionName(): string { + const root = getWorktreeRoot(); + const slug = path.basename(root); + return `kilo-dev-${slug}`; +} + +function sessionExists(sessionName?: string): boolean { + const name = sessionName ?? getSessionName(); + try { + execSync(`tmux has-session -t ${name}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function findOtherKiloDevSessions(): string[] { + const ownSession = getSessionName(); + try { + const output = execSync('tmux list-sessions -F "#{session_name}"', { + encoding: 'utf-8', + }).trim(); + if (output === '') return []; + return output.split('\n').filter(name => name.startsWith('kilo-dev-') && name !== ownSession); + } catch { + // tmux server not running or other error + return []; + } +} + +function createSession(sessionName: string): void { + const repoRoot = getWorktreeRoot(); + execSync(`tmux new-session -d -s ${sessionName} -n dashboard -c ${repoRoot}`, { + stdio: 'ignore', + }); + // Enable mouse so clicking the status-bar window tabs works + execSync(`tmux set-option -t ${sessionName} mouse on`, { stdio: 'ignore' }); + // Enable focus events so tmux detects terminal tab switches (needed for client-focus-in hook) + execSync(`tmux set-option -t ${sessionName} focus-events on`, { stdio: 'ignore' }); + // Copy tmux selection to system clipboard (macOS pbcopy) + execSync(`tmux set-option -t ${sessionName} set-clipboard on`, { stdio: 'ignore' }); + execSync( + `tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, + { stdio: 'ignore' } + ); + execSync( + `tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`, + { stdio: 'ignore' } + ); + // Show window list in status bar with more visible formatting + execSync(`tmux set-option -t ${sessionName} status-position bottom`, { stdio: 'ignore' }); + execSync(`tmux set-option -t ${sessionName} window-status-format " #I:#W "`, { stdio: 'ignore' }); + execSync(`tmux set-option -t ${sessionName} window-status-current-format " #I:#W "`, { + stdio: 'ignore', + }); +} + +function killSession(sessionName: string): void { + try { + execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' }); + } catch { + // Session doesn't exist β€” that's fine + } +} + +function attachSession(sessionName: string): void { + execFileSync('tmux', ['attach-session', '-t', sessionName], { stdio: 'inherit' }); +} + +// --------------------------------------------------------------------------- +// Window management +// --------------------------------------------------------------------------- + +function createWindow(sessionName: string, windowName: string): number { + const output = execSync( + `tmux new-window -d -t ${sessionName} -n ${windowName} -P -F "#{window_index}"`, + { encoding: 'utf-8' } + ).trim(); + return parseInt(output, 10); +} + +function paneTarget(sessionName: string, windowTarget: string | number, pane?: number): string { + return pane !== undefined + ? `${sessionName}:${windowTarget}.${pane}` + : `${sessionName}:${windowTarget}`; +} + +function sendKeys( + sessionName: string, + windowTarget: string | number, + keys: string, + pane?: number +): void { + execSync( + `tmux send-keys -t ${paneTarget(sessionName, windowTarget, pane)} ${escapeForShell(keys)} Enter`, + { + stdio: 'ignore', + } + ); +} + +function sendInterrupt(sessionName: string, windowTarget: string | number, pane?: number): void { + execSync(`tmux send-keys -t ${paneTarget(sessionName, windowTarget, pane)} C-c`, { + stdio: 'ignore', + }); +} + +function selectWindow(sessionName: string, windowTarget: string | number): void { + execSync(`tmux select-window -t ${sessionName}:${windowTarget}`, { stdio: 'ignore' }); +} + +function listWindows(sessionName: string): WindowInfo[] { + try { + const output = execSync( + `tmux list-windows -t ${sessionName} -F "#{window_index}:#{window_name}"`, + { encoding: 'utf-8' } + ).trim(); + if (output === '') return []; + return output.split('\n').map(line => { + const colonIdx = line.indexOf(':'); + return { + index: parseInt(line.slice(0, colonIdx), 10), + name: line.slice(colonIdx + 1), + }; + }); + } catch { + return []; + } +} + +function renameWindow(sessionName: string, windowTarget: string | number, newName: string): void { + execSync(`tmux rename-window -t ${sessionName}:${windowTarget} ${newName}`, { stdio: 'ignore' }); +} + +// --------------------------------------------------------------------------- +// Pane management +// --------------------------------------------------------------------------- + +function splitWindowHorizontal(sessionName: string, windowTarget: string | number): void { + execSync(`tmux split-window -h -t ${sessionName}:${windowTarget}`, { stdio: 'ignore' }); +} + +/** Split a specific pane top/bottom (vertical split = new pane below). Returns the new pane index. */ +function splitPaneVertical( + sessionName: string, + windowTarget: string | number, + pane: number +): number { + const output = execSync( + `tmux split-window -v -t ${sessionName}:${windowTarget}.${pane} -P -F "#{pane_index}"`, + { encoding: 'utf-8' } + ).trim(); + return parseInt(output, 10); +} + +function resizePane( + sessionName: string, + windowTarget: string | number, + pane: number, + width: number +): void { + execSync(`tmux resize-pane -t ${sessionName}:${windowTarget}.${pane} -x ${width}`, { + stdio: 'ignore', + }); +} + +/** + * Apply main-vertical layout with a fixed sidebar width that survives client resizes. + * + * main-vertical = large pane on the left, rest stacked vertically on the right. + * The main-pane-width window option controls pane 0's width; right-column panes + * share the remainder with equal height automatically. + * + * The client-resized hook uses resize-pane to pin pane 0's width β€” this is more + * reliable than re-running select-layout because tmux proportionally scales panes + * before the hook fires, and select-layout may not restore the correct width. + */ +function setMainLeftLayout( + sessionName: string, + windowTarget: string | number, + mainPaneWidth: number +): void { + const target = `${sessionName}:${windowTarget}`; + // Tell main-vertical how wide pane 0 should be + execSync(`tmux set-window-option -t ${target} main-pane-width ${mainPaneWidth}`, { + stdio: 'ignore', + }); + // Apply: pane 0 = mainPaneWidth cols, right-column panes equally share the rest + execSync(`tmux select-layout -t ${target} main-vertical`, { stdio: 'ignore' }); + // Force pane 0 back to the fixed width on resize and on terminal tab switch + const resizeCmd = `resize-pane -t ${target}.0 -x ${mainPaneWidth}`; + execSync(`tmux set-hook -t ${sessionName} client-resized "${resizeCmd}"`, { stdio: 'ignore' }); + execSync(`tmux set-hook -t ${sessionName} client-focus-in "${resizeCmd}"`, { stdio: 'ignore' }); +} + +function swapPane( + sessionName: string, + srcWindow: string | number, + srcPane: number, + dstWindow: string | number, + dstPane: number +): void { + execSync( + `tmux swap-pane -s ${sessionName}:${srcWindow}.${srcPane} -t ${sessionName}:${dstWindow}.${dstPane}`, + { stdio: 'ignore' } + ); +} + +function killWindow(sessionName: string, windowTarget: string | number): void { + execSync(`tmux kill-window -t ${sessionName}:${windowTarget}`, { stdio: 'ignore' }); +} + +/** Kill a single pane (kills the process running in it). */ +function killPane(sessionName: string, windowTarget: string | number, pane: number): void { + execSync(`tmux kill-pane -t ${sessionName}:${windowTarget}.${pane}`, { stdio: 'ignore' }); +} + +/** + * Move a pane from its current window into dstWindow, splitting dstPane. + * The pane's process moves with it β€” no ghost shells, no pty duplication. + * -h = new pane to the right of dstPane + * -v = new pane below dstPane + */ +function joinPane( + sessionName: string, + srcWindow: string | number, + srcPane: number, + dstWindow: string | number, + dstPane: number, + splitDirection: 'h' | 'v' +): void { + execSync( + `tmux join-pane -${splitDirection} -s ${sessionName}:${srcWindow}.${srcPane} -t ${sessionName}:${dstWindow}.${dstPane}`, + { stdio: 'ignore' } + ); +} + +/** + * Move a pane out of its window into a new detached window with the given name. + * Returns the new window index. The process keeps running in the new window. + */ +function breakPane( + sessionName: string, + windowTarget: string | number, + pane: number, + newWindowName: string +): number { + const output = execSync( + `tmux break-pane -d -s ${sessionName}:${windowTarget}.${pane} -n ${newWindowName} -P -F "#{window_index}"`, + { encoding: 'utf-8' } + ).trim(); + return parseInt(output, 10); +} + +/** Count panes in a window */ +function countPanes(sessionName: string, windowTarget: string | number): number { + try { + const output = execSync( + `tmux list-panes -t ${sessionName}:${windowTarget} -F "#{pane_index}"`, + { encoding: 'utf-8' } + ).trim(); + return output === '' ? 0 : output.split('\n').length; + } catch { + return 0; + } +} + +type PaneInfo = { windowIndex: number; paneIndex: number }; + +/** + * Find which window+pane a service currently occupies across the entire session. + * Works whether the service is in its own named window or joined into window 0. + */ +function findServicePane(sessionName: string, serviceName: string): PaneInfo | undefined { + try { + // Check named windows first (service in its own window) + const windows = listWindows(sessionName); + const win = windows.find(w => w.name === serviceName); + if (win) return { windowIndex: win.index, paneIndex: 0 }; + // Not in a named window β€” check window 0 panes by title + const output = execSync( + `tmux list-panes -t ${sessionName}:0 -F "#{pane_index}:#{pane_title}"`, + { encoding: 'utf-8' } + ).trim(); + for (const line of output.split('\n')) { + const colonIdx = line.indexOf(':'); + if (line.slice(colonIdx + 1) === serviceName) { + return { windowIndex: 0, paneIndex: parseInt(line.slice(0, colonIdx), 10) }; + } + } + return undefined; + } catch { + return undefined; + } +} + +function selectPane(sessionName: string, windowTarget: string | number, pane: number): void { + execSync(`tmux select-pane -t ${sessionName}:${windowTarget}.${pane}`, { stdio: 'ignore' }); +} + +/** Set a specific pane's title (shown in pane border when pane-border-status is enabled). */ +function setPaneTitle( + sessionName: string, + windowTarget: string | number, + pane: number, + title: string +): void { + execSync( + `tmux select-pane -t ${paneTarget(sessionName, windowTarget, pane)} -T ${escapeForShell(title)}`, + { stdio: 'ignore' } + ); +} + +/** Enable pane border titles on a window. Each pane shows its title in the top border line. */ +function enablePaneBorders(sessionName: string, windowTarget: string | number): void { + const target = `${sessionName}:${windowTarget}`; + execSync(`tmux set-window-option -t ${target} pane-border-status top`, { stdio: 'ignore' }); + execSync(`tmux set-window-option -t ${target} pane-border-format " #{pane_title} "`, { + stdio: 'ignore', + }); + // Prevent shells from overwriting pane titles via OSC escape sequences + execSync(`tmux set-window-option -t ${target} allow-set-title off`, { stdio: 'ignore' }); +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function isTmuxAvailable(): boolean { + try { + execSync('which tmux', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function isInsideTmux(): boolean { + return process.env.TMUX !== undefined && process.env.TMUX !== ''; +} + +// Shell-escape a string for use in tmux send-keys +function escapeForShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export { + getSessionName, + sessionExists, + findOtherKiloDevSessions, + createSession, + killSession, + attachSession, + createWindow, + sendKeys, + sendInterrupt, + selectWindow, + listWindows, + renameWindow, + splitWindowHorizontal, + splitPaneVertical, + resizePane, + setMainLeftLayout, + swapPane, + killWindow, + killPane, + joinPane, + breakPane, + countPanes, + findServicePane, + selectPane, + setPaneTitle, + enablePaneBorders, + isTmuxAvailable, + isInsideTmux, +}; +export type { WindowInfo, PaneInfo }; diff --git a/dev/review/dev-review.sh b/dev/review/dev-review.sh index 4978291d20..196ab0ebad 100755 --- a/dev/review/dev-review.sh +++ b/dev/review/dev-review.sh @@ -7,7 +7,7 @@ set -uo pipefail # # Services: # 1. Root (Next.js) β€” port 3000 -# 2. Session Worker β€” port 8787 (inspector 9230) +# 2. Session Worker β€” port 8800 (inspector 9230) # 3. Review Worker β€” port 8789 (inspector 9231) # 4. Agent Next Worker β€” port 8794 (inspector 9232) # diff --git a/package.json b/package.json index 5accbda8e7..216ffc92ae 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "promo": "pnpm -s script src/scripts/encrypt-promo-codes.ts", - "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts" + "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", + "dev:start": "tsx --tsconfig tsconfig.scripts.json dev/local/cli.ts up", + "dev:stop": "tsx --tsconfig tsconfig.scripts.json dev/local/cli.ts stop", + "dev:env": "tsx --tsconfig tsconfig.scripts.json dev/local/cli.ts env" }, "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", "dependencies": { @@ -164,6 +167,7 @@ "dependency-cruiser": "^17.3.9", "dotenv": "^17.3.1", "husky": "^9.1.7", + "ink": "^6.8.0", "jest": "^30.3.0", "knip": "^5.86.0", "madge": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7ecfb88a6..af9f9b3165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + ink: + specifier: ^6.8.0 + version: 6.8.0(@types/react@19.2.14)(react-devtools-core@6.1.5)(react@19.2.4) jest: specifier: ^30.3.0 version: 30.3.0(@types/node@22.19.15)(esbuild-register@3.6.0(esbuild@0.27.4)) @@ -1869,6 +1872,10 @@ packages: resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -7947,6 +7954,10 @@ packages: resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} engines: {node: '>=14.16'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-html-community@0.0.8: resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} engines: {'0': node >= 0.8.0} @@ -7981,6 +7992,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -8053,6 +8068,10 @@ packages: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -8525,6 +8544,10 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -8533,10 +8556,18 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -8569,6 +8600,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -8675,6 +8710,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -9297,6 +9336,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -10045,6 +10088,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -10355,6 +10402,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.3: resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} @@ -10368,6 +10419,19 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -10440,6 +10504,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} @@ -10459,6 +10527,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-installed-globally@1.0.0: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} @@ -12123,6 +12196,10 @@ packages: pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -12641,6 +12718,12 @@ packages: peerDependencies: react: '>=16.6.0' + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -12917,6 +13000,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ret@0.2.2: resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} engines: {node: '>=4'} @@ -13184,6 +13271,10 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + slugify@1.6.8: resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==} engines: {node: '>=8.0.0'} @@ -13421,6 +13512,14 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -13574,6 +13673,10 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -13608,6 +13711,10 @@ packages: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -13815,6 +13922,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -14298,6 +14409,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -14357,6 +14472,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -14480,6 +14599,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -14570,6 +14692,11 @@ snapshots: dependencies: json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@alloc/quick-lru@5.2.0': {} '@anatine/zod-mock@3.14.0(@faker-js/faker@10.3.0)(zod@4.3.6)': @@ -21948,6 +22075,10 @@ snapshots: ansi-escapes@6.2.1: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-html-community@0.0.8: {} ansi-html@0.0.9: {} @@ -21968,6 +22099,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -22035,6 +22168,8 @@ snapshots: attr-accept@2.2.5: {} + auto-bind@5.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -22601,6 +22736,8 @@ snapshots: clean-stack@2.2.0: {} + cli-boxes@3.0.0: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -22609,8 +22746,17 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-spinners@2.9.2: {} + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + client-only@0.0.1: {} cliui@6.0.0: @@ -22645,6 +22791,10 @@ snapshots: co@4.6.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + collapse-white-space@2.1.0: {} collect-v8-coverage@1.0.3: {} @@ -22741,6 +22891,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -23328,6 +23480,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -24196,6 +24350,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -24548,6 +24704,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inherits@2.0.3: {} inherits@2.0.4: {} @@ -24556,6 +24714,41 @@ snapshots: ini@4.1.1: {} + ink@6.8.0(@types/react@19.2.14)(react-devtools-core@6.1.5)(react@19.2.4): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.45.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.4 + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.0 + terminal-size: 4.0.1 + type-fest: 5.5.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.19.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + react-devtools-core: 6.1.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.7: {} internmap@2.0.3: {} @@ -24608,6 +24801,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-generator-fn@2.1.0: {} is-generator-function@1.1.2: @@ -24626,6 +24823,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} + is-installed-globally@1.0.0: dependencies: global-directory: 4.0.1 @@ -25568,7 +25767,7 @@ snapshots: json-schema-to-ts@3.1.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 ts-algebra: 2.0.0 json-schema-traverse@0.4.1: {} @@ -27335,6 +27534,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + patch-console@2.0.0: {} + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -27451,7 +27652,7 @@ snapshots: polished@4.3.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 possible-typed-array-names@1.1.0: {} @@ -27977,6 +28178,11 @@ snapshots: react: 19.2.4 react-fast-compare: 3.2.2 + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -28044,7 +28250,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) @@ -28345,6 +28551,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + ret@0.2.2: {} retry@0.13.1: {} @@ -28728,6 +28939,11 @@ snapshots: slash@5.1.0: {} + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.8: {} smol-toml@1.6.0: {} @@ -29048,6 +29264,17 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -29179,6 +29406,8 @@ snapshots: tabbable@6.4.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} tailwindcss-animate@1.0.7(tailwindcss@4.2.1): @@ -29218,6 +29447,8 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 + terminal-size@4.0.1: {} + terser-webpack-plugin@5.4.0(@swc/core@1.15.18)(esbuild@0.27.4)(webpack@5.105.4(@swc/core@1.15.18)(esbuild@0.27.4)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -29404,6 +29635,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -30107,6 +30342,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.0 + wordwrap@1.0.0: {} workerd@1.20250906.0: @@ -30202,6 +30441,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@3.0.3: @@ -30298,6 +30543,8 @@ snapshots: yocto-queue@1.2.2: {} + yoga-layout@3.2.1: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 diff --git a/scripts/dev.sh b/scripts/dev.sh index 9d570ba43d..888b737473 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -TARGET_PORT=${PORT:-3000} +OFFSET=${KILO_PORT_OFFSET:-0} +TARGET_PORT=${PORT:-$((3000 + OFFSET))} # Find an available port starting from TARGET_PORT (same behavior as Next.js auto-increment). # Tries TARGET_PORT through TARGET_PORT+9, then falls back to port 0 (OS-assigned). diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 5ae492dd9a..20db96d6ad 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -161,7 +161,7 @@ export const AGENT_ENV_VARS_PUBLIC_KEY = getEnvVariable('AGENT_ENV_VARS_PUBLIC_K // Gastown Service export const GASTOWN_SERVICE_URL = getEnvVariable('GASTOWN_SERVICE_URL') || - (process.env.NODE_ENV === 'production' ? 'https://gastown.kiloapps.io' : 'http://localhost:8787'); + (process.env.NODE_ENV === 'production' ? 'https://gastown.kiloapps.io' : null); export const GASTOWN_CF_ACCESS_CLIENT_ID = getEnvVariable('GASTOWN_SERVICE_CF_ACCESS_CLIENT_ID'); export const GASTOWN_CF_ACCESS_CLIENT_SECRET = getEnvVariable( 'GASTOWN_SERVICE_CF_ACCESS_CLIENT_SECRET' diff --git a/src/routers/admin/gastown-router.ts b/src/routers/admin/gastown-router.ts index 961ad81c2f..c3dc6925d1 100644 --- a/src/routers/admin/gastown-router.ts +++ b/src/routers/admin/gastown-router.ts @@ -243,9 +243,20 @@ const GastownApiResponseSchema = z.union([ z.object({ success: z.literal(false), error: z.string() }), ]); +function requireGastownUrl(): string { + if (!GASTOWN_SERVICE_URL) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'GASTOWN_SERVICE_URL is not configured', + }); + } + return GASTOWN_SERVICE_URL; +} + /** GET request to the Gastown worker, parsing the response with the given schema. */ async function gastownGet(adminUser: User, path: string, schema: z.ZodType): Promise { - const response = await fetch(`${GASTOWN_SERVICE_URL}${path}`, { + const baseUrl = requireGastownUrl(); + const response = await fetch(`${baseUrl}${path}`, { method: 'GET', headers: buildAdminHeaders(adminUser), }); @@ -272,7 +283,8 @@ async function gastownPatch( body: unknown, schema: z.ZodType ): Promise { - const response = await fetch(`${GASTOWN_SERVICE_URL}${path}`, { + const baseUrl = requireGastownUrl(); + const response = await fetch(`${baseUrl}${path}`, { method: 'PATCH', headers: buildAdminHeaders(adminUser), body: JSON.stringify(body), @@ -305,8 +317,9 @@ async function gastownTrpcGet( input: unknown, schema: z.ZodType ): Promise { + const baseUrl = requireGastownUrl(); const headers = buildAdminHeaders(adminUser); - const url = `${GASTOWN_SERVICE_URL}/trpc/${procedure}?input=${encodeURIComponent(JSON.stringify(input))}`; + const url = `${baseUrl}/trpc/${procedure}?input=${encodeURIComponent(JSON.stringify(input))}`; const response = await fetch(url, { headers }); if (!response.ok) { if (response.status === 404) return null; @@ -330,8 +343,9 @@ async function gastownTrpcMutate( input: unknown, schema: z.ZodType ): Promise { + const baseUrl = requireGastownUrl(); const headers = buildAdminHeaders(adminUser); - const url = `${GASTOWN_SERVICE_URL}/trpc/${procedure}`; + const url = `${baseUrl}/trpc/${procedure}`; const response = await fetch(url, { method: 'POST', headers, diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index b291af68a8..e2dc27063a 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -1286,7 +1286,9 @@ export const kiloclawRouter = createTRPCRouter({ }); const isDev = process.env.NODE_ENV === 'development'; const imageTag = isDev ? ':dev' : ':latest'; - const workerFlag = isDev ? ' --worker-url=http://localhost:8795' : ''; + const workerFlag = isDev + ? ` --worker-url=${process.env.KILOCLAW_API_URL ?? 'http://localhost:8795'}` + : ''; const gmailPushFlag = isDev ? ' --gmail-push-worker-url=${GMAIL_PUSH_WORKER_URL}' : ''; const imageUrl = `ghcr.io/kilo-org/google-setup${imageTag}`; return {