github-actions[bot] commited on
Commit ·
c8ae75d
1
Parent(s): 7ec23c7
deploy from github actions 2026-06-18
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +50 -0
- cli/.gitignore +2 -0
- cli/.npmignore +9 -0
- cli/LICENSE +42 -0
- cli/README.md +125 -0
- cli/cli.js +830 -0
- cli/hooks/postinstall.js +22 -0
- cli/hooks/sqliteRuntime.js +139 -0
- cli/hooks/trayRuntime.js +107 -0
- cli/package.json +48 -0
- cli/scripts/build-cli.js +273 -0
- cli/scripts/buildMitm.js +70 -0
- cli/src/cli/api/client.js +556 -0
- cli/src/cli/menus/apiKeys.js +233 -0
- cli/src/cli/menus/cliTools.js +618 -0
- cli/src/cli/menus/combos.js +477 -0
- cli/src/cli/menus/providers.js +846 -0
- cli/src/cli/menus/settings.js +184 -0
- cli/src/cli/terminalUI.js +121 -0
- cli/src/cli/tray/autostart.js +306 -0
- cli/src/cli/tray/icon.ico +0 -0
- cli/src/cli/tray/icon.png +0 -0
- cli/src/cli/tray/tray.js +322 -0
- cli/src/cli/tray/tray.ps1 +79 -0
- cli/src/cli/tray/trayWin.js +89 -0
- cli/src/cli/utils/clipboard.js +30 -0
- cli/src/cli/utils/display.js +156 -0
- cli/src/cli/utils/endpoint.js +32 -0
- cli/src/cli/utils/format.js +125 -0
- cli/src/cli/utils/input.js +156 -0
- cli/src/cli/utils/menuHelper.js +156 -0
- cli/src/cli/utils/modelSelector.js +136 -0
- custom-server.js +27 -0
- jsconfig.json +12 -0
- next.config.mjs +73 -0
- open-sse/.npmignore +8 -0
- open-sse/AGENTS.md +35 -0
- open-sse/config/appConstants.js +181 -0
- open-sse/config/codexInstructions.js +119 -0
- open-sse/config/constants.js +4 -0
- open-sse/config/defaultThinkingSignature.js +12 -0
- open-sse/config/errorConfig.js +85 -0
- open-sse/config/googleTtsLanguages.js +62 -0
- open-sse/config/kiroConstants.js +277 -0
- open-sse/config/mediaConfig.js +27 -0
- open-sse/config/models.js +13 -0
- open-sse/config/ollamaModels.js +19 -0
- open-sse/config/providerModels.js +83 -0
- open-sse/config/providers.js +19 -0
- open-sse/config/runtimeConfig.js +84 -0
Dockerfile
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for 9Router - HF Spaces 版本
|
| 2 |
+
# 基于 original Dockerfile,适配 HF Spaces 环境(端口 7860)
|
| 3 |
+
ARG NODE_IMAGE=node:22-alpine
|
| 4 |
+
FROM ${NODE_IMAGE} AS base
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
FROM base AS builder
|
| 8 |
+
|
| 9 |
+
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
|
| 10 |
+
|
| 11 |
+
COPY package.json ./
|
| 12 |
+
RUN --mount=type=cache,target=/root/.npm \
|
| 13 |
+
npm install
|
| 14 |
+
|
| 15 |
+
COPY . ./
|
| 16 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 17 |
+
RUN npm run build
|
| 18 |
+
|
| 19 |
+
FROM ${NODE_IMAGE} AS runner
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
LABEL org.opencontainers.image.title="9router"
|
| 23 |
+
|
| 24 |
+
ENV NODE_ENV=production
|
| 25 |
+
ENV PORT=7860
|
| 26 |
+
ENV HOSTNAME=0.0.0.0
|
| 27 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 28 |
+
ENV DATA_DIR=/app/data
|
| 29 |
+
|
| 30 |
+
COPY --from=builder /app/public ./public
|
| 31 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 32 |
+
COPY --from=builder /app/.next/standalone ./
|
| 33 |
+
COPY --from=builder /app/custom-server.js ./custom-server.js
|
| 34 |
+
COPY --from=builder /app/open-sse ./open-sse
|
| 35 |
+
COPY --from=builder /app/src/mitm ./src/mitm
|
| 36 |
+
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
|
| 37 |
+
COPY --from=builder /app/node_modules/next ./node_modules/next
|
| 38 |
+
|
| 39 |
+
RUN mkdir -p /app/data && chown -R node:node /app && \
|
| 40 |
+
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
| 41 |
+
ln -sf /app/data-home /root/.9router 2>/dev/null || true
|
| 42 |
+
|
| 43 |
+
RUN apk --no-cache upgrade && apk --no-cache add su-exec && \
|
| 44 |
+
printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \
|
| 45 |
+
chmod +x /entrypoint.sh
|
| 46 |
+
|
| 47 |
+
EXPOSE 7860
|
| 48 |
+
|
| 49 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
| 50 |
+
CMD ["node", "custom-server.js"]
|
cli/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
app/*
|
| 2 |
+
node_modules/*
|
cli/.npmignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore everything except what's in package.json "files"
|
| 2 |
+
*
|
| 3 |
+
!cli.js
|
| 4 |
+
!hooks/
|
| 5 |
+
!app/
|
| 6 |
+
!package.json
|
| 7 |
+
!README.md
|
| 8 |
+
!LICENSE
|
| 9 |
+
|
cli/LICENSE
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 9Router Contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
|
cli/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 9Router - FREE AI Router & Token Saver
|
| 2 |
+
|
| 3 |
+
**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.**
|
| 4 |
+
|
| 5 |
+
**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
|
| 6 |
+
|
| 7 |
+
[](https://www.npmjs.com/package/9router)
|
| 8 |
+
[](https://www.npmjs.com/package/9router)
|
| 9 |
+
[](https://hub.docker.com/r/decolua/9router)
|
| 10 |
+
[](https://github.com/decolua/9router/pkgs/container/9router)
|
| 11 |
+
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
| 12 |
+
|
| 13 |
+
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 14 |
+
|
| 15 |
+
[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 🤔 Why 9Router?
|
| 20 |
+
|
| 21 |
+
**Stop wasting money, tokens and hitting limits:**
|
| 22 |
+
|
| 23 |
+
- ❌ Subscription quota expires unused every month
|
| 24 |
+
- ❌ Rate limits stop you mid-coding
|
| 25 |
+
- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
|
| 26 |
+
- ❌ Expensive APIs ($20-50/month per provider)
|
| 27 |
+
|
| 28 |
+
**9Router solves this:**
|
| 29 |
+
|
| 30 |
+
- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
|
| 31 |
+
- ✅ **Maximize subscriptions** - Track quota, use every bit before reset
|
| 32 |
+
- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime
|
| 33 |
+
- ✅ **Multi-account** - Round-robin between accounts per provider
|
| 34 |
+
- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## ⚡ Quick Start
|
| 39 |
+
|
| 40 |
+
**Option 1 — npm (recommended for desktop):**
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
npm install -g 9router
|
| 44 |
+
9router
|
| 45 |
+
|
| 46 |
+
# Or run directly with npx
|
| 47 |
+
npx 9router
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
**Option 2 — Docker (server/VPS):**
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
docker run -d --name 9router -p 20128:20128 \
|
| 54 |
+
-v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \
|
| 55 |
+
decolua/9router:latest
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64).
|
| 59 |
+
|
| 60 |
+
🎉 Dashboard opens at `http://localhost:20128`
|
| 61 |
+
|
| 62 |
+
**2. Connect a FREE provider (no signup needed):**
|
| 63 |
+
|
| 64 |
+
Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
|
| 65 |
+
|
| 66 |
+
**3. Use in your CLI tool:**
|
| 67 |
+
|
| 68 |
+
```
|
| 69 |
+
Claude Code/Codex/OpenClaw/Cursor/Cline Settings:
|
| 70 |
+
Endpoint: http://localhost:20128/v1
|
| 71 |
+
API Key: [copy from dashboard]
|
| 72 |
+
Model: kr/claude-sonnet-4.5
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
That's it! Start coding with FREE AI models.
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## 🚀 CLI Options
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
9router # Start with default settings
|
| 83 |
+
9router --port 8080 # Custom port
|
| 84 |
+
9router --no-browser # Don't open browser
|
| 85 |
+
9router --skip-update # Skip auto-update check
|
| 86 |
+
9router --help # Show all options
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
**Dashboard**: `http://localhost:20128/dashboard`
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 🛠️ Supported CLI Tools
|
| 94 |
+
|
| 95 |
+
Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider
|
| 96 |
+
|
| 97 |
+
Any tool supporting OpenAI/Claude-compatible API works.
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## 💾 Data Location
|
| 102 |
+
|
| 103 |
+
- **macOS/Linux**: `~/.9router/db/data.sqlite`
|
| 104 |
+
- **Windows**: `%APPDATA%/9router/db/data.sqlite`
|
| 105 |
+
- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist)
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## 📚 Documentation
|
| 110 |
+
|
| 111 |
+
Full docs, advanced setup, video tutorials & development guide:
|
| 112 |
+
|
| 113 |
+
- **GitHub**: https://github.com/decolua/9router
|
| 114 |
+
- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md
|
| 115 |
+
- **Website**: https://9router.com
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## 🙏 Acknowledgments
|
| 120 |
+
|
| 121 |
+
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation
|
| 122 |
+
|
| 123 |
+
## 📄 License
|
| 124 |
+
|
| 125 |
+
MIT License - see [LICENSE](LICENSE) for details.
|
cli/cli.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
const { spawn, exec, execSync } = require("child_process");
|
| 4 |
+
const path = require("path");
|
| 5 |
+
const fs = require("fs");
|
| 6 |
+
const https = require("https");
|
| 7 |
+
const os = require("os");
|
| 8 |
+
|
| 9 |
+
// Native spinner - no external dependency
|
| 10 |
+
function createSpinner(text) {
|
| 11 |
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
| 12 |
+
let i = 0;
|
| 13 |
+
let interval = null;
|
| 14 |
+
let currentText = text;
|
| 15 |
+
return {
|
| 16 |
+
start() {
|
| 17 |
+
if (process.stdout.isTTY) {
|
| 18 |
+
process.stdout.write(`\r${frames[0]} ${currentText}`);
|
| 19 |
+
interval = setInterval(() => {
|
| 20 |
+
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
|
| 21 |
+
}, 80);
|
| 22 |
+
}
|
| 23 |
+
return this;
|
| 24 |
+
},
|
| 25 |
+
stop() {
|
| 26 |
+
if (interval) {
|
| 27 |
+
clearInterval(interval);
|
| 28 |
+
interval = null;
|
| 29 |
+
}
|
| 30 |
+
if (process.stdout.isTTY) {
|
| 31 |
+
process.stdout.write("\r\x1b[K");
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
succeed(msg) {
|
| 35 |
+
this.stop();
|
| 36 |
+
console.log(`✅ ${msg}`);
|
| 37 |
+
},
|
| 38 |
+
fail(msg) {
|
| 39 |
+
this.stop();
|
| 40 |
+
console.log(`❌ ${msg}`);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const pkg = require("./package.json");
|
| 46 |
+
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
|
| 47 |
+
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
|
| 48 |
+
const args = process.argv.slice(2);
|
| 49 |
+
|
| 50 |
+
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
|
| 51 |
+
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
|
| 52 |
+
// better-sqlite3 is optional. Logs to stderr only on failure.
|
| 53 |
+
try { ensureSqliteRuntime({ silent: true }); } catch {}
|
| 54 |
+
|
| 55 |
+
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
|
| 56 |
+
try { ensureTrayRuntime({ silent: true }); } catch {}
|
| 57 |
+
|
| 58 |
+
// Configuration constants
|
| 59 |
+
const APP_NAME = pkg.name; // Use from package.json
|
| 60 |
+
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
|
| 61 |
+
|
| 62 |
+
const DEFAULT_PORT = 20128;
|
| 63 |
+
const DEFAULT_HOST = "0.0.0.0";
|
| 64 |
+
|
| 65 |
+
// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
|
| 66 |
+
function getLanIp() {
|
| 67 |
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
| 68 |
+
for (const i of ifaces || []) {
|
| 69 |
+
if (i.family === "IPv4" && !i.internal) return i.address;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
return null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
|
| 76 |
+
function getDisplayHost() {
|
| 77 |
+
return host === DEFAULT_HOST ? "localhost" : host;
|
| 78 |
+
}
|
| 79 |
+
const MAX_PORT_ATTEMPTS = 10;
|
| 80 |
+
// Identifiers for killAllAppProcesses - only kill 9router specifically
|
| 81 |
+
const PROCESS_IDENTIFIERS = [
|
| 82 |
+
'9router' // Only package name - avoid killing other apps
|
| 83 |
+
];
|
| 84 |
+
|
| 85 |
+
// Parse arguments
|
| 86 |
+
let port = DEFAULT_PORT;
|
| 87 |
+
let host = DEFAULT_HOST;
|
| 88 |
+
let noBrowser = false;
|
| 89 |
+
let skipUpdate = false;
|
| 90 |
+
let showLog = false;
|
| 91 |
+
let trayMode = false;
|
| 92 |
+
|
| 93 |
+
for (let i = 0; i < args.length; i++) {
|
| 94 |
+
if (args[i] === "--port" || args[i] === "-p") {
|
| 95 |
+
port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
|
| 96 |
+
i++;
|
| 97 |
+
} else if (args[i] === "--host" || args[i] === "-H") {
|
| 98 |
+
host = args[i + 1] || DEFAULT_HOST;
|
| 99 |
+
i++;
|
| 100 |
+
} else if (args[i] === "--no-browser" || args[i] === "-n") {
|
| 101 |
+
noBrowser = true;
|
| 102 |
+
} else if (args[i] === "--log" || args[i] === "-l") {
|
| 103 |
+
showLog = true;
|
| 104 |
+
} else if (args[i] === "--skip-update") {
|
| 105 |
+
skipUpdate = true;
|
| 106 |
+
} else if (args[i] === "--tray" || args[i] === "-t") {
|
| 107 |
+
trayMode = true;
|
| 108 |
+
process.env.TRAY_MODE = "1";
|
| 109 |
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
| 110 |
+
console.log(`
|
| 111 |
+
Usage: ${APP_NAME} [options]
|
| 112 |
+
|
| 113 |
+
Options:
|
| 114 |
+
-p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
|
| 115 |
+
-H, --host <host> Host to bind (default: ${DEFAULT_HOST})
|
| 116 |
+
-n, --no-browser Don't open browser automatically
|
| 117 |
+
-l, --log Show server logs (default: hidden)
|
| 118 |
+
-t, --tray Run in system tray mode (background)
|
| 119 |
+
--skip-update Skip auto-update check
|
| 120 |
+
-h, --help Show this help message
|
| 121 |
+
-v, --version Show version
|
| 122 |
+
`);
|
| 123 |
+
process.exit(0);
|
| 124 |
+
} else if (args[i] === "--version" || args[i] === "-v") {
|
| 125 |
+
console.log(pkg.version);
|
| 126 |
+
process.exit(0);
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Auto-relaunch after update: detached process has no TTY → fallback to tray
|
| 131 |
+
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
|
| 132 |
+
trayMode = true;
|
| 133 |
+
process.env.TRAY_MODE = "1";
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Always use Node.js runtime with absolute path
|
| 137 |
+
const RUNTIME = process.execPath;
|
| 138 |
+
|
| 139 |
+
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
|
| 140 |
+
function compareVersions(a, b) {
|
| 141 |
+
const partsA = a.split(".").map(Number);
|
| 142 |
+
const partsB = b.split(".").map(Number);
|
| 143 |
+
for (let i = 0; i < 3; i++) {
|
| 144 |
+
if (partsA[i] > partsB[i]) return 1;
|
| 145 |
+
if (partsA[i] < partsB[i]) return -1;
|
| 146 |
+
}
|
| 147 |
+
return 0;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Get app data dir (matches app/src/lib/dataDir.js convention)
|
| 151 |
+
function getAppDataDir() {
|
| 152 |
+
return process.platform === "win32"
|
| 153 |
+
? path.join(process.env.APPDATA || "", "9router")
|
| 154 |
+
: path.join(os.homedir(), ".9router");
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Kill PID from file (best-effort, removes file after)
|
| 158 |
+
function killByPidFile(pidFile) {
|
| 159 |
+
try {
|
| 160 |
+
if (!fs.existsSync(pidFile)) return;
|
| 161 |
+
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
| 162 |
+
if (!pid) return;
|
| 163 |
+
try {
|
| 164 |
+
if (process.platform === "win32") {
|
| 165 |
+
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
|
| 166 |
+
} else {
|
| 167 |
+
process.kill(pid, "SIGKILL");
|
| 168 |
+
}
|
| 169 |
+
} catch { }
|
| 170 |
+
try { fs.unlinkSync(pidFile); } catch { }
|
| 171 |
+
} catch { }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Kill tunnel processes (cloudflared/tailscale) by their PID files
|
| 175 |
+
function killTunnelByPidFile() {
|
| 176 |
+
const tunnelDir = path.join(getAppDataDir(), "tunnel");
|
| 177 |
+
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
|
| 178 |
+
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
|
| 182 |
+
function killCloudflaredByAppPort(appPort) {
|
| 183 |
+
if (!appPort) return [];
|
| 184 |
+
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
|
| 185 |
+
const pids = [];
|
| 186 |
+
try {
|
| 187 |
+
if (process.platform === "win32") {
|
| 188 |
+
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
| 189 |
+
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
| 190 |
+
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
| 191 |
+
lines.forEach(line => {
|
| 192 |
+
if (portMatchers.some(m => line.includes(m))) {
|
| 193 |
+
const match = line.match(/^"(\d+)"/);
|
| 194 |
+
if (match && match[1]) pids.push(match[1]);
|
| 195 |
+
}
|
| 196 |
+
});
|
| 197 |
+
} else {
|
| 198 |
+
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
|
| 199 |
+
output.split("\n").forEach(line => {
|
| 200 |
+
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
|
| 201 |
+
const parts = line.trim().split(/\s+/);
|
| 202 |
+
const pid = parts[0];
|
| 203 |
+
if (pid && !isNaN(pid)) pids.push(pid);
|
| 204 |
+
}
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
} catch { }
|
| 208 |
+
return pids;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Kill all 9router processes
|
| 212 |
+
function killAllAppProcesses(appPort) {
|
| 213 |
+
return new Promise((resolve) => {
|
| 214 |
+
try {
|
| 215 |
+
// Kill MIT first (privileged process, needs special handling)
|
| 216 |
+
killProxyByPidFile();
|
| 217 |
+
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
|
| 218 |
+
killTunnelByPidFile();
|
| 219 |
+
|
| 220 |
+
const platform = process.platform;
|
| 221 |
+
let pids = [];
|
| 222 |
+
|
| 223 |
+
// Catch stale PID files: kill cloudflared bound to this app's port
|
| 224 |
+
pids.push(...killCloudflaredByAppPort(appPort));
|
| 225 |
+
|
| 226 |
+
if (platform === "win32") {
|
| 227 |
+
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
|
| 228 |
+
try {
|
| 229 |
+
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
| 230 |
+
const output = execSync(psCmd, {
|
| 231 |
+
encoding: "utf8",
|
| 232 |
+
windowsHide: true,
|
| 233 |
+
timeout: 5000
|
| 234 |
+
});
|
| 235 |
+
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
| 236 |
+
lines.forEach(line => {
|
| 237 |
+
// Whitelist: real node process running 9router/cli.js, or next-server.
|
| 238 |
+
// Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
|
| 239 |
+
const cmd = line.toLowerCase();
|
| 240 |
+
const isAppProcess =
|
| 241 |
+
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
|
| 242 |
+
|| cmd.includes("next-server");
|
| 243 |
+
if (isAppProcess) {
|
| 244 |
+
const match = line.match(/^"(\d+)"/);
|
| 245 |
+
if (match && match[1] && match[1] !== process.pid.toString()) {
|
| 246 |
+
pids.push(match[1]);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
});
|
| 250 |
+
} catch (e) {
|
| 251 |
+
// No processes found or error - continue
|
| 252 |
+
}
|
| 253 |
+
} else {
|
| 254 |
+
// macOS/Linux: use ps to find all matching processes
|
| 255 |
+
try {
|
| 256 |
+
const output = execSync('ps aux 2>/dev/null', {
|
| 257 |
+
encoding: 'utf8',
|
| 258 |
+
timeout: 5000
|
| 259 |
+
});
|
| 260 |
+
const lines = output.split('\n');
|
| 261 |
+
|
| 262 |
+
lines.forEach(line => {
|
| 263 |
+
// Whitelist: real node process running 9router/cli.js, or next-server.
|
| 264 |
+
// Avoids killing grep/strace/editors/cursor that incidentally match "9router".
|
| 265 |
+
const cmd = line.toLowerCase();
|
| 266 |
+
const isAppProcess =
|
| 267 |
+
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
|
| 268 |
+
|| cmd.includes("next-server");
|
| 269 |
+
if (isAppProcess) {
|
| 270 |
+
const parts = line.trim().split(/\s+/);
|
| 271 |
+
const pid = parts[1];
|
| 272 |
+
if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
|
| 273 |
+
pids.push(pid);
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
} catch (e) {
|
| 278 |
+
// No processes found or error - continue
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Kill all found processes
|
| 283 |
+
if (pids.length > 0) {
|
| 284 |
+
pids.forEach(pid => {
|
| 285 |
+
try {
|
| 286 |
+
if (platform === "win32") {
|
| 287 |
+
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
| 288 |
+
} else {
|
| 289 |
+
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
| 290 |
+
}
|
| 291 |
+
} catch (err) {
|
| 292 |
+
// Process already dead or can't kill - continue
|
| 293 |
+
}
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
// Wait for processes to fully terminate
|
| 297 |
+
setTimeout(() => resolve(), 1000);
|
| 298 |
+
} else {
|
| 299 |
+
resolve();
|
| 300 |
+
}
|
| 301 |
+
} catch (err) {
|
| 302 |
+
// Silent fail - continue anyway
|
| 303 |
+
resolve();
|
| 304 |
+
}
|
| 305 |
+
});
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
|
| 309 |
+
function sleepSync(ms) {
|
| 310 |
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Wait until process dies or timeout reached
|
| 314 |
+
function waitForExit(pid, timeoutMs) {
|
| 315 |
+
const deadline = Date.now() + timeoutMs;
|
| 316 |
+
while (Date.now() < deadline) {
|
| 317 |
+
try { process.kill(pid, 0); } catch { return true; }
|
| 318 |
+
sleepSync(100);
|
| 319 |
+
}
|
| 320 |
+
return false;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// Kill MIT server by PID file (runs privileged, needs special handling)
|
| 324 |
+
// Sends SIGTERM first so MIT can clean up host entries before dying.
|
| 325 |
+
function killProxyByPidFile() {
|
| 326 |
+
try {
|
| 327 |
+
const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
|
| 328 |
+
if (!fs.existsSync(pidFile)) return;
|
| 329 |
+
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
| 330 |
+
if (!pid) return;
|
| 331 |
+
|
| 332 |
+
if (process.platform === "win32") {
|
| 333 |
+
// Graceful first (lets server cleanup hosts), then force
|
| 334 |
+
try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
|
| 335 |
+
if (!waitForExit(pid, 1500)) {
|
| 336 |
+
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
| 337 |
+
}
|
| 338 |
+
// Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
|
| 339 |
+
if (!waitForExit(pid, 500)) {
|
| 340 |
+
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
| 341 |
+
}
|
| 342 |
+
} else {
|
| 343 |
+
// SIGTERM via cached sudo token first
|
| 344 |
+
try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
| 345 |
+
catch { try { process.kill(pid, "SIGTERM"); } catch { } }
|
| 346 |
+
if (!waitForExit(pid, 1500)) {
|
| 347 |
+
try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
| 348 |
+
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
try { fs.unlinkSync(pidFile); } catch { }
|
| 352 |
+
} catch { }
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// Kill any process on specific port
|
| 356 |
+
function killProcessOnPort(port) {
|
| 357 |
+
return new Promise((resolve) => {
|
| 358 |
+
try {
|
| 359 |
+
const platform = process.platform;
|
| 360 |
+
let pid;
|
| 361 |
+
|
| 362 |
+
if (platform === "win32") {
|
| 363 |
+
try {
|
| 364 |
+
const output = execSync(`netstat -ano | findstr :${port}`, {
|
| 365 |
+
encoding: 'utf8',
|
| 366 |
+
shell: true,
|
| 367 |
+
windowsHide: true,
|
| 368 |
+
timeout: 5000
|
| 369 |
+
}).trim();
|
| 370 |
+
const lines = output.split('\n').filter(l => l.includes('LISTENING'));
|
| 371 |
+
if (lines.length > 0) {
|
| 372 |
+
pid = lines[0].trim().split(/\s+/).pop();
|
| 373 |
+
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
| 374 |
+
}
|
| 375 |
+
} catch (e) {
|
| 376 |
+
// Port is free or error
|
| 377 |
+
}
|
| 378 |
+
} else {
|
| 379 |
+
// macOS/Linux
|
| 380 |
+
try {
|
| 381 |
+
const pidOutput = execSync(`lsof -ti:${port}`, {
|
| 382 |
+
encoding: 'utf8',
|
| 383 |
+
stdio: ['pipe', 'pipe', 'ignore']
|
| 384 |
+
}).trim();
|
| 385 |
+
if (pidOutput) {
|
| 386 |
+
pid = pidOutput.split('\n')[0];
|
| 387 |
+
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
| 388 |
+
}
|
| 389 |
+
} catch (e) {
|
| 390 |
+
// Port is free or error
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// Wait for port to be released
|
| 395 |
+
setTimeout(() => resolve(), 500);
|
| 396 |
+
} catch (err) {
|
| 397 |
+
// Silent fail - continue anyway
|
| 398 |
+
resolve();
|
| 399 |
+
}
|
| 400 |
+
});
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
// Detect if running in restricted environment (Codespaces, Docker)
|
| 405 |
+
function isRestrictedEnvironment() {
|
| 406 |
+
// Check for Codespaces
|
| 407 |
+
if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
|
| 408 |
+
return "GitHub Codespaces";
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// Check for Docker
|
| 412 |
+
if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
|
| 413 |
+
return "Docker";
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
return null;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
// Check if new version available, return latest version or null
|
| 420 |
+
function checkForUpdate() {
|
| 421 |
+
return new Promise((resolve) => {
|
| 422 |
+
if (skipUpdate) {
|
| 423 |
+
resolve(null);
|
| 424 |
+
return;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
const spinner = createSpinner("Checking for updates...").start();
|
| 428 |
+
let resolved = false;
|
| 429 |
+
|
| 430 |
+
const safetyTimeout = setTimeout(() => {
|
| 431 |
+
if (!resolved) {
|
| 432 |
+
resolved = true;
|
| 433 |
+
spinner.stop();
|
| 434 |
+
resolve(null);
|
| 435 |
+
}
|
| 436 |
+
}, 8000);
|
| 437 |
+
|
| 438 |
+
const done = (version) => {
|
| 439 |
+
if (resolved) return;
|
| 440 |
+
resolved = true;
|
| 441 |
+
clearTimeout(safetyTimeout);
|
| 442 |
+
spinner.stop();
|
| 443 |
+
resolve(version);
|
| 444 |
+
};
|
| 445 |
+
|
| 446 |
+
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
|
| 447 |
+
let data = "";
|
| 448 |
+
res.on("data", chunk => data += chunk);
|
| 449 |
+
res.on("end", () => {
|
| 450 |
+
try {
|
| 451 |
+
const latest = JSON.parse(data);
|
| 452 |
+
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
|
| 453 |
+
done(latest.version);
|
| 454 |
+
} else {
|
| 455 |
+
done(null);
|
| 456 |
+
}
|
| 457 |
+
} catch (e) {
|
| 458 |
+
done(null);
|
| 459 |
+
}
|
| 460 |
+
});
|
| 461 |
+
});
|
| 462 |
+
|
| 463 |
+
req.on("error", () => done(null));
|
| 464 |
+
req.on("timeout", () => { req.destroy(); done(null); });
|
| 465 |
+
});
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// Open browser
|
| 469 |
+
function openBrowser(url) {
|
| 470 |
+
const platform = process.platform;
|
| 471 |
+
let cmd;
|
| 472 |
+
|
| 473 |
+
if (platform === "darwin") {
|
| 474 |
+
cmd = `open "${url}"`;
|
| 475 |
+
} else if (platform === "win32") {
|
| 476 |
+
cmd = `start "" "${url}"`;
|
| 477 |
+
} else {
|
| 478 |
+
cmd = `xdg-open "${url}"`;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
exec(cmd, { windowsHide: true }, (err) => {
|
| 482 |
+
if (err) {
|
| 483 |
+
console.log(`Open browser manually: ${url}`);
|
| 484 |
+
}
|
| 485 |
+
});
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// Find standalone server (bundled in bin/app for published package).
|
| 489 |
+
// Prefer custom-server.js (injects real socket IP) when present.
|
| 490 |
+
const standaloneDir = path.join(__dirname, "app");
|
| 491 |
+
const customServerPath = path.join(standaloneDir, "custom-server.js");
|
| 492 |
+
const serverPath = fs.existsSync(customServerPath)
|
| 493 |
+
? customServerPath
|
| 494 |
+
: path.join(standaloneDir, "server.js");
|
| 495 |
+
|
| 496 |
+
if (!fs.existsSync(serverPath)) {
|
| 497 |
+
console.error("Error: Standalone build not found.");
|
| 498 |
+
console.error("Please run 'npm run build:cli' first.");
|
| 499 |
+
process.exit(1);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// Check for updates FIRST, then start server
|
| 503 |
+
checkForUpdate().then((latestVersion) => {
|
| 504 |
+
killAllAppProcesses(port).then(() => {
|
| 505 |
+
return killProcessOnPort(port);
|
| 506 |
+
}).then(() => {
|
| 507 |
+
startServer(latestVersion);
|
| 508 |
+
});
|
| 509 |
+
});
|
| 510 |
+
|
| 511 |
+
// Show interface selection menu
|
| 512 |
+
async function showInterfaceMenu(latestVersion) {
|
| 513 |
+
const { selectMenu } = require("./src/cli/utils/input");
|
| 514 |
+
const { clearScreen } = require("./src/cli/utils/display");
|
| 515 |
+
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
| 516 |
+
|
| 517 |
+
clearScreen();
|
| 518 |
+
|
| 519 |
+
const displayHost = getDisplayHost();
|
| 520 |
+
|
| 521 |
+
// Detect tunnel/local mode for server URL display
|
| 522 |
+
let serverUrl;
|
| 523 |
+
try {
|
| 524 |
+
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
| 525 |
+
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
|
| 526 |
+
} catch (e) {
|
| 527 |
+
serverUrl = `http://${displayHost}:${port}`;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
| 531 |
+
|
| 532 |
+
const menuItems = [];
|
| 533 |
+
|
| 534 |
+
if (latestVersion) {
|
| 535 |
+
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
menuItems.push(
|
| 539 |
+
{ label: "Web UI (Open in Browser)", icon: "🌐" },
|
| 540 |
+
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
|
| 541 |
+
{ label: "Hide to Tray (Background)", icon: "🔔" },
|
| 542 |
+
{ label: "Exit", icon: "🚪" }
|
| 543 |
+
);
|
| 544 |
+
|
| 545 |
+
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
|
| 546 |
+
|
| 547 |
+
const offset = latestVersion ? 1 : 0;
|
| 548 |
+
|
| 549 |
+
if (latestVersion && selected === 0) return "update";
|
| 550 |
+
if (selected === offset) return "web";
|
| 551 |
+
if (selected === offset + 1) return "terminal";
|
| 552 |
+
if (selected === offset + 2) return "hide";
|
| 553 |
+
return "exit";
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
const MAX_RESTARTS = 2;
|
| 557 |
+
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
| 558 |
+
|
| 559 |
+
function startServer(latestVersion) {
|
| 560 |
+
const displayHost = getDisplayHost();
|
| 561 |
+
const url = `http://${displayHost}:${port}/dashboard`;
|
| 562 |
+
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
|
| 563 |
+
if (host === DEFAULT_HOST) {
|
| 564 |
+
const lanIp = getLanIp();
|
| 565 |
+
if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
let restartCount = 0;
|
| 569 |
+
let serverStartTime = Date.now();
|
| 570 |
+
|
| 571 |
+
const CRASH_LOG_LINES = 50;
|
| 572 |
+
let crashLog = [];
|
| 573 |
+
|
| 574 |
+
function spawnServer() {
|
| 575 |
+
serverStartTime = Date.now();
|
| 576 |
+
crashLog = [];
|
| 577 |
+
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
|
| 578 |
+
cwd: standaloneDir,
|
| 579 |
+
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
| 580 |
+
detached: true,
|
| 581 |
+
windowsHide: true,
|
| 582 |
+
env: {
|
| 583 |
+
...buildEnvWithRuntime(process.env),
|
| 584 |
+
PORT: port.toString(),
|
| 585 |
+
HOSTNAME: host
|
| 586 |
+
}
|
| 587 |
+
});
|
| 588 |
+
if (!showLog && child.stderr) {
|
| 589 |
+
child.stderr.on("data", (data) => {
|
| 590 |
+
const lines = data.toString().split("\n").filter(Boolean);
|
| 591 |
+
crashLog.push(...lines);
|
| 592 |
+
if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
|
| 593 |
+
});
|
| 594 |
+
}
|
| 595 |
+
return child;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
let server = spawnServer();
|
| 599 |
+
|
| 600 |
+
// Cleanup function - force kill server process
|
| 601 |
+
let isCleaningUp = false;
|
| 602 |
+
function cleanup() {
|
| 603 |
+
if (isCleaningUp) return;
|
| 604 |
+
isCleaningUp = true;
|
| 605 |
+
try {
|
| 606 |
+
// Kill tray if running
|
| 607 |
+
try {
|
| 608 |
+
const { killTray } = require("./src/cli/tray/tray");
|
| 609 |
+
killTray();
|
| 610 |
+
} catch (e) { }
|
| 611 |
+
// Kill MIT server (privileged process) via PID file
|
| 612 |
+
killProxyByPidFile();
|
| 613 |
+
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
|
| 614 |
+
killTunnelByPidFile();
|
| 615 |
+
// Kill server process directly
|
| 616 |
+
if (server.pid) {
|
| 617 |
+
process.kill(server.pid, "SIGKILL");
|
| 618 |
+
}
|
| 619 |
+
// Also try to kill process group
|
| 620 |
+
process.kill(-server.pid, "SIGKILL");
|
| 621 |
+
} catch (e) { }
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
|
| 625 |
+
let isShuttingDown = false;
|
| 626 |
+
process.on("uncaughtException", (err) => {
|
| 627 |
+
if (isShuttingDown) return;
|
| 628 |
+
console.error("Error:", err.message);
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
// Handle all exit scenarios
|
| 632 |
+
process.on("SIGINT", () => {
|
| 633 |
+
if (isShuttingDown) return;
|
| 634 |
+
isShuttingDown = true;
|
| 635 |
+
console.log("\nExiting...");
|
| 636 |
+
cleanup();
|
| 637 |
+
setTimeout(() => process.exit(0), 100);
|
| 638 |
+
});
|
| 639 |
+
process.on("SIGTERM", () => {
|
| 640 |
+
if (isShuttingDown) return;
|
| 641 |
+
isShuttingDown = true;
|
| 642 |
+
cleanup();
|
| 643 |
+
setTimeout(() => process.exit(0), 100);
|
| 644 |
+
});
|
| 645 |
+
process.on("SIGHUP", () => {
|
| 646 |
+
if (isShuttingDown) return;
|
| 647 |
+
isShuttingDown = true;
|
| 648 |
+
cleanup();
|
| 649 |
+
setTimeout(() => process.exit(0), 100);
|
| 650 |
+
});
|
| 651 |
+
|
| 652 |
+
// Initialize tray icon (runs alongside TUI)
|
| 653 |
+
const initTrayIcon = () => {
|
| 654 |
+
try {
|
| 655 |
+
const { initTray } = require("./src/cli/tray/tray");
|
| 656 |
+
initTray({
|
| 657 |
+
port,
|
| 658 |
+
onQuit: () => {
|
| 659 |
+
isShuttingDown = true;
|
| 660 |
+
console.log("\n👋 Shutting down from tray...");
|
| 661 |
+
cleanup();
|
| 662 |
+
setTimeout(() => process.exit(0), 100);
|
| 663 |
+
},
|
| 664 |
+
onOpenDashboard: () => openBrowser(url)
|
| 665 |
+
});
|
| 666 |
+
} catch (err) {
|
| 667 |
+
// Tray not available - continue without it
|
| 668 |
+
}
|
| 669 |
+
};
|
| 670 |
+
|
| 671 |
+
// Tray-only mode: no TUI, just tray icon
|
| 672 |
+
if (trayMode) {
|
| 673 |
+
// Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
|
| 674 |
+
process.removeAllListeners("SIGHUP");
|
| 675 |
+
process.on("SIGHUP", () => {});
|
| 676 |
+
|
| 677 |
+
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
|
| 678 |
+
console.log(`Server: http://${displayHost}:${port}`);
|
| 679 |
+
|
| 680 |
+
setTimeout(() => {
|
| 681 |
+
initTrayIcon();
|
| 682 |
+
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
|
| 683 |
+
console.log(" Right-click tray icon to open dashboard or quit.\n");
|
| 684 |
+
}, 2000);
|
| 685 |
+
|
| 686 |
+
return;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
// Wait for server to be ready, then show interface menu loop + tray
|
| 690 |
+
setTimeout(async () => {
|
| 691 |
+
// Start tray icon alongside TUI
|
| 692 |
+
initTrayIcon();
|
| 693 |
+
|
| 694 |
+
try {
|
| 695 |
+
while (true) {
|
| 696 |
+
const choice = await showInterfaceMenu(latestVersion);
|
| 697 |
+
|
| 698 |
+
if (choice === "update") {
|
| 699 |
+
isShuttingDown = true;
|
| 700 |
+
const { clearScreen } = require("./src/cli/utils/display");
|
| 701 |
+
clearScreen();
|
| 702 |
+
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
|
| 703 |
+
console.log(`Run this after exit:\n`);
|
| 704 |
+
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
|
| 705 |
+
cleanup();
|
| 706 |
+
await killAllAppProcesses(port);
|
| 707 |
+
await killProcessOnPort(port);
|
| 708 |
+
setTimeout(() => process.exit(0), 200);
|
| 709 |
+
return;
|
| 710 |
+
} else if (choice === "web") {
|
| 711 |
+
openBrowser(url);
|
| 712 |
+
// Wait for user to come back
|
| 713 |
+
const { pause } = require("./src/cli/utils/input");
|
| 714 |
+
await pause("\nPress Enter to go back to menu...");
|
| 715 |
+
} else if (choice === "terminal") {
|
| 716 |
+
// Start Terminal UI - it will return when user selects Back
|
| 717 |
+
const { startTerminalUI } = require("./src/cli/terminalUI");
|
| 718 |
+
await startTerminalUI(port);
|
| 719 |
+
// Loop continues, show menu again
|
| 720 |
+
} else if (choice === "hide") {
|
| 721 |
+
const { clearScreen } = require("./src/cli/utils/display");
|
| 722 |
+
clearScreen();
|
| 723 |
+
|
| 724 |
+
// Enable auto startup on OS boot
|
| 725 |
+
try {
|
| 726 |
+
const { enableAutoStart } = require("./src/cli/tray/autostart");
|
| 727 |
+
enableAutoStart(__filename);
|
| 728 |
+
} catch (e) { }
|
| 729 |
+
|
| 730 |
+
if (process.platform === "darwin") {
|
| 731 |
+
// macOS: keep current process alive — spawning a detached child puts
|
| 732 |
+
// it outside the login session so NSStatusItem silently fails.
|
| 733 |
+
process.removeAllListeners("SIGHUP");
|
| 734 |
+
process.on("SIGHUP", () => {});
|
| 735 |
+
|
| 736 |
+
console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`);
|
| 737 |
+
console.log(`🔔 9Router is running in tray (PID: ${process.pid})`);
|
| 738 |
+
console.log(` Server: http://${displayHost}:${port}`);
|
| 739 |
+
console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
|
| 740 |
+
|
| 741 |
+
// Tray already init'd at startup — just keep event loop alive.
|
| 742 |
+
return;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
// Windows/Linux: spawn detached bgProcess (systray works fine in child)
|
| 746 |
+
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
|
| 747 |
+
|
| 748 |
+
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
|
| 749 |
+
detached: true,
|
| 750 |
+
stdio: "ignore",
|
| 751 |
+
windowsHide: true,
|
| 752 |
+
env: { ...process.env }
|
| 753 |
+
});
|
| 754 |
+
bgProcess.unref();
|
| 755 |
+
|
| 756 |
+
console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
|
| 757 |
+
console.log(` Server: http://${displayHost}:${port}`);
|
| 758 |
+
console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
|
| 759 |
+
|
| 760 |
+
// cleanup() kills server so bgProcess can claim the port fresh
|
| 761 |
+
cleanup();
|
| 762 |
+
process.exit(0);
|
| 763 |
+
} else if (choice === "exit") {
|
| 764 |
+
isShuttingDown = true;
|
| 765 |
+
console.log("\nExiting...");
|
| 766 |
+
cleanup();
|
| 767 |
+
setTimeout(() => process.exit(0), 100);
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
} catch (err) {
|
| 771 |
+
console.error("Error:", err.message);
|
| 772 |
+
cleanup();
|
| 773 |
+
process.exit(1);
|
| 774 |
+
}
|
| 775 |
+
}, 3000);
|
| 776 |
+
|
| 777 |
+
function attachServerEvents() {
|
| 778 |
+
server.on("error", (err) => {
|
| 779 |
+
console.error("Failed to start server:", err.message);
|
| 780 |
+
if (!isShuttingDown) tryRestart();
|
| 781 |
+
else { cleanup(); process.exit(1); }
|
| 782 |
+
});
|
| 783 |
+
|
| 784 |
+
server.on("close", (code) => {
|
| 785 |
+
if (isShuttingDown || code === 0) {
|
| 786 |
+
process.exit(code || 0);
|
| 787 |
+
return;
|
| 788 |
+
}
|
| 789 |
+
tryRestart(code);
|
| 790 |
+
});
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
function tryRestart(code) {
|
| 794 |
+
const aliveMs = Date.now() - serverStartTime;
|
| 795 |
+
// Reset counter if last run was stable
|
| 796 |
+
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
|
| 797 |
+
|
| 798 |
+
if (restartCount >= MAX_RESTARTS) {
|
| 799 |
+
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
|
| 800 |
+
try {
|
| 801 |
+
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
|
| 802 |
+
if (fs.existsSync(dbPath)) {
|
| 803 |
+
const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
|
| 804 |
+
if (db.settings) db.settings.mitmEnabled = false;
|
| 805 |
+
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
|
| 806 |
+
}
|
| 807 |
+
} catch { /* best effort */ }
|
| 808 |
+
restartCount = 0;
|
| 809 |
+
server = spawnServer();
|
| 810 |
+
attachServerEvents();
|
| 811 |
+
return;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
restartCount++;
|
| 815 |
+
const delay = Math.min(1000 * restartCount, 10000);
|
| 816 |
+
console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
|
| 817 |
+
if (crashLog.length) {
|
| 818 |
+
console.error("\n--- Server crash log ---");
|
| 819 |
+
crashLog.forEach(l => console.error(l));
|
| 820 |
+
console.error("--- End crash log ---\n");
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
setTimeout(() => {
|
| 824 |
+
server = spawnServer();
|
| 825 |
+
attachServerEvents();
|
| 826 |
+
}, delay);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
attachServerEvents();
|
| 830 |
+
}
|
cli/hooks/postinstall.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
|
| 4 |
+
// `9router` start doesn't need network. Failure here is non-fatal —
|
| 5 |
+
// cli.js will retry at runtime if anything is missing.
|
| 6 |
+
const { ensureSqliteRuntime } = require("./sqliteRuntime");
|
| 7 |
+
const { ensureTrayRuntime } = require("./trayRuntime");
|
| 8 |
+
|
| 9 |
+
try {
|
| 10 |
+
ensureSqliteRuntime({ silent: false });
|
| 11 |
+
console.log("[9router] runtime SQLite deps ready");
|
| 12 |
+
} catch (e) {
|
| 13 |
+
console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
ensureTrayRuntime({ silent: false });
|
| 18 |
+
} catch (e) {
|
| 19 |
+
console.warn(`[9router] tray runtime skipped: ${e.message}`);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
process.exit(0);
|
cli/hooks/sqliteRuntime.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules
|
| 2 |
+
// (user-writable, avoids Windows EBUSY locks during npm i -g updates).
|
| 3 |
+
// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in.
|
| 4 |
+
const { execSync, spawnSync } = require("child_process");
|
| 5 |
+
const fs = require("fs");
|
| 6 |
+
const os = require("os");
|
| 7 |
+
const path = require("path");
|
| 8 |
+
|
| 9 |
+
const BETTER_SQLITE3_VERSION = "12.6.2";
|
| 10 |
+
|
| 11 |
+
function getDataDir() {
|
| 12 |
+
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
| 13 |
+
return process.platform === "win32"
|
| 14 |
+
? path.join(process.env.APPDATA || os.homedir(), "9router")
|
| 15 |
+
: path.join(os.homedir(), ".9router");
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function getRuntimeDir() {
|
| 19 |
+
return path.join(getDataDir(), "runtime");
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function getRuntimeNodeModules() {
|
| 23 |
+
return path.join(getRuntimeDir(), "node_modules");
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function ensureRuntimeDir() {
|
| 27 |
+
const dir = getRuntimeDir();
|
| 28 |
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
| 29 |
+
|
| 30 |
+
// Minimal package.json so npm treats it as a project root
|
| 31 |
+
const pkgPath = path.join(dir, "package.json");
|
| 32 |
+
if (!fs.existsSync(pkgPath)) {
|
| 33 |
+
fs.writeFileSync(pkgPath, JSON.stringify({
|
| 34 |
+
name: "9router-runtime",
|
| 35 |
+
version: "1.0.0",
|
| 36 |
+
private: true,
|
| 37 |
+
description: "User-writable runtime deps for 9router (better-sqlite3 native binary)",
|
| 38 |
+
}, null, 2));
|
| 39 |
+
}
|
| 40 |
+
return dir;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function hasModule(name) {
|
| 44 |
+
return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function isBetterSqliteBinaryValid() {
|
| 48 |
+
const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
|
| 49 |
+
if (!fs.existsSync(binary)) return false;
|
| 50 |
+
try {
|
| 51 |
+
const fd = fs.openSync(binary, "r");
|
| 52 |
+
const buf = Buffer.alloc(4);
|
| 53 |
+
fs.readSync(fd, buf, 0, 4, 0);
|
| 54 |
+
fs.closeSync(fd);
|
| 55 |
+
const magic = buf.toString("hex");
|
| 56 |
+
if (process.platform === "linux") return magic.startsWith("7f454c46");
|
| 57 |
+
if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
|
| 58 |
+
if (process.platform === "win32") return magic.startsWith("4d5a");
|
| 59 |
+
return true;
|
| 60 |
+
} catch { return false; }
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Extract a short, user-friendly reason from npm stderr.
|
| 64 |
+
function summarizeNpmError(stderr = "") {
|
| 65 |
+
const text = String(stderr);
|
| 66 |
+
if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable";
|
| 67 |
+
if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)";
|
| 68 |
+
if (/ENOSPC|no space/i.test(text)) return "Not enough disk space";
|
| 69 |
+
if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)";
|
| 70 |
+
if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry";
|
| 71 |
+
const m = text.match(/npm ERR! (.+)/);
|
| 72 |
+
if (m) return m[1].slice(0, 200);
|
| 73 |
+
const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop();
|
| 74 |
+
return lastLine ? lastLine.slice(0, 200) : "Unknown error";
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
|
| 78 |
+
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs];
|
| 79 |
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
| 80 |
+
const res = spawnSync(npmCmd, args, {
|
| 81 |
+
cwd,
|
| 82 |
+
stdio: ["ignore", "pipe", "pipe"],
|
| 83 |
+
timeout,
|
| 84 |
+
shell: process.platform === "win32",
|
| 85 |
+
encoding: "utf8",
|
| 86 |
+
});
|
| 87 |
+
return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" };
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function npmInstall(pkgs, opts = {}) {
|
| 91 |
+
const cwd = ensureRuntimeDir();
|
| 92 |
+
const extra = opts.optional ? ["--no-save"] : [];
|
| 93 |
+
if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
|
| 94 |
+
const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
|
| 95 |
+
if (!res.ok && !opts.silent) {
|
| 96 |
+
const reason = summarizeNpmError(res.stderr);
|
| 97 |
+
console.warn("⚠️ SQLite engine install failed — using fallback");
|
| 98 |
+
console.warn(` Reason: ${reason}`);
|
| 99 |
+
console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
|
| 100 |
+
}
|
| 101 |
+
return res.ok;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Public: ensure better-sqlite3 native module is installed in user-writable
|
| 105 |
+
// runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in.
|
| 106 |
+
// This is purely a *speed optimization* — app works without it via fallbacks.
|
| 107 |
+
function ensureSqliteRuntime({ silent = false } = {}) {
|
| 108 |
+
ensureRuntimeDir();
|
| 109 |
+
|
| 110 |
+
const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
|
| 111 |
+
if (!needBetterSqlite) {
|
| 112 |
+
if (!silent) console.log("✅ SQLite engine ready");
|
| 113 |
+
return { betterSqlite: true };
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
|
| 117 |
+
return {
|
| 118 |
+
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
|
| 119 |
+
};
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Inject runtime + bundled node_modules into NODE_PATH so child Node processes
|
| 123 |
+
// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime).
|
| 124 |
+
function buildEnvWithRuntime(baseEnv = process.env) {
|
| 125 |
+
const runtimeNm = getRuntimeNodeModules();
|
| 126 |
+
const bundledNm = path.join(__dirname, "..", "app", "node_modules");
|
| 127 |
+
const existing = baseEnv.NODE_PATH || "";
|
| 128 |
+
const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter);
|
| 129 |
+
return { ...baseEnv, NODE_PATH };
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
module.exports = {
|
| 133 |
+
ensureSqliteRuntime,
|
| 134 |
+
buildEnvWithRuntime,
|
| 135 |
+
getRuntimeDir,
|
| 136 |
+
getRuntimeNodeModules,
|
| 137 |
+
runNpmInstall,
|
| 138 |
+
summarizeNpmError,
|
| 139 |
+
};
|
cli/hooks/trayRuntime.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules.
|
| 2 |
+
// Windows uses PowerShell NotifyIcon (no binary) → no systray needed.
|
| 3 |
+
// This keeps the published npm tarball free of unsigned Go binaries that
|
| 4 |
+
// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe).
|
| 5 |
+
//
|
| 6 |
+
// We use the maintained `systray2` fork. The original `systray@1.0.5` package
|
| 7 |
+
// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern
|
| 8 |
+
// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon.
|
| 9 |
+
const { spawnSync } = require("child_process");
|
| 10 |
+
const fs = require("fs");
|
| 11 |
+
const path = require("path");
|
| 12 |
+
const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime");
|
| 13 |
+
|
| 14 |
+
const SYSTRAY_PKG = "systray2";
|
| 15 |
+
const SYSTRAY_VERSION = "2.1.4";
|
| 16 |
+
const LEGACY_SYSTRAY_PKG = "systray";
|
| 17 |
+
|
| 18 |
+
function hasSystray() {
|
| 19 |
+
return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json"));
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Remove the legacy `systray` package from all known locations.
|
| 23 |
+
// On Windows it was an AV false-positive risk; on macOS/Linux its bundled
|
| 24 |
+
// binary is broken on modern OS versions.
|
| 25 |
+
function cleanupLegacySystray({ silent = false } = {}) {
|
| 26 |
+
// 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win)
|
| 27 |
+
// 2) npm global nested: <npm_prefix>/node_modules/9router/node_modules/systray
|
| 28 |
+
// __dirname here = <pkg root>/hooks → up 1 = pkg root
|
| 29 |
+
const targets = [
|
| 30 |
+
path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG),
|
| 31 |
+
path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG)
|
| 32 |
+
];
|
| 33 |
+
for (const dir of targets) {
|
| 34 |
+
if (fs.existsSync(dir)) {
|
| 35 |
+
try {
|
| 36 |
+
fs.rmSync(dir, { recursive: true, force: true });
|
| 37 |
+
if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`);
|
| 38 |
+
} catch (e) {
|
| 39 |
+
if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// systray2's npm tarball sometimes ships the bundled Go binary without the
|
| 46 |
+
// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x
|
| 47 |
+
// best-effort so the tray actually starts.
|
| 48 |
+
function chmodSystrayBin({ silent = false } = {}) {
|
| 49 |
+
if (process.platform === "win32") return;
|
| 50 |
+
const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
|
| 51 |
+
const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName);
|
| 52 |
+
if (!fs.existsSync(binPath)) return;
|
| 53 |
+
try {
|
| 54 |
+
fs.chmodSync(binPath, 0o755);
|
| 55 |
+
} catch (e) {
|
| 56 |
+
if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function ensureRuntimeDir() {
|
| 61 |
+
const dir = getRuntimeDir();
|
| 62 |
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
| 63 |
+
const pkgPath = path.join(dir, "package.json");
|
| 64 |
+
if (!fs.existsSync(pkgPath)) {
|
| 65 |
+
fs.writeFileSync(pkgPath, JSON.stringify({
|
| 66 |
+
name: "9router-runtime",
|
| 67 |
+
version: "1.0.0",
|
| 68 |
+
private: true
|
| 69 |
+
}, null, 2));
|
| 70 |
+
}
|
| 71 |
+
return dir;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function npmInstall(pkgs, { silent = false } = {}) {
|
| 75 |
+
const cwd = ensureRuntimeDir();
|
| 76 |
+
if (!silent) console.log("⏳ Installing system tray (first run)...");
|
| 77 |
+
const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 });
|
| 78 |
+
if (!res.ok && !silent) {
|
| 79 |
+
const reason = summarizeNpmError(res.stderr);
|
| 80 |
+
console.warn("⚠️ System tray install failed — tray disabled");
|
| 81 |
+
console.warn(` Reason: ${reason}`);
|
| 82 |
+
console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
|
| 83 |
+
}
|
| 84 |
+
return res.ok;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Public: ensure systray2 is installed on macOS/Linux only.
|
| 88 |
+
// Windows skips entirely (uses PowerShell tray).
|
| 89 |
+
function ensureTrayRuntime({ silent = false } = {}) {
|
| 90 |
+
// Always evict the legacy `systray` package — its binary is broken on
|
| 91 |
+
// modern macOS and an AV false-positive on Windows.
|
| 92 |
+
cleanupLegacySystray({ silent });
|
| 93 |
+
|
| 94 |
+
if (process.platform === "win32") {
|
| 95 |
+
return { systray: false, skipped: true };
|
| 96 |
+
}
|
| 97 |
+
if (hasSystray()) {
|
| 98 |
+
chmodSystrayBin({ silent });
|
| 99 |
+
if (!silent) console.log("✅ System tray ready");
|
| 100 |
+
return { systray: true };
|
| 101 |
+
}
|
| 102 |
+
const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent });
|
| 103 |
+
if (ok) chmodSystrayBin({ silent });
|
| 104 |
+
return { systray: ok && hasSystray() };
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
module.exports = { ensureTrayRuntime };
|
cli/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "9router",
|
| 3 |
+
"version": "0.5.4",
|
| 4 |
+
"description": "9Router CLI - Start and manage 9Router server",
|
| 5 |
+
"bin": {
|
| 6 |
+
"9router": "./cli.js"
|
| 7 |
+
},
|
| 8 |
+
"files": [
|
| 9 |
+
"cli.js",
|
| 10 |
+
"src",
|
| 11 |
+
"hooks",
|
| 12 |
+
"app",
|
| 13 |
+
"README.md",
|
| 14 |
+
"LICENSE"
|
| 15 |
+
],
|
| 16 |
+
"scripts": {
|
| 17 |
+
"dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js",
|
| 18 |
+
"build": "node scripts/build-cli.js",
|
| 19 |
+
"pack:cli": "npm run build && npm pack --pack-destination ../..",
|
| 20 |
+
"publish:cli": "npm run build && npm publish",
|
| 21 |
+
"postinstall": "node hooks/postinstall.js",
|
| 22 |
+
"prepublishOnly": "npm run build"
|
| 23 |
+
},
|
| 24 |
+
"dependencies": {
|
| 25 |
+
"enquirer": "^2.4.1",
|
| 26 |
+
"node-forge": "^1.3.3",
|
| 27 |
+
"node-machine-id": "^1.1.12",
|
| 28 |
+
"react": "19.2.1",
|
| 29 |
+
"react-dom": "19.2.1"
|
| 30 |
+
},
|
| 31 |
+
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
|
| 32 |
+
"comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.",
|
| 33 |
+
"engines": {
|
| 34 |
+
"node": ">=18.0.0"
|
| 35 |
+
},
|
| 36 |
+
"keywords": [
|
| 37 |
+
"9router",
|
| 38 |
+
"cli",
|
| 39 |
+
"proxy",
|
| 40 |
+
"ai",
|
| 41 |
+
"api"
|
| 42 |
+
],
|
| 43 |
+
"license": "MIT",
|
| 44 |
+
"devDependencies": {
|
| 45 |
+
"esbuild": "^0.25.12",
|
| 46 |
+
"nodemon": "^3.1.14"
|
| 47 |
+
}
|
| 48 |
+
}
|
cli/scripts/build-cli.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
const fs = require("fs");
|
| 4 |
+
const path = require("path");
|
| 5 |
+
const { execSync } = require("child_process");
|
| 6 |
+
|
| 7 |
+
const cliDir = path.resolve(__dirname, "..");
|
| 8 |
+
const appDir = path.resolve(cliDir, "..");
|
| 9 |
+
const rootDir = path.resolve(appDir, "..");
|
| 10 |
+
const cliAppDir = path.join(cliDir, "app");
|
| 11 |
+
const buildHomeDir = path.join(cliDir, ".build-home");
|
| 12 |
+
const buildDistDirName = ".next-cli-build";
|
| 13 |
+
const buildDistDir = path.join(appDir, buildDistDirName);
|
| 14 |
+
|
| 15 |
+
// Exclude patterns for files/folders we don't want to copy
|
| 16 |
+
const EXCLUDE_PATTERNS = [
|
| 17 |
+
"@img", // Sharp image processing (not needed with unoptimized images)
|
| 18 |
+
"sharp", // Sharp core lib (not needed with unoptimized images)
|
| 19 |
+
"detect-libc", // Sharp dependency
|
| 20 |
+
".env", // Environment files
|
| 21 |
+
".env.local",
|
| 22 |
+
".env.*.local",
|
| 23 |
+
"*.log", // Log files
|
| 24 |
+
"tmp", // Temp files
|
| 25 |
+
".DS_Store", // macOS files
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
function shouldExclude(name) {
|
| 29 |
+
return EXCLUDE_PATTERNS.some(pattern => {
|
| 30 |
+
if (pattern.includes("*")) {
|
| 31 |
+
const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
|
| 32 |
+
return regex.test(name);
|
| 33 |
+
}
|
| 34 |
+
return name === pattern;
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function copyRecursive(src, dest) {
|
| 39 |
+
if (!fs.existsSync(src)) {
|
| 40 |
+
console.warn(`Warning: Source ${src} does not exist`);
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (!fs.existsSync(dest)) {
|
| 45 |
+
fs.mkdirSync(dest, { recursive: true });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
| 49 |
+
for (const entry of entries) {
|
| 50 |
+
if (shouldExclude(entry.name)) {
|
| 51 |
+
continue;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const srcPath = path.join(src, entry.name);
|
| 55 |
+
const destPath = path.join(dest, entry.name);
|
| 56 |
+
|
| 57 |
+
// Skip broken symlinks (common in workspace setups)
|
| 58 |
+
try {
|
| 59 |
+
fs.accessSync(srcPath);
|
| 60 |
+
} catch {
|
| 61 |
+
continue;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (entry.isDirectory()) {
|
| 65 |
+
copyRecursive(srcPath, destPath);
|
| 66 |
+
} else if (entry.isSymbolicLink()) {
|
| 67 |
+
// Resolve and copy target (avoid linking outside bundle)
|
| 68 |
+
try {
|
| 69 |
+
const real = fs.realpathSync(srcPath);
|
| 70 |
+
if (fs.statSync(real).isDirectory()) {
|
| 71 |
+
copyRecursive(real, destPath);
|
| 72 |
+
} else {
|
| 73 |
+
fs.copyFileSync(real, destPath);
|
| 74 |
+
}
|
| 75 |
+
} catch {}
|
| 76 |
+
} else {
|
| 77 |
+
try {
|
| 78 |
+
fs.copyFileSync(srcPath, destPath);
|
| 79 |
+
} catch {}
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
console.log("📦 Building 9Router CLI package with Next.js...\n");
|
| 85 |
+
|
| 86 |
+
fs.mkdirSync(buildHomeDir, { recursive: true });
|
| 87 |
+
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
|
| 88 |
+
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
|
| 89 |
+
|
| 90 |
+
// Step 0: Sync version from app/cli/package.json to app/package.json
|
| 91 |
+
console.log("0️⃣ Syncing version to app/package.json...");
|
| 92 |
+
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
|
| 93 |
+
const appPkgPath = path.join(appDir, "package.json");
|
| 94 |
+
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
|
| 95 |
+
if (appPkg.version !== cliPkg.version) {
|
| 96 |
+
appPkg.version = cliPkg.version;
|
| 97 |
+
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
|
| 98 |
+
console.log(`✅ Version synced: ${cliPkg.version}\n`);
|
| 99 |
+
} else {
|
| 100 |
+
console.log(`✅ Version already synced: ${cliPkg.version}\n`);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
|
| 104 |
+
console.log("1️⃣ Building Next.js app...");
|
| 105 |
+
try {
|
| 106 |
+
execSync("npm run build", {
|
| 107 |
+
stdio: "inherit",
|
| 108 |
+
cwd: appDir,
|
| 109 |
+
env: {
|
| 110 |
+
...process.env,
|
| 111 |
+
HOME: buildHomeDir,
|
| 112 |
+
USERPROFILE: buildHomeDir,
|
| 113 |
+
APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
|
| 114 |
+
LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
|
| 115 |
+
NEXT_DIST_DIR: buildDistDirName,
|
| 116 |
+
NEXT_TRACING_ROOT_MODE: "workspace",
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
+
console.log("✅ Next.js build completed\n");
|
| 120 |
+
} catch (error) {
|
| 121 |
+
console.error("❌ Next.js build failed");
|
| 122 |
+
process.exit(1);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Step 2: Clean old app/cli/app if exists
|
| 126 |
+
console.log("2️⃣ Cleaning old app/cli/app...");
|
| 127 |
+
if (fs.existsSync(cliAppDir)) {
|
| 128 |
+
fs.rmSync(cliAppDir, { recursive: true, force: true });
|
| 129 |
+
}
|
| 130 |
+
console.log("✅ Cleaned\n");
|
| 131 |
+
|
| 132 |
+
// Step 3: Copy Next.js standalone build to app/cli/app.
|
| 133 |
+
// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
|
| 134 |
+
// node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
|
| 135 |
+
console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
|
| 136 |
+
const standaloneRoot = path.join(appDir, ".next", "standalone");
|
| 137 |
+
const standaloneRootResolved = path.join(buildDistDir, "standalone");
|
| 138 |
+
const standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
|
| 139 |
+
const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
|
| 140 |
+
? standaloneRootToUse
|
| 141 |
+
: path.join(standaloneRootToUse, "app");
|
| 142 |
+
if (!fs.existsSync(standaloneApp)) {
|
| 143 |
+
console.error("❌ Next.js standalone build not found under .next/standalone");
|
| 144 |
+
console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
|
| 145 |
+
process.exit(1);
|
| 146 |
+
}
|
| 147 |
+
copyRecursive(standaloneApp, cliAppDir);
|
| 148 |
+
|
| 149 |
+
// Older nested-app layout stores traced node_modules at standalone root.
|
| 150 |
+
const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
|
| 151 |
+
if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
|
| 152 |
+
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
|
| 153 |
+
}
|
| 154 |
+
console.log("✅ Copied standalone build\n");
|
| 155 |
+
|
| 156 |
+
// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
|
| 157 |
+
const customServerSrc = path.join(appDir, "custom-server.js");
|
| 158 |
+
if (fs.existsSync(customServerSrc)) {
|
| 159 |
+
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
| 160 |
+
console.log("✅ Copied custom-server.js\n");
|
| 161 |
+
} else {
|
| 162 |
+
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
| 166 |
+
// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid
|
| 167 |
+
// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also
|
| 168 |
+
// available as a no-install middle tier.
|
| 169 |
+
console.log("3️⃣ b Configuring SQLite drivers...");
|
| 170 |
+
function ensureModuleInBundle(pkg) {
|
| 171 |
+
const dest = path.join(cliAppDir, "node_modules", pkg);
|
| 172 |
+
if (fs.existsSync(dest)) {
|
| 173 |
+
console.log(`✅ ${pkg} already bundled`);
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
const candidates = [
|
| 177 |
+
path.join(appDir, "node_modules", pkg),
|
| 178 |
+
path.join(rootDir, "node_modules", pkg),
|
| 179 |
+
];
|
| 180 |
+
const src = candidates.find((p) => fs.existsSync(p));
|
| 181 |
+
if (!src) {
|
| 182 |
+
console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`);
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
| 186 |
+
copyRecursive(src, dest);
|
| 187 |
+
console.log(`✅ Bundled ${pkg}`);
|
| 188 |
+
}
|
| 189 |
+
ensureModuleInBundle("sql.js");
|
| 190 |
+
const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3");
|
| 191 |
+
if (fs.existsSync(betterDir)) {
|
| 192 |
+
fs.rmSync(betterDir, { recursive: true, force: true });
|
| 193 |
+
console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)");
|
| 194 |
+
}
|
| 195 |
+
console.log("");
|
| 196 |
+
|
| 197 |
+
// Step 4: Copy static files
|
| 198 |
+
console.log("4️⃣ Copying static files...");
|
| 199 |
+
const staticSrc = path.join(appDir, ".next", "static");
|
| 200 |
+
const staticSrcResolved = path.join(buildDistDir, "static");
|
| 201 |
+
const staticDest = path.join(cliAppDir, buildDistDirName, "static");
|
| 202 |
+
if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
|
| 203 |
+
copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
|
| 204 |
+
console.log("✅ Copied static files\n");
|
| 205 |
+
} else {
|
| 206 |
+
console.log("⏭️ No static files found\n");
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Step 5: Copy public folder if exists
|
| 210 |
+
console.log("5️⃣ Copying public folder...");
|
| 211 |
+
const publicSrc = path.join(appDir, "public");
|
| 212 |
+
const publicDest = path.join(cliAppDir, "public");
|
| 213 |
+
if (fs.existsSync(publicSrc)) {
|
| 214 |
+
copyRecursive(publicSrc, publicDest);
|
| 215 |
+
console.log("✅ Copied public folder\n");
|
| 216 |
+
} else {
|
| 217 |
+
console.log("⏭️ No public folder found\n");
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// Step 6: Copy vendor-chunks (required for production)
|
| 221 |
+
console.log("6️⃣ Copying vendor-chunks...");
|
| 222 |
+
const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
|
| 223 |
+
const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
|
| 224 |
+
const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
|
| 225 |
+
if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
|
| 226 |
+
copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
|
| 227 |
+
console.log("✅ Copied vendor-chunks\n");
|
| 228 |
+
} else {
|
| 229 |
+
console.log("⏭️ No vendor-chunks found\n");
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Step 7: Copy MITM server files (not bundled by Next.js standalone)
|
| 233 |
+
console.log("7️⃣ Copying MITM server files...");
|
| 234 |
+
const mitmSrc = path.join(appDir, "src", "mitm");
|
| 235 |
+
const mitmDest = path.join(cliAppDir, "src", "mitm");
|
| 236 |
+
if (fs.existsSync(mitmSrc)) {
|
| 237 |
+
copyRecursive(mitmSrc, mitmDest);
|
| 238 |
+
console.log("✅ Copied MITM files\n");
|
| 239 |
+
} else {
|
| 240 |
+
console.log("⏭️ No MITM files found\n");
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// Step 7b: Copy standalone updater (headless Node process for install progress)
|
| 244 |
+
console.log("7️⃣ b Copying updater files...");
|
| 245 |
+
const updaterSrc = path.join(appDir, "src", "lib", "updater");
|
| 246 |
+
const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
|
| 247 |
+
if (fs.existsSync(updaterSrc)) {
|
| 248 |
+
copyRecursive(updaterSrc, updaterDest);
|
| 249 |
+
console.log("✅ Copied updater files\n");
|
| 250 |
+
} else {
|
| 251 |
+
console.log("⏭️ No updater files found\n");
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
|
| 255 |
+
console.log("8️⃣ Building MITM server...");
|
| 256 |
+
try {
|
| 257 |
+
execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir });
|
| 258 |
+
console.log("✅ MITM server build completed\n");
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error("❌ MITM build failed");
|
| 261 |
+
process.exit(1);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
console.log("✨ CLI package build completed!");
|
| 265 |
+
console.log(`📁 Output: ${cliAppDir}`);
|
| 266 |
+
|
| 267 |
+
try {
|
| 268 |
+
const { execSync: exec } = require("child_process");
|
| 269 |
+
const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim();
|
| 270 |
+
console.log(`📊 Package size: ${size.split("\t")[0]}`);
|
| 271 |
+
} catch (e) {
|
| 272 |
+
// Silent fail on size check
|
| 273 |
+
}
|
cli/scripts/buildMitm.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const esbuild = require("esbuild");
|
| 2 |
+
const fs = require("fs");
|
| 3 |
+
const path = require("path");
|
| 4 |
+
|
| 5 |
+
// ── Build config ─────────────────────────────────────────
|
| 6 |
+
const BUILD_CONFIG = {
|
| 7 |
+
bundle: true,
|
| 8 |
+
minify: true,
|
| 9 |
+
cleanPlainFiles: true,
|
| 10 |
+
};
|
| 11 |
+
// ─────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
const cliDir = path.resolve(__dirname, "..");
|
| 14 |
+
const appDir = path.resolve(cliDir, "..");
|
| 15 |
+
const cliMitmDir = path.join(cliDir, "app", "src", "mitm");
|
| 16 |
+
// Bundle everything — no externals. This keeps MITM runtime self-contained so
|
| 17 |
+
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
|
| 18 |
+
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
|
| 19 |
+
const EXTERNALS = [];
|
| 20 |
+
const ENTRIES = ["server.js"];
|
| 21 |
+
|
| 22 |
+
async function buildEntry(entry) {
|
| 23 |
+
const mitmSrc = path.join(appDir, "src", "mitm");
|
| 24 |
+
const output = path.join(cliMitmDir, entry);
|
| 25 |
+
|
| 26 |
+
const buildPlugin = {
|
| 27 |
+
name: "build-plugin",
|
| 28 |
+
setup(build) {
|
| 29 |
+
// Stub .git file scanned by esbuild
|
| 30 |
+
build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" }));
|
| 31 |
+
build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" }));
|
| 32 |
+
},
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const steps = [];
|
| 36 |
+
|
| 37 |
+
if (BUILD_CONFIG.bundle) {
|
| 38 |
+
await esbuild.build({
|
| 39 |
+
entryPoints: [path.join(mitmSrc, entry)],
|
| 40 |
+
bundle: true,
|
| 41 |
+
minify: BUILD_CONFIG.minify,
|
| 42 |
+
platform: "node",
|
| 43 |
+
target: "node18",
|
| 44 |
+
external: EXTERNALS,
|
| 45 |
+
plugins: [buildPlugin],
|
| 46 |
+
outfile: output,
|
| 47 |
+
});
|
| 48 |
+
steps.push("bundled");
|
| 49 |
+
if (BUILD_CONFIG.minify) steps.push("minified");
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
console.log(`✅ ${steps.join(" + ")} → ${output}`);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function run() {
|
| 56 |
+
const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", ");
|
| 57 |
+
console.log(`⚙️ Config: ${flags}`);
|
| 58 |
+
|
| 59 |
+
for (const entry of ENTRIES) await buildEntry(entry);
|
| 60 |
+
|
| 61 |
+
if (BUILD_CONFIG.cleanPlainFiles) {
|
| 62 |
+
const keep = new Set(ENTRIES);
|
| 63 |
+
for (const name of fs.readdirSync(cliMitmDir)) {
|
| 64 |
+
if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true });
|
| 65 |
+
}
|
| 66 |
+
console.log("✅ Removed plain MITM files from CLI bundle");
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
run().catch((e) => { console.error(e); process.exit(1); });
|
cli/src/cli/api/client.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const http = require("http");
|
| 2 |
+
const https = require("https");
|
| 3 |
+
const crypto = require("crypto");
|
| 4 |
+
const fs = require("node:fs");
|
| 5 |
+
const path = require("node:path");
|
| 6 |
+
const os = require("node:os");
|
| 7 |
+
const { machineIdSync } = require("node-machine-id");
|
| 8 |
+
|
| 9 |
+
// Default configuration
|
| 10 |
+
const DEFAULT_CONFIG = {
|
| 11 |
+
host: "localhost",
|
| 12 |
+
port: 20128,
|
| 13 |
+
protocol: "http:",
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
| 17 |
+
const CLI_TOKEN_SALT = "9r-cli-auth";
|
| 18 |
+
const APP_NAME = "9router";
|
| 19 |
+
|
| 20 |
+
function getDataDir() {
|
| 21 |
+
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
| 22 |
+
if (process.platform === "win32") {
|
| 23 |
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME);
|
| 24 |
+
}
|
| 25 |
+
return path.join(os.homedir(), `.${APP_NAME}`);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id");
|
| 29 |
+
const AUTH_DIR = path.join(getDataDir(), "auth");
|
| 30 |
+
const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret");
|
| 31 |
+
|
| 32 |
+
let config = { ...DEFAULT_CONFIG };
|
| 33 |
+
let cachedCliToken = null;
|
| 34 |
+
let cachedCliSecret = null;
|
| 35 |
+
|
| 36 |
+
// Read raw machineId from shared file (written by server) → guarantees token match
|
| 37 |
+
function loadRawMachineId() {
|
| 38 |
+
try {
|
| 39 |
+
const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim();
|
| 40 |
+
if (raw) return raw;
|
| 41 |
+
} catch {}
|
| 42 |
+
try { return machineIdSync(); } catch { return ""; }
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Random secret shared with server via file → token unpredictable from machineId alone.
|
| 46 |
+
function loadCliSecret() {
|
| 47 |
+
if (cachedCliSecret) return cachedCliSecret;
|
| 48 |
+
try {
|
| 49 |
+
cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim();
|
| 50 |
+
if (cachedCliSecret) return cachedCliSecret;
|
| 51 |
+
} catch {}
|
| 52 |
+
cachedCliSecret = crypto.randomBytes(32).toString("hex");
|
| 53 |
+
try {
|
| 54 |
+
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
| 55 |
+
fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 });
|
| 56 |
+
} catch {}
|
| 57 |
+
return cachedCliSecret;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function getCliToken() {
|
| 61 |
+
if (cachedCliToken !== null) return cachedCliToken;
|
| 62 |
+
const raw = loadRawMachineId();
|
| 63 |
+
const secret = loadCliSecret();
|
| 64 |
+
cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : "";
|
| 65 |
+
return cachedCliToken;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Configure API client
|
| 70 |
+
* @param {Object} options - Configuration options
|
| 71 |
+
* @param {string} options.host - API host
|
| 72 |
+
* @param {number} options.port - API port
|
| 73 |
+
* @param {string} options.protocol - Protocol (http: or https:)
|
| 74 |
+
*/
|
| 75 |
+
function configure(options = {}) {
|
| 76 |
+
config = { ...config, ...options };
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Make HTTP request to API
|
| 81 |
+
* @param {string} method - HTTP method
|
| 82 |
+
* @param {string} path - API path
|
| 83 |
+
* @param {Object} body - Request body (optional)
|
| 84 |
+
* @returns {Promise<Object>} Response with { success, data/error }
|
| 85 |
+
*/
|
| 86 |
+
function makeRequest(method, path, body = null) {
|
| 87 |
+
return new Promise((resolve) => {
|
| 88 |
+
const httpModule = config.protocol === "https:" ? https : http;
|
| 89 |
+
|
| 90 |
+
const options = {
|
| 91 |
+
hostname: config.host,
|
| 92 |
+
port: config.port,
|
| 93 |
+
path: path,
|
| 94 |
+
method: method,
|
| 95 |
+
headers: {
|
| 96 |
+
"Content-Type": "application/json",
|
| 97 |
+
[CLI_TOKEN_HEADER]: getCliToken(),
|
| 98 |
+
},
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
// Add Content-Length for POST/PUT requests
|
| 102 |
+
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
| 103 |
+
const bodyString = JSON.stringify(body);
|
| 104 |
+
options.headers["Content-Length"] = Buffer.byteLength(bodyString);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const req = httpModule.request(options, (res) => {
|
| 108 |
+
let data = "";
|
| 109 |
+
|
| 110 |
+
res.on("data", (chunk) => {
|
| 111 |
+
data += chunk;
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
res.on("end", () => {
|
| 115 |
+
try {
|
| 116 |
+
const parsed = data ? JSON.parse(data) : {};
|
| 117 |
+
|
| 118 |
+
// Check if response indicates error
|
| 119 |
+
if (res.statusCode >= 400 || parsed.error) {
|
| 120 |
+
resolve({
|
| 121 |
+
success: false,
|
| 122 |
+
error: parsed.error || `HTTP ${res.statusCode}`,
|
| 123 |
+
statusCode: res.statusCode,
|
| 124 |
+
});
|
| 125 |
+
} else {
|
| 126 |
+
resolve({
|
| 127 |
+
success: true,
|
| 128 |
+
data: parsed,
|
| 129 |
+
statusCode: res.statusCode,
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
} catch (err) {
|
| 133 |
+
resolve({
|
| 134 |
+
success: false,
|
| 135 |
+
error: `Failed to parse response: ${err.message}`,
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
req.on("error", (err) => {
|
| 142 |
+
resolve({
|
| 143 |
+
success: false,
|
| 144 |
+
error: `Network error: ${err.message}`,
|
| 145 |
+
});
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
req.on("timeout", () => {
|
| 149 |
+
req.destroy();
|
| 150 |
+
resolve({
|
| 151 |
+
success: false,
|
| 152 |
+
error: "Request timeout",
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
// Set timeout (30 seconds)
|
| 157 |
+
req.setTimeout(30000);
|
| 158 |
+
|
| 159 |
+
// Write body if present
|
| 160 |
+
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
| 161 |
+
req.write(JSON.stringify(body));
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
req.end();
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// ============================================================================
|
| 169 |
+
// PROVIDERS API
|
| 170 |
+
// ============================================================================
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Get all providers
|
| 174 |
+
* @returns {Promise<Object>} { success, data: { connections } }
|
| 175 |
+
*/
|
| 176 |
+
async function getProviders() {
|
| 177 |
+
return makeRequest("GET", "/api/providers");
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/**
|
| 181 |
+
* Get provider by ID
|
| 182 |
+
* @param {string} id - Provider ID
|
| 183 |
+
* @returns {Promise<Object>} { success, data: { connection } }
|
| 184 |
+
*/
|
| 185 |
+
async function getProviderById(id) {
|
| 186 |
+
return makeRequest("GET", `/api/providers/${id}`);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* Test provider connection
|
| 191 |
+
* @param {string} id - Provider ID
|
| 192 |
+
* @returns {Promise<Object>} { success, data: { valid, error } }
|
| 193 |
+
*/
|
| 194 |
+
async function testProvider(id) {
|
| 195 |
+
return makeRequest("POST", `/api/providers/${id}/test`);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* Delete provider
|
| 200 |
+
* @param {string} id - Provider ID
|
| 201 |
+
* @returns {Promise<Object>} { success, data: { message } }
|
| 202 |
+
*/
|
| 203 |
+
async function deleteProvider(id) {
|
| 204 |
+
return makeRequest("DELETE", `/api/providers/${id}`);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* Get provider models
|
| 209 |
+
* @param {string} id - Provider ID
|
| 210 |
+
* @returns {Promise<Object>} { success, data: { provider, connectionId, models } }
|
| 211 |
+
*/
|
| 212 |
+
async function getProviderModels(id) {
|
| 213 |
+
return makeRequest("GET", `/api/providers/${id}/models`);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// ============================================================================
|
| 217 |
+
// OAUTH API
|
| 218 |
+
// ============================================================================
|
| 219 |
+
|
| 220 |
+
/**
|
| 221 |
+
* Get OAuth authorization URL
|
| 222 |
+
* @param {string} provider - Provider ID
|
| 223 |
+
* @returns {Promise<Object>} { success, data: { authUrl, codeVerifier, state, redirectUri } }
|
| 224 |
+
*/
|
| 225 |
+
async function getOAuthAuthUrl(provider) {
|
| 226 |
+
// Codex requires fixed port 1455 and path /auth/callback
|
| 227 |
+
const redirectUri = provider === "codex"
|
| 228 |
+
? "http://localhost:1455/auth/callback"
|
| 229 |
+
: "http://localhost:20128/callback";
|
| 230 |
+
return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/**
|
| 234 |
+
* Exchange OAuth authorization code for token
|
| 235 |
+
* @param {string} provider - Provider ID
|
| 236 |
+
* @param {Object} data - { code, redirectUri, codeVerifier, state }
|
| 237 |
+
* @returns {Promise<Object>} { success, data }
|
| 238 |
+
*/
|
| 239 |
+
async function exchangeOAuthCode(provider, data) {
|
| 240 |
+
return makeRequest("POST", `/api/oauth/${provider}/exchange`, data);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/**
|
| 244 |
+
* Get OAuth device code
|
| 245 |
+
* @param {string} provider - Provider ID
|
| 246 |
+
* @returns {Promise<Object>} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } }
|
| 247 |
+
*/
|
| 248 |
+
async function getOAuthDeviceCode(provider) {
|
| 249 |
+
return makeRequest("GET", `/api/oauth/${provider}/device-code`);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* Poll OAuth token using device code
|
| 254 |
+
* @param {string} provider - Provider ID
|
| 255 |
+
* @param {Object} data - { deviceCode, codeVerifier, extraData }
|
| 256 |
+
* @returns {Promise<Object>} { success, data: { pending } }
|
| 257 |
+
*/
|
| 258 |
+
async function pollOAuthToken(provider, data) {
|
| 259 |
+
return makeRequest("POST", `/api/oauth/${provider}/poll`, data);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/**
|
| 263 |
+
* Create API key provider connection
|
| 264 |
+
* @param {Object} data - { provider, name, apiKey }
|
| 265 |
+
* @returns {Promise<Object>} { success, data }
|
| 266 |
+
*/
|
| 267 |
+
async function createApiKeyProvider(data) {
|
| 268 |
+
return makeRequest("POST", "/api/providers", data);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/**
|
| 272 |
+
* Update provider connection
|
| 273 |
+
* @param {string} id - Connection ID
|
| 274 |
+
* @param {Object} data - { name, priority, defaultModel, isActive }
|
| 275 |
+
* @returns {Promise<Object>} { success, data: { connection } }
|
| 276 |
+
*/
|
| 277 |
+
async function updateConnection(id, data) {
|
| 278 |
+
return makeRequest("PUT", `/api/providers/${id}`, data);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// ============================================================================
|
| 282 |
+
// API KEYS API
|
| 283 |
+
// ============================================================================
|
| 284 |
+
|
| 285 |
+
/**
|
| 286 |
+
* Get all API keys
|
| 287 |
+
* @returns {Promise<Object>} { success, data: { keys } }
|
| 288 |
+
*/
|
| 289 |
+
async function getApiKeys() {
|
| 290 |
+
return makeRequest("GET", "/api/keys");
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/**
|
| 294 |
+
* Create new API key
|
| 295 |
+
* @param {string} name - Key name
|
| 296 |
+
* @returns {Promise<Object>} { success, data: { key, name, id, machineId } }
|
| 297 |
+
*/
|
| 298 |
+
async function createApiKey(name) {
|
| 299 |
+
return makeRequest("POST", "/api/keys", { name });
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/**
|
| 303 |
+
* Delete API key
|
| 304 |
+
* @param {string} id - Key ID
|
| 305 |
+
* @returns {Promise<Object>} { success, data: { success } }
|
| 306 |
+
*/
|
| 307 |
+
async function deleteApiKey(id) {
|
| 308 |
+
return makeRequest("DELETE", `/api/keys/${id}`);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// ============================================================================
|
| 312 |
+
// COMBOS API
|
| 313 |
+
// ============================================================================
|
| 314 |
+
|
| 315 |
+
/**
|
| 316 |
+
* Get all combos
|
| 317 |
+
* @returns {Promise<Object>} { success, data: { combos } }
|
| 318 |
+
*/
|
| 319 |
+
async function getCombos() {
|
| 320 |
+
return makeRequest("GET", "/api/combos");
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Get combo by ID
|
| 325 |
+
* @param {string} id - Combo ID
|
| 326 |
+
* @returns {Promise<Object>} { success, data: combo }
|
| 327 |
+
*/
|
| 328 |
+
async function getComboById(id) {
|
| 329 |
+
return makeRequest("GET", `/api/combos/${id}`);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/**
|
| 333 |
+
* Create new combo
|
| 334 |
+
* @param {Object} data - Combo data { name, models }
|
| 335 |
+
* @returns {Promise<Object>} { success, data: combo }
|
| 336 |
+
*/
|
| 337 |
+
async function createCombo(data) {
|
| 338 |
+
return makeRequest("POST", "/api/combos", data);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* Update combo
|
| 343 |
+
* @param {string} id - Combo ID
|
| 344 |
+
* @param {Object} data - Update data { name?, models? }
|
| 345 |
+
* @returns {Promise<Object>} { success, data: combo }
|
| 346 |
+
*/
|
| 347 |
+
async function updateCombo(id, data) {
|
| 348 |
+
return makeRequest("PUT", `/api/combos/${id}`, data);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/**
|
| 352 |
+
* Delete combo
|
| 353 |
+
* @param {string} id - Combo ID
|
| 354 |
+
* @returns {Promise<Object>} { success, data: { success } }
|
| 355 |
+
*/
|
| 356 |
+
async function deleteCombo(id) {
|
| 357 |
+
return makeRequest("DELETE", `/api/combos/${id}`);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// ============================================================================
|
| 361 |
+
// CLI TOOLS API
|
| 362 |
+
// ============================================================================
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* Get CLI tool settings
|
| 366 |
+
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
| 367 |
+
* @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
|
| 368 |
+
*/
|
| 369 |
+
async function getCliToolSettings(tool) {
|
| 370 |
+
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/**
|
| 374 |
+
* Apply CLI tool settings (POST)
|
| 375 |
+
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
| 376 |
+
* @param {Object} body - Payload depends on tool
|
| 377 |
+
* @returns {Promise<Object>} { success, data }
|
| 378 |
+
*/
|
| 379 |
+
async function applyCliToolSettings(tool, body) {
|
| 380 |
+
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/**
|
| 384 |
+
* Reset CLI tool settings (DELETE)
|
| 385 |
+
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
| 386 |
+
* @returns {Promise<Object>} { success, data }
|
| 387 |
+
*/
|
| 388 |
+
async function resetCliToolSettings(tool) {
|
| 389 |
+
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// ============================================================================
|
| 393 |
+
// SETTINGS API
|
| 394 |
+
// ============================================================================
|
| 395 |
+
|
| 396 |
+
/**
|
| 397 |
+
* Get settings
|
| 398 |
+
* @returns {Promise<Object>} { success, data: settings }
|
| 399 |
+
*/
|
| 400 |
+
async function getSettings() {
|
| 401 |
+
return makeRequest("GET", "/api/settings");
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/**
|
| 405 |
+
* Update settings
|
| 406 |
+
* @param {Object} data - Settings data
|
| 407 |
+
* @returns {Promise<Object>} { success, data: settings }
|
| 408 |
+
*/
|
| 409 |
+
async function updateSettings(data) {
|
| 410 |
+
return makeRequest("PATCH", "/api/settings", data);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
/**
|
| 414 |
+
* Reset dashboard password to default (clears stored hash server-side)
|
| 415 |
+
* @returns {Promise<Object>} { success }
|
| 416 |
+
*/
|
| 417 |
+
async function resetPassword() {
|
| 418 |
+
return makeRequest("POST", "/api/auth/reset-password");
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
// ============================================================================
|
| 422 |
+
// MODELS API
|
| 423 |
+
// ============================================================================
|
| 424 |
+
|
| 425 |
+
/**
|
| 426 |
+
* Get all models (internal API)
|
| 427 |
+
* @returns {Promise<Object>} { success, data: { models } }
|
| 428 |
+
*/
|
| 429 |
+
async function getModels() {
|
| 430 |
+
return makeRequest("GET", "/api/models");
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/**
|
| 434 |
+
* Get available models from active providers + combos (OpenAI compatible)
|
| 435 |
+
* @returns {Promise<Object>} { success, data: { object, data: [...models] } }
|
| 436 |
+
*/
|
| 437 |
+
async function getAvailableModels() {
|
| 438 |
+
return makeRequest("GET", "/v1/models");
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// ============================================================================
|
| 442 |
+
// PROVIDER NODES API (custom providers)
|
| 443 |
+
// ============================================================================
|
| 444 |
+
|
| 445 |
+
async function getProviderNodes() {
|
| 446 |
+
return makeRequest("GET", "/api/provider-nodes");
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
async function createProviderNode(data) {
|
| 450 |
+
return makeRequest("POST", "/api/provider-nodes", data);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
async function updateProviderNode(id, data) {
|
| 454 |
+
return makeRequest("PUT", `/api/provider-nodes/${id}`, data);
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
async function deleteProviderNode(id) {
|
| 458 |
+
return makeRequest("DELETE", `/api/provider-nodes/${id}`);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
async function validateProviderNode(data) {
|
| 462 |
+
return makeRequest("POST", "/api/provider-nodes/validate", data);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// ============================================================================
|
| 466 |
+
// TUNNEL API
|
| 467 |
+
// ============================================================================
|
| 468 |
+
|
| 469 |
+
/**
|
| 470 |
+
* Get tunnel status
|
| 471 |
+
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
|
| 472 |
+
*/
|
| 473 |
+
async function getTunnelStatus() {
|
| 474 |
+
return makeRequest("GET", "/api/tunnel/status");
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/**
|
| 478 |
+
* Enable tunnel
|
| 479 |
+
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
|
| 480 |
+
*/
|
| 481 |
+
async function enableTunnel() {
|
| 482 |
+
return makeRequest("POST", "/api/tunnel/enable");
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
/**
|
| 486 |
+
* Disable tunnel
|
| 487 |
+
* @returns {Promise<Object>} { success, data: { success } }
|
| 488 |
+
*/
|
| 489 |
+
async function disableTunnel() {
|
| 490 |
+
return makeRequest("POST", "/api/tunnel/disable");
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// ============================================================================
|
| 494 |
+
// EXPORTS
|
| 495 |
+
// ============================================================================
|
| 496 |
+
|
| 497 |
+
module.exports = {
|
| 498 |
+
configure,
|
| 499 |
+
|
| 500 |
+
// Providers
|
| 501 |
+
getProviders,
|
| 502 |
+
getProviderById,
|
| 503 |
+
testProvider,
|
| 504 |
+
deleteProvider,
|
| 505 |
+
getProviderModels,
|
| 506 |
+
|
| 507 |
+
// Connection aliases
|
| 508 |
+
testConnection: testProvider,
|
| 509 |
+
deleteConnection: deleteProvider,
|
| 510 |
+
updateConnection,
|
| 511 |
+
|
| 512 |
+
// OAuth
|
| 513 |
+
getOAuthAuthUrl,
|
| 514 |
+
exchangeOAuthCode,
|
| 515 |
+
getOAuthDeviceCode,
|
| 516 |
+
pollOAuthToken,
|
| 517 |
+
createApiKeyProvider,
|
| 518 |
+
|
| 519 |
+
// API Keys
|
| 520 |
+
getApiKeys,
|
| 521 |
+
createApiKey,
|
| 522 |
+
deleteApiKey,
|
| 523 |
+
|
| 524 |
+
// Combos
|
| 525 |
+
getCombos,
|
| 526 |
+
getComboById,
|
| 527 |
+
createCombo,
|
| 528 |
+
updateCombo,
|
| 529 |
+
deleteCombo,
|
| 530 |
+
|
| 531 |
+
// CLI Tools
|
| 532 |
+
getCliToolSettings,
|
| 533 |
+
applyCliToolSettings,
|
| 534 |
+
resetCliToolSettings,
|
| 535 |
+
|
| 536 |
+
// Settings
|
| 537 |
+
getSettings,
|
| 538 |
+
updateSettings,
|
| 539 |
+
resetPassword,
|
| 540 |
+
|
| 541 |
+
// Tunnel
|
| 542 |
+
getTunnelStatus,
|
| 543 |
+
enableTunnel,
|
| 544 |
+
disableTunnel,
|
| 545 |
+
|
| 546 |
+
// Models
|
| 547 |
+
getModels,
|
| 548 |
+
getAvailableModels,
|
| 549 |
+
|
| 550 |
+
// Provider Nodes (custom providers)
|
| 551 |
+
getProviderNodes,
|
| 552 |
+
createProviderNode,
|
| 553 |
+
updateProviderNode,
|
| 554 |
+
deleteProviderNode,
|
| 555 |
+
validateProviderNode,
|
| 556 |
+
};
|
cli/src/cli/menus/apiKeys.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { prompt, confirm, pause } = require("../utils/input");
|
| 3 |
+
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
| 4 |
+
const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
|
| 5 |
+
const { showMenuWithBack } = require("../utils/menuHelper");
|
| 6 |
+
const { copyToClipboard } = require("../utils/clipboard");
|
| 7 |
+
const { getEndpoint } = require("../utils/endpoint");
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Display API keys list with formatted output
|
| 11 |
+
* @param {Array} keys - Array of API key objects
|
| 12 |
+
* @param {number} port - Server port
|
| 13 |
+
*/
|
| 14 |
+
function displayApiKeys(keys, port) {
|
| 15 |
+
console.log("┌─────────────────────────────────────────────────────────┐");
|
| 16 |
+
console.log("│ 🔑 API Keys Management │");
|
| 17 |
+
console.log("├─────────────────────────────────────────────────────────┤");
|
| 18 |
+
// Note: This function is legacy, endpoint shown in menu header instead
|
| 19 |
+
console.log("│ │");
|
| 20 |
+
|
| 21 |
+
if (keys.length === 0) {
|
| 22 |
+
console.log("│ No API keys found. │");
|
| 23 |
+
} else {
|
| 24 |
+
console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`);
|
| 25 |
+
|
| 26 |
+
keys.forEach((key, index) => {
|
| 27 |
+
console.log("│ │");
|
| 28 |
+
console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`);
|
| 29 |
+
|
| 30 |
+
const maskedKey = maskKey(key.key);
|
| 31 |
+
console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`);
|
| 32 |
+
|
| 33 |
+
const created = formatDate(key.createdAt);
|
| 34 |
+
console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`);
|
| 35 |
+
|
| 36 |
+
if (key.lastUsedAt) {
|
| 37 |
+
const lastUsed = getRelativeTime(key.lastUsedAt);
|
| 38 |
+
console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`);
|
| 39 |
+
} else {
|
| 40 |
+
console.log("│ Last used: Never │");
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
console.log("│ │");
|
| 46 |
+
console.log("│ Actions: │");
|
| 47 |
+
console.log("│ 1. Create New API Key │");
|
| 48 |
+
console.log("│ 2. View Full Key (by number) │");
|
| 49 |
+
console.log("│ 3. Copy Key to Clipboard (by number) │");
|
| 50 |
+
console.log("│ 4. Delete Key (by number) │");
|
| 51 |
+
console.log("│ 0. ← Back to Main Menu │");
|
| 52 |
+
console.log("└─────────────────────────────────────────────────────────┘");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Handle creating new API key
|
| 57 |
+
* @returns {Promise<boolean>} Success status
|
| 58 |
+
*/
|
| 59 |
+
async function handleCreateKey() {
|
| 60 |
+
console.log("\n📝 Create New API Key");
|
| 61 |
+
console.log("─".repeat(30));
|
| 62 |
+
|
| 63 |
+
const name = await prompt("Enter key name: ");
|
| 64 |
+
|
| 65 |
+
if (!name) {
|
| 66 |
+
showStatus("Key name cannot be empty", "error");
|
| 67 |
+
await pause();
|
| 68 |
+
return false;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const result = await api.createApiKey(name);
|
| 72 |
+
|
| 73 |
+
if (!result.success) {
|
| 74 |
+
showStatus(`Failed to create key: ${result.error}`, "error");
|
| 75 |
+
await pause();
|
| 76 |
+
return false;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
console.log("\n✅ API Key created successfully!");
|
| 80 |
+
console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!");
|
| 81 |
+
console.log(`\nKey: ${result.data.key}`);
|
| 82 |
+
console.log(`Name: ${result.data.name}`);
|
| 83 |
+
console.log(`ID: ${result.data.id}`);
|
| 84 |
+
|
| 85 |
+
const shouldCopy = await confirm("\nCopy key to clipboard?");
|
| 86 |
+
if (shouldCopy) {
|
| 87 |
+
if (copyToClipboard(result.data.key)) {
|
| 88 |
+
showStatus("Key copied to clipboard!", "success");
|
| 89 |
+
} else {
|
| 90 |
+
showStatus("Failed to copy to clipboard", "error");
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
await pause();
|
| 95 |
+
return true;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Handle viewing full API key
|
| 100 |
+
* @param {Object} key - API key object
|
| 101 |
+
*/
|
| 102 |
+
async function handleViewFullKey(key) {
|
| 103 |
+
console.log("\n🔍 Full API Key");
|
| 104 |
+
console.log("─".repeat(30));
|
| 105 |
+
console.log(`Name: ${key.name}`);
|
| 106 |
+
console.log(`Key: ${key.key}`);
|
| 107 |
+
console.log(`ID: ${key.id}`);
|
| 108 |
+
console.log(`Created: ${formatDate(key.createdAt)}`);
|
| 109 |
+
|
| 110 |
+
if (key.lastUsedAt) {
|
| 111 |
+
console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
|
| 112 |
+
} else {
|
| 113 |
+
console.log("Last used: Never");
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
await pause();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Handle copying API key to clipboard
|
| 121 |
+
* @param {Object} key - API key object
|
| 122 |
+
*/
|
| 123 |
+
async function handleCopyKey(key) {
|
| 124 |
+
if (copyToClipboard(key.key)) {
|
| 125 |
+
showStatus(`Key "${key.name}" copied to clipboard!`, "success");
|
| 126 |
+
} else {
|
| 127 |
+
showStatus("Failed to copy to clipboard", "error");
|
| 128 |
+
}
|
| 129 |
+
await pause();
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Handle deleting API key
|
| 134 |
+
* @param {Object} key - API key object
|
| 135 |
+
* @returns {Promise<boolean>} Success status
|
| 136 |
+
*/
|
| 137 |
+
async function handleDeleteKey(key) {
|
| 138 |
+
console.log(`\n⚠️ Delete API Key: ${key.name}`);
|
| 139 |
+
console.log("─".repeat(30));
|
| 140 |
+
console.log(`Key: ${maskKey(key.key)}`);
|
| 141 |
+
console.log(`Created: ${formatDate(key.createdAt)}`);
|
| 142 |
+
|
| 143 |
+
const confirmed = await confirm("\nAre you sure you want to delete this key?");
|
| 144 |
+
|
| 145 |
+
if (!confirmed) {
|
| 146 |
+
showStatus("Deletion cancelled", "info");
|
| 147 |
+
await pause();
|
| 148 |
+
return false;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const result = await api.deleteApiKey(key.id);
|
| 152 |
+
|
| 153 |
+
if (!result.success) {
|
| 154 |
+
showStatus(`Failed to delete key: ${result.error}`, "error");
|
| 155 |
+
await pause();
|
| 156 |
+
return false;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
showStatus("API key deleted successfully", "success");
|
| 160 |
+
await pause();
|
| 161 |
+
return true;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Show actions for a specific key
|
| 166 |
+
* @param {Object} key - API key object
|
| 167 |
+
* @param {number} port - Server port
|
| 168 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 169 |
+
*/
|
| 170 |
+
async function showKeyActions(key, port, breadcrumb = []) {
|
| 171 |
+
const { endpoint } = await getEndpoint(port);
|
| 172 |
+
await showMenuWithBack({
|
| 173 |
+
title: `🔑 ${key.name}`,
|
| 174 |
+
breadcrumb: [...breadcrumb, key.name],
|
| 175 |
+
headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
|
| 176 |
+
items: [
|
| 177 |
+
{
|
| 178 |
+
label: "Copy to Clipboard",
|
| 179 |
+
action: async () => {
|
| 180 |
+
await handleCopyKey(key);
|
| 181 |
+
return true;
|
| 182 |
+
}
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
label: "Delete Key",
|
| 186 |
+
action: async () => {
|
| 187 |
+
await handleDeleteKey(key);
|
| 188 |
+
return false; // Exit after delete
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
]
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/**
|
| 196 |
+
* Main API Keys menu
|
| 197 |
+
* @param {number} port - Server port number
|
| 198 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 199 |
+
*/
|
| 200 |
+
async function showApiKeysMenu(port, breadcrumb = []) {
|
| 201 |
+
const { showListMenu } = require("../utils/menuHelper");
|
| 202 |
+
|
| 203 |
+
const { endpoint } = await getEndpoint(port);
|
| 204 |
+
await showListMenu({
|
| 205 |
+
title: "🔑 API Keys Management",
|
| 206 |
+
breadcrumb,
|
| 207 |
+
headerContent: `Endpoint: ${endpoint}`,
|
| 208 |
+
fetchItems: async () => {
|
| 209 |
+
const result = await api.getApiKeys();
|
| 210 |
+
if (!result.success) {
|
| 211 |
+
clearScreen();
|
| 212 |
+
showStatus(`Failed to fetch API keys: ${result.error}`, "error");
|
| 213 |
+
await pause();
|
| 214 |
+
return null;
|
| 215 |
+
}
|
| 216 |
+
return { items: result.data.keys || [] };
|
| 217 |
+
},
|
| 218 |
+
formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
|
| 219 |
+
onSelect: async (key) => {
|
| 220 |
+
await showKeyActions(key, port, breadcrumb);
|
| 221 |
+
},
|
| 222 |
+
createAction: {
|
| 223 |
+
label: "Create New API Key",
|
| 224 |
+
action: async () => {
|
| 225 |
+
await handleCreateKey();
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
module.exports = {
|
| 232 |
+
showApiKeysMenu
|
| 233 |
+
};
|
cli/src/cli/menus/cliTools.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { pause, confirm } = require("../utils/input");
|
| 3 |
+
const { showStatus } = require("../utils/display");
|
| 4 |
+
const { selectModelFromList } = require("../utils/modelSelector");
|
| 5 |
+
const { showMenuWithBack } = require("../utils/menuHelper");
|
| 6 |
+
const { getEndpoint } = require("../utils/endpoint");
|
| 7 |
+
|
| 8 |
+
const COLORS = {
|
| 9 |
+
reset: "\x1b[0m",
|
| 10 |
+
green: "\x1b[32m",
|
| 11 |
+
red: "\x1b[31m",
|
| 12 |
+
dim: "\x1b[2m",
|
| 13 |
+
cyan: "\x1b[36m"
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// Claude model types with defaults (matching Web UI)
|
| 17 |
+
const CLAUDE_MODEL_TYPES = [
|
| 18 |
+
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
|
| 19 |
+
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
|
| 20 |
+
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Get first available API key from server
|
| 27 |
+
* @returns {Promise<string|null>}
|
| 28 |
+
*/
|
| 29 |
+
async function getFirstApiKey() {
|
| 30 |
+
const result = await api.getApiKeys();
|
| 31 |
+
const keys = result.success ? (result.data.keys || []) : [];
|
| 32 |
+
return keys.length > 0 ? keys[0].key : null;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// ─── Claude Code ──────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Build header showing current Claude config status
|
| 39 |
+
* @returns {Promise<string>}
|
| 40 |
+
*/
|
| 41 |
+
async function buildClaudeHeader() {
|
| 42 |
+
const result = await api.getCliToolSettings("claude");
|
| 43 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 44 |
+
|
| 45 |
+
const settings = result.data.settings;
|
| 46 |
+
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
|
| 47 |
+
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
| 48 |
+
const lines = [];
|
| 49 |
+
|
| 50 |
+
if (currentUrl) {
|
| 51 |
+
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
|
| 52 |
+
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
|
| 53 |
+
if (currentKey) {
|
| 54 |
+
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
|
| 55 |
+
}
|
| 56 |
+
} else {
|
| 57 |
+
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
|
| 58 |
+
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return lines.join("\n");
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Get current Claude model from settings
|
| 66 |
+
* @param {string} envKey
|
| 67 |
+
* @returns {Promise<string>}
|
| 68 |
+
*/
|
| 69 |
+
async function getClaudeModel(envKey) {
|
| 70 |
+
const result = await api.getCliToolSettings("claude");
|
| 71 |
+
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Quick setup for Claude Code — sets endpoint, key, and all default models
|
| 76 |
+
* @param {number} port
|
| 77 |
+
*/
|
| 78 |
+
async function claudeQuickSetup(port) {
|
| 79 |
+
const { endpoint } = await getEndpoint(port);
|
| 80 |
+
const apiKey = await getFirstApiKey();
|
| 81 |
+
|
| 82 |
+
if (!apiKey) {
|
| 83 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 84 |
+
await pause();
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
|
| 89 |
+
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
|
| 90 |
+
|
| 91 |
+
const result = await api.applyCliToolSettings("claude", { env });
|
| 92 |
+
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 93 |
+
await pause();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Select and save a specific Claude model type
|
| 98 |
+
* @param {Object} modelType
|
| 99 |
+
* @param {number} port
|
| 100 |
+
*/
|
| 101 |
+
async function claudeSelectModel(modelType, port) {
|
| 102 |
+
const current = await getClaudeModel(modelType.envKey);
|
| 103 |
+
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
|
| 104 |
+
if (!selected) return;
|
| 105 |
+
|
| 106 |
+
const env = { [modelType.envKey]: selected };
|
| 107 |
+
|
| 108 |
+
// Also set base URL if not configured yet
|
| 109 |
+
const settingsResult = await api.getCliToolSettings("claude");
|
| 110 |
+
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
|
| 111 |
+
const { endpoint } = await getEndpoint(port);
|
| 112 |
+
const apiKey = await getFirstApiKey();
|
| 113 |
+
env.ANTHROPIC_BASE_URL = endpoint;
|
| 114 |
+
env.API_TIMEOUT_MS = "600000";
|
| 115 |
+
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const result = await api.applyCliToolSettings("claude", { env });
|
| 119 |
+
showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 120 |
+
await pause();
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Reset Claude Code settings
|
| 125 |
+
*/
|
| 126 |
+
async function claudeReset() {
|
| 127 |
+
const result = await api.resetCliToolSettings("claude");
|
| 128 |
+
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 129 |
+
await pause();
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Claude Code submenu
|
| 134 |
+
* @param {number} port
|
| 135 |
+
* @param {Array<string>} breadcrumb
|
| 136 |
+
*/
|
| 137 |
+
async function showClaudeCodeMenu(port, breadcrumb = []) {
|
| 138 |
+
await showMenuWithBack({
|
| 139 |
+
title: "🔧 Claude Code Settings",
|
| 140 |
+
breadcrumb,
|
| 141 |
+
headerContent: buildClaudeHeader,
|
| 142 |
+
refresh: async () => ({
|
| 143 |
+
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
|
| 144 |
+
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
| 145 |
+
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
|
| 146 |
+
}),
|
| 147 |
+
items: [
|
| 148 |
+
{
|
| 149 |
+
label: "⚡ Quick Setup (recommended)",
|
| 150 |
+
action: async () => { await claudeQuickSetup(port); return true; }
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
label: (d) => `Sonnet → ${d.sonnet}`,
|
| 154 |
+
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
label: (d) => `Opus → ${d.opus}`,
|
| 158 |
+
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
label: (d) => `Haiku → ${d.haiku}`,
|
| 162 |
+
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
label: "Reset to Default",
|
| 166 |
+
action: async () => { await claudeReset(); return true; }
|
| 167 |
+
}
|
| 168 |
+
]
|
| 169 |
+
});
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ─── Codex CLI ────────────────────────────────────────────────────────────────
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* Build header showing current Codex config status
|
| 176 |
+
* @returns {Promise<string>}
|
| 177 |
+
*/
|
| 178 |
+
async function buildCodexHeader() {
|
| 179 |
+
const result = await api.getCliToolSettings("codex");
|
| 180 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 181 |
+
|
| 182 |
+
const { installed, has9Router, config } = result.data;
|
| 183 |
+
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
|
| 184 |
+
|
| 185 |
+
if (!has9Router) {
|
| 186 |
+
return [
|
| 187 |
+
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
| 188 |
+
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
| 189 |
+
].join("\n");
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Parse base_url and model from raw TOML string
|
| 193 |
+
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
|
| 194 |
+
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
|
| 195 |
+
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
|
| 196 |
+
const model = modelMatch ? modelMatch[1] : "";
|
| 197 |
+
|
| 198 |
+
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
| 199 |
+
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
|
| 200 |
+
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
| 201 |
+
return lines.join("\n");
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* Quick setup for Codex CLI
|
| 206 |
+
* @param {number} port
|
| 207 |
+
*/
|
| 208 |
+
async function codexQuickSetup(port) {
|
| 209 |
+
const { endpoint } = await getEndpoint(port);
|
| 210 |
+
const apiKey = await getFirstApiKey();
|
| 211 |
+
|
| 212 |
+
if (!apiKey) {
|
| 213 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 214 |
+
await pause();
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Get model selection
|
| 219 |
+
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
| 220 |
+
if (!model) return;
|
| 221 |
+
|
| 222 |
+
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
|
| 223 |
+
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 224 |
+
await pause();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* Reset Codex CLI settings
|
| 229 |
+
*/
|
| 230 |
+
async function codexReset() {
|
| 231 |
+
const result = await api.resetCliToolSettings("codex");
|
| 232 |
+
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 233 |
+
await pause();
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* Codex CLI submenu
|
| 238 |
+
* @param {number} port
|
| 239 |
+
* @param {Array<string>} breadcrumb
|
| 240 |
+
*/
|
| 241 |
+
async function showCodexMenu(port, breadcrumb = []) {
|
| 242 |
+
await showMenuWithBack({
|
| 243 |
+
title: "🤖 Codex CLI Settings",
|
| 244 |
+
breadcrumb,
|
| 245 |
+
headerContent: buildCodexHeader,
|
| 246 |
+
refresh: async () => ({}),
|
| 247 |
+
items: [
|
| 248 |
+
{
|
| 249 |
+
label: "⚡ Quick Setup",
|
| 250 |
+
action: async () => { await codexQuickSetup(port); return true; }
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
label: "Reset to Default",
|
| 254 |
+
action: async () => { await codexReset(); return true; }
|
| 255 |
+
}
|
| 256 |
+
]
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// ─── Factory Droid ────────────────────────────────────────────────────────────
|
| 261 |
+
|
| 262 |
+
/**
|
| 263 |
+
* Build header showing current Droid config status
|
| 264 |
+
* @returns {Promise<string>}
|
| 265 |
+
*/
|
| 266 |
+
async function buildDroidHeader() {
|
| 267 |
+
const result = await api.getCliToolSettings("droid");
|
| 268 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 269 |
+
|
| 270 |
+
const { installed, has9Router, settings } = result.data;
|
| 271 |
+
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
|
| 272 |
+
|
| 273 |
+
if (!has9Router) {
|
| 274 |
+
return [
|
| 275 |
+
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
| 276 |
+
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
| 277 |
+
].join("\n");
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// Extract 9Router custom model config
|
| 281 |
+
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
|
| 282 |
+
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
| 283 |
+
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
|
| 284 |
+
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
|
| 285 |
+
return lines.join("\n");
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/**
|
| 289 |
+
* Quick setup for Factory Droid
|
| 290 |
+
* @param {number} port
|
| 291 |
+
*/
|
| 292 |
+
async function droidQuickSetup(port) {
|
| 293 |
+
const { endpoint } = await getEndpoint(port);
|
| 294 |
+
const apiKey = await getFirstApiKey();
|
| 295 |
+
|
| 296 |
+
if (!apiKey) {
|
| 297 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 298 |
+
await pause();
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
| 303 |
+
if (!model) return;
|
| 304 |
+
|
| 305 |
+
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
|
| 306 |
+
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 307 |
+
await pause();
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/**
|
| 311 |
+
* Reset Factory Droid settings
|
| 312 |
+
*/
|
| 313 |
+
async function droidReset() {
|
| 314 |
+
const result = await api.resetCliToolSettings("droid");
|
| 315 |
+
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 316 |
+
await pause();
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Factory Droid submenu
|
| 321 |
+
* @param {number} port
|
| 322 |
+
* @param {Array<string>} breadcrumb
|
| 323 |
+
*/
|
| 324 |
+
async function showDroidMenu(port, breadcrumb = []) {
|
| 325 |
+
await showMenuWithBack({
|
| 326 |
+
title: "🤖 Factory Droid Settings",
|
| 327 |
+
breadcrumb,
|
| 328 |
+
headerContent: buildDroidHeader,
|
| 329 |
+
refresh: async () => ({}),
|
| 330 |
+
items: [
|
| 331 |
+
{
|
| 332 |
+
label: "⚡ Quick Setup",
|
| 333 |
+
action: async () => { await droidQuickSetup(port); return true; }
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
label: "Reset to Default",
|
| 337 |
+
action: async () => { await droidReset(); return true; }
|
| 338 |
+
}
|
| 339 |
+
]
|
| 340 |
+
});
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// ─── Open Claw ────────────────────────────────────────────────────────────────
|
| 344 |
+
|
| 345 |
+
/**
|
| 346 |
+
* Build header showing current OpenClaw config status
|
| 347 |
+
* @returns {Promise<string>}
|
| 348 |
+
*/
|
| 349 |
+
async function buildOpenClawHeader() {
|
| 350 |
+
const result = await api.getCliToolSettings("openclaw");
|
| 351 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 352 |
+
|
| 353 |
+
const { installed, has9Router, settings } = result.data;
|
| 354 |
+
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
|
| 355 |
+
|
| 356 |
+
if (!has9Router) {
|
| 357 |
+
return [
|
| 358 |
+
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
| 359 |
+
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
| 360 |
+
].join("\n");
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// Extract 9Router provider config
|
| 364 |
+
const provider = settings?.models?.providers?.["9router"];
|
| 365 |
+
const primary = settings?.agents?.defaults?.model?.primary || "";
|
| 366 |
+
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
|
| 367 |
+
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
| 368 |
+
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
|
| 369 |
+
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
| 370 |
+
return lines.join("\n");
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/**
|
| 374 |
+
* Quick setup for Open Claw
|
| 375 |
+
* @param {number} port
|
| 376 |
+
*/
|
| 377 |
+
async function openClawQuickSetup(port) {
|
| 378 |
+
const { endpoint } = await getEndpoint(port);
|
| 379 |
+
const apiKey = await getFirstApiKey();
|
| 380 |
+
|
| 381 |
+
if (!apiKey) {
|
| 382 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 383 |
+
await pause();
|
| 384 |
+
return;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
| 388 |
+
if (!model) return;
|
| 389 |
+
|
| 390 |
+
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
|
| 391 |
+
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 392 |
+
await pause();
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* Reset Open Claw settings
|
| 397 |
+
*/
|
| 398 |
+
async function openClawReset() {
|
| 399 |
+
const result = await api.resetCliToolSettings("openclaw");
|
| 400 |
+
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 401 |
+
await pause();
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/**
|
| 405 |
+
* Open Claw submenu
|
| 406 |
+
* @param {number} port
|
| 407 |
+
* @param {Array<string>} breadcrumb
|
| 408 |
+
*/
|
| 409 |
+
async function showOpenClawMenu(port, breadcrumb = []) {
|
| 410 |
+
await showMenuWithBack({
|
| 411 |
+
title: "🦞 Open Claw Settings",
|
| 412 |
+
breadcrumb,
|
| 413 |
+
headerContent: buildOpenClawHeader,
|
| 414 |
+
refresh: async () => ({}),
|
| 415 |
+
items: [
|
| 416 |
+
{
|
| 417 |
+
label: "⚡ Quick Setup",
|
| 418 |
+
action: async () => { await openClawQuickSetup(port); return true; }
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
label: "Reset to Default",
|
| 422 |
+
action: async () => { await openClawReset(); return true; }
|
| 423 |
+
}
|
| 424 |
+
]
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
|
| 429 |
+
|
| 430 |
+
async function buildOpenCodeHeader() {
|
| 431 |
+
const result = await api.getCliToolSettings("opencode");
|
| 432 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 433 |
+
|
| 434 |
+
const { installed, has9Router, opencode } = result.data;
|
| 435 |
+
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
|
| 436 |
+
|
| 437 |
+
if (!has9Router) {
|
| 438 |
+
return [
|
| 439 |
+
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
| 440 |
+
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
| 441 |
+
].join("\n");
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
| 445 |
+
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
|
| 446 |
+
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
|
| 447 |
+
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
|
| 448 |
+
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
|
| 449 |
+
}
|
| 450 |
+
return lines.join("\n");
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
async function openCodeQuickSetup(port) {
|
| 454 |
+
const { endpoint } = await getEndpoint(port);
|
| 455 |
+
const apiKey = await getFirstApiKey();
|
| 456 |
+
|
| 457 |
+
if (!apiKey) {
|
| 458 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 459 |
+
await pause();
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// Pick first model (also becomes active model by default)
|
| 464 |
+
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
|
| 465 |
+
if (!firstModel) return;
|
| 466 |
+
|
| 467 |
+
const models = [firstModel];
|
| 468 |
+
|
| 469 |
+
// Optionally add more models
|
| 470 |
+
while (true) {
|
| 471 |
+
const more = await confirm(`Add another model? (current: ${models.length})`);
|
| 472 |
+
if (!more) break;
|
| 473 |
+
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
|
| 474 |
+
if (!next) break;
|
| 475 |
+
if (!models.includes(next)) models.push(next);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
// Optional subagent model
|
| 479 |
+
let subagentModel = firstModel;
|
| 480 |
+
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
|
| 481 |
+
if (wantSubagent) {
|
| 482 |
+
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
|
| 483 |
+
if (picked) subagentModel = picked;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
const result = await api.applyCliToolSettings("opencode", {
|
| 487 |
+
baseUrl: endpoint,
|
| 488 |
+
apiKey,
|
| 489 |
+
models,
|
| 490 |
+
activeModel: firstModel,
|
| 491 |
+
subagentModel,
|
| 492 |
+
});
|
| 493 |
+
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 494 |
+
await pause();
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
async function openCodeReset() {
|
| 498 |
+
const result = await api.resetCliToolSettings("opencode");
|
| 499 |
+
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 500 |
+
await pause();
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
async function showOpenCodeMenu(port, breadcrumb = []) {
|
| 504 |
+
await showMenuWithBack({
|
| 505 |
+
title: "💻 OpenCode CLI Settings",
|
| 506 |
+
breadcrumb,
|
| 507 |
+
headerContent: buildOpenCodeHeader,
|
| 508 |
+
refresh: async () => ({}),
|
| 509 |
+
items: [
|
| 510 |
+
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
|
| 511 |
+
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
|
| 512 |
+
]
|
| 513 |
+
});
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
|
| 517 |
+
|
| 518 |
+
async function buildHermesHeader() {
|
| 519 |
+
const result = await api.getCliToolSettings("hermes");
|
| 520 |
+
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
| 521 |
+
|
| 522 |
+
const { installed, has9Router, settings } = result.data;
|
| 523 |
+
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
|
| 524 |
+
|
| 525 |
+
if (!has9Router) {
|
| 526 |
+
return [
|
| 527 |
+
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
| 528 |
+
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
| 529 |
+
].join("\n");
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
const model = settings?.model || {};
|
| 533 |
+
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
| 534 |
+
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
|
| 535 |
+
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
|
| 536 |
+
return lines.join("\n");
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
async function hermesQuickSetup(port) {
|
| 540 |
+
const { endpoint } = await getEndpoint(port);
|
| 541 |
+
const apiKey = await getFirstApiKey();
|
| 542 |
+
|
| 543 |
+
if (!apiKey) {
|
| 544 |
+
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
| 545 |
+
await pause();
|
| 546 |
+
return;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
|
| 550 |
+
if (!model) return;
|
| 551 |
+
|
| 552 |
+
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
|
| 553 |
+
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 554 |
+
await pause();
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
async function hermesReset() {
|
| 558 |
+
const result = await api.resetCliToolSettings("hermes");
|
| 559 |
+
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
| 560 |
+
await pause();
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
async function showHermesMenu(port, breadcrumb = []) {
|
| 564 |
+
await showMenuWithBack({
|
| 565 |
+
title: "⚡ Hermes Agent Settings",
|
| 566 |
+
breadcrumb,
|
| 567 |
+
headerContent: buildHermesHeader,
|
| 568 |
+
refresh: async () => ({}),
|
| 569 |
+
items: [
|
| 570 |
+
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
|
| 571 |
+
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
|
| 572 |
+
]
|
| 573 |
+
});
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
|
| 577 |
+
|
| 578 |
+
/**
|
| 579 |
+
* Main CLI Tools menu
|
| 580 |
+
* @param {number} port
|
| 581 |
+
* @param {Array<string>} breadcrumb
|
| 582 |
+
*/
|
| 583 |
+
async function showCliToolsMenu(port, breadcrumb = []) {
|
| 584 |
+
const { endpoint } = await getEndpoint(port);
|
| 585 |
+
await showMenuWithBack({
|
| 586 |
+
title: "🔧 CLI Tools",
|
| 587 |
+
breadcrumb,
|
| 588 |
+
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
|
| 589 |
+
items: [
|
| 590 |
+
{
|
| 591 |
+
label: "Claude Code",
|
| 592 |
+
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
|
| 593 |
+
},
|
| 594 |
+
{
|
| 595 |
+
label: "Codex CLI",
|
| 596 |
+
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
|
| 597 |
+
},
|
| 598 |
+
{
|
| 599 |
+
label: "Factory Droid",
|
| 600 |
+
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
label: "Open Claw",
|
| 604 |
+
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
|
| 605 |
+
},
|
| 606 |
+
{
|
| 607 |
+
label: "OpenCode",
|
| 608 |
+
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
label: "Hermes",
|
| 612 |
+
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
|
| 613 |
+
}
|
| 614 |
+
]
|
| 615 |
+
});
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
module.exports = { showCliToolsMenu };
|
cli/src/cli/menus/combos.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { prompt, confirm, pause } = require("../utils/input");
|
| 3 |
+
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
| 4 |
+
const { formatDate } = require("../utils/format");
|
| 5 |
+
const { selectModelFromList } = require("../utils/modelSelector");
|
| 6 |
+
const { showMenuWithBack } = require("../utils/menuHelper");
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Format model to string (handle both string and object)
|
| 10 |
+
*/
|
| 11 |
+
function formatModel(model) {
|
| 12 |
+
if (typeof model === "string") return model;
|
| 13 |
+
if (model && typeof model === "object") {
|
| 14 |
+
return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
|
| 15 |
+
}
|
| 16 |
+
return String(model);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Show actions for a specific combo
|
| 21 |
+
* @param {Object} combo - Combo object
|
| 22 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 23 |
+
*/
|
| 24 |
+
async function showComboActions(combo, breadcrumb = []) {
|
| 25 |
+
const modelsChain = Array.isArray(combo.models)
|
| 26 |
+
? combo.models.map(formatModel).join(" → ")
|
| 27 |
+
: "";
|
| 28 |
+
|
| 29 |
+
await showMenuWithBack({
|
| 30 |
+
title: `🔀 ${combo.name}`,
|
| 31 |
+
breadcrumb: [...breadcrumb, combo.name],
|
| 32 |
+
headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
|
| 33 |
+
items: [
|
| 34 |
+
{
|
| 35 |
+
label: "Edit Combo",
|
| 36 |
+
action: async () => {
|
| 37 |
+
await handleEditSingleCombo(combo);
|
| 38 |
+
return true;
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
label: "Delete Combo",
|
| 43 |
+
action: async () => {
|
| 44 |
+
await handleDeleteSingleCombo(combo);
|
| 45 |
+
return false; // Exit after delete
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
]
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Handle editing a single combo
|
| 54 |
+
* @param {Object} combo - Combo to edit
|
| 55 |
+
*/
|
| 56 |
+
async function handleEditSingleCombo(combo) {
|
| 57 |
+
clearScreen();
|
| 58 |
+
console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
|
| 59 |
+
|
| 60 |
+
const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
|
| 61 |
+
const name = newName || combo.name;
|
| 62 |
+
|
| 63 |
+
console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
|
| 64 |
+
console.log("\nSelect models for this combo (add one by one):");
|
| 65 |
+
|
| 66 |
+
const models = [];
|
| 67 |
+
let addMore = true;
|
| 68 |
+
|
| 69 |
+
while (addMore) {
|
| 70 |
+
const currentChain = models.length > 0 ? models.join(" → ") : "None";
|
| 71 |
+
const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
|
| 72 |
+
|
| 73 |
+
if (model) {
|
| 74 |
+
models.push(model);
|
| 75 |
+
console.log(`\n✓ Added: ${model}`);
|
| 76 |
+
console.log(`Current chain: ${models.join(" → ")}\n`);
|
| 77 |
+
|
| 78 |
+
const continueAdding = await confirm("Add another model?");
|
| 79 |
+
addMore = continueAdding;
|
| 80 |
+
} else {
|
| 81 |
+
addMore = false;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Use new models if any were added, otherwise keep current
|
| 86 |
+
const finalModels = models.length > 0 ? models : combo.models;
|
| 87 |
+
|
| 88 |
+
const result = await api.updateCombo(combo.id, { name, models: finalModels });
|
| 89 |
+
|
| 90 |
+
if (result.success) {
|
| 91 |
+
showStatus("Combo updated!", "success");
|
| 92 |
+
} else {
|
| 93 |
+
showStatus(`Update failed: ${result.error}`, "error");
|
| 94 |
+
}
|
| 95 |
+
await pause();
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Handle deleting a single combo
|
| 100 |
+
* @param {Object} combo - Combo to delete
|
| 101 |
+
*/
|
| 102 |
+
async function handleDeleteSingleCombo(combo) {
|
| 103 |
+
const confirmed = await confirm(`Delete combo "${combo.name}"?`);
|
| 104 |
+
if (confirmed) {
|
| 105 |
+
const result = await api.deleteCombo(combo.id);
|
| 106 |
+
if (result.success) {
|
| 107 |
+
showStatus("Combo deleted!", "success");
|
| 108 |
+
} else {
|
| 109 |
+
showStatus(`Delete failed: ${result.error}`, "error");
|
| 110 |
+
}
|
| 111 |
+
await pause();
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Main combos menu - list all combos and actions
|
| 117 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 118 |
+
*/
|
| 119 |
+
async function showCombosMenu(breadcrumb = []) {
|
| 120 |
+
const { showListMenu } = require("../utils/menuHelper");
|
| 121 |
+
|
| 122 |
+
await showListMenu({
|
| 123 |
+
title: "🔀 Combos Management",
|
| 124 |
+
breadcrumb,
|
| 125 |
+
fetchItems: async () => {
|
| 126 |
+
const result = await api.getCombos();
|
| 127 |
+
if (!result.success) {
|
| 128 |
+
clearScreen();
|
| 129 |
+
showStatus(`Failed to load combos: ${result.error}`, "error");
|
| 130 |
+
await pause();
|
| 131 |
+
return null;
|
| 132 |
+
}
|
| 133 |
+
return { items: result.data.combos || [] };
|
| 134 |
+
},
|
| 135 |
+
formatItem: (combo) => {
|
| 136 |
+
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
|
| 137 |
+
const maxLen = 35;
|
| 138 |
+
const displayModels = modelsChain.length > maxLen
|
| 139 |
+
? modelsChain.substring(0, maxLen - 3) + "..."
|
| 140 |
+
: modelsChain;
|
| 141 |
+
return `${combo.name}: ${displayModels}`;
|
| 142 |
+
},
|
| 143 |
+
onSelect: async (combo) => {
|
| 144 |
+
await showComboActions(combo, breadcrumb);
|
| 145 |
+
},
|
| 146 |
+
createAction: {
|
| 147 |
+
label: "Create New Combo",
|
| 148 |
+
action: async () => {
|
| 149 |
+
await handleCreateCombo();
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* Show combo detail with stats
|
| 157 |
+
*/
|
| 158 |
+
async function showComboDetail(comboId) {
|
| 159 |
+
clearScreen();
|
| 160 |
+
|
| 161 |
+
const result = await api.getComboById(comboId);
|
| 162 |
+
|
| 163 |
+
if (!result.success) {
|
| 164 |
+
showStatus(`Failed to load combo: ${result.error}`, "error");
|
| 165 |
+
await pause();
|
| 166 |
+
return;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const combo = result.data;
|
| 170 |
+
|
| 171 |
+
console.log("┌─────────────────────────────────────────────────────────┐");
|
| 172 |
+
console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`);
|
| 173 |
+
console.log("├─────────────────────────────────────────────────────────┤");
|
| 174 |
+
console.log("│ │");
|
| 175 |
+
console.log(`│ ID: ${combo.id.padEnd(51)} │`);
|
| 176 |
+
console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`);
|
| 177 |
+
console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`);
|
| 178 |
+
console.log("│ │");
|
| 179 |
+
console.log("│ Model Chain: │");
|
| 180 |
+
|
| 181 |
+
// Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
|
| 182 |
+
const models = Array.isArray(combo.models) ? combo.models : [];
|
| 183 |
+
models.forEach((modelStr, index) => {
|
| 184 |
+
const arrow = index < models.length - 1 ? " →" : " ";
|
| 185 |
+
const displayText = `${index + 1}. ${modelStr}${arrow}`;
|
| 186 |
+
const padding = Math.max(0, 54 - displayText.length);
|
| 187 |
+
console.log(`│ ${displayText}${" ".repeat(padding)} │`);
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
console.log("│ │");
|
| 191 |
+
console.log("└─────────────────────────────────────────────────────────┘");
|
| 192 |
+
|
| 193 |
+
await pause();
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* Format combo for menu display
|
| 198 |
+
*/
|
| 199 |
+
function formatComboLabel(combo) {
|
| 200 |
+
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
|
| 201 |
+
const maxLen = 40;
|
| 202 |
+
const displayModels = modelsChain.length > maxLen
|
| 203 |
+
? modelsChain.substring(0, maxLen - 3) + "..."
|
| 204 |
+
: modelsChain;
|
| 205 |
+
return `${combo.name}: ${displayModels}`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* Create new combo
|
| 210 |
+
*/
|
| 211 |
+
async function handleCreateCombo() {
|
| 212 |
+
clearScreen();
|
| 213 |
+
|
| 214 |
+
showStatus("Create New Combo", "info");
|
| 215 |
+
console.log();
|
| 216 |
+
|
| 217 |
+
// Get combo name
|
| 218 |
+
const name = await prompt("Combo name: ");
|
| 219 |
+
if (!name) {
|
| 220 |
+
showStatus("Combo name is required", "error");
|
| 221 |
+
await pause();
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Fetch available models
|
| 226 |
+
showStatus("Loading available models...", "info");
|
| 227 |
+
const modelsResult = await api.getModels();
|
| 228 |
+
|
| 229 |
+
if (!modelsResult.success) {
|
| 230 |
+
showStatus(`Failed to load models: ${modelsResult.error}`, "error");
|
| 231 |
+
await pause();
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
const availableModels = modelsResult.data.models || [];
|
| 236 |
+
|
| 237 |
+
if (availableModels.length === 0) {
|
| 238 |
+
showStatus("No models available. Please add providers first.", "warning");
|
| 239 |
+
await pause();
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// Select models for chain
|
| 244 |
+
const selectedModels = [];
|
| 245 |
+
|
| 246 |
+
console.log();
|
| 247 |
+
showStatus("Select models for the chain (minimum 2)", "info");
|
| 248 |
+
|
| 249 |
+
while (true) {
|
| 250 |
+
clearScreen();
|
| 251 |
+
console.log(`Creating combo: ${name}`);
|
| 252 |
+
console.log(`Selected models (${selectedModels.length}):`);
|
| 253 |
+
|
| 254 |
+
if (selectedModels.length > 0) {
|
| 255 |
+
selectedModels.forEach((m, i) => {
|
| 256 |
+
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
|
| 257 |
+
});
|
| 258 |
+
} else {
|
| 259 |
+
console.log(" (none)");
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
console.log();
|
| 263 |
+
console.log("Available models:");
|
| 264 |
+
availableModels.forEach((m, i) => {
|
| 265 |
+
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
console.log();
|
| 269 |
+
console.log("Actions:");
|
| 270 |
+
console.log(" - Enter number to add model");
|
| 271 |
+
console.log(" - Type 'done' to finish (min 2 models)");
|
| 272 |
+
console.log(" - Type 'cancel' to abort");
|
| 273 |
+
|
| 274 |
+
const input = await prompt("\nAction: ");
|
| 275 |
+
|
| 276 |
+
if (input.toLowerCase() === "cancel") {
|
| 277 |
+
showStatus("Cancelled", "warning");
|
| 278 |
+
await pause();
|
| 279 |
+
return;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if (input.toLowerCase() === "done") {
|
| 283 |
+
if (selectedModels.length < 2) {
|
| 284 |
+
showStatus("Please select at least 2 models", "error");
|
| 285 |
+
await pause();
|
| 286 |
+
continue;
|
| 287 |
+
}
|
| 288 |
+
break;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
const num = parseInt(input, 10);
|
| 292 |
+
if (isNaN(num) || num < 1 || num > availableModels.length) {
|
| 293 |
+
showStatus("Invalid model number", "error");
|
| 294 |
+
await pause();
|
| 295 |
+
continue;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
selectedModels.push(availableModels[num - 1]);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Create combo
|
| 302 |
+
showStatus("Creating combo...", "info");
|
| 303 |
+
|
| 304 |
+
const createResult = await api.createCombo({
|
| 305 |
+
name,
|
| 306 |
+
models: selectedModels
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
if (!createResult.success) {
|
| 310 |
+
showStatus(`Failed to create combo: ${createResult.error}`, "error");
|
| 311 |
+
await pause();
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
showStatus(`Combo "${name}" created successfully!`, "success");
|
| 316 |
+
await pause();
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Edit combo - select which combo to edit
|
| 321 |
+
*/
|
| 322 |
+
async function handleEditCombo(combos) {
|
| 323 |
+
if (combos.length === 0) {
|
| 324 |
+
showStatus("No combos available", "warning");
|
| 325 |
+
await pause();
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
let selectedCombo = null;
|
| 330 |
+
|
| 331 |
+
await showMenuWithBack({
|
| 332 |
+
title: "✏️ Select Combo to Edit",
|
| 333 |
+
items: combos.map(combo => ({
|
| 334 |
+
label: formatComboLabel(combo),
|
| 335 |
+
action: async () => {
|
| 336 |
+
selectedCombo = combo;
|
| 337 |
+
return false;
|
| 338 |
+
}
|
| 339 |
+
}))
|
| 340 |
+
});
|
| 341 |
+
|
| 342 |
+
if (!selectedCombo) return;
|
| 343 |
+
await editSingleCombo(selectedCombo);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/**
|
| 347 |
+
* Edit a single combo
|
| 348 |
+
*/
|
| 349 |
+
async function editSingleCombo(combo) {
|
| 350 |
+
clearScreen();
|
| 351 |
+
showStatus(`Editing combo: ${combo.name}`, "info");
|
| 352 |
+
console.log();
|
| 353 |
+
|
| 354 |
+
const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
|
| 355 |
+
const editModels = await confirm("Edit model chain?");
|
| 356 |
+
|
| 357 |
+
let newModels = combo.models;
|
| 358 |
+
|
| 359 |
+
if (editModels) {
|
| 360 |
+
newModels = [];
|
| 361 |
+
|
| 362 |
+
while (true) {
|
| 363 |
+
clearScreen();
|
| 364 |
+
console.log(`Editing combo: ${combo.name}`);
|
| 365 |
+
console.log(`Selected models (${newModels.length}):`);
|
| 366 |
+
|
| 367 |
+
if (newModels.length > 0) {
|
| 368 |
+
newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
|
| 369 |
+
} else {
|
| 370 |
+
console.log(" (none)");
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
|
| 374 |
+
|
| 375 |
+
const model = await selectModelFromList("Add Model", "");
|
| 376 |
+
|
| 377 |
+
if (model === null) {
|
| 378 |
+
showStatus("Cancelled", "warning");
|
| 379 |
+
await pause();
|
| 380 |
+
return;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
if (model === "done") {
|
| 384 |
+
if (newModels.length < 2) {
|
| 385 |
+
showStatus("Please select at least 2 models", "error");
|
| 386 |
+
await pause();
|
| 387 |
+
continue;
|
| 388 |
+
}
|
| 389 |
+
break;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
newModels.push(model);
|
| 393 |
+
showStatus(`Added: ${model}`, "success");
|
| 394 |
+
await pause();
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
const updateData = {};
|
| 399 |
+
if (newName) updateData.name = newName;
|
| 400 |
+
if (editModels) updateData.models = newModels;
|
| 401 |
+
|
| 402 |
+
if (Object.keys(updateData).length === 0) {
|
| 403 |
+
showStatus("No changes made", "warning");
|
| 404 |
+
await pause();
|
| 405 |
+
return;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
showStatus("Updating combo...", "info");
|
| 409 |
+
|
| 410 |
+
const updateResult = await api.updateCombo(combo.id, updateData);
|
| 411 |
+
|
| 412 |
+
if (!updateResult.success) {
|
| 413 |
+
showStatus(`Failed to update combo: ${updateResult.error}`, "error");
|
| 414 |
+
await pause();
|
| 415 |
+
return;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
showStatus("Combo updated successfully!", "success");
|
| 419 |
+
await pause();
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/**
|
| 423 |
+
* Delete combo - select which combo to delete
|
| 424 |
+
*/
|
| 425 |
+
async function handleDeleteCombo(combos) {
|
| 426 |
+
if (combos.length === 0) {
|
| 427 |
+
showStatus("No combos available", "warning");
|
| 428 |
+
await pause();
|
| 429 |
+
return;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
let selectedCombo = null;
|
| 433 |
+
|
| 434 |
+
await showMenuWithBack({
|
| 435 |
+
title: "🗑️ Select Combo to Delete",
|
| 436 |
+
items: combos.map(combo => ({
|
| 437 |
+
label: formatComboLabel(combo),
|
| 438 |
+
action: async () => {
|
| 439 |
+
selectedCombo = combo;
|
| 440 |
+
return false;
|
| 441 |
+
}
|
| 442 |
+
}))
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
if (!selectedCombo) return;
|
| 446 |
+
|
| 447 |
+
clearScreen();
|
| 448 |
+
showStatus(`Combo: ${selectedCombo.name}`, "warning");
|
| 449 |
+
const modelsDisplay = Array.isArray(selectedCombo.models)
|
| 450 |
+
? selectedCombo.models.map(formatModel).join(" → ")
|
| 451 |
+
: "";
|
| 452 |
+
console.log(`Models: ${modelsDisplay}`);
|
| 453 |
+
console.log();
|
| 454 |
+
|
| 455 |
+
const confirmed = await confirm("Are you sure you want to delete this combo?");
|
| 456 |
+
|
| 457 |
+
if (!confirmed) {
|
| 458 |
+
showStatus("Cancelled", "info");
|
| 459 |
+
await pause();
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
showStatus("Deleting combo...", "info");
|
| 464 |
+
|
| 465 |
+
const deleteResult = await api.deleteCombo(selectedCombo.id);
|
| 466 |
+
|
| 467 |
+
if (!deleteResult.success) {
|
| 468 |
+
showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
|
| 469 |
+
await pause();
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
showStatus("Combo deleted successfully!", "success");
|
| 474 |
+
await pause();
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
module.exports = { showCombosMenu };
|
cli/src/cli/menus/providers.js
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { prompt, confirm, pause } = require("../utils/input");
|
| 3 |
+
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
| 4 |
+
const { formatDate, getRelativeTime } = require("../utils/format");
|
| 5 |
+
const { showMenuWithBack } = require("../utils/menuHelper");
|
| 6 |
+
const { copyToClipboard } = require("../utils/clipboard");
|
| 7 |
+
|
| 8 |
+
// ANSI colors for styling
|
| 9 |
+
const COLORS = {
|
| 10 |
+
reset: "\x1b[0m",
|
| 11 |
+
bold: "\x1b[1m",
|
| 12 |
+
cyan: "\x1b[36m",
|
| 13 |
+
dim: "\x1b[2m"
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// Provider models - static config (synced from open-sse/config/providerModels.js)
|
| 17 |
+
const PROVIDER_MODELS = {
|
| 18 |
+
cc: [
|
| 19 |
+
{ id: "claude-opus-4-5-20251101" },
|
| 20 |
+
{ id: "claude-sonnet-4-5-20250929" },
|
| 21 |
+
{ id: "claude-haiku-4-5-20251001" },
|
| 22 |
+
],
|
| 23 |
+
cx: [
|
| 24 |
+
{ id: "gpt-5.2-codex" },
|
| 25 |
+
{ id: "gpt-5.2" },
|
| 26 |
+
{ id: "gpt-5.1-codex-max" },
|
| 27 |
+
{ id: "gpt-5.1-codex" },
|
| 28 |
+
{ id: "gpt-5.1-codex-mini" },
|
| 29 |
+
{ id: "gpt-5.1" },
|
| 30 |
+
{ id: "gpt-5-codex" },
|
| 31 |
+
{ id: "gpt-5-codex-mini" },
|
| 32 |
+
],
|
| 33 |
+
gc: [
|
| 34 |
+
{ id: "gemini-3-flash-preview" },
|
| 35 |
+
{ id: "gemini-3-pro-preview" },
|
| 36 |
+
{ id: "gemini-2.5-pro" },
|
| 37 |
+
{ id: "gemini-2.5-flash" },
|
| 38 |
+
{ id: "gemini-2.5-flash-lite" },
|
| 39 |
+
],
|
| 40 |
+
qw: [
|
| 41 |
+
{ id: "qwen3-coder-plus" },
|
| 42 |
+
{ id: "qwen3-coder-flash" },
|
| 43 |
+
{ id: "vision-model" },
|
| 44 |
+
],
|
| 45 |
+
if: [
|
| 46 |
+
{ id: "qwen3-coder-plus" },
|
| 47 |
+
{ id: "kimi-k2" },
|
| 48 |
+
{ id: "kimi-k2-thinking" },
|
| 49 |
+
{ id: "deepseek-r1" },
|
| 50 |
+
{ id: "deepseek-v3.2-chat" },
|
| 51 |
+
{ id: "deepseek-v3.2-reasoner" },
|
| 52 |
+
{ id: "minimax-m2" },
|
| 53 |
+
{ id: "glm-4.7" },
|
| 54 |
+
],
|
| 55 |
+
ag: [
|
| 56 |
+
{ id: "gemini-3-flash-agent" },
|
| 57 |
+
{ id: "gemini-3.5-flash-low" },
|
| 58 |
+
{ id: "gemini-3.5-flash-extra-low" },
|
| 59 |
+
{ id: "gemini-pro-agent" },
|
| 60 |
+
{ id: "gemini-3.1-pro-low" },
|
| 61 |
+
{ id: "claude-sonnet-4-6" },
|
| 62 |
+
{ id: "claude-opus-4-6-thinking" },
|
| 63 |
+
{ id: "gpt-oss-120b-medium" },
|
| 64 |
+
{ id: "gemini-3-flash" },
|
| 65 |
+
],
|
| 66 |
+
gh: [
|
| 67 |
+
{ id: "gpt-5" },
|
| 68 |
+
{ id: "gpt-5-mini" },
|
| 69 |
+
{ id: "gpt-5.1-codex" },
|
| 70 |
+
{ id: "gpt-5.1-codex-max" },
|
| 71 |
+
{ id: "gpt-4.1" },
|
| 72 |
+
{ id: "claude-4.5-sonnet" },
|
| 73 |
+
{ id: "claude-4.5-opus" },
|
| 74 |
+
{ id: "claude-4.5-haiku" },
|
| 75 |
+
{ id: "gemini-3-pro" },
|
| 76 |
+
{ id: "gemini-3-flash" },
|
| 77 |
+
{ id: "gemini-2.5-pro" },
|
| 78 |
+
{ id: "grok-code-fast-1" },
|
| 79 |
+
],
|
| 80 |
+
kr: [
|
| 81 |
+
{ id: "claude-sonnet-4.5" },
|
| 82 |
+
{ id: "claude-haiku-4.5" },
|
| 83 |
+
],
|
| 84 |
+
openai: [
|
| 85 |
+
{ id: "gpt-4o" },
|
| 86 |
+
{ id: "gpt-4o-mini" },
|
| 87 |
+
{ id: "gpt-4-turbo" },
|
| 88 |
+
{ id: "o1" },
|
| 89 |
+
{ id: "o1-mini" },
|
| 90 |
+
],
|
| 91 |
+
anthropic: [
|
| 92 |
+
{ id: "claude-sonnet-4-20250514" },
|
| 93 |
+
{ id: "claude-opus-4-20250514" },
|
| 94 |
+
{ id: "claude-3-5-sonnet-20241022" },
|
| 95 |
+
],
|
| 96 |
+
gemini: [
|
| 97 |
+
{ id: "gemini-3-pro-preview" },
|
| 98 |
+
{ id: "gemini-2.5-pro" },
|
| 99 |
+
{ id: "gemini-2.5-flash" },
|
| 100 |
+
{ id: "gemini-2.5-flash-lite" },
|
| 101 |
+
],
|
| 102 |
+
openrouter: [
|
| 103 |
+
{ id: "auto" },
|
| 104 |
+
],
|
| 105 |
+
glm: [
|
| 106 |
+
{ id: "glm-4.7" },
|
| 107 |
+
{ id: "glm-4.6v" },
|
| 108 |
+
],
|
| 109 |
+
kimi: [
|
| 110 |
+
{ id: "kimi-latest" },
|
| 111 |
+
],
|
| 112 |
+
minimax: [
|
| 113 |
+
{ id: "MiniMax-M2.1" },
|
| 114 |
+
],
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// Provider definitions
|
| 118 |
+
const OAUTH_PROVIDERS = {
|
| 119 |
+
claude: { id: "claude", alias: "cc", name: "Claude Code" },
|
| 120 |
+
codex: { id: "codex", alias: "cx", name: "OpenAI Codex" },
|
| 121 |
+
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" },
|
| 122 |
+
github: { id: "github", alias: "gh", name: "GitHub Copilot" },
|
| 123 |
+
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" },
|
| 124 |
+
iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
|
| 125 |
+
qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
|
| 126 |
+
kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const APIKEY_PROVIDERS = {
|
| 130 |
+
openrouter: { id: "openrouter", name: "OpenRouter" },
|
| 131 |
+
glm: { id: "glm", name: "GLM Coding" },
|
| 132 |
+
minimax: { id: "minimax", name: "Minimax Coding" },
|
| 133 |
+
kimi: { id: "kimi", name: "Kimi Coding" },
|
| 134 |
+
openai: { id: "openai", name: "OpenAI" },
|
| 135 |
+
anthropic: { id: "anthropic", name: "Anthropic" },
|
| 136 |
+
gemini: { id: "gemini", name: "Gemini" },
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Get auth type for provider
|
| 143 |
+
* @param {string} providerId - Provider ID
|
| 144 |
+
* @returns {string} "oauth" or "apikey"
|
| 145 |
+
*/
|
| 146 |
+
function getAuthType(providerId) {
|
| 147 |
+
return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey";
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Count connections by provider
|
| 152 |
+
* @param {Array} connections - Array of connection objects
|
| 153 |
+
* @returns {Object} Map of providerId -> count
|
| 154 |
+
*/
|
| 155 |
+
function countConnectionsByProvider(connections) {
|
| 156 |
+
const counts = {};
|
| 157 |
+
connections.forEach(conn => {
|
| 158 |
+
const providerId = conn.provider || conn.providerId;
|
| 159 |
+
counts[providerId] = (counts[providerId] || 0) + 1;
|
| 160 |
+
});
|
| 161 |
+
return counts;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Show main providers menu
|
| 166 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 167 |
+
*/
|
| 168 |
+
async function showProvidersMenu(breadcrumb = []) {
|
| 169 |
+
// Build provider items list
|
| 170 |
+
const providerItems = [];
|
| 171 |
+
|
| 172 |
+
Object.values(OAUTH_PROVIDERS).forEach(provider => {
|
| 173 |
+
providerItems.push({
|
| 174 |
+
provider,
|
| 175 |
+
authType: "oauth",
|
| 176 |
+
label: (data) => {
|
| 177 |
+
const count = data.counts[provider.id] || 0;
|
| 178 |
+
return `${provider.name} (OAuth) - ${count} Connected`;
|
| 179 |
+
},
|
| 180 |
+
action: async (data) => {
|
| 181 |
+
await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]);
|
| 182 |
+
return true;
|
| 183 |
+
}
|
| 184 |
+
});
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
Object.values(APIKEY_PROVIDERS).forEach(provider => {
|
| 188 |
+
providerItems.push({
|
| 189 |
+
provider,
|
| 190 |
+
authType: "apikey",
|
| 191 |
+
label: (data) => {
|
| 192 |
+
const count = data.counts[provider.id] || 0;
|
| 193 |
+
return `${provider.name} (API) - ${count} Connected`;
|
| 194 |
+
},
|
| 195 |
+
action: async (data) => {
|
| 196 |
+
await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]);
|
| 197 |
+
return true;
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// Custom provider nodes section
|
| 203 |
+
providerItems.push({
|
| 204 |
+
label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`,
|
| 205 |
+
action: async () => true, // separator, no-op
|
| 206 |
+
isSeparator: true,
|
| 207 |
+
});
|
| 208 |
+
providerItems.push({
|
| 209 |
+
label: (data) => {
|
| 210 |
+
const count = data.nodeCount || 0;
|
| 211 |
+
return `Custom Providers - ${count} Configured`;
|
| 212 |
+
},
|
| 213 |
+
action: async () => {
|
| 214 |
+
await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]);
|
| 215 |
+
return true;
|
| 216 |
+
}
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
await showMenuWithBack({
|
| 220 |
+
title: "🔌 Providers Management",
|
| 221 |
+
breadcrumb,
|
| 222 |
+
refresh: async () => {
|
| 223 |
+
const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]);
|
| 224 |
+
if (!provRes.success) {
|
| 225 |
+
showStatus(`Failed to fetch providers: ${provRes.error}`, "error");
|
| 226 |
+
await pause();
|
| 227 |
+
return null;
|
| 228 |
+
}
|
| 229 |
+
const connections = provRes.data.connections || [];
|
| 230 |
+
const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : [];
|
| 231 |
+
return {
|
| 232 |
+
connections,
|
| 233 |
+
counts: countConnectionsByProvider(connections),
|
| 234 |
+
nodeCount: nodes.length,
|
| 235 |
+
};
|
| 236 |
+
},
|
| 237 |
+
items: providerItems
|
| 238 |
+
});
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* Build provider header with alias and models
|
| 243 |
+
* @param {string} providerId - Provider ID
|
| 244 |
+
* @returns {string}
|
| 245 |
+
*/
|
| 246 |
+
function buildProviderHeader(providerId) {
|
| 247 |
+
const provider = ALL_PROVIDERS[providerId];
|
| 248 |
+
const alias = provider.alias || providerId;
|
| 249 |
+
|
| 250 |
+
const lines = [];
|
| 251 |
+
lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`);
|
| 252 |
+
|
| 253 |
+
// Get models from static config
|
| 254 |
+
const models = PROVIDER_MODELS[alias] || [];
|
| 255 |
+
if (models.length > 0) {
|
| 256 |
+
const modelList = models
|
| 257 |
+
.slice(0, 5)
|
| 258 |
+
.map(m => `${alias}/${m.id}`)
|
| 259 |
+
.join(", ");
|
| 260 |
+
const more = models.length > 5 ? ` (+${models.length - 5} more)` : "";
|
| 261 |
+
lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`);
|
| 262 |
+
} else {
|
| 263 |
+
lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return lines.join("\n");
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/**
|
| 270 |
+
* Show provider detail with connections and actions
|
| 271 |
+
* @param {string} providerId - Provider ID
|
| 272 |
+
* @param {string} authType - "oauth" or "apikey"
|
| 273 |
+
* @param {Array} allConnections - All connections
|
| 274 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 275 |
+
*/
|
| 276 |
+
async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) {
|
| 277 |
+
const provider = ALL_PROVIDERS[providerId];
|
| 278 |
+
const { showListMenu } = require("../utils/menuHelper");
|
| 279 |
+
|
| 280 |
+
await showListMenu({
|
| 281 |
+
title: `🔌 ${provider.name} (${authType.toUpperCase()})`,
|
| 282 |
+
breadcrumb,
|
| 283 |
+
backLabel: "← Back to Providers",
|
| 284 |
+
headerContent: buildProviderHeader(providerId),
|
| 285 |
+
fetchItems: async () => {
|
| 286 |
+
const response = await api.getProviders();
|
| 287 |
+
if (response.success) {
|
| 288 |
+
allConnections.length = 0;
|
| 289 |
+
allConnections.push(...(response.data.connections || []));
|
| 290 |
+
}
|
| 291 |
+
const providerConns = allConnections.filter(conn =>
|
| 292 |
+
(conn.provider || conn.providerId) === providerId
|
| 293 |
+
);
|
| 294 |
+
return { items: providerConns };
|
| 295 |
+
},
|
| 296 |
+
formatItem: (conn) => {
|
| 297 |
+
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
|
| 298 |
+
const name = conn.name || conn.email || conn.displayName || "Unnamed";
|
| 299 |
+
return `${name} (${status})`;
|
| 300 |
+
},
|
| 301 |
+
onSelect: async (conn) => {
|
| 302 |
+
await showConnectionActions(conn, providerId, breadcrumb);
|
| 303 |
+
},
|
| 304 |
+
createAction: {
|
| 305 |
+
label: "Add New Connection",
|
| 306 |
+
action: async () => {
|
| 307 |
+
await handleAddConnection(providerId, authType);
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
});
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Show actions for a specific connection
|
| 315 |
+
* @param {Object} connection - Connection object
|
| 316 |
+
* @param {string} providerId - Provider ID
|
| 317 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 318 |
+
*/
|
| 319 |
+
async function showConnectionActions(connection, providerId, breadcrumb = []) {
|
| 320 |
+
const name = connection.name || connection.email || connection.displayName || "Unnamed";
|
| 321 |
+
const status = connection.testStatus === "active" ? "✓ Active" :
|
| 322 |
+
connection.testStatus === "error" ? "✗ Error" : "? Unknown";
|
| 323 |
+
|
| 324 |
+
await showMenuWithBack({
|
| 325 |
+
title: `🔌 ${name}`,
|
| 326 |
+
breadcrumb: [...breadcrumb, name],
|
| 327 |
+
headerContent: `Connection: ${name}\nStatus: ${status}`,
|
| 328 |
+
items: [
|
| 329 |
+
{
|
| 330 |
+
label: "Rename Connection",
|
| 331 |
+
action: async () => {
|
| 332 |
+
const newName = await prompt(`New name (current: ${name}): `);
|
| 333 |
+
if (newName && newName.trim()) {
|
| 334 |
+
showStatus("Renaming connection...", "info");
|
| 335 |
+
const result = await api.updateConnection(connection.id, { name: newName.trim() });
|
| 336 |
+
if (result.success) {
|
| 337 |
+
showStatus("Connection renamed!", "success");
|
| 338 |
+
connection.name = newName.trim();
|
| 339 |
+
} else {
|
| 340 |
+
showStatus(`Rename failed: ${result.error}`, "error");
|
| 341 |
+
}
|
| 342 |
+
await pause();
|
| 343 |
+
}
|
| 344 |
+
return true;
|
| 345 |
+
}
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
label: "Test Connection",
|
| 349 |
+
action: async () => {
|
| 350 |
+
showStatus("Testing connection...", "info");
|
| 351 |
+
const result = await api.testConnection(connection.id);
|
| 352 |
+
if (result.success) {
|
| 353 |
+
showStatus("Connection is working!", "success");
|
| 354 |
+
} else {
|
| 355 |
+
showStatus(`Test failed: ${result.error}`, "error");
|
| 356 |
+
}
|
| 357 |
+
await pause();
|
| 358 |
+
return true;
|
| 359 |
+
}
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
label: "Delete Connection",
|
| 363 |
+
action: async () => {
|
| 364 |
+
const confirmed = await confirm(`Delete connection "${name}"?`);
|
| 365 |
+
if (confirmed) {
|
| 366 |
+
const result = await api.deleteConnection(connection.id);
|
| 367 |
+
if (result.success) {
|
| 368 |
+
showStatus("Connection deleted!", "success");
|
| 369 |
+
} else {
|
| 370 |
+
showStatus(`Delete failed: ${result.error}`, "error");
|
| 371 |
+
}
|
| 372 |
+
await pause();
|
| 373 |
+
return false; // Exit menu after delete
|
| 374 |
+
}
|
| 375 |
+
return true;
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
]
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/**
|
| 383 |
+
* Handle adding new connection
|
| 384 |
+
* @param {string} providerId - Provider ID
|
| 385 |
+
* @param {string} authType - "oauth" or "apikey"
|
| 386 |
+
*/
|
| 387 |
+
// Providers that use Device Code Flow (terminal-based polling)
|
| 388 |
+
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
|
| 389 |
+
|
| 390 |
+
/**
|
| 391 |
+
* Handle adding new connection - auto-detect flow type
|
| 392 |
+
* @param {string} providerId - Provider ID
|
| 393 |
+
* @param {string} authType - "oauth" or "apikey"
|
| 394 |
+
*/
|
| 395 |
+
async function handleAddConnection(providerId, authType) {
|
| 396 |
+
if (authType === "apikey") {
|
| 397 |
+
await handleAddApiKeyConnection(providerId);
|
| 398 |
+
} else {
|
| 399 |
+
// OAuth: auto-detect flow type based on provider
|
| 400 |
+
if (DEVICE_CODE_PROVIDERS.includes(providerId)) {
|
| 401 |
+
// Device Code Flow for GitHub, Qwen, Kiro
|
| 402 |
+
await handleAddDeviceCodeConnection(providerId);
|
| 403 |
+
} else {
|
| 404 |
+
// Authorization Code Flow for Claude, Codex, Gemini, etc.
|
| 405 |
+
await handleAddOAuthConnection(providerId);
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
/**
|
| 411 |
+
* Handle adding API Key connection
|
| 412 |
+
* @param {string} providerId - Provider ID
|
| 413 |
+
*/
|
| 414 |
+
async function handleAddApiKeyConnection(providerId) {
|
| 415 |
+
clearScreen();
|
| 416 |
+
const provider = ALL_PROVIDERS[providerId];
|
| 417 |
+
console.log(`\n➕ Add ${provider.name} API Key Connection\n`);
|
| 418 |
+
|
| 419 |
+
const name = await prompt("Connection Name: ");
|
| 420 |
+
if (!name) {
|
| 421 |
+
showStatus("Cancelled", "warning");
|
| 422 |
+
await pause();
|
| 423 |
+
return;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
const apiKey = await prompt("API Key: ");
|
| 427 |
+
if (!apiKey) {
|
| 428 |
+
showStatus("Cancelled", "warning");
|
| 429 |
+
await pause();
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
showStatus("Creating connection...", "info");
|
| 434 |
+
|
| 435 |
+
const result = await api.createApiKeyProvider({
|
| 436 |
+
provider: providerId,
|
| 437 |
+
name,
|
| 438 |
+
apiKey
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
if (result.success) {
|
| 442 |
+
showStatus("✓ Connection created successfully!", "success");
|
| 443 |
+
} else {
|
| 444 |
+
showStatus(`✗ Failed: ${result.error}`, "error");
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
await pause();
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/**
|
| 451 |
+
* Handle adding OAuth Authorization Code connection
|
| 452 |
+
* User opens URL manually and pastes callback URL
|
| 453 |
+
* @param {string} providerId - Provider ID
|
| 454 |
+
*/
|
| 455 |
+
async function handleAddOAuthConnection(providerId) {
|
| 456 |
+
clearScreen();
|
| 457 |
+
const provider = ALL_PROVIDERS[providerId];
|
| 458 |
+
|
| 459 |
+
// Step 1: Get auth URL
|
| 460 |
+
showStatus("Requesting authorization URL...", "info");
|
| 461 |
+
const authResult = await api.getOAuthAuthUrl(providerId);
|
| 462 |
+
|
| 463 |
+
if (!authResult.success) {
|
| 464 |
+
showStatus(`Failed: ${authResult.error}`, "error");
|
| 465 |
+
await pause();
|
| 466 |
+
return;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
const authData = authResult.data || authResult;
|
| 470 |
+
const authUrl = authData.authUrl;
|
| 471 |
+
const codeVerifier = authData.codeVerifier;
|
| 472 |
+
const state = authData.state;
|
| 473 |
+
const redirectUri = authData.redirectUri;
|
| 474 |
+
|
| 475 |
+
if (!authUrl) {
|
| 476 |
+
showStatus("Failed: No auth URL received", "error");
|
| 477 |
+
await pause();
|
| 478 |
+
return;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Step 2: Show URL and instructions
|
| 482 |
+
clearScreen();
|
| 483 |
+
showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`);
|
| 484 |
+
|
| 485 |
+
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`);
|
| 486 |
+
console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`);
|
| 487 |
+
if (copyToClipboard(authUrl)) {
|
| 488 |
+
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
|
| 489 |
+
}
|
| 490 |
+
console.log();
|
| 491 |
+
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`);
|
| 492 |
+
console.log();
|
| 493 |
+
console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`);
|
| 494 |
+
console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`);
|
| 495 |
+
console.log();
|
| 496 |
+
|
| 497 |
+
const callbackUrl = await prompt(" Paste callback URL: ");
|
| 498 |
+
if (!callbackUrl) {
|
| 499 |
+
showStatus("Cancelled", "warning");
|
| 500 |
+
await pause();
|
| 501 |
+
return;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// Step 3: Parse callback URL and extract code
|
| 505 |
+
let code, urlState, error;
|
| 506 |
+
try {
|
| 507 |
+
const url = new URL(callbackUrl.trim());
|
| 508 |
+
code = url.searchParams.get("code");
|
| 509 |
+
urlState = url.searchParams.get("state");
|
| 510 |
+
error = url.searchParams.get("error");
|
| 511 |
+
|
| 512 |
+
if (error) {
|
| 513 |
+
const errorDesc = url.searchParams.get("error_description") || error;
|
| 514 |
+
showStatus(`Authorization failed: ${errorDesc}`, "error");
|
| 515 |
+
await pause();
|
| 516 |
+
return;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
if (!code) {
|
| 520 |
+
showStatus("No authorization code found in URL", "error");
|
| 521 |
+
await pause();
|
| 522 |
+
return;
|
| 523 |
+
}
|
| 524 |
+
} catch (err) {
|
| 525 |
+
showStatus("Invalid URL format", "error");
|
| 526 |
+
await pause();
|
| 527 |
+
return;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// Step 4: Exchange code for tokens
|
| 531 |
+
console.log();
|
| 532 |
+
showStatus("Exchanging code for tokens...", "info");
|
| 533 |
+
const exchangeResult = await api.exchangeOAuthCode(providerId, {
|
| 534 |
+
code,
|
| 535 |
+
redirectUri,
|
| 536 |
+
codeVerifier,
|
| 537 |
+
state: urlState || state
|
| 538 |
+
});
|
| 539 |
+
|
| 540 |
+
if (exchangeResult.success) {
|
| 541 |
+
showStatus("Connection created successfully!", "success");
|
| 542 |
+
} else {
|
| 543 |
+
showStatus(`Failed: ${exchangeResult.error}`, "error");
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
await pause();
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/**
|
| 550 |
+
* Handle adding OAuth Device Code connection
|
| 551 |
+
* @param {string} providerId - Provider ID
|
| 552 |
+
*/
|
| 553 |
+
async function handleAddDeviceCodeConnection(providerId) {
|
| 554 |
+
clearScreen();
|
| 555 |
+
const provider = ALL_PROVIDERS[providerId];
|
| 556 |
+
|
| 557 |
+
// Step 1: Request device code
|
| 558 |
+
showStatus("Requesting device code...", "info");
|
| 559 |
+
const deviceResult = await api.getOAuthDeviceCode(providerId);
|
| 560 |
+
|
| 561 |
+
if (!deviceResult.success) {
|
| 562 |
+
showStatus(`Failed: ${deviceResult.error}`, "error");
|
| 563 |
+
await pause();
|
| 564 |
+
return;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
const deviceData = deviceResult.data || deviceResult;
|
| 568 |
+
const device_code = deviceData.device_code;
|
| 569 |
+
const user_code = deviceData.user_code;
|
| 570 |
+
const verification_uri = deviceData.verification_uri;
|
| 571 |
+
const verification_uri_complete = deviceData.verification_uri_complete;
|
| 572 |
+
const codeVerifier = deviceData.codeVerifier;
|
| 573 |
+
const extraData = deviceData.extraData || deviceData;
|
| 574 |
+
|
| 575 |
+
if (!device_code) {
|
| 576 |
+
showStatus("Failed: No device code received", "error");
|
| 577 |
+
await pause();
|
| 578 |
+
return;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// Step 2: Show instructions
|
| 582 |
+
clearScreen();
|
| 583 |
+
const deviceUrl = verification_uri_complete || verification_uri;
|
| 584 |
+
showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`);
|
| 585 |
+
|
| 586 |
+
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`);
|
| 587 |
+
if (copyToClipboard(deviceUrl)) {
|
| 588 |
+
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
|
| 589 |
+
}
|
| 590 |
+
console.log();
|
| 591 |
+
if (!verification_uri_complete && user_code) {
|
| 592 |
+
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`);
|
| 593 |
+
console.log();
|
| 594 |
+
}
|
| 595 |
+
console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`);
|
| 596 |
+
console.log();
|
| 597 |
+
|
| 598 |
+
// Step 3: Poll for token
|
| 599 |
+
const maxAttempts = 60; // 5 minutes (5s interval)
|
| 600 |
+
for (let i = 0; i < maxAttempts; i++) {
|
| 601 |
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
| 602 |
+
|
| 603 |
+
const pollResult = await api.pollOAuthToken(providerId, {
|
| 604 |
+
deviceCode: device_code,
|
| 605 |
+
codeVerifier,
|
| 606 |
+
extraData
|
| 607 |
+
});
|
| 608 |
+
|
| 609 |
+
if (pollResult.success) {
|
| 610 |
+
showStatus("\nConnection created successfully!", "success");
|
| 611 |
+
await pause();
|
| 612 |
+
return;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
// Check if still pending (pending flag is at root level, not in data)
|
| 616 |
+
const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down";
|
| 617 |
+
if (!isPending) {
|
| 618 |
+
showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error");
|
| 619 |
+
await pause();
|
| 620 |
+
return;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
process.stdout.write(".");
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
showStatus("\nTimeout waiting for authorization", "error");
|
| 627 |
+
await pause();
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
// ============================================================================
|
| 631 |
+
// CUSTOM PROVIDERS (provider nodes)
|
| 632 |
+
// ============================================================================
|
| 633 |
+
|
| 634 |
+
const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"];
|
| 635 |
+
const OPENAI_API_TYPES = ["chat", "responses"];
|
| 636 |
+
|
| 637 |
+
/**
|
| 638 |
+
* Show custom providers section in main providers menu
|
| 639 |
+
* @param {Array} nodes - List of provider nodes
|
| 640 |
+
* @param {Array} connections - All connections
|
| 641 |
+
* @param {Array<string>} breadcrumb
|
| 642 |
+
*/
|
| 643 |
+
async function showCustomProvidersMenu(breadcrumb = []) {
|
| 644 |
+
const { showListMenu } = require("../utils/menuHelper");
|
| 645 |
+
|
| 646 |
+
await showListMenu({
|
| 647 |
+
title: "🔧 Custom Providers",
|
| 648 |
+
breadcrumb,
|
| 649 |
+
backLabel: "← Back to Providers",
|
| 650 |
+
fetchItems: async () => {
|
| 651 |
+
const res = await api.getProviderNodes();
|
| 652 |
+
if (!res.success) return { items: [] };
|
| 653 |
+
return { items: res.data.nodes || res.data || [] };
|
| 654 |
+
},
|
| 655 |
+
formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`,
|
| 656 |
+
onSelect: async (node) => {
|
| 657 |
+
await showCustomNodeDetail(node, [...breadcrumb, node.name]);
|
| 658 |
+
},
|
| 659 |
+
createAction: {
|
| 660 |
+
label: "➕ Add Custom Provider",
|
| 661 |
+
action: async () => {
|
| 662 |
+
await handleAddCustomNode();
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
});
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
/**
|
| 669 |
+
* Show detail menu for a custom provider node
|
| 670 |
+
*/
|
| 671 |
+
async function showCustomNodeDetail(node, breadcrumb = []) {
|
| 672 |
+
await showMenuWithBack({
|
| 673 |
+
title: `🔧 ${node.name}`,
|
| 674 |
+
breadcrumb,
|
| 675 |
+
headerContent: [
|
| 676 |
+
`Type: ${node.type}`,
|
| 677 |
+
`Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`,
|
| 678 |
+
`Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`,
|
| 679 |
+
].join("\n"),
|
| 680 |
+
items: [
|
| 681 |
+
{
|
| 682 |
+
label: "Connections",
|
| 683 |
+
action: async () => {
|
| 684 |
+
await showCustomNodeConnections(node, breadcrumb);
|
| 685 |
+
return true;
|
| 686 |
+
}
|
| 687 |
+
},
|
| 688 |
+
{
|
| 689 |
+
label: "Edit Node",
|
| 690 |
+
action: async () => {
|
| 691 |
+
await handleEditCustomNode(node);
|
| 692 |
+
return true;
|
| 693 |
+
}
|
| 694 |
+
},
|
| 695 |
+
{
|
| 696 |
+
label: "Delete Node",
|
| 697 |
+
action: async () => {
|
| 698 |
+
const confirmed = await confirm(`Delete "${node.name}" and all its connections?`);
|
| 699 |
+
if (confirmed) {
|
| 700 |
+
const res = await api.deleteProviderNode(node.id);
|
| 701 |
+
if (res.success) {
|
| 702 |
+
showStatus("Node deleted!", "success");
|
| 703 |
+
} else {
|
| 704 |
+
showStatus(`Delete failed: ${res.error}`, "error");
|
| 705 |
+
}
|
| 706 |
+
await pause();
|
| 707 |
+
return false;
|
| 708 |
+
}
|
| 709 |
+
return true;
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
]
|
| 713 |
+
});
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
/**
|
| 717 |
+
* Show connections for a custom provider node
|
| 718 |
+
*/
|
| 719 |
+
async function showCustomNodeConnections(node, breadcrumb = []) {
|
| 720 |
+
const { showListMenu } = require("../utils/menuHelper");
|
| 721 |
+
|
| 722 |
+
await showListMenu({
|
| 723 |
+
title: `🔌 ${node.name} – Connections`,
|
| 724 |
+
breadcrumb,
|
| 725 |
+
backLabel: "← Back",
|
| 726 |
+
fetchItems: async () => {
|
| 727 |
+
const res = await api.getProviders();
|
| 728 |
+
if (!res.success) return { items: [] };
|
| 729 |
+
const all = res.data.connections || [];
|
| 730 |
+
const items = all.filter(c => c.provider === node.id);
|
| 731 |
+
return { items };
|
| 732 |
+
},
|
| 733 |
+
formatItem: (conn) => {
|
| 734 |
+
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
|
| 735 |
+
return `${conn.name || "Unnamed"} (${status})`;
|
| 736 |
+
},
|
| 737 |
+
onSelect: async (conn) => {
|
| 738 |
+
await showConnectionActions(conn, node.id, breadcrumb);
|
| 739 |
+
},
|
| 740 |
+
createAction: {
|
| 741 |
+
label: "Add API Key Connection",
|
| 742 |
+
action: async () => {
|
| 743 |
+
await handleAddCustomNodeConnection(node);
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
});
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
/**
|
| 750 |
+
* Add API key connection to a custom provider node
|
| 751 |
+
*/
|
| 752 |
+
async function handleAddCustomNodeConnection(node) {
|
| 753 |
+
clearScreen();
|
| 754 |
+
console.log(`\n➕ Add Connection to ${node.name}\n`);
|
| 755 |
+
|
| 756 |
+
const name = await prompt("Connection Name: ");
|
| 757 |
+
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
|
| 758 |
+
|
| 759 |
+
const apiKey = await prompt("API Key: ");
|
| 760 |
+
if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; }
|
| 761 |
+
|
| 762 |
+
showStatus("Creating connection...", "info");
|
| 763 |
+
const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey });
|
| 764 |
+
|
| 765 |
+
showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
|
| 766 |
+
await pause();
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
/**
|
| 770 |
+
* Handle adding a new custom provider node
|
| 771 |
+
*/
|
| 772 |
+
async function handleAddCustomNode() {
|
| 773 |
+
clearScreen();
|
| 774 |
+
console.log("\n➕ Add Custom Provider\n");
|
| 775 |
+
|
| 776 |
+
// Step 1: Select type
|
| 777 |
+
const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
|
| 778 |
+
console.log(`Select type:\n${typeChoices}\n`);
|
| 779 |
+
const typeInput = await prompt("Type (1/2): ");
|
| 780 |
+
const typeIdx = parseInt(typeInput) - 1;
|
| 781 |
+
if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) {
|
| 782 |
+
showStatus("Cancelled", "warning"); await pause(); return;
|
| 783 |
+
}
|
| 784 |
+
const type = CUSTOM_NODE_TYPES[typeIdx];
|
| 785 |
+
|
| 786 |
+
// Step 2: Inputs
|
| 787 |
+
const name = await prompt("Name: ");
|
| 788 |
+
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
|
| 789 |
+
|
| 790 |
+
const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): ");
|
| 791 |
+
if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; }
|
| 792 |
+
|
| 793 |
+
const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): ");
|
| 794 |
+
if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; }
|
| 795 |
+
|
| 796 |
+
// Step 3: API type (OpenAI only)
|
| 797 |
+
let apiType;
|
| 798 |
+
if (type === "openai-compatible") {
|
| 799 |
+
const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
|
| 800 |
+
console.log(`\nAPI Type:\n${apiTypeChoices}\n`);
|
| 801 |
+
const apiTypeInput = await prompt("API Type (1/2, default 1): ");
|
| 802 |
+
const apiTypeIdx = parseInt(apiTypeInput) - 1;
|
| 803 |
+
apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat";
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
showStatus("Creating provider node...", "info");
|
| 807 |
+
const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) };
|
| 808 |
+
const res = await api.createProviderNode(body);
|
| 809 |
+
|
| 810 |
+
showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
|
| 811 |
+
await pause();
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
/**
|
| 815 |
+
* Handle editing a custom provider node
|
| 816 |
+
*/
|
| 817 |
+
async function handleEditCustomNode(node) {
|
| 818 |
+
clearScreen();
|
| 819 |
+
console.log(`\n✏️ Edit ${node.name}\n`);
|
| 820 |
+
console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`);
|
| 821 |
+
|
| 822 |
+
const name = await prompt(`Name (${node.name}): `);
|
| 823 |
+
const baseUrl = await prompt(`Base URL (${node.baseUrl}): `);
|
| 824 |
+
const prefix = await prompt(`Prefix (${node.prefix}): `);
|
| 825 |
+
|
| 826 |
+
const updates = {};
|
| 827 |
+
if (name && name.trim()) updates.name = name.trim();
|
| 828 |
+
if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim();
|
| 829 |
+
if (prefix && prefix.trim()) updates.prefix = prefix.trim();
|
| 830 |
+
|
| 831 |
+
if (!Object.keys(updates).length) {
|
| 832 |
+
showStatus("No changes", "warning"); await pause(); return;
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
showStatus("Updating...", "info");
|
| 836 |
+
const res = await api.updateProviderNode(node.id, updates);
|
| 837 |
+
if (res.success) {
|
| 838 |
+
Object.assign(node, updates);
|
| 839 |
+
showStatus("✓ Updated!", "success");
|
| 840 |
+
} else {
|
| 841 |
+
showStatus(`✗ Failed: ${res.error}`, "error");
|
| 842 |
+
}
|
| 843 |
+
await pause();
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
module.exports = { showProvidersMenu };
|
cli/src/cli/menus/settings.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { confirm, pause } = require("../utils/input");
|
| 3 |
+
const { showStatus } = require("../utils/display");
|
| 4 |
+
const { showMenuWithBack } = require("../utils/menuHelper");
|
| 5 |
+
|
| 6 |
+
// ANSI colors
|
| 7 |
+
const COLORS = {
|
| 8 |
+
reset: "\x1b[0m",
|
| 9 |
+
green: "\x1b[32m",
|
| 10 |
+
red: "\x1b[31m",
|
| 11 |
+
yellow: "\x1b[33m",
|
| 12 |
+
dim: "\x1b[2m",
|
| 13 |
+
cyan: "\x1b[36m"
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const DEFAULT_PASSWORD = "123456";
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Show settings menu (tunnel + RTK + reset password)
|
| 20 |
+
* @param {Array<string>} breadcrumb - Breadcrumb path
|
| 21 |
+
*/
|
| 22 |
+
async function showSettingsMenu(breadcrumb = []) {
|
| 23 |
+
await showMenuWithBack({
|
| 24 |
+
title: "⚙️ Settings",
|
| 25 |
+
breadcrumb,
|
| 26 |
+
headerContent: async (data) => {
|
| 27 |
+
const lines = [];
|
| 28 |
+
|
| 29 |
+
// Tunnel section
|
| 30 |
+
const tunnel = data?.tunnel || {};
|
| 31 |
+
if (tunnel.enabled && tunnel.publicUrl) {
|
| 32 |
+
lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
|
| 33 |
+
lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
|
| 34 |
+
} else {
|
| 35 |
+
lines.push(` Endpoint: http://localhost:20128/v1`);
|
| 36 |
+
lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// RTK section
|
| 40 |
+
const rtkOn = data?.settings?.rtkEnabled !== false;
|
| 41 |
+
lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
|
| 42 |
+
|
| 43 |
+
// Auth mode section
|
| 44 |
+
const authMode = data?.settings?.authMode || "password";
|
| 45 |
+
const authColor = authMode === "password" ? COLORS.green : COLORS.yellow;
|
| 46 |
+
lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`);
|
| 47 |
+
|
| 48 |
+
return lines.join("\n");
|
| 49 |
+
},
|
| 50 |
+
refresh: async () => {
|
| 51 |
+
const [tunnelRes, settingsRes] = await Promise.all([
|
| 52 |
+
api.getTunnelStatus(),
|
| 53 |
+
api.getSettings()
|
| 54 |
+
]);
|
| 55 |
+
return {
|
| 56 |
+
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
|
| 57 |
+
settings: settingsRes.success ? (settingsRes.data || {}) : {}
|
| 58 |
+
};
|
| 59 |
+
},
|
| 60 |
+
items: [
|
| 61 |
+
{
|
| 62 |
+
label: "Tunnel ON",
|
| 63 |
+
action: async () => { await enableTunnel(); return true; }
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
label: "Tunnel OFF",
|
| 67 |
+
action: async () => { await disableTunnel(); return true; }
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
label: (d) => {
|
| 71 |
+
const on = d?.settings?.rtkEnabled !== false;
|
| 72 |
+
return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`;
|
| 73 |
+
},
|
| 74 |
+
action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
label: "🔑 Reset Password to Default",
|
| 78 |
+
action: async () => { await resetPassword(); return true; }
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
label: (d) => {
|
| 82 |
+
const mode = d?.settings?.authMode || "password";
|
| 83 |
+
return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`;
|
| 84 |
+
},
|
| 85 |
+
action: async () => { await resetAuthMode(); return true; }
|
| 86 |
+
}
|
| 87 |
+
]
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Reset authMode to "password" via API. Used when OIDC is misconfigured
|
| 93 |
+
* and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token.
|
| 94 |
+
*/
|
| 95 |
+
async function resetAuthMode() {
|
| 96 |
+
const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?");
|
| 97 |
+
if (!ok) {
|
| 98 |
+
showStatus("Cancelled", "info");
|
| 99 |
+
await pause();
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const result = await api.updateSettings({ authMode: "password" });
|
| 104 |
+
if (result.success) {
|
| 105 |
+
showStatus("Auth mode reset to password. OIDC disabled.", "success");
|
| 106 |
+
} else {
|
| 107 |
+
showStatus(`Failed: ${result.error}`, "error");
|
| 108 |
+
}
|
| 109 |
+
await pause();
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Enable tunnel via API
|
| 114 |
+
*/
|
| 115 |
+
async function enableTunnel() {
|
| 116 |
+
showStatus("Creating tunnel...", "info");
|
| 117 |
+
const result = await api.enableTunnel();
|
| 118 |
+
|
| 119 |
+
if (result.success) {
|
| 120 |
+
const { publicUrl, shortId, alreadyRunning } = result.data || {};
|
| 121 |
+
if (alreadyRunning) {
|
| 122 |
+
showStatus(`Tunnel already running: ${publicUrl}`, "success");
|
| 123 |
+
} else {
|
| 124 |
+
showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
|
| 125 |
+
}
|
| 126 |
+
} else {
|
| 127 |
+
showStatus(`Failed: ${result.error}`, "error");
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
await pause();
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Disable tunnel via API
|
| 135 |
+
*/
|
| 136 |
+
async function disableTunnel() {
|
| 137 |
+
const result = await api.disableTunnel();
|
| 138 |
+
|
| 139 |
+
if (result.success) {
|
| 140 |
+
showStatus("Tunnel disabled", "success");
|
| 141 |
+
} else {
|
| 142 |
+
showStatus(`Failed: ${result.error}`, "error");
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
await pause();
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Toggle RTK (Token Saver) via API
|
| 150 |
+
* @param {boolean} currentlyOn
|
| 151 |
+
*/
|
| 152 |
+
async function toggleRtk(currentlyOn) {
|
| 153 |
+
const next = !currentlyOn;
|
| 154 |
+
const result = await api.updateSettings({ rtkEnabled: next });
|
| 155 |
+
if (result.success) {
|
| 156 |
+
showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success");
|
| 157 |
+
} else {
|
| 158 |
+
showStatus(`Failed: ${result.error}`, "error");
|
| 159 |
+
}
|
| 160 |
+
await pause();
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Reset dashboard password to default via server API (writes the live SQLite DB).
|
| 165 |
+
* After reset, user can log in with the default password "123456".
|
| 166 |
+
*/
|
| 167 |
+
async function resetPassword() {
|
| 168 |
+
const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
|
| 169 |
+
if (!ok) {
|
| 170 |
+
showStatus("Cancelled", "info");
|
| 171 |
+
await pause();
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const result = await api.resetPassword();
|
| 176 |
+
if (result.success) {
|
| 177 |
+
showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
|
| 178 |
+
} else {
|
| 179 |
+
showStatus(`Failed to reset password: ${result.error}`, "error");
|
| 180 |
+
}
|
| 181 |
+
await pause();
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
module.exports = { showSettingsMenu };
|
cli/src/cli/terminalUI.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("./api/client");
|
| 2 |
+
const { showMenuWithBack } = require("./utils/menuHelper");
|
| 3 |
+
const { showProvidersMenu } = require("./menus/providers");
|
| 4 |
+
const { showApiKeysMenu } = require("./menus/apiKeys");
|
| 5 |
+
const { showCombosMenu } = require("./menus/combos");
|
| 6 |
+
const { showSettingsMenu } = require("./menus/settings");
|
| 7 |
+
const { showCliToolsMenu } = require("./menus/cliTools");
|
| 8 |
+
|
| 9 |
+
const COLORS = {
|
| 10 |
+
reset: "\x1b[0m",
|
| 11 |
+
green: "\x1b[32m",
|
| 12 |
+
red: "\x1b[31m",
|
| 13 |
+
dim: "\x1b[2m",
|
| 14 |
+
cyan: "\x1b[36m"
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// Cached header (SWR): show last value instantly, refresh in background.
|
| 18 |
+
let cachedHeader = "";
|
| 19 |
+
let fetchingHeader = false;
|
| 20 |
+
|
| 21 |
+
function renderHeader(port, keys, tunnel) {
|
| 22 |
+
const tunnelEnabled = tunnel && tunnel.enabled === true;
|
| 23 |
+
const lines = [];
|
| 24 |
+
if (tunnelEnabled && tunnel.publicUrl) {
|
| 25 |
+
lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
|
| 26 |
+
lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
|
| 27 |
+
} else {
|
| 28 |
+
lines.push(`Endpoint: http://localhost:${port}/v1`);
|
| 29 |
+
lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
|
| 30 |
+
}
|
| 31 |
+
if (!keys || keys.length === 0) {
|
| 32 |
+
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
|
| 33 |
+
} else {
|
| 34 |
+
lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`);
|
| 35 |
+
keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`));
|
| 36 |
+
}
|
| 37 |
+
return lines.join("\n");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async function refreshHeaderBg(port) {
|
| 41 |
+
if (fetchingHeader) return;
|
| 42 |
+
fetchingHeader = true;
|
| 43 |
+
try {
|
| 44 |
+
const [keysResult, tunnelResult] = await Promise.all([
|
| 45 |
+
api.getApiKeys(),
|
| 46 |
+
api.getTunnelStatus()
|
| 47 |
+
]);
|
| 48 |
+
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
|
| 49 |
+
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
|
| 50 |
+
cachedHeader = renderHeader(port, keys, tunnel);
|
| 51 |
+
} finally {
|
| 52 |
+
fetchingHeader = false;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function getHeader(port) {
|
| 57 |
+
// Kick off background refresh; return cache (or placeholder on first call).
|
| 58 |
+
refreshHeaderBg(port);
|
| 59 |
+
return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Start Terminal UI
|
| 64 |
+
* @param {number} port - Server port number
|
| 65 |
+
*/
|
| 66 |
+
async function startTerminalUI(port) {
|
| 67 |
+
// Configure API client
|
| 68 |
+
api.configure({ port });
|
| 69 |
+
|
| 70 |
+
const basePath = ["9Router"];
|
| 71 |
+
|
| 72 |
+
// Prime header cache before first render
|
| 73 |
+
await refreshHeaderBg(port);
|
| 74 |
+
|
| 75 |
+
// Main menu
|
| 76 |
+
await showMenuWithBack({
|
| 77 |
+
title: "📡 9Router Terminal UI",
|
| 78 |
+
breadcrumb: basePath,
|
| 79 |
+
headerContent: () => getHeader(port),
|
| 80 |
+
items: [
|
| 81 |
+
{
|
| 82 |
+
label: "Providers",
|
| 83 |
+
action: async () => {
|
| 84 |
+
await showProvidersMenu([...basePath, "Providers"]);
|
| 85 |
+
return true; // Continue
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
label: "API Keys",
|
| 90 |
+
action: async () => {
|
| 91 |
+
await showApiKeysMenu(port, [...basePath, "API Keys"]);
|
| 92 |
+
return true;
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
label: "Combos",
|
| 97 |
+
action: async () => {
|
| 98 |
+
await showCombosMenu([...basePath, "Combos"]);
|
| 99 |
+
return true;
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
label: "CLI Tools",
|
| 104 |
+
action: async () => {
|
| 105 |
+
await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
|
| 106 |
+
return true;
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
label: "Settings",
|
| 111 |
+
action: async () => {
|
| 112 |
+
await showSettingsMenu([...basePath, "Settings"]);
|
| 113 |
+
return true;
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
],
|
| 117 |
+
backLabel: "← Back to Interface Menu"
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
module.exports = { startTerminalUI };
|
cli/src/cli/tray/autostart.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require("fs");
|
| 2 |
+
const path = require("path");
|
| 3 |
+
const os = require("os");
|
| 4 |
+
const { execSync } = require("child_process");
|
| 5 |
+
|
| 6 |
+
const APP_NAME = "9router";
|
| 7 |
+
const APP_LABEL = "com.9router.autostart";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Resolve the absolute path to this package's cli.js.
|
| 11 |
+
*
|
| 12 |
+
* Order of preference:
|
| 13 |
+
* 1. Explicit `cliPath` argument — cleanest, used when called from running
|
| 14 |
+
* cli.js with `__filename`.
|
| 15 |
+
* 2. `process.argv[1]` if it's our cli.js — true when 9router is currently
|
| 16 |
+
* running and the tray menu fires this code path.
|
| 17 |
+
* 3. Compute relative to this file's own location. autostart.js lives at
|
| 18 |
+
* `<pkg>/src/cli/tray/autostart.js`, so cli.js is three levels up.
|
| 19 |
+
* This works for any global install layout (nvm, Volta, asdf, Homebrew,
|
| 20 |
+
* /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9)
|
| 21 |
+
* or a hardcoded `/usr/local/...` path.
|
| 22 |
+
*
|
| 23 |
+
* Returns null if no candidate exists — callers should not write an autostart
|
| 24 |
+
* entry pointing at a non-existent script.
|
| 25 |
+
*/
|
| 26 |
+
function getCliJsPath(cliPath) {
|
| 27 |
+
if (cliPath) {
|
| 28 |
+
const resolved = path.resolve(cliPath);
|
| 29 |
+
if (fs.existsSync(resolved)) return resolved;
|
| 30 |
+
}
|
| 31 |
+
if (process.argv[1]) {
|
| 32 |
+
const resolved = path.resolve(process.argv[1]);
|
| 33 |
+
if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) {
|
| 34 |
+
return resolved;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
const computed = path.resolve(__dirname, "..", "..", "..", "cli.js");
|
| 38 |
+
if (fs.existsSync(computed)) return computed;
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Enable auto startup on OS boot
|
| 44 |
+
* @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
|
| 45 |
+
* @returns {boolean} success
|
| 46 |
+
*/
|
| 47 |
+
function enableAutoStart(cliPath) {
|
| 48 |
+
const platform = process.platform;
|
| 49 |
+
|
| 50 |
+
if (!["darwin", "win32", "linux"].includes(platform)) return false;
|
| 51 |
+
if (platform === "linux" && !process.env.DISPLAY) return false;
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
if (platform === "darwin") return enableMacOS(cliPath);
|
| 55 |
+
if (platform === "win32") return enableWindows(cliPath);
|
| 56 |
+
if (platform === "linux") return enableLinux(cliPath);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
// Silent fail — autostart is optional
|
| 59 |
+
}
|
| 60 |
+
return false;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Disable auto startup
|
| 65 |
+
* @returns {boolean} success
|
| 66 |
+
*/
|
| 67 |
+
function disableAutoStart() {
|
| 68 |
+
const platform = process.platform;
|
| 69 |
+
try {
|
| 70 |
+
if (platform === "darwin") return disableMacOS();
|
| 71 |
+
if (platform === "win32") return disableWindows();
|
| 72 |
+
if (platform === "linux") return disableLinux();
|
| 73 |
+
} catch (err) {}
|
| 74 |
+
return false;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Check if autostart is enabled.
|
| 79 |
+
*
|
| 80 |
+
* On macOS, both the plist file and the launchd registration must be present —
|
| 81 |
+
* otherwise the tray menu would lie about the state (showing "✓ Enabled" even
|
| 82 |
+
* when launchd has the agent in a failed state or hasn't loaded it).
|
| 83 |
+
*/
|
| 84 |
+
function isAutoStartEnabled() {
|
| 85 |
+
const platform = process.platform;
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
if (platform === "darwin") {
|
| 89 |
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
| 90 |
+
if (!fs.existsSync(plistPath)) return false;
|
| 91 |
+
try {
|
| 92 |
+
execSync(`launchctl list ${APP_LABEL}`, {
|
| 93 |
+
stdio: ["ignore", "ignore", "ignore"],
|
| 94 |
+
timeout: 3000
|
| 95 |
+
});
|
| 96 |
+
return true;
|
| 97 |
+
} catch (e) {
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
} else if (platform === "win32") {
|
| 101 |
+
const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
|
| 102 |
+
return fs.existsSync(startupPath);
|
| 103 |
+
} else if (platform === "linux") {
|
| 104 |
+
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
|
| 105 |
+
return fs.existsSync(desktopPath);
|
| 106 |
+
}
|
| 107 |
+
} catch (e) {}
|
| 108 |
+
return false;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ============ macOS ============
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Returns true when the current Node process IS the running instance that
|
| 115 |
+
* launchd is managing under our agent label.
|
| 116 |
+
*
|
| 117 |
+
* `launchctl unload <plist>` (and `load`) for an Aqua user-domain agent sends
|
| 118 |
+
* SIGTERM to the running process. When the running 9router cli.js was itself
|
| 119 |
+
* spawned by the autostart launchd agent (i.e. user enabled autostart at
|
| 120 |
+
* some point, then rebooted, then clicked the tray icon's "Disable
|
| 121 |
+
* Auto-start" menu item), an unload would kill the very process executing
|
| 122 |
+
* the click handler — and the tray icon would disappear instead of the menu
|
| 123 |
+
* label flipping back to "Enable Auto-start". This helper lets the enable
|
| 124 |
+
* and disable paths sidestep that by skipping launchctl when we'd otherwise
|
| 125 |
+
* be killing ourselves.
|
| 126 |
+
*/
|
| 127 |
+
function isAgentSelfMacOS() {
|
| 128 |
+
try {
|
| 129 |
+
const output = execSync(`launchctl list ${APP_LABEL}`, {
|
| 130 |
+
encoding: "utf8",
|
| 131 |
+
stdio: ["ignore", "pipe", "ignore"],
|
| 132 |
+
timeout: 3000
|
| 133 |
+
});
|
| 134 |
+
const match = output.match(/"PID"\s*=\s*(\d+)/);
|
| 135 |
+
return !!(match && parseInt(match[1], 10) === process.pid);
|
| 136 |
+
} catch (e) {
|
| 137 |
+
return false;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function enableMacOS(cliPath) {
|
| 142 |
+
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
| 143 |
+
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
|
| 144 |
+
|
| 145 |
+
if (!fs.existsSync(launchAgentsDir)) {
|
| 146 |
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const nodePath = process.execPath;
|
| 150 |
+
const routerScript = getCliJsPath(cliPath);
|
| 151 |
+
// Don't write a broken plist that references a non-existent script.
|
| 152 |
+
if (!routerScript) return false;
|
| 153 |
+
|
| 154 |
+
// Invoke node + cli.js directly with absolute paths — no shell wrapper.
|
| 155 |
+
// The previous design ran `zsh -l -c "..."` so a login shell would source
|
| 156 |
+
// nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user,
|
| 157 |
+
// some setups don't put node on PATH from a non-interactive login shell).
|
| 158 |
+
// EnvironmentVariables.PATH explicitly includes node's bin dir so child
|
| 159 |
+
// processes spawned by cli.js (npm install at runtime, etc.) resolve.
|
| 160 |
+
const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`;
|
| 161 |
+
|
| 162 |
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
| 163 |
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
| 164 |
+
<plist version="1.0">
|
| 165 |
+
<dict>
|
| 166 |
+
<key>Label</key>
|
| 167 |
+
<string>${APP_LABEL}</string>
|
| 168 |
+
<key>ProgramArguments</key>
|
| 169 |
+
<array>
|
| 170 |
+
<string>${nodePath}</string>
|
| 171 |
+
<string>${routerScript}</string>
|
| 172 |
+
<string>--tray</string>
|
| 173 |
+
<string>--skip-update</string>
|
| 174 |
+
</array>
|
| 175 |
+
<key>EnvironmentVariables</key>
|
| 176 |
+
<dict>
|
| 177 |
+
<key>PATH</key>
|
| 178 |
+
<string>${launchPath}</string>
|
| 179 |
+
</dict>
|
| 180 |
+
<key>RunAtLoad</key>
|
| 181 |
+
<true/>
|
| 182 |
+
<key>KeepAlive</key>
|
| 183 |
+
<false/>
|
| 184 |
+
<key>StandardOutPath</key>
|
| 185 |
+
<string>/tmp/9router.log</string>
|
| 186 |
+
<key>StandardErrorPath</key>
|
| 187 |
+
<string>/tmp/9router.error.log</string>
|
| 188 |
+
</dict>
|
| 189 |
+
</plist>`;
|
| 190 |
+
|
| 191 |
+
fs.writeFileSync(plistPath, plistContent);
|
| 192 |
+
|
| 193 |
+
// If we're the running agent already, launchctl unload/load would send
|
| 194 |
+
// ourselves SIGTERM. Skip it — the plist file is updated on disk and
|
| 195 |
+
// launchd will pick it up at next login. isAutoStartEnabled() will still
|
| 196 |
+
// return true because launchctl already has the agent loaded.
|
| 197 |
+
if (isAgentSelfMacOS()) {
|
| 198 |
+
return true;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// Register with launchd in the current session. Without this, the agent
|
| 202 |
+
// only takes effect on the next user login and the user has no signal that
|
| 203 |
+
// anything actually happened. `unload` first defends against re-enable
|
| 204 |
+
// replacing an existing plist.
|
| 205 |
+
try {
|
| 206 |
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
|
| 207 |
+
} catch (e) {}
|
| 208 |
+
try {
|
| 209 |
+
execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" });
|
| 210 |
+
} catch (e) {
|
| 211 |
+
// Even if load fails, the plist is on disk and will be picked up at next
|
| 212 |
+
// login; report success based on the file write.
|
| 213 |
+
}
|
| 214 |
+
return true;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function disableMacOS() {
|
| 218 |
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
| 219 |
+
|
| 220 |
+
// Don't kill ourselves: when the current process is the running agent,
|
| 221 |
+
// `launchctl unload` would send SIGTERM and the user clicking
|
| 222 |
+
// "Disable Auto-start" from the tray menu would lose their tray icon
|
| 223 |
+
// instead of just flipping the menu label. Skip the unload — removing the
|
| 224 |
+
// plist file is enough to prevent the agent from starting on next login.
|
| 225 |
+
if (!isAgentSelfMacOS()) {
|
| 226 |
+
try {
|
| 227 |
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
|
| 228 |
+
} catch (e) {}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
if (fs.existsSync(plistPath)) {
|
| 232 |
+
fs.unlinkSync(plistPath);
|
| 233 |
+
}
|
| 234 |
+
return true;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ============ Windows ============
|
| 238 |
+
|
| 239 |
+
function enableWindows(cliPath) {
|
| 240 |
+
const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
|
| 241 |
+
const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
|
| 242 |
+
|
| 243 |
+
if (!fs.existsSync(startupDir)) return false;
|
| 244 |
+
|
| 245 |
+
const nodePath = process.execPath;
|
| 246 |
+
const routerScript = getCliJsPath(cliPath);
|
| 247 |
+
if (!routerScript) return false;
|
| 248 |
+
|
| 249 |
+
// Run node + cli.js directly, hidden window. Avoids the fragile
|
| 250 |
+
// `9router.cmd` lookup that depended on the npm prefix path.
|
| 251 |
+
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
|
| 252 |
+
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
|
| 253 |
+
`;
|
| 254 |
+
fs.writeFileSync(vbsPath, vbsContent);
|
| 255 |
+
return true;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function disableWindows() {
|
| 259 |
+
const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
|
| 260 |
+
if (fs.existsSync(vbsPath)) {
|
| 261 |
+
fs.unlinkSync(vbsPath);
|
| 262 |
+
}
|
| 263 |
+
return true;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// ============ Linux ============
|
| 267 |
+
|
| 268 |
+
function enableLinux(cliPath) {
|
| 269 |
+
const autostartDir = path.join(os.homedir(), ".config", "autostart");
|
| 270 |
+
const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
|
| 271 |
+
|
| 272 |
+
if (!fs.existsSync(autostartDir)) {
|
| 273 |
+
try { fs.mkdirSync(autostartDir, { recursive: true }); }
|
| 274 |
+
catch (e) { return false; }
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
const nodePath = process.execPath;
|
| 278 |
+
const routerScript = getCliJsPath(cliPath);
|
| 279 |
+
if (!routerScript) return false;
|
| 280 |
+
|
| 281 |
+
const desktopContent = `[Desktop Entry]
|
| 282 |
+
Type=Application
|
| 283 |
+
Name=9Router
|
| 284 |
+
Comment=9Router API Proxy
|
| 285 |
+
Exec=${nodePath} ${routerScript} --tray --skip-update
|
| 286 |
+
Hidden=false
|
| 287 |
+
NoDisplay=false
|
| 288 |
+
X-GNOME-Autostart-enabled=true
|
| 289 |
+
`;
|
| 290 |
+
fs.writeFileSync(desktopPath, desktopContent);
|
| 291 |
+
return true;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function disableLinux() {
|
| 295 |
+
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
|
| 296 |
+
if (fs.existsSync(desktopPath)) {
|
| 297 |
+
fs.unlinkSync(desktopPath);
|
| 298 |
+
}
|
| 299 |
+
return true;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
module.exports = {
|
| 303 |
+
enableAutoStart,
|
| 304 |
+
disableAutoStart,
|
| 305 |
+
isAutoStartEnabled
|
| 306 |
+
};
|
cli/src/cli/tray/icon.ico
ADDED
|
|
cli/src/cli/tray/icon.png
ADDED
|
|
cli/src/cli/tray/tray.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { exec } = require("child_process");
|
| 2 |
+
const fs = require("fs");
|
| 3 |
+
const path = require("path");
|
| 4 |
+
|
| 5 |
+
let trayInstance = null;
|
| 6 |
+
let isWinTray = false;
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Get icon base64 from file — used for systray (mac/linux)
|
| 10 |
+
*/
|
| 11 |
+
function getIconBase64() {
|
| 12 |
+
const isWin = process.platform === "win32";
|
| 13 |
+
const iconFile = isWin ? "icon.ico" : "icon.png";
|
| 14 |
+
try {
|
| 15 |
+
const iconPath = path.join(__dirname, iconFile);
|
| 16 |
+
if (fs.existsSync(iconPath)) {
|
| 17 |
+
return fs.readFileSync(iconPath).toString("base64");
|
| 18 |
+
}
|
| 19 |
+
} catch (e) {}
|
| 20 |
+
// Fallback: minimal green dot icon (PNG)
|
| 21 |
+
return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Check if system tray is supported on current OS
|
| 26 |
+
* Supported: macOS, Windows, Linux (with GUI)
|
| 27 |
+
*/
|
| 28 |
+
function isTraySupported() {
|
| 29 |
+
const platform = process.platform;
|
| 30 |
+
if (!["darwin", "win32", "linux"].includes(platform)) {
|
| 31 |
+
return false;
|
| 32 |
+
}
|
| 33 |
+
if (platform === "linux" && !process.env.DISPLAY) {
|
| 34 |
+
return false;
|
| 35 |
+
}
|
| 36 |
+
return true;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Initialize system tray with menu
|
| 41 |
+
* @param {Object} options - { port, onQuit, onOpenDashboard }
|
| 42 |
+
* @returns {Object|null} tray instance or null if not supported/failed
|
| 43 |
+
*/
|
| 44 |
+
function initTray(options) {
|
| 45 |
+
if (!isTraySupported()) {
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Windows uses PowerShell NotifyIcon (AV-safe), others use systray
|
| 50 |
+
if (process.platform === "win32") {
|
| 51 |
+
return initWindowsTray(options);
|
| 52 |
+
}
|
| 53 |
+
return initUnixTray(options);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Build menu items array shared between platforms
|
| 58 |
+
*/
|
| 59 |
+
function buildMenuItems(port, autostartEnabled) {
|
| 60 |
+
return [
|
| 61 |
+
{ title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false },
|
| 62 |
+
{ title: "Open Dashboard", tooltip: "Open in browser", enabled: true },
|
| 63 |
+
{
|
| 64 |
+
title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
|
| 65 |
+
tooltip: "Run on OS startup",
|
| 66 |
+
enabled: true
|
| 67 |
+
},
|
| 68 |
+
{ title: "Quit", tooltip: "Stop server and exit", enabled: true }
|
| 69 |
+
];
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Menu item indexes
|
| 73 |
+
const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 };
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Get current autostart state
|
| 77 |
+
*/
|
| 78 |
+
function getAutostartEnabled() {
|
| 79 |
+
try {
|
| 80 |
+
const { isAutoStartEnabled } = require("./autostart");
|
| 81 |
+
return isAutoStartEnabled();
|
| 82 |
+
} catch (e) {
|
| 83 |
+
return false;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Handle menu item click (shared logic)
|
| 89 |
+
*/
|
| 90 |
+
function handleClick(index, options, onAutostartToggle) {
|
| 91 |
+
const { onQuit, onOpenDashboard, port } = options;
|
| 92 |
+
if (index === MENU_INDEX.DASHBOARD) {
|
| 93 |
+
if (onOpenDashboard) onOpenDashboard();
|
| 94 |
+
else openBrowser(`http://localhost:${port}/dashboard`);
|
| 95 |
+
} else if (index === MENU_INDEX.AUTOSTART) {
|
| 96 |
+
const enabled = getAutostartEnabled();
|
| 97 |
+
try {
|
| 98 |
+
const { enableAutoStart, disableAutoStart } = require("./autostart");
|
| 99 |
+
if (enabled) disableAutoStart();
|
| 100 |
+
else enableAutoStart();
|
| 101 |
+
onAutostartToggle(!enabled);
|
| 102 |
+
} catch (e) {}
|
| 103 |
+
} else if (index === MENU_INDEX.QUIT) {
|
| 104 |
+
console.log("\n👋 Shutting down...");
|
| 105 |
+
if (onQuit) onQuit();
|
| 106 |
+
killTray();
|
| 107 |
+
setTimeout(() => process.exit(0), 500);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Windows tray via PowerShell NotifyIcon
|
| 113 |
+
*/
|
| 114 |
+
function initWindowsTray(options) {
|
| 115 |
+
const { port } = options;
|
| 116 |
+
try {
|
| 117 |
+
const { initWinTray } = require("./trayWin");
|
| 118 |
+
const iconPath = path.join(__dirname, "icon.ico");
|
| 119 |
+
const autostartEnabled = getAutostartEnabled();
|
| 120 |
+
const items = buildMenuItems(port, autostartEnabled);
|
| 121 |
+
|
| 122 |
+
trayInstance = initWinTray({
|
| 123 |
+
iconPath,
|
| 124 |
+
tooltip: `9Router - Port ${port}`,
|
| 125 |
+
items,
|
| 126 |
+
onClick: (index) => {
|
| 127 |
+
handleClick(index, options, (newEnabled) => {
|
| 128 |
+
const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start";
|
| 129 |
+
trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true);
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
isWinTray = true;
|
| 135 |
+
return trayInstance;
|
| 136 |
+
} catch (err) {
|
| 137 |
+
return null;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* macOS/Linux tray via systray binary
|
| 143 |
+
*
|
| 144 |
+
* Prefers `systray2` (active fork of `systray`, ships newer
|
| 145 |
+
* getlantern/systray-portable binaries that work on macOS 14+ and Apple
|
| 146 |
+
* Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2
|
| 147 |
+
* is not available, though that binary's Mach-O headers are rejected by
|
| 148 |
+
* modern dyld and the icon will not appear.
|
| 149 |
+
*/
|
| 150 |
+
function resolveSystray() {
|
| 151 |
+
let runtimeDir = null;
|
| 152 |
+
try {
|
| 153 |
+
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
|
| 154 |
+
runtimeDir = getRuntimeNodeModules();
|
| 155 |
+
} catch (e) {}
|
| 156 |
+
|
| 157 |
+
// 1) systray2 in runtime dir (where ensureTrayRuntime installs it)
|
| 158 |
+
if (runtimeDir) {
|
| 159 |
+
try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {}
|
| 160 |
+
}
|
| 161 |
+
// 2) systray2 resolvable from the package's own node_modules / NODE_PATH
|
| 162 |
+
try { return { mod: require("systray2").default, isV2: true }; } catch (e) {}
|
| 163 |
+
// 3) Legacy systray fallback (unlikely to render on modern macOS)
|
| 164 |
+
try { return { mod: require("systray").default, isV2: false }; } catch (e) {}
|
| 165 |
+
if (runtimeDir) {
|
| 166 |
+
try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {}
|
| 167 |
+
}
|
| 168 |
+
return null;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function chmodTrayBin(pkgName) {
|
| 172 |
+
// systray2's npm tarball occasionally lands without +x on the bundled Go
|
| 173 |
+
// binary (observed on macOS). spawn() then fails with EACCES. Best-effort
|
| 174 |
+
// chmod on every init avoids a hard-to-diagnose silent tray failure.
|
| 175 |
+
try {
|
| 176 |
+
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
|
| 177 |
+
const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
|
| 178 |
+
const candidates = [
|
| 179 |
+
path.join(getRuntimeNodeModules(), pkgName, "traybin", binName),
|
| 180 |
+
path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName)
|
| 181 |
+
];
|
| 182 |
+
for (const p of candidates) {
|
| 183 |
+
if (fs.existsSync(p)) fs.chmodSync(p, 0o755);
|
| 184 |
+
}
|
| 185 |
+
} catch (e) {}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function initUnixTray(options) {
|
| 189 |
+
const { port } = options;
|
| 190 |
+
try {
|
| 191 |
+
const resolved = resolveSystray();
|
| 192 |
+
if (!resolved) return null;
|
| 193 |
+
const { mod: SysTray, isV2 } = resolved;
|
| 194 |
+
|
| 195 |
+
chmodTrayBin(isV2 ? "systray2" : "systray");
|
| 196 |
+
|
| 197 |
+
const autostartEnabled = getAutostartEnabled();
|
| 198 |
+
const items = buildMenuItems(port, autostartEnabled);
|
| 199 |
+
|
| 200 |
+
const menu = {
|
| 201 |
+
icon: getIconBase64(),
|
| 202 |
+
// The bundled icon.png is a full-color RGBA logo. Don't mark it as a
|
| 203 |
+
// template icon: macOS would then render it as a solid white square
|
| 204 |
+
// because template mode only uses the alpha channel.
|
| 205 |
+
isTemplateIcon: false,
|
| 206 |
+
title: "",
|
| 207 |
+
tooltip: `9Router - Port ${port}`,
|
| 208 |
+
items
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
trayInstance = new SysTray({ menu, debug: false, copyDir: true });
|
| 212 |
+
isWinTray = false;
|
| 213 |
+
|
| 214 |
+
trayInstance.onClick((action) => {
|
| 215 |
+
handleClick(action.seq_id, options, (newEnabled) => {
|
| 216 |
+
trayInstance.sendAction({
|
| 217 |
+
type: "update-item",
|
| 218 |
+
item: {
|
| 219 |
+
title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
|
| 220 |
+
tooltip: "Run on OS startup",
|
| 221 |
+
enabled: true
|
| 222 |
+
},
|
| 223 |
+
seq_id: MENU_INDEX.AUTOSTART
|
| 224 |
+
});
|
| 225 |
+
});
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
if (isV2) {
|
| 229 |
+
// systray2 exposes a ready() promise instead of onReady/onError. Surface
|
| 230 |
+
// failures (binary crash, EACCES, etc.) so users can see why the icon
|
| 231 |
+
// didn't appear instead of getting a misleading "running in tray" log.
|
| 232 |
+
trayInstance.ready().catch((err) => {
|
| 233 |
+
process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`);
|
| 234 |
+
});
|
| 235 |
+
} else {
|
| 236 |
+
trayInstance.onReady(() => {});
|
| 237 |
+
trayInstance.onError(() => {});
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return trayInstance;
|
| 241 |
+
} catch (err) {
|
| 242 |
+
process.stderr.write(`[9router] tray init error: ${err.message}\n`);
|
| 243 |
+
return null;
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* Kill tray, wait Go binary fully exit (returns Promise).
|
| 249 |
+
* Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess
|
| 250 |
+
* spawns a new tray, otherwise the new icon silently fails to register.
|
| 251 |
+
*/
|
| 252 |
+
function killTray() {
|
| 253 |
+
const instance = trayInstance;
|
| 254 |
+
const wasWin = isWinTray;
|
| 255 |
+
trayInstance = null;
|
| 256 |
+
if (!instance) return Promise.resolve();
|
| 257 |
+
|
| 258 |
+
if (wasWin) {
|
| 259 |
+
try { instance.kill(); } catch (e) {}
|
| 260 |
+
return Promise.resolve();
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Unix: get the Go tray child process handle.
|
| 264 |
+
let proc = null;
|
| 265 |
+
try {
|
| 266 |
+
proc = instance._process || (typeof instance.process === "function" ? instance.process() : null);
|
| 267 |
+
} catch (e) {}
|
| 268 |
+
|
| 269 |
+
// Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call
|
| 270 |
+
// systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on
|
| 271 |
+
// the macOS menubar until logout, causing duplicate icons after re-spawn.
|
| 272 |
+
const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} };
|
| 273 |
+
const closeIpc = () => { try { instance.kill(false); } catch (e) {} };
|
| 274 |
+
|
| 275 |
+
if (!proc || !proc.pid) {
|
| 276 |
+
gracefulQuit();
|
| 277 |
+
closeIpc();
|
| 278 |
+
return Promise.resolve();
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
return new Promise((resolve) => {
|
| 282 |
+
let done = false;
|
| 283 |
+
const finish = () => { if (done) return; done = true; closeIpc(); resolve(); };
|
| 284 |
+
|
| 285 |
+
proc.once("exit", finish);
|
| 286 |
+
gracefulQuit();
|
| 287 |
+
|
| 288 |
+
// Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive.
|
| 289 |
+
setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800);
|
| 290 |
+
setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600);
|
| 291 |
+
|
| 292 |
+
// Fallback poll in case "exit" never fires (detached child, pipe closed)
|
| 293 |
+
const deadline = Date.now() + 3000;
|
| 294 |
+
const poll = setInterval(() => {
|
| 295 |
+
try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; }
|
| 296 |
+
if (Date.now() > deadline) { clearInterval(poll); finish(); }
|
| 297 |
+
}, 50);
|
| 298 |
+
});
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/**
|
| 302 |
+
* Open browser
|
| 303 |
+
*/
|
| 304 |
+
function openBrowser(url) {
|
| 305 |
+
const platform = process.platform;
|
| 306 |
+
let cmd;
|
| 307 |
+
|
| 308 |
+
if (platform === "darwin") {
|
| 309 |
+
cmd = `open "${url}"`;
|
| 310 |
+
} else if (platform === "win32") {
|
| 311 |
+
cmd = `start "" "${url}"`;
|
| 312 |
+
} else {
|
| 313 |
+
cmd = `xdg-open "${url}"`;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
exec(cmd);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
module.exports = {
|
| 320 |
+
initTray,
|
| 321 |
+
killTray
|
| 322 |
+
};
|
cli/src/cli/tray/tray.ps1
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 9Router tray icon for Windows using NotifyIcon
|
| 2 |
+
# IPC: stdin JSON commands, stdout JSON events
|
| 3 |
+
param([string]$IconPath, [string]$Tooltip)
|
| 4 |
+
|
| 5 |
+
Add-Type -AssemblyName System.Windows.Forms
|
| 6 |
+
Add-Type -AssemblyName System.Drawing
|
| 7 |
+
|
| 8 |
+
$ErrorActionPreference = "Stop"
|
| 9 |
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
| 10 |
+
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
| 11 |
+
$OutputEncoding = [System.Text.Encoding]::UTF8
|
| 12 |
+
|
| 13 |
+
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
|
| 14 |
+
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
|
| 15 |
+
$script:notifyIcon.Text = $Tooltip
|
| 16 |
+
$script:notifyIcon.Visible = $true
|
| 17 |
+
|
| 18 |
+
$script:menu = New-Object System.Windows.Forms.ContextMenuStrip
|
| 19 |
+
$script:notifyIcon.ContextMenuStrip = $script:menu
|
| 20 |
+
$script:items = @()
|
| 21 |
+
|
| 22 |
+
function Write-Event($obj) {
|
| 23 |
+
$json = $obj | ConvertTo-Json -Compress
|
| 24 |
+
[Console]::Out.WriteLine($json)
|
| 25 |
+
[Console]::Out.Flush()
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function Add-MenuItem($index, $title, $enabled) {
|
| 29 |
+
$item = New-Object System.Windows.Forms.ToolStripMenuItem
|
| 30 |
+
$item.Text = $title
|
| 31 |
+
$item.Enabled = $enabled
|
| 32 |
+
$idx = $index
|
| 33 |
+
$item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
|
| 34 |
+
$script:menu.Items.Add($item) | Out-Null
|
| 35 |
+
$script:items += $item
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function Update-MenuItem($index, $title, $enabled) {
|
| 39 |
+
if ($index -lt $script:items.Count) {
|
| 40 |
+
$script:items[$index].Text = $title
|
| 41 |
+
$script:items[$index].Enabled = $enabled
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function Set-Tooltip($text) {
|
| 46 |
+
# NotifyIcon.Text max 63 chars
|
| 47 |
+
if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
|
| 48 |
+
$script:notifyIcon.Text = $text
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# Background reader thread polls stdin via timer on UI thread
|
| 52 |
+
$script:timer = New-Object System.Windows.Forms.Timer
|
| 53 |
+
$script:timer.Interval = 100
|
| 54 |
+
$script:timer.Add_Tick({
|
| 55 |
+
try {
|
| 56 |
+
while ([Console]::In.Peek() -ne -1) {
|
| 57 |
+
$line = [Console]::In.ReadLine()
|
| 58 |
+
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
| 59 |
+
$cmd = $line | ConvertFrom-Json
|
| 60 |
+
switch ($cmd.action) {
|
| 61 |
+
"add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
| 62 |
+
"update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
| 63 |
+
"set-tooltip" { Set-Tooltip $cmd.text }
|
| 64 |
+
"ready" { Write-Event @{ type = "ready" } }
|
| 65 |
+
"kill" {
|
| 66 |
+
$script:notifyIcon.Visible = $false
|
| 67 |
+
$script:notifyIcon.Dispose()
|
| 68 |
+
[System.Windows.Forms.Application]::Exit()
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
} catch {
|
| 73 |
+
Write-Event @{ type = "error"; message = $_.Exception.Message }
|
| 74 |
+
}
|
| 75 |
+
})
|
| 76 |
+
$script:timer.Start()
|
| 77 |
+
|
| 78 |
+
Write-Event @{ type = "started" }
|
| 79 |
+
[System.Windows.Forms.Application]::Run()
|
cli/src/cli/tray/trayWin.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { spawn } = require("child_process");
|
| 2 |
+
const path = require("path");
|
| 3 |
+
const readline = require("readline");
|
| 4 |
+
|
| 5 |
+
// PowerShell-based tray for Windows (AV-safe, zero binary deps)
|
| 6 |
+
|
| 7 |
+
let psProcess = null;
|
| 8 |
+
let clickHandler = null;
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Send JSON command to PowerShell tray process via stdin
|
| 12 |
+
*/
|
| 13 |
+
function sendCommand(cmd) {
|
| 14 |
+
if (psProcess && psProcess.stdin.writable) {
|
| 15 |
+
psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Initialize Windows tray using PowerShell NotifyIcon
|
| 21 |
+
* @param {Object} options - { iconPath, tooltip, items, onClick }
|
| 22 |
+
* items: [{ title, enabled }]
|
| 23 |
+
* @returns {Object|null} controller with sendAction/kill
|
| 24 |
+
*/
|
| 25 |
+
function initWinTray(options) {
|
| 26 |
+
const { iconPath, tooltip, items, onClick } = options;
|
| 27 |
+
clickHandler = onClick;
|
| 28 |
+
|
| 29 |
+
const scriptPath = path.join(__dirname, "tray.ps1");
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
psProcess = spawn(
|
| 33 |
+
"powershell.exe",
|
| 34 |
+
[
|
| 35 |
+
"-NoProfile",
|
| 36 |
+
"-ExecutionPolicy", "Bypass",
|
| 37 |
+
"-WindowStyle", "Hidden",
|
| 38 |
+
"-InputFormat", "Text",
|
| 39 |
+
"-OutputFormat", "Text",
|
| 40 |
+
"-File", scriptPath,
|
| 41 |
+
"-IconPath", iconPath,
|
| 42 |
+
"-Tooltip", tooltip
|
| 43 |
+
],
|
| 44 |
+
{ windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
|
| 45 |
+
);
|
| 46 |
+
} catch (err) {
|
| 47 |
+
return null;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const rl = readline.createInterface({ input: psProcess.stdout });
|
| 51 |
+
rl.on("line", (line) => {
|
| 52 |
+
try {
|
| 53 |
+
const evt = JSON.parse(line);
|
| 54 |
+
if (evt.type === "click" && clickHandler) {
|
| 55 |
+
clickHandler(evt.index);
|
| 56 |
+
}
|
| 57 |
+
} catch (e) {}
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
psProcess.on("error", () => {});
|
| 61 |
+
psProcess.stderr.on("data", () => {});
|
| 62 |
+
|
| 63 |
+
// Send initial menu items
|
| 64 |
+
items.forEach((item, index) => {
|
| 65 |
+
sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
updateItem(index, title, enabled) {
|
| 70 |
+
sendCommand({ action: "update-item", index, title, enabled });
|
| 71 |
+
},
|
| 72 |
+
setTooltip(text) {
|
| 73 |
+
sendCommand({ action: "set-tooltip", text });
|
| 74 |
+
},
|
| 75 |
+
kill() {
|
| 76 |
+
try {
|
| 77 |
+
sendCommand({ action: "kill" });
|
| 78 |
+
} catch (e) {}
|
| 79 |
+
setTimeout(() => {
|
| 80 |
+
if (psProcess && !psProcess.killed) {
|
| 81 |
+
try { psProcess.kill(); } catch (e) {}
|
| 82 |
+
}
|
| 83 |
+
psProcess = null;
|
| 84 |
+
}, 300);
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
module.exports = { initWinTray };
|
cli/src/cli/utils/clipboard.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { execSync } = require("child_process");
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Copy text to clipboard based on OS
|
| 5 |
+
* @param {string} text - Text to copy
|
| 6 |
+
* @returns {boolean} Success status
|
| 7 |
+
*/
|
| 8 |
+
function copyToClipboard(text) {
|
| 9 |
+
try {
|
| 10 |
+
const platform = process.platform;
|
| 11 |
+
|
| 12 |
+
if (platform === "darwin") {
|
| 13 |
+
execSync("pbcopy", { input: text });
|
| 14 |
+
} else if (platform === "win32") {
|
| 15 |
+
execSync("clip", { input: text });
|
| 16 |
+
} else {
|
| 17 |
+
// Linux - try xclip first, then xsel
|
| 18 |
+
try {
|
| 19 |
+
execSync("xclip -selection clipboard", { input: text });
|
| 20 |
+
} catch {
|
| 21 |
+
execSync("xsel --clipboard --input", { input: text });
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
return true;
|
| 25 |
+
} catch (error) {
|
| 26 |
+
return false;
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
module.exports = { copyToClipboard };
|
cli/src/cli/utils/display.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { formatNumber } = require("./format");
|
| 2 |
+
|
| 3 |
+
// ANSI color codes
|
| 4 |
+
const COLORS = {
|
| 5 |
+
reset: "\x1b[0m",
|
| 6 |
+
success: "\x1b[32m",
|
| 7 |
+
error: "\x1b[31m",
|
| 8 |
+
warning: "\x1b[33m",
|
| 9 |
+
info: "\x1b[36m",
|
| 10 |
+
dim: "\x1b[2m",
|
| 11 |
+
bold: "\x1b[1m",
|
| 12 |
+
bright: "\x1b[1m",
|
| 13 |
+
cyan: "\x1b[36m"
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// Box drawing characters
|
| 17 |
+
const BOX_CHARS = {
|
| 18 |
+
topLeft: "┌",
|
| 19 |
+
topRight: "┐",
|
| 20 |
+
bottomLeft: "└",
|
| 21 |
+
bottomRight: "┘",
|
| 22 |
+
horizontal: "─",
|
| 23 |
+
vertical: "│"
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Draw a box with border around content
|
| 28 |
+
* @param {string} title - Box title
|
| 29 |
+
* @param {string} content - Content to display inside box
|
| 30 |
+
* @param {number} [width=60] - Box width
|
| 31 |
+
*/
|
| 32 |
+
function showBox(title, content, width = 60) {
|
| 33 |
+
const innerWidth = width - 4;
|
| 34 |
+
const lines = content.split("\n");
|
| 35 |
+
|
| 36 |
+
// Top border with title
|
| 37 |
+
const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
|
| 38 |
+
` ${title} ` +
|
| 39 |
+
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
|
| 40 |
+
BOX_CHARS.topRight;
|
| 41 |
+
|
| 42 |
+
console.log(topBorder);
|
| 43 |
+
|
| 44 |
+
// Content lines
|
| 45 |
+
lines.forEach(line => {
|
| 46 |
+
const paddedLine = line.padEnd(innerWidth);
|
| 47 |
+
console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
// Bottom border
|
| 51 |
+
const bottomBorder = BOX_CHARS.bottomLeft +
|
| 52 |
+
BOX_CHARS.horizontal.repeat(innerWidth + 2) +
|
| 53 |
+
BOX_CHARS.bottomRight;
|
| 54 |
+
|
| 55 |
+
console.log(bottomBorder);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Display a menu with numbered items
|
| 60 |
+
* @param {string} title - Menu title
|
| 61 |
+
* @param {string[]} items - Array of menu items
|
| 62 |
+
* @param {string} [footer] - Optional footer text
|
| 63 |
+
*/
|
| 64 |
+
function showMenu(title, items, footer) {
|
| 65 |
+
console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
|
| 66 |
+
console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
|
| 67 |
+
|
| 68 |
+
items.forEach((item, index) => {
|
| 69 |
+
console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
if (footer) {
|
| 73 |
+
console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
|
| 74 |
+
}
|
| 75 |
+
console.log();
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Display data in table format
|
| 80 |
+
* @param {string[]} headers - Array of column headers
|
| 81 |
+
* @param {Array<Array<string|number>>} rows - Array of row data
|
| 82 |
+
*/
|
| 83 |
+
function showTable(headers, rows) {
|
| 84 |
+
if (!headers.length || !rows.length) {
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Calculate column widths
|
| 89 |
+
const colWidths = headers.map((header, i) => {
|
| 90 |
+
const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
|
| 91 |
+
return Math.max(header.length, maxDataWidth);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// Print header
|
| 95 |
+
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
|
| 96 |
+
console.log(COLORS.bold + headerRow + COLORS.reset);
|
| 97 |
+
|
| 98 |
+
// Print separator
|
| 99 |
+
const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
|
| 100 |
+
console.log(COLORS.dim + separator + COLORS.reset);
|
| 101 |
+
|
| 102 |
+
// Print rows
|
| 103 |
+
rows.forEach(row => {
|
| 104 |
+
const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
|
| 105 |
+
console.log(rowStr);
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Show colored status message
|
| 111 |
+
* @param {string} message - Message to display
|
| 112 |
+
* @param {string} [type="info"] - Status type: success, error, warning, info
|
| 113 |
+
*/
|
| 114 |
+
function showStatus(message, type = "info") {
|
| 115 |
+
const symbols = {
|
| 116 |
+
success: "✓",
|
| 117 |
+
error: "✗",
|
| 118 |
+
warning: "⚠",
|
| 119 |
+
info: "ℹ"
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const color = COLORS[type] || COLORS.info;
|
| 123 |
+
const symbol = symbols[type] || symbols.info;
|
| 124 |
+
|
| 125 |
+
console.log(`${color}${symbol} ${message}${COLORS.reset}`);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* Clear the terminal screen
|
| 130 |
+
*/
|
| 131 |
+
function clearScreen() {
|
| 132 |
+
console.clear();
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Show menu header with title and subtitle
|
| 137 |
+
* @param {string} title - Main title
|
| 138 |
+
* @param {string} subtitle - Optional subtitle
|
| 139 |
+
*/
|
| 140 |
+
function showHeader(title, subtitle) {
|
| 141 |
+
console.log(`\n${"=".repeat(60)}`);
|
| 142 |
+
console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
|
| 143 |
+
if (subtitle) {
|
| 144 |
+
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
|
| 145 |
+
}
|
| 146 |
+
console.log(`${"=".repeat(60)}\n`);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
module.exports = {
|
| 150 |
+
showBox,
|
| 151 |
+
showMenu,
|
| 152 |
+
showTable,
|
| 153 |
+
showStatus,
|
| 154 |
+
clearScreen,
|
| 155 |
+
showHeader
|
| 156 |
+
};
|
cli/src/cli/utils/endpoint.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
|
| 3 |
+
const COLORS = {
|
| 4 |
+
reset: "\x1b[0m",
|
| 5 |
+
green: "\x1b[32m"
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Get endpoint URL based on tunnel status
|
| 10 |
+
* @param {number} port - Local server port
|
| 11 |
+
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
|
| 12 |
+
*/
|
| 13 |
+
async function getEndpoint(port) {
|
| 14 |
+
const result = await api.getTunnelStatus();
|
| 15 |
+
const tunnelEnabled = result.success && result.data?.enabled === true;
|
| 16 |
+
const publicUrl = result.success ? result.data?.publicUrl : "";
|
| 17 |
+
|
| 18 |
+
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
|
| 19 |
+
return { endpoint, tunnelEnabled };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Get endpoint with color formatting
|
| 24 |
+
* @param {number} port - Local server port
|
| 25 |
+
* @returns {Promise<string>} Colored endpoint string
|
| 26 |
+
*/
|
| 27 |
+
async function getEndpointColored(port) {
|
| 28 |
+
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
| 29 |
+
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
module.exports = { getEndpoint, getEndpointColored };
|
cli/src/cli/utils/format.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Truncate text with ellipsis
|
| 3 |
+
* @param {string} text - Text to truncate
|
| 4 |
+
* @param {number} maxLength - Maximum length
|
| 5 |
+
* @returns {string} Truncated text
|
| 6 |
+
*/
|
| 7 |
+
function truncate(text, maxLength) {
|
| 8 |
+
if (!text || text.length <= maxLength) {
|
| 9 |
+
return text;
|
| 10 |
+
}
|
| 11 |
+
return text.substring(0, maxLength - 3) + "...";
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Mask API key showing only first and last characters
|
| 16 |
+
* @param {string} key - API key to mask
|
| 17 |
+
* @returns {string} Masked key
|
| 18 |
+
*/
|
| 19 |
+
function maskKey(key) {
|
| 20 |
+
if (!key || key.length < 8) {
|
| 21 |
+
return "***";
|
| 22 |
+
}
|
| 23 |
+
const firstChars = key.substring(0, 4);
|
| 24 |
+
const lastChars = key.substring(key.length - 4);
|
| 25 |
+
return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Format date to readable string
|
| 30 |
+
* @param {Date|string|number} date - Date to format
|
| 31 |
+
* @returns {string} Formatted date string
|
| 32 |
+
*/
|
| 33 |
+
function formatDate(date) {
|
| 34 |
+
const d = new Date(date);
|
| 35 |
+
if (isNaN(d.getTime())) {
|
| 36 |
+
return "Invalid Date";
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const year = d.getFullYear();
|
| 40 |
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
| 41 |
+
const day = String(d.getDate()).padStart(2, "0");
|
| 42 |
+
const hours = String(d.getHours()).padStart(2, "0");
|
| 43 |
+
const minutes = String(d.getMinutes()).padStart(2, "0");
|
| 44 |
+
const seconds = String(d.getSeconds()).padStart(2, "0");
|
| 45 |
+
|
| 46 |
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Format number with commas
|
| 51 |
+
* @param {number} num - Number to format
|
| 52 |
+
* @returns {string} Formatted number
|
| 53 |
+
*/
|
| 54 |
+
function formatNumber(num) {
|
| 55 |
+
if (typeof num !== "number" || isNaN(num)) {
|
| 56 |
+
return "0";
|
| 57 |
+
}
|
| 58 |
+
return num.toLocaleString("en-US");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Format bytes to human readable size
|
| 63 |
+
* @param {number} bytes - Bytes to format
|
| 64 |
+
* @returns {string} Formatted size string
|
| 65 |
+
*/
|
| 66 |
+
function formatBytes(bytes) {
|
| 67 |
+
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
|
| 68 |
+
return "0 B";
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
| 72 |
+
let size = bytes;
|
| 73 |
+
let unitIndex = 0;
|
| 74 |
+
|
| 75 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 76 |
+
size /= 1024;
|
| 77 |
+
unitIndex++;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Get relative time string
|
| 85 |
+
* @param {Date|string|number} date - Date to compare
|
| 86 |
+
* @returns {string} Relative time string
|
| 87 |
+
*/
|
| 88 |
+
function getRelativeTime(date) {
|
| 89 |
+
const d = new Date(date);
|
| 90 |
+
if (isNaN(d.getTime())) {
|
| 91 |
+
return "Invalid Date";
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const now = new Date();
|
| 95 |
+
const diffMs = now - d;
|
| 96 |
+
const diffSec = Math.floor(diffMs / 1000);
|
| 97 |
+
const diffMin = Math.floor(diffSec / 60);
|
| 98 |
+
const diffHour = Math.floor(diffMin / 60);
|
| 99 |
+
const diffDay = Math.floor(diffHour / 24);
|
| 100 |
+
const diffMonth = Math.floor(diffDay / 30);
|
| 101 |
+
const diffYear = Math.floor(diffDay / 365);
|
| 102 |
+
|
| 103 |
+
if (diffSec < 60) {
|
| 104 |
+
return "just now";
|
| 105 |
+
} else if (diffMin < 60) {
|
| 106 |
+
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
| 107 |
+
} else if (diffHour < 24) {
|
| 108 |
+
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
|
| 109 |
+
} else if (diffDay < 30) {
|
| 110 |
+
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
| 111 |
+
} else if (diffMonth < 12) {
|
| 112 |
+
return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
|
| 113 |
+
} else {
|
| 114 |
+
return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
module.exports = {
|
| 119 |
+
truncate,
|
| 120 |
+
maskKey,
|
| 121 |
+
formatDate,
|
| 122 |
+
formatNumber,
|
| 123 |
+
formatBytes,
|
| 124 |
+
getRelativeTime
|
| 125 |
+
};
|
cli/src/cli/utils/input.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const readline = require("readline");
|
| 2 |
+
|
| 3 |
+
const COLORS = {
|
| 4 |
+
reset: "\x1b[0m",
|
| 5 |
+
bright: "\x1b[1m",
|
| 6 |
+
dim: "\x1b[2m",
|
| 7 |
+
underline: "\x1b[4m",
|
| 8 |
+
reverse: "\x1b[7m",
|
| 9 |
+
cyan: "\x1b[36m",
|
| 10 |
+
green: "\x1b[32m",
|
| 11 |
+
yellow: "\x1b[33m",
|
| 12 |
+
blue: "\x1b[34m",
|
| 13 |
+
white: "\x1b[37m",
|
| 14 |
+
bgGreen: "\x1b[42m",
|
| 15 |
+
bgBlue: "\x1b[44m",
|
| 16 |
+
black: "\x1b[30m",
|
| 17 |
+
terracotta: "\x1b[38;2;217;119;87m",
|
| 18 |
+
bgTerracotta: "\x1b[48;2;217;119;87m"
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
// Prime stdin once globally. Toggling raw mode between menus adds latency on
|
| 22 |
+
// macOS, so we keep raw mode on for the whole TUI session.
|
| 23 |
+
let rawPrimed = false;
|
| 24 |
+
function primeRawOnce() {
|
| 25 |
+
if (rawPrimed || !process.stdin.isTTY) return;
|
| 26 |
+
try {
|
| 27 |
+
readline.emitKeypressEvents(process.stdin);
|
| 28 |
+
process.stdin.setRawMode(true);
|
| 29 |
+
process.stdin.setEncoding("utf8");
|
| 30 |
+
process.stdin.resume();
|
| 31 |
+
rawPrimed = true;
|
| 32 |
+
} catch {}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function suspendRawFor(fn) {
|
| 36 |
+
// Temporarily drop raw mode so readline.question can buffer line input.
|
| 37 |
+
const wasPrimed = rawPrimed;
|
| 38 |
+
if (wasPrimed && process.stdin.isTTY) {
|
| 39 |
+
try { process.stdin.setRawMode(false); } catch {}
|
| 40 |
+
}
|
| 41 |
+
return fn().finally(() => {
|
| 42 |
+
if (wasPrimed && process.stdin.isTTY) {
|
| 43 |
+
try { process.stdin.setRawMode(true); } catch {}
|
| 44 |
+
process.stdin.resume();
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function prompt(question) {
|
| 50 |
+
return suspendRawFor(() => new Promise((resolve) => {
|
| 51 |
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
| 52 |
+
rl.question(question, (answer) => {
|
| 53 |
+
rl.close();
|
| 54 |
+
resolve((answer || "").trim());
|
| 55 |
+
});
|
| 56 |
+
}));
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function select(question, options) {
|
| 60 |
+
console.log(question);
|
| 61 |
+
options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
|
| 62 |
+
while (true) {
|
| 63 |
+
const answer = await prompt("\nSelect option (number): ");
|
| 64 |
+
const num = parseInt(answer, 10);
|
| 65 |
+
if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1;
|
| 66 |
+
console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
async function confirm(question) {
|
| 71 |
+
while (true) {
|
| 72 |
+
const answer = await prompt(`${question} (y/n): `);
|
| 73 |
+
const lower = answer.toLowerCase();
|
| 74 |
+
if (lower === "y" || lower === "yes") return true;
|
| 75 |
+
if (lower === "n" || lower === "no") return false;
|
| 76 |
+
console.log("Please answer 'y' or 'n'");
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
async function pause(message = "Press Enter to continue...") {
|
| 81 |
+
return suspendRawFor(() => new Promise((resolve) => {
|
| 82 |
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
| 83 |
+
rl.question(message, () => { rl.close(); resolve(); });
|
| 84 |
+
}));
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright
|
| 89 |
+
* (no underline). Uses readline keypress + raw 'data' fallback to prevent
|
| 90 |
+
* arrow-key escape sequence leaks on macOS.
|
| 91 |
+
*/
|
| 92 |
+
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
|
| 93 |
+
return new Promise((resolve) => {
|
| 94 |
+
let selectedIndex = defaultIndex;
|
| 95 |
+
let isActive = true;
|
| 96 |
+
|
| 97 |
+
primeRawOnce();
|
| 98 |
+
if (!process.stdin.isTTY) { resolve(-1); return; }
|
| 99 |
+
|
| 100 |
+
const renderMenu = () => {
|
| 101 |
+
if (!isActive) return;
|
| 102 |
+
process.stdout.write("\x1b[2J\x1b[H");
|
| 103 |
+
const width = Math.min(process.stdout.columns || 40, 40);
|
| 104 |
+
console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
|
| 105 |
+
console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
|
| 106 |
+
if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
|
| 107 |
+
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
|
| 108 |
+
if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
|
| 109 |
+
console.log();
|
| 110 |
+
if (headerContent) { console.log(headerContent); console.log(); }
|
| 111 |
+
|
| 112 |
+
const isWin = process.platform === "win32";
|
| 113 |
+
items.forEach((item, index) => {
|
| 114 |
+
const isSelected = index === selectedIndex;
|
| 115 |
+
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
|
| 116 |
+
if (isSelected) {
|
| 117 |
+
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
|
| 118 |
+
} else {
|
| 119 |
+
console.log(` ${icon} ${item.label}`);
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const cleanup = () => {
|
| 125 |
+
if (!isActive) return;
|
| 126 |
+
isActive = false;
|
| 127 |
+
process.stdin.removeListener("keypress", onKeypress);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const move = (delta) => {
|
| 131 |
+
selectedIndex = (selectedIndex + delta + items.length) % items.length;
|
| 132 |
+
renderMenu();
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const onKeypress = (_str, key) => {
|
| 136 |
+
if (!isActive || !key) return;
|
| 137 |
+
if (key.name === "up") return move(-1);
|
| 138 |
+
if (key.name === "down") return move(1);
|
| 139 |
+
if (key.name === "return") { cleanup(); resolve(selectedIndex); return; }
|
| 140 |
+
if (key.name === "escape") { cleanup(); resolve(-1); return; }
|
| 141 |
+
if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); }
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
process.stdin.on("keypress", onKeypress);
|
| 145 |
+
renderMenu();
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
module.exports = {
|
| 150 |
+
prompt,
|
| 151 |
+
select,
|
| 152 |
+
confirm,
|
| 153 |
+
pause,
|
| 154 |
+
selectMenu,
|
| 155 |
+
COLORS
|
| 156 |
+
};
|
cli/src/cli/utils/menuHelper.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { selectMenu } = require("./input");
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Show a menu with back button at top and handle selection
|
| 5 |
+
* @param {Object} config - Menu configuration
|
| 6 |
+
* @param {string} config.title - Menu title
|
| 7 |
+
* @param {string} config.headerContent - Optional header content
|
| 8 |
+
* @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
|
| 9 |
+
* @param {string} config.backLabel - Back button label (default: "← Back")
|
| 10 |
+
* @param {number} config.defaultIndex - Default selected index (default: 0)
|
| 11 |
+
* @param {Function} config.refresh - Optional refresh function to call after each action
|
| 12 |
+
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
|
| 13 |
+
* @returns {Promise<void>}
|
| 14 |
+
*/
|
| 15 |
+
async function showMenuWithBack(config) {
|
| 16 |
+
const {
|
| 17 |
+
title,
|
| 18 |
+
headerContent = "",
|
| 19 |
+
items,
|
| 20 |
+
backLabel = "← Back",
|
| 21 |
+
defaultIndex = 0,
|
| 22 |
+
refresh = null,
|
| 23 |
+
breadcrumb = []
|
| 24 |
+
} = config;
|
| 25 |
+
|
| 26 |
+
while (true) {
|
| 27 |
+
// Call refresh if provided
|
| 28 |
+
let refreshedData = null;
|
| 29 |
+
if (refresh) {
|
| 30 |
+
refreshedData = await refresh();
|
| 31 |
+
if (refreshedData === null) {
|
| 32 |
+
// Refresh failed, exit menu
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Build menu items with back at top
|
| 38 |
+
const menuItems = [
|
| 39 |
+
{ label: backLabel, icon: "☆" },
|
| 40 |
+
...items.map(item => ({
|
| 41 |
+
label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
|
| 42 |
+
icon: "☆"
|
| 43 |
+
}))
|
| 44 |
+
];
|
| 45 |
+
|
| 46 |
+
// Resolve headerContent if it's a function
|
| 47 |
+
const resolvedHeader = typeof headerContent === "function"
|
| 48 |
+
? await headerContent(refreshedData)
|
| 49 |
+
: headerContent;
|
| 50 |
+
|
| 51 |
+
const selected = await selectMenu(
|
| 52 |
+
title,
|
| 53 |
+
menuItems,
|
| 54 |
+
defaultIndex,
|
| 55 |
+
"",
|
| 56 |
+
resolvedHeader,
|
| 57 |
+
breadcrumb
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
// Back or ESC
|
| 61 |
+
if (selected === -1 || selected === 0) {
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Execute action for selected item
|
| 66 |
+
const actionIndex = selected - 1;
|
| 67 |
+
const item = items[actionIndex];
|
| 68 |
+
|
| 69 |
+
if (item && item.action) {
|
| 70 |
+
const shouldContinue = await item.action(refreshedData);
|
| 71 |
+
// If action returns false, exit menu
|
| 72 |
+
if (shouldContinue === false) {
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Show a list menu where items are fetched dynamically
|
| 81 |
+
* @param {Object} config - Menu configuration
|
| 82 |
+
* @param {string} config.title - Menu title
|
| 83 |
+
* @param {string} config.headerContent - Optional header content
|
| 84 |
+
* @param {Function} config.fetchItems - Async function to fetch items array
|
| 85 |
+
* @param {Function} config.formatItem - Function to format each item to {label, data}
|
| 86 |
+
* @param {Function} config.onSelect - Action when item is selected
|
| 87 |
+
* @param {Object} config.createAction - Optional create action {label, action}
|
| 88 |
+
* @param {string} config.backLabel - Back button label
|
| 89 |
+
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
|
| 90 |
+
* @returns {Promise<void>}
|
| 91 |
+
*/
|
| 92 |
+
async function showListMenu(config) {
|
| 93 |
+
const {
|
| 94 |
+
title,
|
| 95 |
+
headerContent = "",
|
| 96 |
+
fetchItems,
|
| 97 |
+
formatItem,
|
| 98 |
+
onSelect,
|
| 99 |
+
createAction = null,
|
| 100 |
+
backLabel = "← Back",
|
| 101 |
+
breadcrumb = []
|
| 102 |
+
} = config;
|
| 103 |
+
|
| 104 |
+
while (true) {
|
| 105 |
+
// Fetch items
|
| 106 |
+
const result = await fetchItems();
|
| 107 |
+
if (!result) {
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const items = result.items || [];
|
| 112 |
+
const metadata = result.metadata || {};
|
| 113 |
+
|
| 114 |
+
// Build menu items
|
| 115 |
+
const menuItems = [{ label: backLabel, icon: "☆" }];
|
| 116 |
+
|
| 117 |
+
if (createAction) {
|
| 118 |
+
menuItems.push({ label: createAction.label, icon: "☆" });
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
items.forEach(item => {
|
| 122 |
+
const formatted = formatItem(item);
|
| 123 |
+
menuItems.push({ label: formatted, icon: "☆" });
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
const header = typeof headerContent === "function"
|
| 127 |
+
? await headerContent(metadata)
|
| 128 |
+
: headerContent;
|
| 129 |
+
|
| 130 |
+
const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
|
| 131 |
+
|
| 132 |
+
// Back or ESC
|
| 133 |
+
if (selected === -1 || selected === 0) {
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Create action
|
| 138 |
+
if (createAction && selected === 1) {
|
| 139 |
+
await createAction.action();
|
| 140 |
+
continue;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// Select item
|
| 144 |
+
const offset = createAction ? 2 : 1;
|
| 145 |
+
const itemIndex = selected - offset;
|
| 146 |
+
|
| 147 |
+
if (itemIndex >= 0 && itemIndex < items.length) {
|
| 148 |
+
await onSelect(items[itemIndex]);
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
module.exports = {
|
| 154 |
+
showMenuWithBack,
|
| 155 |
+
showListMenu
|
| 156 |
+
};
|
cli/src/cli/utils/modelSelector.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const api = require("../api/client");
|
| 2 |
+
const { prompt } = require("./input");
|
| 3 |
+
const { clearScreen } = require("./display");
|
| 4 |
+
|
| 5 |
+
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
|
| 6 |
+
const PROVIDER_ALIAS_ORDER = [
|
| 7 |
+
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
|
| 8 |
+
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
// Alias to display name mapping
|
| 12 |
+
const PROVIDER_ALIAS_NAMES = {
|
| 13 |
+
cc: "Claude Code",
|
| 14 |
+
ag: "Antigravity",
|
| 15 |
+
cx: "OpenAI Codex",
|
| 16 |
+
if: "iFlow AI",
|
| 17 |
+
qw: "Qwen Code",
|
| 18 |
+
gc: "Gemini CLI",
|
| 19 |
+
gh: "GitHub Copilot",
|
| 20 |
+
kr: "Kiro AI",
|
| 21 |
+
openrouter: "OpenRouter",
|
| 22 |
+
glm: "GLM Coding",
|
| 23 |
+
kimi: "Kimi Coding",
|
| 24 |
+
minimax: "Minimax Coding",
|
| 25 |
+
openai: "OpenAI",
|
| 26 |
+
anthropic: "Anthropic",
|
| 27 |
+
gemini: "Gemini"
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Get all available models grouped by provider + combos
|
| 32 |
+
* @returns {Promise<{combos: Array, groups: Object}>}
|
| 33 |
+
*/
|
| 34 |
+
async function getAvailableModelsGrouped() {
|
| 35 |
+
const result = await api.getAvailableModels();
|
| 36 |
+
if (!result.success) return { combos: [], groups: {} };
|
| 37 |
+
|
| 38 |
+
const models = result.data?.data || [];
|
| 39 |
+
const combos = [];
|
| 40 |
+
const groups = {};
|
| 41 |
+
|
| 42 |
+
models.forEach(m => {
|
| 43 |
+
if (m.owned_by === "combo") {
|
| 44 |
+
combos.push(m.id);
|
| 45 |
+
} else {
|
| 46 |
+
const provider = m.owned_by;
|
| 47 |
+
if (!groups[provider]) {
|
| 48 |
+
groups[provider] = [];
|
| 49 |
+
}
|
| 50 |
+
groups[provider].push(m.id);
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
return { combos, groups };
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Display model list and prompt for selection
|
| 59 |
+
* @param {string} title - Title to display
|
| 60 |
+
* @param {string} currentValue - Current selected value (optional)
|
| 61 |
+
* @param {Object} options - { excludeCombos?: boolean }
|
| 62 |
+
* @returns {Promise<string|null>} Selected model ID or null if cancelled
|
| 63 |
+
*/
|
| 64 |
+
async function selectModelFromList(title, currentValue = "", options = {}) {
|
| 65 |
+
const { excludeCombos = false } = options;
|
| 66 |
+
const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
|
| 67 |
+
const combos = excludeCombos ? [] : rawCombos;
|
| 68 |
+
|
| 69 |
+
const totalModels = combos.length + Object.values(groups).flat().length;
|
| 70 |
+
if (totalModels === 0) {
|
| 71 |
+
return null;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Build flat list for selection
|
| 75 |
+
const allModels = [];
|
| 76 |
+
|
| 77 |
+
// Display
|
| 78 |
+
clearScreen();
|
| 79 |
+
console.log(`\n🎯 ${title}`);
|
| 80 |
+
console.log("=".repeat(50));
|
| 81 |
+
if (currentValue) {
|
| 82 |
+
console.log(`Current: ${currentValue}\n`);
|
| 83 |
+
} else {
|
| 84 |
+
console.log();
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
let idx = 1;
|
| 88 |
+
|
| 89 |
+
// Combos first (skipped when excludeCombos is true)
|
| 90 |
+
if (combos.length > 0) {
|
| 91 |
+
console.log("[Combos]");
|
| 92 |
+
combos.forEach(combo => {
|
| 93 |
+
console.log(` ${idx}. ${combo}`);
|
| 94 |
+
allModels.push(combo);
|
| 95 |
+
idx++;
|
| 96 |
+
});
|
| 97 |
+
console.log();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Provider groups in order (by alias)
|
| 101 |
+
const sortedProviders = Object.keys(groups).sort((a, b) => {
|
| 102 |
+
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
|
| 103 |
+
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
|
| 104 |
+
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
sortedProviders.forEach(provider => {
|
| 108 |
+
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
|
| 109 |
+
console.log(`[${providerName}]`);
|
| 110 |
+
groups[provider].forEach(model => {
|
| 111 |
+
console.log(` ${idx}. ${model}`);
|
| 112 |
+
allModels.push(model);
|
| 113 |
+
idx++;
|
| 114 |
+
});
|
| 115 |
+
console.log();
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
console.log(" 0. Cancel\n");
|
| 119 |
+
|
| 120 |
+
// Prompt for number input
|
| 121 |
+
const input = await prompt("Enter number: ");
|
| 122 |
+
const num = parseInt(input, 10);
|
| 123 |
+
|
| 124 |
+
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
|
| 125 |
+
return null;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return allModels[num - 1];
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
module.exports = {
|
| 132 |
+
selectModelFromList,
|
| 133 |
+
getAvailableModelsGrouped,
|
| 134 |
+
PROVIDER_ALIAS_ORDER,
|
| 135 |
+
PROVIDER_ALIAS_NAMES
|
| 136 |
+
};
|
custom-server.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const http = require("http");
|
| 2 |
+
|
| 3 |
+
const origCreate = http.createServer.bind(http);
|
| 4 |
+
|
| 5 |
+
// Wrap Next standalone HTTP server: derive client IP from the TCP socket
|
| 6 |
+
// (unspoofable) and strip client-supplied forwarding headers so downstream
|
| 7 |
+
// rate-limiting keys on the real peer address instead of attacker-controlled XFF.
|
| 8 |
+
http.createServer = (...args) => {
|
| 9 |
+
const handler = args.find((a) => typeof a === "function");
|
| 10 |
+
const rest = args.filter((a) => typeof a !== "function");
|
| 11 |
+
if (!handler) return origCreate(...args);
|
| 12 |
+
const wrapped = (req, res) => {
|
| 13 |
+
const ip = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : "";
|
| 14 |
+
// Forwarding headers present = request arrived via a reverse proxy; loopback
|
| 15 |
+
// socket is the proxy hop, not the end-user, so it must not be trusted as local.
|
| 16 |
+
const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
|
| 17 |
+
delete req.headers["x-9r-real-ip"];
|
| 18 |
+
delete req.headers["x-forwarded-for"];
|
| 19 |
+
delete req.headers["x-9r-via-proxy"];
|
| 20 |
+
req.headers["x-9r-real-ip"] = ip;
|
| 21 |
+
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
|
| 22 |
+
return handler(req, res);
|
| 23 |
+
};
|
| 24 |
+
return origCreate(...rest, wrapped);
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
require("./server.js");
|
jsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"baseUrl": ".",
|
| 4 |
+
"paths": {
|
| 5 |
+
"@/*": ["./src/*"],
|
| 6 |
+
"open-sse": ["./open-sse"],
|
| 7 |
+
"open-sse/*": ["./open-sse/*"]
|
| 8 |
+
},
|
| 9 |
+
"module": "ESNext",
|
| 10 |
+
"moduleResolution": "bundler"
|
| 11 |
+
}
|
| 12 |
+
}
|
next.config.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fileURLToPath } from "node:url";
|
| 2 |
+
import { dirname, join } from "node:path";
|
| 3 |
+
|
| 4 |
+
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
| 5 |
+
// CLI bundling needs workspace root so tracing includes hoisted node_modules (slim ~50MB).
|
| 6 |
+
// Docker / default uses projectRoot so server.js lands at /app/server.js (not nested).
|
| 7 |
+
const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace"
|
| 8 |
+
? join(projectRoot, "..")
|
| 9 |
+
: projectRoot;
|
| 10 |
+
const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE || "128mb";
|
| 11 |
+
|
| 12 |
+
/** @type {import('next').NextConfig} */
|
| 13 |
+
const nextConfig = {
|
| 14 |
+
distDir: process.env.NEXT_DIST_DIR || ".next",
|
| 15 |
+
output: "standalone",
|
| 16 |
+
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
|
| 17 |
+
turbopack: {
|
| 18 |
+
root: tracingRoot
|
| 19 |
+
},
|
| 20 |
+
outputFileTracingRoot: tracingRoot,
|
| 21 |
+
outputFileTracingExcludes: {
|
| 22 |
+
"*": ["./gitbook/**/*"]
|
| 23 |
+
},
|
| 24 |
+
images: {
|
| 25 |
+
unoptimized: true
|
| 26 |
+
},
|
| 27 |
+
env: {},
|
| 28 |
+
experimental: {
|
| 29 |
+
// #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites.
|
| 30 |
+
proxyClientMaxBodySize,
|
| 31 |
+
// Cache fetch responses across HMR refreshes for faster dev reloads.
|
| 32 |
+
serverComponentsHmrCache: true,
|
| 33 |
+
},
|
| 34 |
+
webpack: (config, { isServer }) => {
|
| 35 |
+
// Ignore fs/path modules in browser bundle
|
| 36 |
+
if (!isServer) {
|
| 37 |
+
config.resolve.fallback = {
|
| 38 |
+
...config.resolve.fallback,
|
| 39 |
+
fs: false,
|
| 40 |
+
path: false,
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
// Exclude logs, .next, gitbook subapp from watcher
|
| 44 |
+
config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ };
|
| 45 |
+
return config;
|
| 46 |
+
},
|
| 47 |
+
async rewrites() {
|
| 48 |
+
return [
|
| 49 |
+
{
|
| 50 |
+
source: "/v1/v1/:path*",
|
| 51 |
+
destination: "/api/v1/:path*"
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
source: "/v1/v1",
|
| 55 |
+
destination: "/api/v1"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
source: "/codex/:path*",
|
| 59 |
+
destination: "/api/v1/responses"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
source: "/v1/:path*",
|
| 63 |
+
destination: "/api/v1/:path*"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
source: "/v1",
|
| 67 |
+
destination: "/api/v1"
|
| 68 |
+
}
|
| 69 |
+
];
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
export default nextConfig;
|
open-sse/.npmignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
*.log
|
| 3 |
+
.DS_Store
|
| 4 |
+
test/
|
| 5 |
+
*.test.js
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
|
open-sse/AGENTS.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# open-sse
|
| 2 |
+
|
| 3 |
+
Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM chat, image, embedding, tts, stt, search), streamed back in the client's format.
|
| 4 |
+
|
| 5 |
+
## Request lifecycle (chat)
|
| 6 |
+
|
| 7 |
+
`handlers/chatCore.js` → `services/model.js` `parseModel` (resolve `provider/model`) → `executors/index.js` `getExecutor(provider)` → `translator/index.js` `translateRequest` (client format → provider format) → `executor.execute()` (streams upstream) → `translateResponse` (provider chunks → client format) → SSE out.
|
| 8 |
+
|
| 9 |
+
## Directory map
|
| 10 |
+
|
| 11 |
+
- `config/` — ALL constants/config (no hardcode elsewhere). `providers.js`/`registry/` (provider defs), `providerModels.js` (alias→models matrix), `runtimeConfig.js` (timeouts, token limits), `*Constants.js`.
|
| 12 |
+
- `translator/` — format conversion. `request/<from>-to-<to>.js`, `response/<from>-to-<to>.js`, `schema/` (enums: ROLE, CLAUDE_BLOCK…), `concerns/` (shared logic), `formats/` (per-format). See `tests/translator/AGENTS.md`.
|
| 13 |
+
- `executors/` — per-provider upstream call. `base.js` (BaseExecutor), one file per special provider, `index.js` map.
|
| 14 |
+
- `providers/` — registry build + `capabilities.js` + `pricing.js`. Entry: `index.js` (PROVIDERS).
|
| 15 |
+
- `handlers/` — per-modality cores (chat/image/embedding/tts/stt/search) + sub-provider folders.
|
| 16 |
+
- `services/` — `tokenRefresh/`, `usage/`, `combo.js`, `accountFallback.js`, `model.js`.
|
| 17 |
+
- `utils/` — streamHandler, error, sessionManager, claudeCloaking.
|
| 18 |
+
|
| 19 |
+
## Conventions
|
| 20 |
+
|
| 21 |
+
- Config-driven, DRY, camelCase. NEVER hardcode values, models, or block/role strings — use `config/` + `schema/` constants.
|
| 22 |
+
- Translator pipeline pivots through OpenAI as the intermediate format. A translator registered on the exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop.
|
| 23 |
+
- Translators self-register via `register(from, to, reqFn, resFn)` as an import side-effect — new files MUST be imported in `translator/index.js`.
|
| 24 |
+
|
| 25 |
+
## How to add
|
| 26 |
+
|
| 27 |
+
- **Provider**: copy `providers/REGISTRY_TEMPLATE.js` → `providers/registry/{id}.js`; add models to `config/providerModels.js`. Generic providers need no executor (DefaultExecutor handles OpenAI-compatible APIs).
|
| 28 |
+
- **Executor** (only for non-standard upstream): subclass `BaseExecutor` (override `getBaseUrls`/`buildHeaders`/`buildUrl`/`execute`), register in `executors/index.js` map. `getExecutor` falls back to `DefaultExecutor` when absent.
|
| 29 |
+
- **Translator**: add `request|response/<from>-to-<to>.js` calling `register(...)`, then import it in `translator/index.js`. Reuse `schema/` + `concerns/` — don't re-implement parsing.
|
| 30 |
+
|
| 31 |
+
## Pitfalls
|
| 32 |
+
|
| 33 |
+
- OpenAI bridge is lossy (thinking, non-base64 images, tool ids, is_error) — prefer a direct route for fragile pairs.
|
| 34 |
+
- `registry/index.js` is an auto-generated static import list; regenerate it (don't hand-edit) after adding a `registry/{id}.js`. REGISTRY_TEMPLATE is excluded by design.
|
| 35 |
+
- Special binary/protobuf formats (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their executor.
|
open-sse/config/appConstants.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { platform, arch } from "os";
|
| 2 |
+
import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
|
| 3 |
+
|
| 4 |
+
// === Gemini CLI === derive từ registry gemini-cli.transport
|
| 5 |
+
export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
|
| 6 |
+
export const GEMINI_CLI_API_CLIENT = PROVIDERS["gemini-cli"]?.apiClient;
|
| 7 |
+
|
| 8 |
+
// Map Node arch to Gemini CLI arch string (x64/x86/arm64/...)
|
| 9 |
+
function geminiCLIArch() {
|
| 10 |
+
const a = arch();
|
| 11 |
+
if (a === "ia32") return "x86";
|
| 12 |
+
return a;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function geminiCLIUserAgent(model = "unknown") {
|
| 16 |
+
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${platform()}; ${geminiCLIArch()}; terminal)`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// === GitHub Copilot ===
|
| 20 |
+
// Derive từ registry github.transport.copilot
|
| 21 |
+
const _ghCopilot = PROVIDERS.github?.copilot || {};
|
| 22 |
+
export const GITHUB_COPILOT = {
|
| 23 |
+
VSCODE_VERSION: _ghCopilot.vscodeVersion,
|
| 24 |
+
COPILOT_CHAT_VERSION: _ghCopilot.chatVersion,
|
| 25 |
+
USER_AGENT: _ghCopilot.userAgent,
|
| 26 |
+
API_VERSION: _ghCopilot.apiVersion,
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
// === Antigravity enums ===
|
| 30 |
+
export const IDE_TYPE = {
|
| 31 |
+
UNSPECIFIED: 0,
|
| 32 |
+
JETSKI: 10,
|
| 33 |
+
ANTIGRAVITY: 9,
|
| 34 |
+
PLUGINS: 7
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export const PLATFORM = {
|
| 38 |
+
UNSPECIFIED: 0,
|
| 39 |
+
DARWIN_AMD64: 1,
|
| 40 |
+
DARWIN_ARM64: 2,
|
| 41 |
+
LINUX_AMD64: 3,
|
| 42 |
+
LINUX_ARM64: 4,
|
| 43 |
+
WINDOWS_AMD64: 5
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export const PLUGIN_TYPE = {
|
| 47 |
+
UNSPECIFIED: 0,
|
| 48 |
+
CLOUD_CODE: 1,
|
| 49 |
+
GEMINI: 2
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
export function getPlatformEnum() {
|
| 53 |
+
const os = platform();
|
| 54 |
+
const architecture = arch();
|
| 55 |
+
if (os === "darwin") return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64;
|
| 56 |
+
if (os === "linux") return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64;
|
| 57 |
+
if (os === "win32") return PLATFORM.WINDOWS_AMD64;
|
| 58 |
+
return PLATFORM.UNSPECIFIED;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function getPlatformUserAgent() {
|
| 62 |
+
return `antigravity/1.104.0 ${platform()}/${arch()}`;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export const CLIENT_METADATA = {
|
| 66 |
+
ideType: IDE_TYPE.ANTIGRAVITY,
|
| 67 |
+
platform: getPlatformEnum(),
|
| 68 |
+
pluginType: PLUGIN_TYPE.GEMINI
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// Internal anti-loop header
|
| 72 |
+
export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
| 73 |
+
|
| 74 |
+
// Suffix added to client tools when forwarding to Antigravity provider (anti-ban cloaking)
|
| 75 |
+
export const AG_TOOL_SUFFIX = "_ide";
|
| 76 |
+
|
| 77 |
+
// Suffix added to client tools when forwarding to Claude provider (anti-ban cloaking)
|
| 78 |
+
export const CLAUDE_TOOL_SUFFIX = "_ide";
|
| 79 |
+
|
| 80 |
+
// CC native default tools — these are Claude Code's own tools, kept as decoys
|
| 81 |
+
// Client tools matching these names are skipped (not renamed), others get _cc suffix
|
| 82 |
+
export const CC_DEFAULT_TOOLS = new Set([
|
| 83 |
+
"Task",
|
| 84 |
+
"TaskOutput",
|
| 85 |
+
"TaskStop",
|
| 86 |
+
"TaskCreate",
|
| 87 |
+
"TaskGet",
|
| 88 |
+
"TaskUpdate",
|
| 89 |
+
"TaskList",
|
| 90 |
+
"Bash",
|
| 91 |
+
"Glob",
|
| 92 |
+
"Grep",
|
| 93 |
+
"Read",
|
| 94 |
+
"Edit",
|
| 95 |
+
"Write",
|
| 96 |
+
"NotebookEdit",
|
| 97 |
+
"WebFetch",
|
| 98 |
+
"WebSearch",
|
| 99 |
+
"AskUserQuestion",
|
| 100 |
+
"Skill",
|
| 101 |
+
"EnterPlanMode",
|
| 102 |
+
"ExitPlanMode",
|
| 103 |
+
]);
|
| 104 |
+
|
| 105 |
+
// AG native default tools — kept as decoys with neutral description/properties
|
| 106 |
+
// These names must match exactly what AG sends in the real request log
|
| 107 |
+
export const AG_DEFAULT_TOOLS = new Set([
|
| 108 |
+
"browser_subagent",
|
| 109 |
+
"command_status",
|
| 110 |
+
"find_by_name",
|
| 111 |
+
"generate_image",
|
| 112 |
+
"grep_search",
|
| 113 |
+
"list_dir",
|
| 114 |
+
"list_resources",
|
| 115 |
+
"multi_replace_file_content",
|
| 116 |
+
"notify_user",
|
| 117 |
+
"read_resource",
|
| 118 |
+
"read_terminal",
|
| 119 |
+
"read_url_content",
|
| 120 |
+
"replace_file_content",
|
| 121 |
+
"run_command",
|
| 122 |
+
"search_web",
|
| 123 |
+
"send_command_input",
|
| 124 |
+
"task_boundary",
|
| 125 |
+
"view_content_chunk",
|
| 126 |
+
"view_file",
|
| 127 |
+
"write_to_file"
|
| 128 |
+
]);
|
| 129 |
+
|
| 130 |
+
// Antigravity chat/stream headers
|
| 131 |
+
export const ANTIGRAVITY_HEADERS = {
|
| 132 |
+
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
// Cloud Code Assist API
|
| 136 |
+
export const CLOUD_CODE_API = {
|
| 137 |
+
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
| 138 |
+
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
export const LOAD_CODE_ASSIST_HEADERS = {
|
| 142 |
+
"Content-Type": "application/json",
|
| 143 |
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
| 144 |
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
| 145 |
+
"Client-Metadata": JSON.stringify({ ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }),
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
export const LOAD_CODE_ASSIST_METADATA = {
|
| 149 |
+
ideType: IDE_TYPE.ANTIGRAVITY,
|
| 150 |
+
platform: getPlatformEnum(),
|
| 151 |
+
pluginType: PLUGIN_TYPE.GEMINI,
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
// System prompts
|
| 155 |
+
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
|
| 156 |
+
export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
|
| 157 |
+
|
| 158 |
+
// Derive từ registry oauth.refreshLeadMs
|
| 159 |
+
export const REFRESH_LEAD_MS = Object.fromEntries(
|
| 160 |
+
Object.entries(PROVIDER_OAUTH).filter(([, o]) => o.refreshLeadMs).map(([id, o]) => [id, o.refreshLeadMs])
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
// OAuth endpoints
|
| 164 |
+
export const OAUTH_ENDPOINTS = {
|
| 165 |
+
google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
|
| 166 |
+
openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
|
| 167 |
+
anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
|
| 168 |
+
qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
|
| 169 |
+
iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
|
| 170 |
+
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
// Generate Kimi OAuth custom headers
|
| 174 |
+
export function buildKimiHeaders() {
|
| 175 |
+
return {
|
| 176 |
+
"X-Msh-Platform": "9router",
|
| 177 |
+
"X-Msh-Version": "2.1.2",
|
| 178 |
+
"X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown",
|
| 179 |
+
"X-Msh-Device-Id": `kimi-${Date.now()}`
|
| 180 |
+
};
|
| 181 |
+
}
|
open-sse/config/codexInstructions.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Default instructions for Codex models
|
| 2 |
+
|
| 3 |
+
export const CODEX_DEFAULT_INSTRUCTIONS = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
|
| 4 |
+
|
| 5 |
+
## General
|
| 6 |
+
|
| 7 |
+
- When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.)
|
| 8 |
+
|
| 9 |
+
## Editing constraints
|
| 10 |
+
|
| 11 |
+
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
| 12 |
+
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
|
| 13 |
+
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
| 14 |
+
- You may be in a dirty git worktree.
|
| 15 |
+
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
| 16 |
+
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
| 17 |
+
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
| 18 |
+
* If the changes are in unrelated files, just ignore them and don't revert them.
|
| 19 |
+
- Do not amend a commit unless explicitly requested to do so.
|
| 20 |
+
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
|
| 21 |
+
- **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user.
|
| 22 |
+
|
| 23 |
+
## Plan tool
|
| 24 |
+
|
| 25 |
+
When using the planning tool:
|
| 26 |
+
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
|
| 27 |
+
- Do not make single-step plans.
|
| 28 |
+
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
| 29 |
+
|
| 30 |
+
## Codex CLI harness, sandboxing, and approvals
|
| 31 |
+
|
| 32 |
+
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
| 33 |
+
|
| 34 |
+
Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are:
|
| 35 |
+
- **read-only**: The sandbox only permits reading files.
|
| 36 |
+
- **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval.
|
| 37 |
+
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
| 38 |
+
|
| 39 |
+
Network sandboxing defines whether network can be accessed without approval. Options for \`network_access\` are:
|
| 40 |
+
- **restricted**: Requires approval
|
| 41 |
+
- **enabled**: No approval needed
|
| 42 |
+
|
| 43 |
+
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for \`approval_policy\` are
|
| 44 |
+
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
| 45 |
+
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
| 46 |
+
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the \`shell\` command description.)
|
| 47 |
+
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with \`danger-full-access\`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
| 48 |
+
|
| 49 |
+
When you are running with \`approval_policy == on-request\`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
| 50 |
+
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
| 51 |
+
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
| 52 |
+
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
| 53 |
+
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the \`sandbox_permissions\` and \`justification\` parameters - do not message the user before requesting approval for the command.
|
| 54 |
+
- You are about to take a potentially destructive action such as an \`rm\` or \`git reset\` that the user did not explicitly ask for
|
| 55 |
+
- (for all of these, you should weigh alternative paths that do not require approval)
|
| 56 |
+
|
| 57 |
+
When \`sandbox_mode\` is set to read-only, you'll need to request approval for any command that isn't a read.
|
| 58 |
+
|
| 59 |
+
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
| 60 |
+
|
| 61 |
+
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
| 62 |
+
|
| 63 |
+
When requesting approval to execute a command that will require escalated privileges:
|
| 64 |
+
- Provide the \`sandbox_permissions\` parameter with the value \`"require_escalated"\`
|
| 65 |
+
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
| 66 |
+
|
| 67 |
+
## Special user requests
|
| 68 |
+
|
| 69 |
+
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as \`date\`), you should do so.
|
| 70 |
+
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
| 71 |
+
|
| 72 |
+
## Frontend tasks
|
| 73 |
+
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
|
| 74 |
+
Aim for interfaces that feel intentional, bold, and a bit surprising.
|
| 75 |
+
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
|
| 76 |
+
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
|
| 77 |
+
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
|
| 78 |
+
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
|
| 79 |
+
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
| 80 |
+
- Ensure the page loads properly on both desktop and mobile
|
| 81 |
+
|
| 82 |
+
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
| 83 |
+
|
| 84 |
+
## Presenting your work and final message
|
| 85 |
+
|
| 86 |
+
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
| 87 |
+
|
| 88 |
+
- Default: be very concise; friendly coding teammate tone.
|
| 89 |
+
- Ask only when needed; suggest ideas; mirror the user's style.
|
| 90 |
+
- For substantial work, summarize clearly; follow final‑answer formatting.
|
| 91 |
+
- Skip heavy formatting for simple confirmations.
|
| 92 |
+
- Don't dump large files you've written; reference paths only.
|
| 93 |
+
- No "save/copy this file" - User is on the same machine.
|
| 94 |
+
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
|
| 95 |
+
- For code changes:
|
| 96 |
+
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
|
| 97 |
+
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
|
| 98 |
+
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
| 99 |
+
- The user does not command execution outputs. When asked to show the output of a command (e.g. \`git show\`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
| 100 |
+
|
| 101 |
+
### Final answer structure and style guidelines
|
| 102 |
+
|
| 103 |
+
- Plain text; CLI handles styling. Use structure only when it helps scanability.
|
| 104 |
+
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
|
| 105 |
+
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
|
| 106 |
+
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
|
| 107 |
+
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
|
| 108 |
+
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
|
| 109 |
+
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
|
| 110 |
+
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
|
| 111 |
+
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
|
| 112 |
+
- File References: When referencing files in your response follow the below rules:
|
| 113 |
+
* Use inline code to make file paths clickable.
|
| 114 |
+
* Each reference should have a stand alone path. Even if it's the same file.
|
| 115 |
+
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
| 116 |
+
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
| 117 |
+
* Do not use URIs like file://, vscode://, or https://.
|
| 118 |
+
* Do not provide range of lines
|
| 119 |
+
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5`;
|
open-sse/config/constants.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Barrel re-export — consumers can migrate to specific files over time
|
| 2 |
+
export * from "./providers.js";
|
| 3 |
+
export * from "./appConstants.js";
|
| 4 |
+
export * from "./runtimeConfig.js";
|
open-sse/config/defaultThinkingSignature.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Default signature for thinking mode when no signature from thinkingStore
|
| 2 |
+
export const DEFAULT_THINKING_CLAUDE_SIGNATURE = "EpwGCkYIChgCKkCzVUuRrg7CcglSUWEef4rH6o35g9UYS8ZPe0/VomQTBsFx6sttYNj5l8GqgW6ejuHyYqpFToxIbZl0bw17l5dJEgzCnqDO0Z8fRlMrNgsaDLS1cnCjC53KBqE0CCIwAADQdo1eO+7qPAmo8J4WR3JPmr92S97kmvr5K1iPMiOpkZNj8mEXW8uzBoOJs/9ZKoMFiqHJ3UObwaJDqFOW70E9oCwDoc6jesaWVAEdN5vWfKMpIkjFJjECdjIdkxyJNJ8Ib8yXVal3qwE7uThoPRqSZDdHB5mmwPEjWE/90cSYCbtX2YsJki1265CabBb8/QEkODXg4kgRrL+c8e8rRXz/dr1RswvaPuzEdGKHRNi9UooNUeOK4/ebx1KkP9YZttyohN9GWqlts36kOoW0Cfie/ABDgF9g534BPth/sstxDM6d79QlRmh6NxizyTF74DXJI34u0M4tTRchqE5pAq85SgdJaa+dix1yJPMji8m6nZkwJbscJb9rdc2MKyKWjz8QL2+rTSSuZ2F1k1qSsW0xNcI7qLcI12Vncfn/VqY6YOIZy/saZBR0ezXvN6g+UYbuIdyVg7AyIFZt3nbrO7/kmOEb2VKzygwklHGEIJHfFgMpH3JSrAzbZIowVHOF7VaJ+KXRFDCFin7hHTOiOsdg+1ij1mML9Z/x/9CP4b7OUcaQm1llDZPSHc6rZMNL3DdB+fW5YfmNgKU35S+7AMtA10nVILzDAk1UV4T2K9Do09JlI6rjOs9UuULlIN2Z0eE8YTlANR6uQcw7lMcdfqYE8tke4rDKc2dDiaS5vVe45VewICNpdXGN11yw8QqH7p27CR1HtN30e0tHXOR3bIwWk/Yb6O5fTaKG6Ri8e5ZCPvdD9HqepVi188nM0iTjJqL58F3ni04ECIhcbyaQWnuTes1Kw4CMwiZDLQkk8Hgz7HkUOf1btQTF/0nhD7ry0n0hAEg2PaDM3V6TjOjf4hEldRmeqERcQF1PfgKb6ZM12rlIIfUqKACczWJSzTV158+47HX36o0cgux6nFlv/DE+sEiRVxgB";
|
| 3 |
+
|
| 4 |
+
export const DEFAULT_THINKING_AG_SIGNATURE = "EuwGCukGAXLI2nxwZIq54WWSoL/YN0P3TsDZ7zRnLi8g0S4aVr2HUGxvaHKySuY6HAVzcE0GPGjXrytLIldxthSvfxgUlJh6Qa9Z+Oj5QZBlYdg6HaJ6yuY5R7waE6rdwBsRf7Ft2j3DJ9rMi9qhWFqApewYtPhls3VHtuvND3l8Rm09+lbAXQs6KKWEWrxNLKTBkfpMgXhRERc/TQRMZu1twAablm6/Zk1tsYRvfWKLsNbeKF+CCojJdXJKvnR/8Ouuoa+Y2Ti20hcW7aZIIjZDFYPU//k6Ybmhg69J/imbFai2ckhfLaisqdDkdoIiBJScTOUvYqP6AE9d4MsydSC+UlhIMk4hoP76R8vUSCZRMkjOaDXstf/QoVZKbt94wyRZgAJ1G0BqI8L5ow86kLpA4wJEtxsRGymOE4bKUvApveBakYDNM9APkf+LbtbzWSseGjoZcSlycF9iN8Q2XNYKRrHbv3Lr5Y8JjdH/5y/6SHkNehTEZugaeGnSPSyCTWto1kQgHpxdWmhkLfJGNUGLmue7Mesj4TSms4J33mRpYVhNB/J333FCqIP0hr/E7BkkjEn7yZ4X7SQlh+xKPurapsnHRwiKmtsilmEFrnTE9iQr+pMr6M29qqFNv1tr5yumbaJw8JW9sB15tNsRv+dW6BjNanbsKz7HCgKUBc8tGy+7YuhXzAfViyRefcjK7eZW0Fbyt7AbybJTKz78W8NH7ye6LAwzOebXpeZ4D43fNIt8bKh26qgduSQv/7o+pAflkuqHZ99YWgHQ8h8OkZFi3eOiSYjsjhdZ/czWOdoPI/OnqIldzMPF5YlrKBLFX8VhRKVmqgsmWf5PHGulHhMkVlS+XG2UIseGy69ARa93D78Gsa+1n1kJr7EEB7Rh+27vUMxVYLdz1yMSvE5nalTAlg/ZeG8+XQ0cHuAI3KbQpHW2Q++RdXfm5JzD5WdJZUU+Zn8t8UUn85BH4RxZLeE0qJikgSsKoYVBc6YhiMjhPgkR95ReimY4Z0xCJdRo1gjexOFeODZMpQF6Yxnoic7IrdgsFA3iePTbFnPp3IAM1fAThWhXJUn3QInUOTd5o1qmTmn6REbL15g/JQNl+dqUoPkhleeb2V3kjqp1okmO3wMZbPknR3S1LZNmlS72/iBQUm+n2b/RCn4PjmM2";
|
| 5 |
+
|
| 6 |
+
export const DEFAULT_THINKING_VERTEX_SIGNATURE = "CloBjz1rX5+yg1ILh/Ag+suum5k1f/9m/hI0XDQ33lsQIYnOHLn9KZwN0C7E4jgep5MzZvz5Se1Z1xxYrA1+Iz0Il4tabBhaDfMKNa5dGdEA3KnikfjfIpMlPaAKaQGPPWtf2hodPdBgguiZqDn+Qz2LwGEqHVJ16LVBUpeSx7UnYBLSwio8cyNy0jPijOh5QXKLTeHVdO2tKKcCCrtG2JCW3dOSrW2qA8eyAg40iQUnMNECjbcjkqB1+zrab7jX9ILwg7L9OgqYAQGPPWtfT4nzaPzSkXePAa920abYxPs3fg/RHDlg8PUVFLa+ko6qOjt7nXJTMxN0cpCwUCFX7eHHcMnA6vApyA/rXvJiAABkHZ3HilAktXRtxr/thHU0H8/4H5gT3kzoQcq9aMznrKomd3ct0mFi0ioSKnOEfoY1Mrfj00p/ZWm0tT7Wrjcm3BQXZ+T9Vrb94k+6CjtcEBrGCq8BAY89a1/vTMczqwB1NP3HCCuBdnds2vDXkj6XAYaXjsjmik8tGqwMKHz8R9RAWsx6SO6pkGEpXXpRzAaUx6c+aofsL/z1xOcN7ArCAa6uEeQKEgNngZuCP05p4+9P95epVmgOjFa4KfsPnyg+NKUkEFmpPSDrIRyMT+xERlclVcCI98/u7i8a9+vTbgzl8TRFYryClNH37K1ye5i6kqSGDUcMyiEasjke5BxbUh3i6wqMAgGPPWtfk+A+iY38QAldu117FEkTIkzbYOIt67lk9c6Ou3Y3Ct8TFHFw5QwGfSFc0YWjeTFHdm9UdV5jPK35p6VfhiRSva3w2+JLIHb4jvv5HutZPOJ3yQTt/+hUDj80oMNMbwnxNZvCEdzKS+D9vwmTACAm5H0ZetBSH2gPJXnhhuQo9AegS3wIWVR2a5k643Vx9r4u4pOvij4476lxKswIHvqsjL4jnTzRCvd44G6dn7vD0ENGb1K/i+dMRQMcOBaOxPN0ynk9bKxXWRDbZ+Rhakfr+y74z+6eYCdRPVqO9I7s+riilFuRIfaQ+U6/vuVKGIWEVKCfZZi0z6H5Xgz1xmse0u0AsittDlIKxwEBjz1rX783A0vehvUeabRia+/pX46IsN5efTAxFEBUeUce3jLuXIghkMV2b8KNhUs2G0aZldDDewRQbkluQabBMDT82N5I7reJP0VZgLIKccCL5DoGv1J7YWM2npLMIgZ6aP8aSlT3PFFJ0IXbUZUrzduczmIm6nzAJf9zxmq1aIFYw8YrgW8RjUdy0UvUmRoBEShSGUrvsyaRTl7J//KJW5utIPunFMu53GPWLidCFHzM1QA3Cj1+4zv5UXajP/V92RQayWbzCvYBAY89a1+yzoVSWukUGH7kX71Tg9dx7HA7OyKYwnYaqekG98zJfcUM/3KoiiiotW5t4xYu//ksEl36bSWvUHsRnxGByg+3WYdnZqKg0AtdRB/EXbI5PsjvS5ko96bkjSuFkY3TjHGwAM2B94K6/t6OTE/NBbxCsY9sT4d+1sbFv/iyfmfCnfvJaSzGmC9CDWKy4iqQ/vBNWps9j1JXk0p5uPAYC2BaMkxl5xoTVZqI3zAuRtQF5JLmPPy+PdqOgFxMKcLGNhwp7dbhIFLF68vCYQ9CL0NnK2d3CFk1UFVYxsi1TsolR1xahe/Rxt5HZDz/z65nevrQ";
|
| 7 |
+
|
| 8 |
+
export const DEFAULT_THINKING_GEMINI_CLI_SIGNATURE = "CiQBjz1rX/AlslZWMe5RgBt4Tv9j4+YNZTTez+JH2/+5oAlICygKXgGPPWtf7/Sux9eLYap/bmYAdPqFThLXj+l7o0DLu/hdgU98MA9ZrlRDNHXx+T0tuY8AcnjPZbiDyOq2bE11Fjhsk6p5axqayaapC/Pt9GczcgIQf1z15WTxCeKWAPYKYQGPPWtfDYj0nlNFNoTlU39RC91Z16xFKJ2MLEmkm+NvimsoOJ6be3g2BssNPtJ/9BKDXRA5cVs17tBeeW72lH8TMB5999udtxHM2SiUsnWsrHlfVuGSCpNQQ+5REw8HNvEKkgEBjz1rXzBNWrqZGbjun55K+vgYPBhJO2qZ67uRWXUA5/qcU12U/mbi5XoA3swoxYE8LEXfZvFFC9WG/W28QNCA0Qd4Trk/WkWiAwZmB8a84Fs14rkv3wqyxwFavPkJorqurAfd2XzGiFy0sB0ITCOPYi1HzDGV5WfXk6b9k+jT66/RuzGa8EcSOWo/QtC3Bkhgowo4AY89a1/f/tw8A02zjIoK7JVDAbf8W4UfmbApJJhwXIiGtu1M0JItObx7g2reYqT+HHL2Q/R4VDc=";
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
export const DEFAULT_THINKING_TEXT = "...";
|
| 12 |
+
|
open-sse/config/errorConfig.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// OpenAI-compatible error types mapping (client-facing)
|
| 2 |
+
export const ERROR_TYPES = {
|
| 3 |
+
400: { type: "invalid_request_error", code: "bad_request" },
|
| 4 |
+
401: { type: "authentication_error", code: "invalid_api_key" },
|
| 5 |
+
402: { type: "billing_error", code: "payment_required" },
|
| 6 |
+
403: { type: "permission_error", code: "insufficient_quota" },
|
| 7 |
+
404: { type: "invalid_request_error", code: "model_not_found" },
|
| 8 |
+
406: { type: "invalid_request_error", code: "model_not_supported" },
|
| 9 |
+
429: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
| 10 |
+
500: { type: "server_error", code: "internal_server_error" },
|
| 11 |
+
502: { type: "server_error", code: "bad_gateway" },
|
| 12 |
+
503: { type: "server_error", code: "service_unavailable" },
|
| 13 |
+
504: { type: "server_error", code: "gateway_timeout" }
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// Default error messages per status code (client-facing)
|
| 17 |
+
export const DEFAULT_ERROR_MESSAGES = {
|
| 18 |
+
400: "Bad request",
|
| 19 |
+
401: "Invalid API key provided",
|
| 20 |
+
402: "Payment required",
|
| 21 |
+
403: "You exceeded your current quota",
|
| 22 |
+
404: "Model not found",
|
| 23 |
+
406: "Model not supported",
|
| 24 |
+
429: "Rate limit exceeded",
|
| 25 |
+
500: "Internal server error",
|
| 26 |
+
502: "Bad gateway - upstream provider error",
|
| 27 |
+
503: "Service temporarily unavailable",
|
| 28 |
+
504: "Gateway timeout"
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Exponential backoff config for rate limits
|
| 32 |
+
export const BACKOFF_CONFIG = {
|
| 33 |
+
base: 2000,
|
| 34 |
+
max: 5 * 60 * 1000,
|
| 35 |
+
maxLevel: 15
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// Default cooldown for transient/unknown errors
|
| 39 |
+
export const TRANSIENT_COOLDOWN_MS = 30 * 1000;
|
| 40 |
+
|
| 41 |
+
// Hard cap for provider-reported rate limit cooldown (e.g. codex resets_at can be 5-6h)
|
| 42 |
+
export const MAX_RATE_LIMIT_COOLDOWN_MS = 30 * 60 * 1000;
|
| 43 |
+
|
| 44 |
+
// Cooldown durations (ms)
|
| 45 |
+
const COOLDOWN = {
|
| 46 |
+
long: 2 * 60 * 1000,
|
| 47 |
+
short: 5 * 1000,
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Unified error classification rules.
|
| 52 |
+
* Checked top-to-bottom: text rules first (by order), then status rules.
|
| 53 |
+
* Each rule: { text?, status?, cooldownMs?, backoff? }
|
| 54 |
+
* - text: substring match (case-insensitive) on error message
|
| 55 |
+
* - status: HTTP status code match
|
| 56 |
+
* - cooldownMs: fixed cooldown duration
|
| 57 |
+
* - backoff: true = use exponential backoff (rate limit)
|
| 58 |
+
*/
|
| 59 |
+
export const ERROR_RULES = [
|
| 60 |
+
// --- Text-based rules (checked first, order = priority) ---
|
| 61 |
+
{ text: "no credentials", cooldownMs: COOLDOWN.long },
|
| 62 |
+
{ text: "request not allowed", cooldownMs: COOLDOWN.short },
|
| 63 |
+
{ text: "improperly formed request", cooldownMs: COOLDOWN.long },
|
| 64 |
+
{ text: "rate limit", backoff: true },
|
| 65 |
+
{ text: "too many requests", backoff: true },
|
| 66 |
+
{ text: "quota exceeded", backoff: true },
|
| 67 |
+
{ text: "capacity", backoff: true },
|
| 68 |
+
{ text: "overloaded", backoff: true },
|
| 69 |
+
|
| 70 |
+
// --- Status-based rules (fallback when text doesn't match) ---
|
| 71 |
+
{ status: 401, cooldownMs: COOLDOWN.long },
|
| 72 |
+
{ status: 402, cooldownMs: COOLDOWN.long },
|
| 73 |
+
{ status: 403, cooldownMs: COOLDOWN.long },
|
| 74 |
+
{ status: 404, cooldownMs: COOLDOWN.long },
|
| 75 |
+
{ status: 429, backoff: true },
|
| 76 |
+
];
|
| 77 |
+
|
| 78 |
+
// Backward compat: COOLDOWN_MS object (used by index.js re-export)
|
| 79 |
+
export const COOLDOWN_MS = {
|
| 80 |
+
unauthorized: COOLDOWN.long,
|
| 81 |
+
paymentRequired: COOLDOWN.long,
|
| 82 |
+
notFound: COOLDOWN.long,
|
| 83 |
+
transient: TRANSIENT_COOLDOWN_MS,
|
| 84 |
+
requestNotAllowed: COOLDOWN.short,
|
| 85 |
+
};
|
open-sse/config/googleTtsLanguages.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const GOOGLE_TTS_LANGUAGES = [
|
| 2 |
+
{ id: "af", name: "Afrikaans", type: "tts" },
|
| 3 |
+
{ id: "ar", name: "Arabic", type: "tts" },
|
| 4 |
+
{ id: "bg", name: "Bulgarian", type: "tts" },
|
| 5 |
+
{ id: "bn", name: "Bengali", type: "tts" },
|
| 6 |
+
{ id: "bs", name: "Bosnian", type: "tts" },
|
| 7 |
+
{ id: "ca", name: "Catalan", type: "tts" },
|
| 8 |
+
{ id: "cs", name: "Czech", type: "tts" },
|
| 9 |
+
{ id: "cy", name: "Welsh", type: "tts" },
|
| 10 |
+
{ id: "da", name: "Danish", type: "tts" },
|
| 11 |
+
{ id: "de", name: "German", type: "tts" },
|
| 12 |
+
{ id: "el", name: "Greek", type: "tts" },
|
| 13 |
+
{ id: "en", name: "English", type: "tts" },
|
| 14 |
+
{ id: "eo", name: "Esperanto", type: "tts" },
|
| 15 |
+
{ id: "es", name: "Spanish", type: "tts" },
|
| 16 |
+
{ id: "et", name: "Estonian", type: "tts" },
|
| 17 |
+
{ id: "fi", name: "Finnish", type: "tts" },
|
| 18 |
+
{ id: "fr", name: "French", type: "tts" },
|
| 19 |
+
{ id: "gu", name: "Gujarati", type: "tts" },
|
| 20 |
+
{ id: "hi", name: "Hindi", type: "tts" },
|
| 21 |
+
{ id: "hr", name: "Croatian", type: "tts" },
|
| 22 |
+
{ id: "hu", name: "Hungarian", type: "tts" },
|
| 23 |
+
{ id: "hy", name: "Armenian", type: "tts" },
|
| 24 |
+
{ id: "id", name: "Indonesian", type: "tts" },
|
| 25 |
+
{ id: "is", name: "Icelandic", type: "tts" },
|
| 26 |
+
{ id: "it", name: "Italian", type: "tts" },
|
| 27 |
+
{ id: "ja", name: "Japanese", type: "tts" },
|
| 28 |
+
{ id: "jw", name: "Javanese", type: "tts" },
|
| 29 |
+
{ id: "km", name: "Khmer", type: "tts" },
|
| 30 |
+
{ id: "kn", name: "Kannada", type: "tts" },
|
| 31 |
+
{ id: "ko", name: "Korean", type: "tts" },
|
| 32 |
+
{ id: "la", name: "Latin", type: "tts" },
|
| 33 |
+
{ id: "lv", name: "Latvian", type: "tts" },
|
| 34 |
+
{ id: "mk", name: "Macedonian", type: "tts" },
|
| 35 |
+
{ id: "ml", name: "Malayalam", type: "tts" },
|
| 36 |
+
{ id: "mr", name: "Marathi", type: "tts" },
|
| 37 |
+
{ id: "my", name: "Myanmar (Burmese)", type: "tts" },
|
| 38 |
+
{ id: "ne", name: "Nepali", type: "tts" },
|
| 39 |
+
{ id: "nl", name: "Dutch", type: "tts" },
|
| 40 |
+
{ id: "no", name: "Norwegian", type: "tts" },
|
| 41 |
+
{ id: "pl", name: "Polish", type: "tts" },
|
| 42 |
+
{ id: "pt", name: "Portuguese", type: "tts" },
|
| 43 |
+
{ id: "ro", name: "Romanian", type: "tts" },
|
| 44 |
+
{ id: "ru", name: "Russian", type: "tts" },
|
| 45 |
+
{ id: "si", name: "Sinhala", type: "tts" },
|
| 46 |
+
{ id: "sk", name: "Slovak", type: "tts" },
|
| 47 |
+
{ id: "sq", name: "Albanian", type: "tts" },
|
| 48 |
+
{ id: "sr", name: "Serbian", type: "tts" },
|
| 49 |
+
{ id: "su", name: "Sundanese", type: "tts" },
|
| 50 |
+
{ id: "sv", name: "Swedish", type: "tts" },
|
| 51 |
+
{ id: "sw", name: "Swahili", type: "tts" },
|
| 52 |
+
{ id: "ta", name: "Tamil", type: "tts" },
|
| 53 |
+
{ id: "te", name: "Telugu", type: "tts" },
|
| 54 |
+
{ id: "th", name: "Thai", type: "tts" },
|
| 55 |
+
{ id: "tl", name: "Filipino", type: "tts" },
|
| 56 |
+
{ id: "tr", name: "Turkish", type: "tts" },
|
| 57 |
+
{ id: "uk", name: "Ukrainian", type: "tts" },
|
| 58 |
+
{ id: "ur", name: "Urdu", type: "tts" },
|
| 59 |
+
{ id: "vi", name: "Vietnamese", type: "tts" },
|
| 60 |
+
{ id: "zh-CN", name: "Chinese (Simplified)", type: "tts" },
|
| 61 |
+
{ id: "zh-TW", name: "Chinese (Traditional)", type: "tts" },
|
| 62 |
+
];
|
open-sse/config/kiroConstants.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Kiro-specific constants and helpers.
|
| 3 |
+
*
|
| 4 |
+
* Mirrors the behaviour of `internal/translator/kiro/common/constants.go` and
|
| 5 |
+
* `internal/translator/kiro/claude/kiro_claude_request.go` from the
|
| 6 |
+
* CLIProxyAPIPlus reference implementation, scoped down to what 9router needs:
|
| 7 |
+
*
|
| 8 |
+
* - `-agentic` model suffix detection + chunked-write system prompt
|
| 9 |
+
* - reasoning / thinking trigger detection (Anthropic-Beta header,
|
| 10 |
+
* Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag)
|
| 11 |
+
* - the `<thinking_mode>enabled</thinking_mode>` system-prompt injection
|
| 12 |
+
* that turns Kiro reasoning on
|
| 13 |
+
*
|
| 14 |
+
* Kiro upstream does not advertise `-agentic` model IDs; they are a 9router
|
| 15 |
+
* fiction. The suffix is stripped before the request leaves this process.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
import { extractThinking } from "../translator/concerns/thinkingUnified.js";
|
| 19 |
+
import { effortToBudget } from "../translator/concerns/thinking.js";
|
| 20 |
+
|
| 21 |
+
export const KIRO_AGENTIC_SUFFIX = "-agentic";
|
| 22 |
+
export const KIRO_THINKING_SUFFIX = "-thinking";
|
| 23 |
+
|
| 24 |
+
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
|
| 25 |
+
// Used when an account cannot resolve its own profileArn. Builder ID and social
|
| 26 |
+
// (Google/GitHub) sign-ins map to different shared profiles.
|
| 27 |
+
export const KIRO_DEFAULT_PROFILE_ARNS = {
|
| 28 |
+
"builder-id": "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX",
|
| 29 |
+
social: "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK",
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
// Back-compat single default (Builder ID).
|
| 33 |
+
export const KIRO_DEFAULT_PROFILE_ARN = KIRO_DEFAULT_PROFILE_ARNS["builder-id"];
|
| 34 |
+
|
| 35 |
+
/** Resolve the shared default profileArn for a given auth method. */
|
| 36 |
+
export function resolveDefaultProfileArn(authMethod) {
|
| 37 |
+
const social = authMethod === "google" || authMethod === "github";
|
| 38 |
+
return social ? KIRO_DEFAULT_PROFILE_ARNS.social : KIRO_DEFAULT_PROFILE_ARNS["builder-id"];
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
|
| 42 |
+
|
| 43 |
+
export const KIRO_AGENTIC_SYSTEM_PROMPT = `
|
| 44 |
+
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
|
| 45 |
+
|
| 46 |
+
You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure.
|
| 47 |
+
|
| 48 |
+
## ABSOLUTE LIMITS
|
| 49 |
+
- **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS
|
| 50 |
+
- **RECOMMENDED 300 LINES** or less for optimal performance
|
| 51 |
+
- **NEVER** write entire files in one operation if >300 lines
|
| 52 |
+
|
| 53 |
+
## MANDATORY CHUNKED WRITE STRATEGY
|
| 54 |
+
|
| 55 |
+
### For NEW FILES (>300 lines total):
|
| 56 |
+
1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite
|
| 57 |
+
2. THEN: Append remaining content in 250-300 line chunks using file append operations
|
| 58 |
+
3. REPEAT: Continue appending until complete
|
| 59 |
+
|
| 60 |
+
### For EDITING EXISTING FILES:
|
| 61 |
+
1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed
|
| 62 |
+
2. NEVER rewrite entire files - use incremental modifications
|
| 63 |
+
3. Split large refactors into multiple small, focused edits
|
| 64 |
+
|
| 65 |
+
### For LARGE CODE GENERATION:
|
| 66 |
+
1. Generate in logical sections (imports, types, functions separately)
|
| 67 |
+
2. Write each section as a separate operation
|
| 68 |
+
3. Use append operations for subsequent sections
|
| 69 |
+
|
| 70 |
+
## EXAMPLES OF CORRECT BEHAVIOR
|
| 71 |
+
|
| 72 |
+
CORRECT: Writing a 600-line file
|
| 73 |
+
- Operation 1: Write lines 1-300 (initial file creation)
|
| 74 |
+
- Operation 2: Append lines 301-600
|
| 75 |
+
|
| 76 |
+
CORRECT: Editing multiple functions
|
| 77 |
+
- Operation 1: Edit function A
|
| 78 |
+
- Operation 2: Edit function B
|
| 79 |
+
- Operation 3: Edit function C
|
| 80 |
+
|
| 81 |
+
WRONG: Writing 500 lines in single operation -> TIMEOUT
|
| 82 |
+
WRONG: Rewriting entire file to change 5 lines -> TIMEOUT
|
| 83 |
+
WRONG: Generating massive code blocks without chunking -> TIMEOUT
|
| 84 |
+
|
| 85 |
+
## WHY THIS MATTERS
|
| 86 |
+
- Server has 2-3 minute timeout for operations
|
| 87 |
+
- Large writes exceed timeout and FAIL completely
|
| 88 |
+
- Chunked writes are FASTER and more RELIABLE
|
| 89 |
+
- Failed writes waste time and require retry
|
| 90 |
+
|
| 91 |
+
REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation.
|
| 92 |
+
`.trim();
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Resolve the Kiro thinking budget requested by a client.
|
| 96 |
+
*
|
| 97 |
+
* Reuses the shared thinkingUnified parser (extractThinking) so every client
|
| 98 |
+
* shape (Claude output_config.effort / thinking.budget_tokens, OpenAI
|
| 99 |
+
* reasoning_effort / reasoning.effort, Gemini, Qwen) maps consistently. Explicit
|
| 100 |
+
* `none`/`off`/disabled wins and returns null (no prefix injected).
|
| 101 |
+
* buildThinkingSystemPrefix performs Kiro's final 1..32000 clamp.
|
| 102 |
+
*
|
| 103 |
+
* @param {object} body OpenAI/Claude-shaped request body
|
| 104 |
+
* @param {object} [headers] Original inbound HTTP headers (case-insensitive)
|
| 105 |
+
* @param {string} [model] Model id the caller asked for
|
| 106 |
+
* @returns {number|null} budget to inject, or null when thinking is disabled
|
| 107 |
+
*/
|
| 108 |
+
export function resolveKiroThinkingBudget(body, headers, model) {
|
| 109 |
+
const cfg = extractThinking(body);
|
| 110 |
+
if (cfg) {
|
| 111 |
+
if (cfg.mode === "none") return null;
|
| 112 |
+
if (cfg.mode === "budget") return cfg.budget;
|
| 113 |
+
if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT;
|
| 114 |
+
return KIRO_THINKING_BUDGET_DEFAULT;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (headers) {
|
| 118 |
+
const beta = pickHeader(headers, "anthropic-beta");
|
| 119 |
+
if (typeof beta === "string" && beta.toLowerCase().includes("interleaved-thinking")) {
|
| 120 |
+
return KIRO_THINKING_BUDGET_DEFAULT;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if (containsThinkingModeTag(body)) return KIRO_THINKING_BUDGET_DEFAULT;
|
| 125 |
+
|
| 126 |
+
if (typeof model === "string" && model) {
|
| 127 |
+
const m = model.toLowerCase();
|
| 128 |
+
if (m.includes("thinking") || m.includes("-reason")) return KIRO_THINKING_BUDGET_DEFAULT;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return null;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* Detect whether an inbound request is asking for reasoning / thinking output.
|
| 136 |
+
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
|
| 137 |
+
*
|
| 138 |
+
* @param {object} body OpenAI-shaped request body (post-translation)
|
| 139 |
+
* @param {object} [headers] Original inbound HTTP headers (case-insensitive)
|
| 140 |
+
* @param {string} [model] Model id the caller asked for (post-strip ok)
|
| 141 |
+
* @returns {boolean}
|
| 142 |
+
*/
|
| 143 |
+
export function isThinkingEnabled(body, headers, model) {
|
| 144 |
+
return resolveKiroThinkingBudget(body, headers, model) !== null;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Detect whether a model id refers to a 9router synthetic agentic variant.
|
| 149 |
+
* Agentic variants share the same upstream model as the base; the only
|
| 150 |
+
* difference is the chunked-write system prompt this module injects.
|
| 151 |
+
*
|
| 152 |
+
* @param {string} model
|
| 153 |
+
* @returns {boolean}
|
| 154 |
+
*/
|
| 155 |
+
export function isAgenticModel(model) {
|
| 156 |
+
return typeof model === "string" && model.endsWith(KIRO_AGENTIC_SUFFIX);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Strip the `-agentic` suffix from a model id, leaving the upstream-real id.
|
| 161 |
+
*
|
| 162 |
+
* @param {string} model
|
| 163 |
+
* @returns {string}
|
| 164 |
+
*/
|
| 165 |
+
export function stripAgenticSuffix(model) {
|
| 166 |
+
if (!isAgenticModel(model)) return model;
|
| 167 |
+
return model.slice(0, -KIRO_AGENTIC_SUFFIX.length);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Detect whether a model id is a 9router synthetic thinking variant
|
| 172 |
+
* (e.g. `claude-sonnet-4.5-thinking`). Same upstream model as the base; the
|
| 173 |
+
* only difference is `<thinking_mode>enabled</thinking_mode>` injection.
|
| 174 |
+
*
|
| 175 |
+
* Note: real Kiro thinking-capable variants exist (e.g. `kimi-k2-thinking` in
|
| 176 |
+
* other providers), but for the `kr/` namespace there is no `-thinking`
|
| 177 |
+
* model on Kiro upstream. Treat the suffix as a synthetic alias.
|
| 178 |
+
*
|
| 179 |
+
* @param {string} model Model id with `-agentic` already stripped
|
| 180 |
+
* @returns {boolean}
|
| 181 |
+
*/
|
| 182 |
+
export function isThinkingModel(model) {
|
| 183 |
+
return typeof model === "string" && model.endsWith(KIRO_THINKING_SUFFIX);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Strip the `-thinking` suffix from a model id.
|
| 188 |
+
*
|
| 189 |
+
* @param {string} model
|
| 190 |
+
* @returns {string}
|
| 191 |
+
*/
|
| 192 |
+
export function stripThinkingSuffix(model) {
|
| 193 |
+
if (!isThinkingModel(model)) return model;
|
| 194 |
+
return model.slice(0, -KIRO_THINKING_SUFFIX.length);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* Resolve a 9router model id to the real upstream Kiro model id, plus flags
|
| 199 |
+
* describing which behaviours the suffixes implied.
|
| 200 |
+
*
|
| 201 |
+
* resolveKiroModel("claude-sonnet-4.5-thinking-agentic")
|
| 202 |
+
* => { upstream: "claude-sonnet-4.5", agentic: true, thinking: true }
|
| 203 |
+
* resolveKiroModel("claude-sonnet-4.5-thinking")
|
| 204 |
+
* => { upstream: "claude-sonnet-4.5", agentic: false, thinking: true }
|
| 205 |
+
* resolveKiroModel("claude-sonnet-4.5-agentic")
|
| 206 |
+
* => { upstream: "claude-sonnet-4.5", agentic: true, thinking: false }
|
| 207 |
+
* resolveKiroModel("claude-sonnet-4.5")
|
| 208 |
+
* => { upstream: "claude-sonnet-4.5", agentic: false, thinking: false }
|
| 209 |
+
*
|
| 210 |
+
* @param {string} model
|
| 211 |
+
* @returns {{ upstream: string, agentic: boolean, thinking: boolean }}
|
| 212 |
+
*/
|
| 213 |
+
export function resolveKiroModel(model) {
|
| 214 |
+
let upstream = model;
|
| 215 |
+
let agentic = false;
|
| 216 |
+
let thinking = false;
|
| 217 |
+
if (isAgenticModel(upstream)) {
|
| 218 |
+
agentic = true;
|
| 219 |
+
upstream = stripAgenticSuffix(upstream);
|
| 220 |
+
}
|
| 221 |
+
if (isThinkingModel(upstream)) {
|
| 222 |
+
thinking = true;
|
| 223 |
+
upstream = stripThinkingSuffix(upstream);
|
| 224 |
+
}
|
| 225 |
+
return { upstream, agentic, thinking };
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/**
|
| 229 |
+
* Build the magic system-prompt prefix that turns Kiro reasoning on.
|
| 230 |
+
* Same shape as CLIProxyAPIPlus.
|
| 231 |
+
*
|
| 232 |
+
* @param {number} [budget=KIRO_THINKING_BUDGET_DEFAULT]
|
| 233 |
+
*/
|
| 234 |
+
export function buildThinkingSystemPrefix(budget = KIRO_THINKING_BUDGET_DEFAULT) {
|
| 235 |
+
const safeBudget = Math.max(1, Math.min(32000, Number(budget) || KIRO_THINKING_BUDGET_DEFAULT));
|
| 236 |
+
return `<thinking_mode>enabled</thinking_mode>\n<max_thinking_length>${safeBudget}</max_thinking_length>`;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function pickHeader(headers, name) {
|
| 240 |
+
if (!headers) return undefined;
|
| 241 |
+
if (typeof headers.get === "function") {
|
| 242 |
+
return headers.get(name);
|
| 243 |
+
}
|
| 244 |
+
const lower = name.toLowerCase();
|
| 245 |
+
for (const key of Object.keys(headers)) {
|
| 246 |
+
if (key.toLowerCase() === lower) {
|
| 247 |
+
return headers[key];
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
return undefined;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
function containsThinkingModeTag(body) {
|
| 254 |
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
| 255 |
+
for (const msg of messages) {
|
| 256 |
+
if (!msg) continue;
|
| 257 |
+
if (msg.role !== "system" && msg.role !== "user") continue;
|
| 258 |
+
const content = msg.content;
|
| 259 |
+
if (typeof content === "string") {
|
| 260 |
+
if (containsTagInText(content)) return true;
|
| 261 |
+
} else if (Array.isArray(content)) {
|
| 262 |
+
for (const part of content) {
|
| 263 |
+
const text = part?.text;
|
| 264 |
+
if (typeof text === "string" && containsTagInText(text)) return true;
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
if (typeof body?.system === "string" && containsTagInText(body.system)) return true;
|
| 269 |
+
return false;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function containsTagInText(text) {
|
| 273 |
+
if (!text) return false;
|
| 274 |
+
if (!text.includes("<thinking_mode>")) return false;
|
| 275 |
+
return text.includes("<thinking_mode>enabled</thinking_mode>")
|
| 276 |
+
|| text.includes("<thinking_mode>interleaved</thinking_mode>");
|
| 277 |
+
}
|
open-sse/config/mediaConfig.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Central config for remote-media fetching security limits.
|
| 2 |
+
|
| 3 |
+
// Max bytes accepted from a remote image fetch (reject larger to prevent memory DoS).
|
| 4 |
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB
|
| 5 |
+
|
| 6 |
+
// Fetch timeout for remote media.
|
| 7 |
+
export const FETCH_TIMEOUT_MS = 10000;
|
| 8 |
+
|
| 9 |
+
// Magic-byte signatures -> mime. Each entry: { sig:[bytes], offset, mime }.
|
| 10 |
+
// offset>0 for containers where the signature is not at byte 0 (e.g. webp).
|
| 11 |
+
export const IMAGE_SIGNATURES = [
|
| 12 |
+
{ sig: [0x89, 0x50, 0x4e, 0x47], offset: 0, mime: "image/png" },
|
| 13 |
+
{ sig: [0xff, 0xd8, 0xff], offset: 0, mime: "image/jpeg" },
|
| 14 |
+
{ sig: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
|
| 15 |
+
{ sig: [0x52, 0x49, 0x46, 0x46], offset: 0, mime: "image/webp", verifyWebp: true },
|
| 16 |
+
{ sig: [0x42, 0x4d], offset: 0, mime: "image/bmp" },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
// Hostnames/IPs that must never be fetched (SSRF guard for loopback + cloud metadata).
|
| 20 |
+
export const BLOCKED_HOSTS = new Set([
|
| 21 |
+
"localhost",
|
| 22 |
+
"127.0.0.1",
|
| 23 |
+
"0.0.0.0",
|
| 24 |
+
"::1",
|
| 25 |
+
"169.254.169.254", // AWS/GCP/Azure IMDS
|
| 26 |
+
"metadata.google.internal",
|
| 27 |
+
]);
|
open-sse/config/models.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Model metadata registry
|
| 2 |
+
// Only define models that differ from DEFAULT_MODEL_INFO
|
| 3 |
+
// Custom entries are merged over default
|
| 4 |
+
const DEFAULT_MODEL_INFO = {
|
| 5 |
+
type: ["chat"],
|
| 6 |
+
contextWindow: 200000,
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const MODEL_INFO = {};
|
| 10 |
+
|
| 11 |
+
export function getModelInfo(modelId) {
|
| 12 |
+
return { ...DEFAULT_MODEL_INFO, ...MODEL_INFO[modelId] };
|
| 13 |
+
}
|
open-sse/config/ollamaModels.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const ollamaModels = {
|
| 2 |
+
models: [
|
| 3 |
+
{
|
| 4 |
+
name: "llama3.2",
|
| 5 |
+
modified_at: "2025-12-26T00:00:00Z",
|
| 6 |
+
size: 2000000000,
|
| 7 |
+
digest: "abc123def456",
|
| 8 |
+
details: { format: "gguf", family: "llama", parameter_size: "3B", quantization_level: "Q4_K_M" }
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
name: "qwen2.5",
|
| 12 |
+
modified_at: "2025-12-26T00:00:00Z",
|
| 13 |
+
size: 4000000000,
|
| 14 |
+
digest: "def456abc123",
|
| 15 |
+
details: { format: "gguf", family: "qwen", parameter_size: "7B", quantization_level: "Q4_K_M" }
|
| 16 |
+
}
|
| 17 |
+
]
|
| 18 |
+
};
|
| 19 |
+
|
open-sse/config/providerModels.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PROVIDERS } from "./providers.js";
|
| 2 |
+
import REGISTRY from "../providers/registry/index.js";
|
| 3 |
+
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
| 4 |
+
import { PROVIDER_MODELS } from "../providers/index.js";
|
| 5 |
+
import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js";
|
| 6 |
+
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
| 7 |
+
|
| 8 |
+
export { PROVIDER_MODELS };
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
// Helper functions
|
| 12 |
+
export function getProviderModels(aliasOrId) {
|
| 13 |
+
return PROVIDER_MODELS[aliasOrId] || [];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function getDefaultModel(aliasOrId) {
|
| 17 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 18 |
+
return models?.[0]?.id || null;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) {
|
| 22 |
+
if (passthroughProviders.has(aliasOrId)) return true;
|
| 23 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 24 |
+
if (!models) return false;
|
| 25 |
+
return models.some(m => m.id === modelId);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function findModelName(aliasOrId, modelId) {
|
| 29 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 30 |
+
if (!models) return modelId;
|
| 31 |
+
const found = models.find(m => m.id === modelId);
|
| 32 |
+
return found?.name || modelId;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function getModelTargetFormat(aliasOrId, modelId) {
|
| 36 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 37 |
+
if (!models) return null;
|
| 38 |
+
return modelTargetFormat(models.find(m => m.id === modelId));
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function getModelType(aliasOrId, modelId) {
|
| 42 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 43 |
+
if (!models) return null;
|
| 44 |
+
const found = models.find(m => m.id === modelId);
|
| 45 |
+
return found?.kind || found?.type || null;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function getModelUpstreamId(aliasOrId, modelId) {
|
| 49 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 50 |
+
const found = models?.find(m => m.id === modelId);
|
| 51 |
+
if (found?.upstreamModelId) return found.upstreamModelId;
|
| 52 |
+
if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) {
|
| 53 |
+
return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length);
|
| 54 |
+
}
|
| 55 |
+
return modelId;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function getModelQuotaFamily(aliasOrId, modelId) {
|
| 59 |
+
const models = PROVIDER_MODELS[aliasOrId];
|
| 60 |
+
return modelQuotaFamily(models?.find(m => m.id === modelId));
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
|
| 64 |
+
// vertex/vertex-partner keep alias=id (kept via the `|| id` fallback in consumers).
|
| 65 |
+
export const OAUTH_ALIASES = Object.fromEntries(
|
| 66 |
+
REGISTRY.filter(r => r.alias && r.alias !== r.id).map(r => [r.id, r.alias])
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
// Derived from PROVIDERS — no need to maintain manually
|
| 70 |
+
export const PROVIDER_ID_TO_ALIAS = Object.fromEntries(
|
| 71 |
+
Object.keys(PROVIDERS).map(id => [id, OAUTH_ALIASES[id] || id])
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
export function getModelsByProviderId(providerId) {
|
| 75 |
+
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
| 76 |
+
return PROVIDER_MODELS[alias] || [];
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Get strip list for a model entry (explicit opt-in only)
|
| 80 |
+
// Returns array of content types to strip, e.g. ["image", "audio"]
|
| 81 |
+
export function getModelStrip(alias, modelId) {
|
| 82 |
+
return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
|
| 83 |
+
}
|
open-sse/config/providers.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Barrel: PROVIDERS now built from providers/registry (transport co-located with models)
|
| 2 |
+
import { PROVIDERS } from "../providers/index.js";
|
| 3 |
+
export { PROVIDERS, PROVIDER_OAUTH } from "../providers/index.js";
|
| 4 |
+
|
| 5 |
+
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";
|
| 6 |
+
|
| 7 |
+
export function resolveOllamaLocalHost(credentials) {
|
| 8 |
+
const raw = credentials?.providerSpecificData?.baseUrl?.trim();
|
| 9 |
+
return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, "");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
// Region URLs single-source from registry xiaomi-tokenplan.transport
|
| 13 |
+
export const XIAOMI_TOKENPLAN_REGIONS = PROVIDERS["xiaomi-tokenplan"]?.regions || {};
|
| 14 |
+
export const XIAOMI_TOKENPLAN_DEFAULT_REGION = PROVIDERS["xiaomi-tokenplan"]?.defaultRegion;
|
| 15 |
+
|
| 16 |
+
export function resolveXiaomiTokenplanBaseUrl(credentials) {
|
| 17 |
+
const region = credentials?.providerSpecificData?.region;
|
| 18 |
+
return XIAOMI_TOKENPLAN_REGIONS[region] || XIAOMI_TOKENPLAN_REGIONS[XIAOMI_TOKENPLAN_DEFAULT_REGION];
|
| 19 |
+
}
|
open-sse/config/runtimeConfig.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// HTTP status codes
|
| 2 |
+
export const HTTP_STATUS = {
|
| 3 |
+
BAD_REQUEST: 400,
|
| 4 |
+
UNAUTHORIZED: 401,
|
| 5 |
+
PAYMENT_REQUIRED: 402,
|
| 6 |
+
FORBIDDEN: 403,
|
| 7 |
+
NOT_FOUND: 404,
|
| 8 |
+
NOT_ACCEPTABLE: 406,
|
| 9 |
+
REQUEST_TIMEOUT: 408,
|
| 10 |
+
RATE_LIMITED: 429,
|
| 11 |
+
SERVER_ERROR: 500,
|
| 12 |
+
BAD_GATEWAY: 502,
|
| 13 |
+
SERVICE_UNAVAILABLE: 503,
|
| 14 |
+
GATEWAY_TIMEOUT: 504
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// Re-export error config (backward compat)
|
| 18 |
+
export { ERROR_TYPES, DEFAULT_ERROR_MESSAGES, BACKOFF_CONFIG, COOLDOWN_MS } from "./errorConfig.js";
|
| 19 |
+
|
| 20 |
+
// Cache TTLs (seconds)
|
| 21 |
+
export const CACHE_TTL = {
|
| 22 |
+
userInfo: 300, // 5 minutes
|
| 23 |
+
modelAlias: 3600 // 1 hour
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Memory management config
|
| 27 |
+
export const MEMORY_CONFIG = {
|
| 28 |
+
sessionTtlMs: 2 * 60 * 60 * 1000,
|
| 29 |
+
sessionCleanupIntervalMs: 30 * 60 * 1000,
|
| 30 |
+
dnsCacheTtlMs: 5 * 60 * 1000,
|
| 31 |
+
proxyDispatchersMaxSize: 20,
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// Parse a positive integer env override, falling back to a default.
|
| 35 |
+
function envMs(name, def) {
|
| 36 |
+
const raw = process.env[name];
|
| 37 |
+
if (raw == null || raw === "") return def;
|
| 38 |
+
const n = parseInt(raw, 10);
|
| 39 |
+
return Number.isFinite(n) && n > 0 ? n : def;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
|
| 43 |
+
// slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
|
| 44 |
+
export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
|
| 45 |
+
|
| 46 |
+
// Time-to-first-token timeout (prompt prefill). Env: STREAM_FIRST_CHUNK_TIMEOUT_MS.
|
| 47 |
+
export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_MS", 200 * 1000);
|
| 48 |
+
|
| 49 |
+
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration
|
| 50 |
+
export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
|
| 51 |
+
|
| 52 |
+
// Default token limits
|
| 53 |
+
export const DEFAULT_MAX_TOKENS = 64000;
|
| 54 |
+
export const DEFAULT_MIN_TOKENS = 32000;
|
| 55 |
+
|
| 56 |
+
// Retry config for 429 responses (legacy - kept for backward compatibility)
|
| 57 |
+
export const RETRY_CONFIG = {
|
| 58 |
+
maxAttempts: 2,
|
| 59 |
+
delayMs: 2000
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// Default retry config by status code: { attempts, delayMs }
|
| 63 |
+
// Backward compat: if value is a number, treated as attempts with RETRY_CONFIG.delayMs
|
| 64 |
+
export const DEFAULT_RETRY_CONFIG = {
|
| 65 |
+
429: { attempts: 0, delayMs: 0 },
|
| 66 |
+
502: { attempts: 3, delayMs: 3000 },
|
| 67 |
+
503: { attempts: 3, delayMs: 2000 },
|
| 68 |
+
504: { attempts: 2, delayMs: 3000 }
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// Normalize a retry entry to { attempts, delayMs }
|
| 72 |
+
export function resolveRetryEntry(entry) {
|
| 73 |
+
if (entry == null) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs };
|
| 74 |
+
if (typeof entry === "number") return { attempts: entry, delayMs: RETRY_CONFIG.delayMs };
|
| 75 |
+
return {
|
| 76 |
+
attempts: entry.attempts || 0,
|
| 77 |
+
delayMs: entry.delayMs != null ? entry.delayMs : RETRY_CONFIG.delayMs
|
| 78 |
+
};
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Requests containing these texts will bypass provider
|
| 82 |
+
export const SKIP_PATTERNS = [
|
| 83 |
+
"Please write a 5-10 word title for the following conversation:"
|
| 84 |
+
];
|